From d99d9a259f89ac94a53e8aef0174c985592db4bb Mon Sep 17 00:00:00 2001 From: Bailey Dixon Date: Sun, 19 Apr 2026 14:43:14 -0400 Subject: [PATCH] feat(cli): docker-style agent verbs + daemon-connect helper MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds `arc ls`, `arc attach`, `arc send`, `arc stop` top-level verbs that talk to the v3 daemon via the `@axiom-labs/arc-client` SDK, plus a shared `connectDaemon()` helper that auto-starts the daemon and reads the root token from `~/.arc/auth.json`. The write-path verbs (attach/send/stop) print a friendly hint when the daemon returns `unimplemented`, since Unit 2 owns the corresponding lifecycle wiring. - `packages/cli/src/daemon-client.ts` — `connectDaemon`, `withDaemonClient`, `hasErrorCode` helpers; auto-start forwards `process.execArgv` so the TS loader survives into the detached child during dev. - `packages/cli/src/commands/{ls,attach,send,stop-agent}.ts` — new verbs. - `packages/cli/src/cli.ts` — register the four verbs; drop `ls` as an alias for `list` so the new command reaches `handleLs`. - `tests/cli-daemon-verbs.test.ts` — in-process daemon + handlers, asserts empty `ls`, `ls --json`, and friendly errors on bogus ids. --- packages/cli/src/cli.ts | 37 +++++- packages/cli/src/commands/attach.ts | 60 +++++++++ packages/cli/src/commands/ls.ts | 72 ++++++++++ packages/cli/src/commands/send.ts | 29 ++++ packages/cli/src/commands/stop-agent.ts | 25 ++++ packages/cli/src/daemon-client.ts | 169 ++++++++++++++++++++++++ tests/cli-daemon-verbs.test.ts | 127 ++++++++++++++++++ 7 files changed, 518 insertions(+), 1 deletion(-) create mode 100644 packages/cli/src/commands/attach.ts create mode 100644 packages/cli/src/commands/ls.ts create mode 100644 packages/cli/src/commands/send.ts create mode 100644 packages/cli/src/commands/stop-agent.ts create mode 100644 packages/cli/src/daemon-client.ts create mode 100644 tests/cli-daemon-verbs.test.ts diff --git a/packages/cli/src/cli.ts b/packages/cli/src/cli.ts index b2f19df..5357346 100644 --- a/packages/cli/src/cli.ts +++ b/packages/cli/src/cli.ts @@ -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"); @@ -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 ") + .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 ") + .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 ") + .description("Stop a running agent") + .action(async (agentId: string) => { + const mod = await import("./commands/stop-agent.js"); + await mod.handleStopAgent(agentId); + }); + // === Session Commands === program diff --git a/packages/cli/src/commands/attach.ts b/packages/cli/src/commands/attach.ts new file mode 100644 index 0000000..3299350 --- /dev/null +++ b/packages/cli/src/commands/attach.ts @@ -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 { + 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((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; +} diff --git a/packages/cli/src/commands/ls.ts b/packages/cli/src/commands/ls.ts new file mode 100644 index 0000000..a711292 --- /dev/null +++ b/packages/cli/src/commands/ls.ts @@ -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 { + 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())}`; +} diff --git a/packages/cli/src/commands/send.ts b/packages/cli/src/commands/send.ts new file mode 100644 index 0000000..037dc3d --- /dev/null +++ b/packages/cli/src/commands/send.ts @@ -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 { + 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; + } + }); +} diff --git a/packages/cli/src/commands/stop-agent.ts b/packages/cli/src/commands/stop-agent.ts new file mode 100644 index 0000000..5ea6ec9 --- /dev/null +++ b/packages/cli/src/commands/stop-agent.ts @@ -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 { + 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; + } + }); +} diff --git a/packages/cli/src/daemon-client.ts b/packages/cli/src/daemon-client.ts new file mode 100644 index 0000000..0889ebf --- /dev/null +++ b/packages/cli/src/daemon-client.ts @@ -0,0 +1,169 @@ +import { spawn } from "node:child_process"; +import fs from "node:fs"; +import http from "node:http"; +import path from "node:path"; +import { ArcClient } from "@axiom-labs/arc-client"; +import { + DEFAULT_PORT, + loadDaemonConfig, + type AuthFile, +} from "@axiom-labs/arc-daemon"; + +export interface ConnectDaemonOptions { + host?: string; + port?: number; + token?: string; + /** Disable auto-start if daemon isn't running (useful for tests). */ + noAutoStart?: boolean; + /** How long to wait for auto-start to come up (ms). */ + startTimeoutMs?: number; + /** Disable auto-reconnect (default true for CLI one-shots). */ + noReconnect?: boolean; +} + +/** + * Connect to the ARC daemon, auto-starting it if not already running. + * Reads the shared root token from `~/.arc/auth.json` unless `token` is + * passed explicitly. The returned client is already authenticated. + */ +export async function connectDaemon(opts: ConnectDaemonOptions = {}): Promise { + const cfg = loadDaemonConfig({ + ...(opts.port !== undefined ? { port: opts.port } : {}), + ...(opts.host !== undefined ? { host: opts.host } : {}), + }); + const { host, port } = cfg; + const startTimeoutMs = opts.startTimeoutMs ?? 5000; + + if (!(await probeHealth(host, port, 500))) { + if (opts.noAutoStart) { + throw new Error( + `daemon not running on ${host}:${port}. Start it with \`arc daemon start\`.`, + ); + } + autoStartDaemon(port); + if (!(await waitForHealth(host, port, startTimeoutMs))) { + throw new Error( + `could not reach daemon on ${host}:${port} after auto-start. Try \`arc daemon start --foreground\` to inspect startup.`, + ); + } + } + + const token = opts.token ?? readAuthToken(cfg.authPath); + if (!token) { + throw new Error( + `no auth token available at ${cfg.authPath}. Start the daemon once to generate one.`, + ); + } + + const client = new ArcClient({ + url: `ws://${host}:${port}`, + token, + noReconnect: opts.noReconnect ?? true, + }); + try { + await client.connect(); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + throw new Error(`failed to authenticate with daemon: ${message}`); + } + return client; +} + +function readAuthToken(authPath: string): string | null { + try { + const raw = fs.readFileSync(authPath, "utf8"); + const parsed = JSON.parse(raw) as Partial; + if (typeof parsed?.rootToken === "string" && parsed.rootToken.length > 0) { + return parsed.rootToken; + } + } catch { + // fall through — token missing or unreadable + } + return null; +} + +function probeHealth(host: string, port: number, timeoutMs: number): Promise { + return new Promise((resolve) => { + const req = http.request( + { host, port, path: "/health", method: "GET", timeout: timeoutMs }, + (res) => { + res.resume(); + resolve(res.statusCode === 200); + }, + ); + req.on("error", () => resolve(false)); + req.on("timeout", () => { + req.destroy(); + resolve(false); + }); + req.end(); + }); +} + +async function waitForHealth(host: string, port: number, timeoutMs: number): Promise { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + if (await probeHealth(host, port, 400)) return true; + await new Promise((r) => setTimeout(r, 150)); + } + return false; +} + +/** + * When running under `tsx` (dev), forward `process.execArgv` so the child + * keeps the TS loader hooks. In production (`dist/index.js`), execArgv is + * empty and plain node runs the bundled JS. + */ +function autoStartDaemon(port: number): void { + const entry = process.argv[1]; + if (!entry) return; + const nodeArgs = [...process.execArgv, entry, "daemon", "start", "--foreground"]; + if (port !== DEFAULT_PORT) nodeArgs.push("--port", String(port)); + + try { + const child = spawn(process.execPath, nodeArgs, { + detached: true, + stdio: "ignore", + env: process.env, + cwd: path.resolve("."), + windowsHide: true, + }); + child.unref(); + } catch { + // Surface via waitForHealth timeout rather than crashing here. + } +} + +/** True if `err` carries the given RPC error code from the daemon. */ +export function hasErrorCode(err: unknown, code: string): boolean { + return ( + !!err && + typeof err === "object" && + typeof (err as { code?: unknown }).code === "string" && + (err as { code: string }).code === code + ); +} + +/** + * Shared wrapper: connect to the daemon, run `fn(client)`, and always + * close the client even on error. Errors from `connectDaemon` are printed + * and converted into exit-code 1. + */ +export async function withDaemonClient( + fn: (client: ArcClient) => Promise, +): Promise { + let client: ArcClient; + try { + client = await connectDaemon(); + } catch (err) { + const { error } = await import("./display.js"); + error(err instanceof Error ? err.message : String(err)); + process.exitCode = 1; + return; + } + try { + await fn(client); + } finally { + await client.close().catch(() => {}); + } +} diff --git a/tests/cli-daemon-verbs.test.ts b/tests/cli-daemon-verbs.test.ts new file mode 100644 index 0000000..e744e88 --- /dev/null +++ b/tests/cli-daemon-verbs.test.ts @@ -0,0 +1,127 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { startDaemon, type DaemonHandle } from "@axiom-labs/arc-daemon"; +import { handleLs } from "../packages/cli/src/commands/ls.js"; +import { handleAttach } from "../packages/cli/src/commands/attach.js"; +import { handleSend } from "../packages/cli/src/commands/send.js"; +import { handleStopAgent } from "../packages/cli/src/commands/stop-agent.js"; + +interface TestCtx { + tmp: string; + port: number; + handle: DaemonHandle & { stop: () => Promise }; + prevArcDir: string | undefined; + prevArcPort: string | undefined; +} + +let ctx: TestCtx | null = null; + +function captureStdout(): { restore: () => string } { + const chunks: string[] = []; + const original = process.stdout.write.bind(process.stdout); + (process.stdout.write as unknown) = (chunk: unknown): boolean => { + chunks.push(typeof chunk === "string" ? chunk : chunk instanceof Buffer ? chunk.toString("utf8") : String(chunk)); + return true; + }; + return { + restore: () => { + process.stdout.write = original; + return chunks.join(""); + }, + }; +} + +function captureStderr(): { restore: () => string } { + const chunks: string[] = []; + const original = process.stderr.write.bind(process.stderr); + (process.stderr.write as unknown) = (chunk: unknown): boolean => { + chunks.push(typeof chunk === "string" ? chunk : chunk instanceof Buffer ? chunk.toString("utf8") : String(chunk)); + return true; + }; + return { + restore: () => { + process.stderr.write = original; + return chunks.join(""); + }, + }; +} + +describe("CLI daemon verbs", () => { + beforeEach(async () => { + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "arc-cli-verbs-")); + const port = 17900 + Math.floor(Math.random() * 90); + const prevArcDir = process.env["ARC_DIR"]; + const prevArcPort = process.env["ARC_PORT"]; + process.env["ARC_DIR"] = tmp; + process.env["ARC_PORT"] = String(port); + const handle = await startDaemon({ port, version: "1.0.0-alpha.0-test" }); + ctx = { tmp, port, handle, prevArcDir, prevArcPort }; + // Reset any lingering exit code from a previous test. + process.exitCode = 0; + }); + + afterEach(async () => { + if (!ctx) return; + await ctx.handle.stop(); + // Restore env + if (ctx.prevArcDir === undefined) delete process.env["ARC_DIR"]; + else process.env["ARC_DIR"] = ctx.prevArcDir; + if (ctx.prevArcPort === undefined) delete process.env["ARC_PORT"]; + else process.env["ARC_PORT"] = ctx.prevArcPort; + fs.rmSync(ctx.tmp, { recursive: true, force: true }); + ctx = null; + process.exitCode = 0; + }); + + it("arc ls prints a header and no rows when no agents exist", async () => { + const cap = captureStdout(); + await handleLs({}); + const out = cap.restore(); + // With zero agents, should print the friendly "(no agents)" hint. + expect(out).toMatch(/no agents/i); + expect(process.exitCode ?? 0).toBe(0); + }); + + it("arc ls --json emits an empty array when no agents exist", async () => { + const cap = captureStdout(); + await handleLs({ json: true }); + const out = cap.restore(); + expect(JSON.parse(out)).toEqual([]); + expect(process.exitCode ?? 0).toBe(0); + }); + + it("arc attach prints a friendly error and exits non-zero", async () => { + const capOut = captureStdout(); + const capErr = captureStderr(); + await handleAttach("does-not-exist-123"); + capOut.restore(); + const err = capErr.restore(); + // Either "not implemented" (agent.attach unimplemented — Unit 2 pending) + // or "not found" if Unit 2 has landed. Either way we must not crash and + // must print a helpful message + exit non-zero. + expect(err.length).toBeGreaterThan(0); + expect(process.exitCode).toBe(1); + }); + + it("arc send prints a friendly error and exits non-zero", async () => { + const capOut = captureStdout(); + const capErr = captureStderr(); + await handleSend("does-not-exist-123", "hello"); + capOut.restore(); + const err = capErr.restore(); + expect(err.length).toBeGreaterThan(0); + expect(process.exitCode).toBe(1); + }); + + it("arc stop prints a friendly error and exits non-zero", async () => { + const capOut = captureStdout(); + const capErr = captureStderr(); + await handleStopAgent("does-not-exist-123"); + capOut.restore(); + const err = capErr.restore(); + expect(err.length).toBeGreaterThan(0); + expect(process.exitCode).toBe(1); + }); +});