All checks were successful
CI (Node) / build-test (push) Successful in 9s
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.
381 lines
14 KiB
JavaScript
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);
|
|
});
|