Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
---
name: "OPSX: Apply"
name: "opsx-apply"
description: Implement tasks from an OpenSpec change (Experimental)
category: Workflow
tags: [workflow, artifacts, experimental]
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
---
name: "OPSX: Archive"
name: "opsx-archive"
description: Archive a completed change in the experimental workflow
category: Workflow
tags: [workflow, archive, experimental]
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
---
name: "OPSX: Continue"
name: "opsx-continue"
description: Continue working on a change - create the next artifact (Experimental)
category: Workflow
tags: [workflow, artifacts, experimental]
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
---
name: "OPSX: Explore"
name: "opsx-explore"
description: "Enter explore mode - think through ideas, investigate problems, clarify requirements"
category: Workflow
tags: [workflow, explore, experimental, thinking]
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
---
name: "OPSX: New"
name: "opsx-new"
description: Start a new change using the experimental artifact workflow (OPSX)
category: Workflow
tags: [workflow, artifacts, experimental]
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
---
name: "OPSX: Propose"
name: "opsx-propose"
description: Propose a new change - create it and generate all artifacts in one step
category: Workflow
tags: [workflow, artifacts, experimental]
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
---
name: "OPSX: Sync"
name: "opsx-sync"
description: Sync delta specs from a change to main specs
category: Workflow
tags: [workflow, specs, experimental]
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
---
name: "OPSX: Verify"
name: "opsx-verify"
description: Verify implementation matches change artifacts before archiving
category: Workflow
tags: [workflow, verify, experimental]
Expand Down
3 changes: 1 addition & 2 deletions .dotagents/config.toml
Original file line number Diff line number Diff line change
Expand Up @@ -5,5 +5,4 @@ features = [
"skill",
]

