From 1c2c1d2f7eaae8fc9aef2e4d0956d1c37fe758d2 Mon Sep 17 00:00:00 2001 From: Mika Kuns Date: Wed, 20 May 2026 16:29:38 +0200 Subject: [PATCH] feat(cli): add watch --block subcommand with documented exit codes Co-Authored-By: Claude Opus 4.7 (1M context) --- node/src/cli.ts | 49 ++++++++++++++ node/tests/cli-watch.test.ts | 122 +++++++++++++++++++++++++++++++++++ 2 files changed, 171 insertions(+) create mode 100644 node/tests/cli-watch.test.ts diff --git a/node/src/cli.ts b/node/src/cli.ts index 13698f8..e86166f 100644 --- a/node/src/cli.ts +++ b/node/src/cli.ts @@ -289,6 +289,55 @@ program } }); +program + .command("watch") + .description( + "Block until one message arrives for --name, print it, and exit. Designed to be run as a Claude Code background bash task so its output surfaces via BashOutput.", + ) + .requiredOption("--name ", "Mailbox to watch") + .option("--block", "Long-poll for a message (default behavior; flag accepted for clarity)") + .option( + "--timeout ", + "Long-poll timeout in seconds (1..300, default 25)", + (v) => parseInt(v, 10), + 25, + ) + .option("--url ", "Daemon base URL", DEFAULT_URL) + .action(async (opts: { name: string; block?: boolean; timeout: number; url: string }) => { + const url = `${opts.url}/v1/watch?name=${encodeURIComponent(opts.name)}&timeout=${opts.timeout}`; + let res: Response; + try { + res = await fetch(url, { headers: { "X-Mailbox": opts.name, Accept: "application/json" } }); + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + console.error(`Could not reach daemon at ${opts.url}: ${msg}`); + process.exit(2); + } + + if (res.status === 204) { + process.exit(3); + } + + if (res.status === 200) { + const body = (await res.json()) as { from: string; body: string; sentAt: string }; + process.stdout.write(`[Claude-Mailbox] Mail from ${body.from}:\n${body.body}\n`); + process.exit(0); + } + + if (res.status === 409) { + const body = (await res.json().catch(() => ({}))) as { to?: string }; + const newName = body.to ?? ""; + process.stdout.write( + `[Claude-Mailbox] Mailbox renamed to '${newName}'. Restart watcher with --name ${newName}.\n`, + ); + process.exit(0); + } + + const text = await res.text().catch(() => ""); + console.error(`watch failed: HTTP ${res.status}${text ? ` — ${text}` : ""}`); + process.exit(1); + }); + program .command("mcp-stdio") .description( diff --git a/node/tests/cli-watch.test.ts b/node/tests/cli-watch.test.ts new file mode 100644 index 0000000..b3e5a32 --- /dev/null +++ b/node/tests/cli-watch.test.ts @@ -0,0 +1,122 @@ +import { describe, it, expect, beforeEach, afterEach } from "vitest"; +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join, dirname } from "node:path"; +import { fileURLToPath } from "node:url"; +import { spawn } from "node:child_process"; +import { MailboxStore } from "../src/db.js"; +import { buildServer } from "../src/server.js"; +import type { FastifyInstance } from "fastify"; + +const here = dirname(fileURLToPath(import.meta.url)); +const CLI = join(here, "..", "dist", "cli.js"); + +let dir: string; +let store: MailboxStore; +let app: FastifyInstance; +let baseUrl: string; + +beforeEach(async () => { + dir = mkdtempSync(join(tmpdir(), "claude-mailbox-cli-watch-")); + store = new MailboxStore(join(dir, "test.db")); + app = await buildServer( + { + port: 0, + bind: "127.0.0.1", + dbPath: join(dir, "test.db"), + hideAfterMinutes: 0, + deleteAfterMinutes: 0, + sweepIntervalMinutes: 0, + }, + store, + ); + await app.listen({ host: "127.0.0.1", port: 0 }); + const addr = app.server.address(); + if (!addr || typeof addr === "string") throw new Error("no address"); + baseUrl = `http://127.0.0.1:${addr.port}`; +}); + +afterEach(async () => { + store.rejectAllWaiters(); + await app.close(); + store.close(); + rmSync(dir, { recursive: true, force: true }); +}); + +// Async helper: spawn CLI and collect output without blocking the event loop. +// spawnSync cannot be used here because the test process hosts the Fastify server, +// and spawnSync blocks the event loop, preventing the server from handling connections. +function runCli(args: string[], timeoutMs: number = 8000): Promise<{ + status: number | null; + stdout: string; + stderr: string; +}> { + return new Promise((resolve) => { + const child = spawn(process.execPath, [CLI, ...args], { + stdio: ["ignore", "pipe", "pipe"], + }); + let stdout = ""; + let stderr = ""; + child.stdout.on("data", (d) => { stdout += d.toString(); }); + child.stderr.on("data", (d) => { stderr += d.toString(); }); + const timer = setTimeout(() => { + child.kill(); + resolve({ status: null, stdout, stderr }); + }, timeoutMs); + child.on("exit", (code) => { + clearTimeout(timer); + resolve({ status: code, stdout, stderr }); + }); + }); +} + +describe("claude-mailbox watch CLI", () => { + it("exits 0 with a formatted message when one is pending", async () => { + store.upsertMailbox("alice"); + store.upsertMailbox("bob"); + store.send("alice", "bob", "hello watcher"); + + const r = await runCli(["watch", "--block", "--name", "bob", "--timeout", "2", "--url", baseUrl]); + expect(r.status).toBe(0); + expect(r.stdout).toContain("[Claude-Mailbox] Mail from alice:"); + expect(r.stdout).toContain("hello watcher"); + }); + + it("exits 3 silently on timeout", async () => { + store.upsertMailbox("bob"); + const r = await runCli(["watch", "--block", "--name", "bob", "--timeout", "1", "--url", baseUrl]); + expect(r.status).toBe(3); + expect(r.stdout).toBe(""); + }); + + it("exits 0 with rename notice when the mailbox is renamed mid-wait", async () => { + store.upsertMailbox("oldname"); + const child = spawn( + process.execPath, + [CLI, "watch", "--block", "--name", "oldname", "--timeout", "3", "--url", baseUrl], + { stdio: ["ignore", "pipe", "pipe"] }, + ); + let stdout = ""; + child.stdout.on("data", (d) => { stdout += d.toString(); }); + + setTimeout(() => store.rename("oldname", "newname"), 300); + + const code: number = await new Promise((r) => child.on("exit", (c) => r(c ?? 1))); + expect(code).toBe(0); + expect(stdout).toContain("renamed to 'newname'"); + }); + + it("exits 2 when the daemon is unreachable", async () => { + const r = await runCli([ + "watch", "--block", "--name", "bob", "--timeout", "1", + "--url", "http://127.0.0.1:1", // port 1 = guaranteed connection refused + ]); + expect(r.status).toBe(2); + }); + + it("exits 1 when --name is missing", async () => { + const r = await runCli(["watch", "--block", "--timeout", "1", "--url", baseUrl]); + expect(r.status).not.toBe(0); + expect(r.stderr).toMatch(/required.*name|name.*required/i); + }); +});