Skip to content
Open
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
37 changes: 36 additions & 1 deletion packages/cli/src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,6 @@ export function createProgram(): Command {

program
.command("list")
.alias("ls")
.description("List all profiles")
.action(async () => {
const mod = await import("./commands/profile.js");
Expand Down Expand Up @@ -254,6 +253,42 @@ export function createProgram(): Command {
await mod.handleDaemonLogs(opts);
});

// === Docker-style Agent Verbs (talk to the daemon) ===

program
.command("ls")
.description("List agents tracked by the daemon")
.option("--all", "Include completed/stopped agents")
.option("--json", "Output machine-readable JSON")
.action(async (opts: { all?: boolean; json?: boolean }) => {
const mod = await import("./commands/ls.js");
await mod.handleLs(opts);
});

program
.command("attach <agentId>")
.description("Attach to a running agent's terminal output")
.action(async (agentId: string) => {
const mod = await import("./commands/attach.js");
await mod.handleAttach(agentId);
});

program
.command("send <agentId> <text>")
.description("Send a message to a running agent")
.action(async (agentId: string, text: string) => {
const mod = await import("./commands/send.js");
await mod.handleSend(agentId, text);
});

program
.command("stop <agentId>")
.description("Stop a running agent")
.action(async (agentId: string) => {
const mod = await import("./commands/stop-agent.js");
await mod.handleStopAgent(agentId);
});

// === Session Commands ===

program
Expand Down
60 changes: 60 additions & 0 deletions packages/cli/src/commands/attach.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
import { connectDaemon, hasErrorCode } from "../daemon-client.js";
import { error, info } from "../display.js";

/**
* Stream terminal bytes from a running agent. Ctrl+C detaches cleanly
* without stopping the underlying agent. Unit 2 owns the daemon-side
* producer; until that lands we surface the `unimplemented` error.
*/
export async function handleAttach(agentId: string): Promise<void> {
if (!agentId) {
error("missing agent id");
process.exitCode = 1;
return;
}

let client;
try {
client = await connectDaemon({ noReconnect: true });
} catch (err) {
error(err instanceof Error ? err.message : String(err));
process.exitCode = 1;
return;
}

client.attachTerminal((_id, bytes) => {
process.stdout.write(Buffer.from(bytes));
});

let resolveDone: () => void;
const done = new Promise<void>((resolve) => {
resolveDone = resolve;
});
const detach = async () => {
client.attachTerminal(null);
await client.close().catch(() => {});
info(`detached from ${agentId}`);
resolveDone();
};
process.once("SIGINT", async () => {
await detach();
process.exit(0);
});

try {
await client.call("agent.attach", { agentId });
} catch (err) {
if (hasErrorCode(err, "unimplemented")) {
error("agent.attach is not implemented yet — the daemon-side streaming producer lands in the next unit.");
} else if (hasErrorCode(err, "not_found")) {
error(`agent not found: ${agentId}`);
} else {
error(err instanceof Error ? err.message : String(err));
}
await detach();
process.exitCode = 1;
return;
}

await done;
}
72 changes: 72 additions & 0 deletions packages/cli/src/commands/ls.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
import { withDaemonClient } from "../daemon-client.js";
import { error, subtle } from "../display.js";

export interface LsOptions {
all?: boolean;
json?: boolean;
}

const HIDDEN_STATUSES = new Set(["completed", "stopped"]);

export async function handleLs(opts: LsOptions = {}): Promise<void> {
await withDaemonClient(async (client) => {
try {
const { agents } = await client.agents.list();
const visible = opts.all ? agents : agents.filter((a) => !HIDDEN_STATUSES.has(a.status));

if (opts.json) {
process.stdout.write(JSON.stringify(visible, null, 2) + "\n");
return;
}

if (visible.length === 0) {
process.stdout.write(subtle("(no agents)") + "\n");
return;
}

printTable(visible);
} catch (err) {
error(err instanceof Error ? err.message : String(err));
process.exitCode = 1;
}
});
}

interface AgentRow {
id: string;
profile: string;
status: string;
updatedAt: number;
}

function printTable(agents: AgentRow[]): void {
const rows = agents.map((a) => ({
id: a.id,
profile: a.profile,
status: a.status,
updated: formatTime(a.updatedAt),
}));

const cols = [
{ header: "ID", key: "id" as const, min: 2 },
{ header: "PROFILE", key: "profile" as const, min: 7 },
{ header: "STATUS", key: "status" as const, min: 6 },
{ header: "UPDATED", key: "updated" as const, min: 7 },
];
const widths = cols.map((c) => Math.max(c.min, ...rows.map((r) => r[c.key].length)));

const line = (cells: string[]) =>
cells.map((v, i) => v.padEnd(widths[i]!)).join(" ") + "\n";

process.stdout.write(line(cols.map((c) => c.header)));
for (const r of rows) {
process.stdout.write(line(cols.map((c) => r[c.key])));
}
}

function formatTime(ts: number): string {
if (!Number.isFinite(ts) || ts <= 0) return "-";
const d = new Date(ts);
const pad = (n: number, w = 2) => n.toString().padStart(w, "0");
return `${pad(d.getFullYear(), 4)}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`;
}
29 changes: 29 additions & 0 deletions packages/cli/src/commands/send.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import { hasErrorCode, withDaemonClient } from "../daemon-client.js";
import { error, success } from "../display.js";

export async function handleSend(agentId: string, text: string): Promise<void> {
if (!agentId) {
error("missing agent id");
process.exitCode = 1;
return;
}
if (!text) {
error("missing message text");
process.exitCode = 1;
return;
}

await withDaemonClient(async (client) => {
try {
await client.agents.send({ agentId, text });
success(`sent to ${agentId}`);
} catch (err) {
if (hasErrorCode(err, "unimplemented")) {
error("agent.send is not implemented yet — the daemon-side lifecycle lands in the next unit.");
} else {
error(err instanceof Error ? err.message : String(err));
}
process.exitCode = 1;
}
});
}
25 changes: 25 additions & 0 deletions packages/cli/src/commands/stop-agent.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import { hasErrorCode, withDaemonClient } from "../daemon-client.js";
import { error, success } from "../display.js";

// Named `stop-agent.ts` to avoid clashing with the `daemon stop` handler.
export async function handleStopAgent(agentId: string): Promise<void> {
if (!agentId) {
error("missing agent id");
process.exitCode = 1;
return;
}

await withDaemonClient(async (client) => {
try {
await client.agents.stop({ agentId });
success(`stopped ${agentId}`);
} catch (err) {
if (hasErrorCode(err, "unimplemented")) {
error("agent.stop is not implemented yet — the daemon-side lifecycle lands in the next unit.");
} else {
error(err instanceof Error ? err.message : String(err));
}
process.exitCode = 1;
}
});
}
Loading
Loading