Files
ClaudeMailbox/node/src/cli.ts
Mika Kuns ac626f678b
All checks were successful
CI (Node) / build-test (push) Successful in 9s
fix(cli,plugin): CLAUDE_MAILBOX_URL env override + port-conflict-aware doctor
The plugin's UserPromptSubmit and SessionStart hooks call `claude-mailbox`
with no --url flag, so they previously always hit the hardcoded
http://127.0.0.1:47822/mcp default. If port 47822 was held by another
local service (e.g. ClaudeDo), the daemon couldn't bind there and every
hook was talking to the wrong process.

CLI default for --url now resolves to $CLAUDE_MAILBOX_URL when set,
falling back to http://127.0.0.1:47822. Doctor gained a Step 2 that
probes /health on 47822, identifies foreign occupants, picks a free
port, writes both ~/.claude-mailbox/mailbox.json and the
CLAUDE_MAILBOX_URL entry in .claude/settings.json env so the hooks
follow along automatically.

Also adds a fallback hint when Windows schtasks /Create fails with
Access is denied (Group Policy restricts non-admin task creation): run
install-autostart from an elevated shell, or accept an ephemeral serve
for the current session.
2026-05-19 13:30:51 +02:00

381 lines
14 KiB
JavaScript

#!/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<string, string>; body?: unknown } = {},
): Promise<unknown> {
const headers: Record<string, string> = { 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>", "Port to listen on", (v) => parseInt(v, 10))
.option("--bind <address>", "Bind address")
.option("--db-path <path>", "SQLite database path")
.option("--config <path>", "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 <name>", "Recipient mailbox")
.requiredOption("--from <name>", "Sender mailbox (X-Mailbox header)")
.requiredOption("--body <text>", "Message body")
.option("--url <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 <name>", "Mailbox name")
.option("--url <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 <name>",
"Explicit mailbox name. Overrides hook stdin and $CLAUDE_MAILBOX_NAME.",
)
.option("--url <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 <url>", "Daemon base URL", DEFAULT_URL)
.option(
"--peer-window-minutes <minutes>",
"Only show peers seen within this many minutes (default 60)",
(v) => parseInt(v, 10),
60,
)
.option(
"--max-peers <n>",
"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="<their-name>", 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 <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 <name>", "Mailbox name to check")
.option("--user", "Patch ~/.claude/settings.json (default)")
.option("--project", "Patch <cwd>/.claude/settings.json")
.option("--url <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 <cwd>/.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>", "Port to listen on", (v) => parseInt(v, 10))
.option("--bind <address>", "Bind address")
.option("--db-path <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);
});