targets = ["claude", "opencode"]
variables = { "agent_name" = "my agent" }
targets = ["claude", "opencode", "cursor"]
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,9 @@ temp
.wrangler
.vercel
#region dotagents
.agents/
.claude/
.cursor/
.opencode/
AGENTS.md
CLAUDE.md
Expand Down
2 changes: 2 additions & 0 deletions apps/cli/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@
"build": "bun build src/cli.ts --compile --env 'CLI_PUBLIC_*' --outfile dist/cyrusd"
},
"dependencies": {
"@acp-kit/core": "^0.10.2",
"@agentclientprotocol/sdk": "^1.1.0",
"@commander-js/extra-typings": "^15.0.0",
"@cyrus/connections": "workspace:*",
"@orpc/server": "catalog:",
Expand Down
17 changes: 17 additions & 0 deletions apps/cli/src/commands/agents/add.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import { addAgent } from "@/store/agents";
import { print } from "@/utils/style";
import type { AgentEntry } from "@/validators/agent";

export async function add(name: string, entry: AgentEntry): Promise<void> {
const saved = await addAgent(name, entry);
saved.match({
ok: () => {
const args = entry.args.length > 0 ? ` ${entry.args.join(" ")}` : "";
print.success`✓ added agent "${name}" with command ${entry.command}${args}`;
},
err: (message) => {
print.error`${message}`;
process.exit(1);
},
});
}
80 changes: 80 additions & 0 deletions apps/cli/src/commands/agents/doctor.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
import { pingAcpAgent } from "@/core/acp/ping";
import { getAgent, listAgents } from "@/store/agents";
import { green, print, red } from "@/utils/style";
import type { AgentEntry } from "@/validators/agent";

type HealthResult = {
healthy: boolean;
error?: string;
};

async function checkAgent(entry: AgentEntry): Promise<HealthResult> {
return (await pingAcpAgent(entry)).match<HealthResult>({
ok: () => ({ healthy: true }),
err: (error) => ({ healthy: false, error }),
});
}

function printHealth(name: string, result: HealthResult): void {
const status = result.healthy ? green("healthy") : red("unhealthy");
print.line`${name}: ${status}`;
if (result.error) {
print.line` ${result.error}`;
}
}

async function checkSingleAgentHealth(name: string) {
const entry = await getAgent(name);
entry.match({
ok: async (agent) => {
if (!agent) {
print.error`agent "${name}" is not registered`;
print.dim`Add it with \`cyrusd agents add ${name} --cmd <command>\`.`;
process.exit(1);
}

const result = await checkAgent(agent);
printHealth(name, result);
if (!result.healthy) process.exit(1);
},
err: (message) => {
print.error`${message}`;
process.exit(1);
},
});
}

async function checkAllAgentsHealth() {
const agents = await listAgents();
await agents.match({
ok: async (registry) => {
const entries = Object.entries(registry).sort(([a], [b]) =>
a.localeCompare(b)
);

if (entries.length === 0) {
print.dim`No agents registered. Add one with \`cyrusd agents add <name> --cmd <command>\`.`;
process.exit(1);
}

const results = await Promise.all(
entries.map(async ([name, entry]) => ({
name,
result: await checkAgent(entry),
}))
);

for (const { name, result } of results) printHealth(name, result);

if (results.some(({ result }) => !result.healthy)) process.exit(1);
},
err: (message) => {
print.error`${message}`;
process.exit(1);
},
});
}

export async function doctor(name?: string): Promise<void> {
name ? await checkSingleAgentHealth(name) : await checkAllAgentsHealth();
}
55 changes: 55 additions & 0 deletions apps/cli/src/commands/agents/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
import type { Command } from "@commander-js/extra-typings";
import { commandArgParser, nameArgParser } from "@/validators/acp";
import { add } from "./add";
import { doctor } from "./doctor";
import { list } from "./list";
import { rm } from "./rm";
import { update } from "./update";

export function registerAgentsCommands(program: Command) {
const agents = program
.command("agents")
.description("Manage registered ACP agents");

agents
.command("list")
.alias("ls")
.description("List registered agents")
.action(list);

agents
.command("add")
.description("Register a new agent")
.argument("<name>", "agent name", nameArgParser)
.requiredOption("--cmd <command>", "executable command", commandArgParser)
.option("--args <args...>", "arguments passed to the agent command")
.action((name, options) =>
add(name, { command: options.cmd, args: options.args ?? [] })
);

agents
.command("rm")
.description("Remove a registered agent")
.argument("<name>", "agent name", nameArgParser)
.action(rm);

agents
.command("update")
.description("Update a registered agent")
.argument("<name>", "agent name", nameArgParser)
.option("--cmd <command>", "executable command", commandArgParser)
.option("--args <args...>", "arguments passed to the agent command")
.action((name, options) =>
update(name, { command: options.cmd, args: options.args })
);

agents
.command("doctor")
.description("Check agent command and ACP connectivity")
.option(
"--name <name>",
"agent name (omit to check all registered agents)",
nameArgParser
)
.action((options) => doctor(options.name));
}
27 changes: 27 additions & 0 deletions apps/cli/src/commands/agents/list.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import { listAgents } from "@/store/agents";
import { print } from "@/utils/style";

export async function list(): Promise<void> {
const agents = await listAgents();
agents.match({
ok: (registry) => {
const names = Object.keys(registry).sort();

if (names.length === 0) {
print.dim`No agents registered. Add one with \`cyrusd agents add <name> --cmd <command>\`.`;
return;
}

for (const name of names) {
const entry = registry[name];
if (!entry) continue;
const args = entry.args.length > 0 ? ` ${entry.args.join(" ")}` : "";
print.line`${name}: ${entry.command}${args}`;
}
},
err: (message) => {
print.error`${message}`;
process.exit(1);
},
});
}
15 changes: 15 additions & 0 deletions apps/cli/src/commands/agents/rm.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import { removeAgent } from "@/store/agents";
import { print } from "@/utils/style";

export async function rm(name: string): Promise<void> {
const removed = await removeAgent(name);
removed.match({
ok: () => {
print.success`✓ removed agent "${name}"`;
},
err: (message) => {
print.error`${message}`;
process.exit(1);
},
});
}
47 changes: 47 additions & 0 deletions apps/cli/src/commands/agents/update.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import { getAgent, updateAgent } from "@/store/agents";
import { print } from "@/utils/style";
import type { AgentEntry } from "@/validators/agent";

type AgentUpdate = {
command?: string;
args?: string[];
};

export async function update(name: string, patch: AgentUpdate): Promise<void> {
if (patch.command === undefined && patch.args === undefined) {
print.error`provide --cmd or --args`;
process.exit(1);
}

const existing = await getAgent(name);
await existing.match({
ok: async (agent) => {
if (!agent) {
print.error`agent "${name}" not found`;
process.exit(1);
}

const entry: AgentEntry = {
command: patch.command ?? agent.command,
args: patch.args ?? agent.args,
};

const saved = await updateAgent(name, entry);
saved.match({
ok: () => {
print.success`✓ updated agent "${name}"`;
const args = entry.args.length > 0 ? ` ${entry.args.join(" ")}` : "";
print.dim` ${entry.command}${args}`;
},
err: (message) => {
print.error`${message}`;
process.exit(1);
},
});
},
err: (message) => {
print.error`${message}`;
process.exit(1);
},
});
}
27 changes: 17 additions & 10 deletions apps/cli/src/commands/auth/login.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { Result } from "better-result";
import { authClient } from "@/lib/auth";
import { getOrCreate, set } from "@/store/config";
import { generateName } from "@/utils/identity";
import { getOrCreate, set } from "@/utils/store";
import { blue, bold, cyan, print, underline } from "@/utils/style";

export const CLIENT_ID = "cyrus-cli";
Expand Down Expand Up @@ -36,17 +36,24 @@ export async function login(): Promise<void> {

while (Date.now() < deadline) {
await Bun.sleep(interval);
const poll = await authClient.device
.token({
grant_type: GRANT_TYPE,
device_code: deviceCode,
client_id: CLIENT_ID,
})
.catch(() => ({
const poll = await Result.tryPromise({
try: () =>
authClient.device.token({
grant_type: GRANT_TYPE,
device_code: deviceCode,
client_id: CLIENT_ID,
}),
catch: () => ({
data: null,
error: { error: "authorization_pending", error_description: "" },
}));
const { data: token, error: tokenError } = poll;
}),
});
const { data: token, error: tokenError } = poll.isOk()
? poll.value
: {
data: null,
error: { error: "authorization_pending", error_description: "" },
};
const accessToken = token?.access_token;

if (accessToken) {
Expand Down
11 changes: 5 additions & 6 deletions apps/cli/src/commands/auth/logout.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { Result } from "better-result";
import { authClient } from "@/lib/auth";
import { get, remove } from "@/utils/store";
import { get, remove } from "@/store/config";
import { print } from "@/utils/style";

export async function logout(): Promise<void> {
Expand All @@ -8,10 +9,8 @@ export async function logout(): Promise<void> {
print.dim`Not logged in.`;
return;
}
try {
await authClient.signOut();
} finally {
await remove("token");
}

await Result.tryPromise(() => authClient.signOut());
await remove("token");
print.success`✓ Logged out.`;
}
Loading
Loading