feat(cli): add watch --block subcommand with documented exit codes
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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 <name>", "Mailbox to watch")
|
||||||
|
.option("--block", "Long-poll for a message (default behavior; flag accepted for clarity)")
|
||||||
|
.option(
|
||||||
|
"--timeout <seconds>",
|
||||||
|
"Long-poll timeout in seconds (1..300, default 25)",
|
||||||
|
(v) => parseInt(v, 10),
|
||||||
|
25,
|
||||||
|
)
|
||||||
|
.option("--url <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 ?? "<unknown>";
|
||||||
|
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
|
program
|
||||||
.command("mcp-stdio")
|
.command("mcp-stdio")
|
||||||
.description(
|
.description(
|
||||||
|
|||||||
122
node/tests/cli-watch.test.ts
Normal file
122
node/tests/cli-watch.test.ts
Normal file
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user