#!/usr/bin/env node import { Command } from "commander"; import { existsSync, readFileSync } from "node:fs"; import { dirname, join } from "node:path"; import { fileURLToPath } from "node:url"; import { resolveConfig, baseUrl, DEFAULT_PORT } from "./config.js"; import { startServer } from "./server.js"; import { autostartManager } from "./autostart/index.js"; import { applyInstall, applyUninstall, buildHookCommand, deriveSessionName, formatActivePeerList, formatMessagesForHook, parseHookStdin, readSettings, readStdinIfPiped, settingsPathFor, writeSettings, type HookMessage, type HookScope, type PeerEntry, } from "./hook.js"; function readVersion(): string { try { const here = dirname(fileURLToPath(import.meta.url)); const pkg = JSON.parse(readFileSync(join(here, "..", "package.json"), "utf8")) as { version?: string; }; return pkg.version ?? "unknown"; } catch { return "unknown"; } } const HARDCODED_DEFAULT_URL = `http://127.0.0.1:${DEFAULT_PORT}`; const ENV_URL = (process.env["CLAUDE_MAILBOX_URL"] ?? "").trim(); const DEFAULT_URL = ENV_URL || HARDCODED_DEFAULT_URL; async function callJson( method: string, url: string, init: { headers?: Record; body?: unknown } = {}, ): Promise { const headers: Record = { Accept: "application/json", ...(init.headers ?? {}) }; let body: string | undefined; if (init.body !== undefined) { headers["Content-Type"] = "application/json"; body = JSON.stringify(init.body); } const res = await fetch(url, { method, headers, body }); const text = await res.text(); if (!res.ok) { throw new Error(`${method} ${url} → ${res.status}: ${text}`); } return text.length ? JSON.parse(text) : null; } function reportClientError(err: unknown, url: string): never { const msg = err instanceof Error ? err.message : String(err); console.error(`Could not reach daemon at ${url}: ${msg}`); console.error("Is 'claude-mailbox serve' running?"); process.exit(2); } const program = new Command(); program .name("claude-mailbox") .description("MCP mail server that lets parallel Claude sessions coordinate.") .version(readVersion(), "-V, --version"); program .command("serve") .description("Run the daemon in the foreground.") .option("--port ", "Port to listen on", (v) => parseInt(v, 10)) .option("--bind
", "Bind address") .option("--db-path ", "SQLite database path") .option("--config ", "Path to mailbox.json") .action(async (opts: { port?: number; bind?: string; dbPath?: string; config?: string }) => { const cfg = resolveConfig(opts); try { const { app } = await startServer(cfg); app.log.info(`ClaudeMailbox listening on ${baseUrl(cfg)} (db: ${cfg.dbPath})`); } catch (err) { const msg = err instanceof Error ? err.message : String(err); if (/EADDRINUSE|already in use/i.test(msg)) { console.error( `Port ${cfg.port} is already in use. Another claude-mailbox instance may be running.`, ); process.exit(3); } console.error(msg); process.exit(1); } }); program .command("send") .description("Send a message via REST.") .requiredOption("--to ", "Recipient mailbox") .requiredOption("--from ", "Sender mailbox (X-Mailbox header)") .requiredOption("--body ", "Message body") .option("--url ", "Daemon base URL", DEFAULT_URL) .action(async (opts: { to: string; from: string; body: string; url: string }) => { try { const out = await callJson("POST", `${opts.url}/v1/send`, { headers: { "X-Mailbox": opts.from }, body: { to: opts.to, body: opts.body }, }); console.log(JSON.stringify(out, null, 2)); } catch (err) { reportClientError(err, opts.url); } }); program .command("peek") .description("Non-consuming inbox status.") .requiredOption("--name ", "Mailbox name") .option("--url ", "Daemon base URL", DEFAULT_URL) .action(async (opts: { name: string; url: string }) => { try { const out = await callJson( "GET", `${opts.url}/v1/peek?name=${encodeURIComponent(opts.name)}`, ); console.log(JSON.stringify(out, null, 2)); } catch (err) { reportClientError(err, opts.url); } }); function resolveHookMailboxName(explicit: string | undefined): string | null { if (explicit && explicit.trim()) return explicit.trim(); const stdin = parseHookStdin(readStdinIfPiped()); const sid = stdin?.session_id?.trim(); if (sid) { const base = (process.env["CLAUDE_MAILBOX_NAME"] ?? "").trim() || null; return deriveSessionName(sid, base); } const envName = (process.env["CLAUDE_MAILBOX_NAME"] ?? "").trim(); return envName || null; } program .command("check") .description( "Pull pending messages and mark delivered. In --hook mode the name is auto-derived from the SessionStart/UserPromptSubmit stdin (session_id), optionally flavored by $CLAUDE_MAILBOX_NAME.", ) .option( "--name ", "Explicit mailbox name. Overrides hook stdin and $CLAUDE_MAILBOX_NAME.", ) .option("--url ", "Daemon base URL", DEFAULT_URL) .option( "--hook", "Hook mode: human-readable output, silent when no mailbox configured or daemon unreachable. Emits a one-line setup hint when name resolves but daemon is unreachable.", ) .action(async (opts: { name?: string; url: string; hook?: boolean }) => { const name = opts.hook ? resolveHookMailboxName(opts.name) : (opts.name ?? process.env["CLAUDE_MAILBOX_NAME"] ?? "").trim() || null; if (!name) { if (opts.hook) return; console.error("Missing --name (or set CLAUDE_MAILBOX_NAME)."); process.exit(1); } try { const out = await callJson( "POST", `${opts.url}/v1/check-inbox?name=${encodeURIComponent(name)}`, { headers: { "X-Mailbox": name } }, ); if (opts.hook) { const messages = (Array.isArray(out) ? out : []) as HookMessage[]; const text = formatMessagesForHook(name, messages); if (text) process.stdout.write(text); return; } console.log(JSON.stringify(out, null, 2)); } catch (err) { if (opts.hook) { const msg = err instanceof Error ? err.message : String(err); if (/ECONNREFUSED|fetch failed|ENOTFOUND|connect/i.test(msg)) { process.stdout.write( `[Claude-Mailbox] Daemon not reachable at ${opts.url}. Run \`claude-mailbox install-autostart\` (one-time) or \`claude-mailbox start\`.\n`, ); } return; } reportClientError(err, opts.url); } }); program .command("session-announce") .description( "SessionStart-hook helper: derives the session's mailbox name from stdin session_id, registers it with the daemon, and announces the identity + currently active peers to context.", ) .option("--url ", "Daemon base URL", DEFAULT_URL) .option( "--peer-window-minutes ", "Only show peers seen within this many minutes (default 60)", (v) => parseInt(v, 10), 60, ) .option( "--max-peers ", "Maximum number of peers to list (default 10)", (v) => parseInt(v, 10), 10, ) .action(async (opts: { url: string; peerWindowMinutes: number; maxPeers: number }) => { const stdin = parseHookStdin(readStdinIfPiped()); const sid = stdin?.session_id?.trim(); if (!sid) return; const base = (process.env["CLAUDE_MAILBOX_NAME"] ?? "").trim() || null; const name = deriveSessionName(sid, base); const lines = [ `Claude-Mailbox: your mailbox name this session is \`${name}\`.`, `When using mcp__mailbox__* tools, ALWAYS pass this name explicitly:`, ` - mcp__mailbox__send: from="${name}"`, ` - mcp__mailbox__check_inbox: name="${name}"`, ` - mcp__mailbox__peek_inbox: name="${name}"`, ` - mcp__mailbox__list_mailboxes: name="${name}"`, `Peers reach you with: mcp__mailbox__send(from="", to="${name}", body="...")`, ]; try { const out = await callJson("GET", `${opts.url}/v1/list`, { headers: { "X-Mailbox": name }, }); const all = (Array.isArray(out) ? out : []) as PeerEntry[]; lines.push( "", ...formatActivePeerList(all, name, { windowMinutes: opts.peerWindowMinutes, maxPeers: opts.maxPeers, }), ); } catch (err) { const msg = err instanceof Error ? err.message : String(err); if (/ECONNREFUSED|fetch failed|ENOTFOUND|connect/i.test(msg)) { lines.push( "", `[Claude-Mailbox] Daemon not reachable at ${opts.url}. Run \`claude-mailbox install-autostart\` (one-time) or \`claude-mailbox start\`.`, ); } } lines.push(""); process.stdout.write(lines.join("\n")); }); program .command("list") .description("List known mailboxes.") .option("--url ", "Daemon base URL", DEFAULT_URL) .action(async (opts: { url: string }) => { try { const out = await callJson("GET", `${opts.url}/v1/list`); console.log(JSON.stringify(out, null, 2)); } catch (err) { reportClientError(err, opts.url); } }); program .command("install-hook") .description( "Install a Claude Code UserPromptSubmit hook that checks the mailbox on every prompt. Idempotent.", ) .requiredOption("--name ", "Mailbox name to check") .option("--user", "Patch ~/.claude/settings.json (default)") .option("--project", "Patch /.claude/settings.json") .option("--url ", "Daemon base URL to embed in the hook command") .action(async (opts: { name: string; user?: boolean; project?: boolean; url?: string }) => { if (opts.user && opts.project) { console.error("Pick either --user or --project, not both."); process.exit(1); } const scope: HookScope = opts.project ? "project" : "user"; const path = settingsPathFor(scope); const settings = readSettings(path); const command = buildHookCommand(opts.name, opts.url); const result = applyInstall(settings, command); if (result.changed) { writeSettings(path, settings); console.log(`Hook installed in ${path}`); console.log(`Command: ${command}`); } else { console.log(`Hook already present in ${path}; nothing to do.`); } }); program .command("uninstall-hook") .description("Remove the claude-mailbox UserPromptSubmit hook from Claude Code settings.") .option("--user", "Patch ~/.claude/settings.json (default)") .option("--project", "Patch /.claude/settings.json") .action(async (opts: { user?: boolean; project?: boolean }) => { if (opts.user && opts.project) { console.error("Pick either --user or --project, not both."); process.exit(1); } const scope: HookScope = opts.project ? "project" : "user"; const path = settingsPathFor(scope); if (!existsSync(path)) { console.log(`No settings file at ${path}; nothing to remove.`); return; } const settings = readSettings(path); const result = applyUninstall(settings); if (result.changed) { writeSettings(path, settings); console.log(`Hook removed from ${path}`); } else { console.log(`No claude-mailbox hook found in ${path}; nothing to remove.`); } }); program .command("install-autostart") .description( "Register autostart for the current OS (Scheduled Task / launchd / systemd-user). Use --service on Windows for a Windows Service (admin).", ) .option("--service", "Windows: install as a Windows Service (requires admin) instead of a Scheduled Task") .option("--port ", "Port to listen on", (v) => parseInt(v, 10)) .option("--bind
", "Bind address") .option("--db-path ", "SQLite database path") .action(async (opts: { service?: boolean; port?: number; bind?: string; dbPath?: string }) => { const mgr = await autostartManager(opts.service ? "service" : "default"); await mgr.install({ port: opts.port, bind: opts.bind, dbPath: opts.dbPath }); console.log("Autostart installed."); }); program .command("uninstall-autostart") .description("Remove autostart for the current OS.") .option("--service", "Windows: uninstall the Windows Service variant") .option("--purge", "Also delete database and config") .action(async (opts: { service?: boolean; purge?: boolean }) => { const mgr = await autostartManager(opts.service ? "service" : "default"); await mgr.uninstall(!!opts.purge); console.log("Autostart removed."); }); program .command("start") .description("Start the autostart-managed daemon.") .option("--service", "Windows: target the Windows Service variant") .action(async (opts: { service?: boolean }) => { const mgr = await autostartManager(opts.service ? "service" : "default"); await mgr.start(); }); program .command("stop") .description("Stop the autostart-managed daemon.") .option("--service", "Windows: target the Windows Service variant") .action(async (opts: { service?: boolean }) => { const mgr = await autostartManager(opts.service ? "service" : "default"); await mgr.stop(); }); program .command("status") .description("Print autostart status (Running | Stopped | NotInstalled).") .option("--service", "Windows: target the Windows Service variant") .action(async (opts: { service?: boolean }) => { const mgr = await autostartManager(opts.service ? "service" : "default"); console.log(await mgr.status()); }); program.parseAsync(process.argv).catch((err) => { console.error(err instanceof Error ? err.message : String(err)); process.exit(1); });