feat(plugin): ship Claude Code plugin + marketplace manifest

Adds a /plugin marketplace at the repo root and a `claude-mailbox` plugin under
plugin/ that wires the UserPromptSubmit hook without needing the per-user
`install-hook` step. The hook command (`claude-mailbox check --hook`) now reads
the mailbox name from $CLAUDE_MAILBOX_NAME when --name is omitted and emits a
one-line setup hint when the daemon is unreachable, so a missing daemon is loud
instead of invisible.

The plugin only contains the Claude Code glue — the daemon binary is still a
separate prerequisite (`npm i -g @kuns/claude-mailbox` + install-autostart),
and the plugin/README plus main README spell out the three-step setup.
This commit is contained in:
Mika Kuns
2026-05-19 10:49:36 +02:00
parent 66967167bc
commit 5c5843e62d
7 changed files with 216 additions and 8 deletions

View File

@@ -127,29 +127,45 @@ program
program
.command("check")
.description("Pull pending messages and mark delivered.")
.requiredOption("--name <name>", "Mailbox name (also sent as X-Mailbox)")
.description(
"Pull pending messages and mark delivered. In --hook mode the name can come from CLAUDE_MAILBOX_NAME.",
)
.option("--name <name>", "Mailbox name (also sent as X-Mailbox). Falls back to $CLAUDE_MAILBOX_NAME.")
.option("--url <url>", "Daemon base URL", DEFAULT_URL)
.option(
"--hook",
"Hook mode: human-readable output, silent on empty inbox or unreachable daemon (exit 0)",
"Hook mode: human-readable output, silent when no mailbox configured or daemon unreachable (exit 0). Emits a one-line setup hint when name is set but daemon is unreachable.",
)
.action(async (opts: { name: string; url: string; hook?: boolean }) => {
.action(async (opts: { name?: string; url: string; hook?: boolean }) => {
const name = (opts.name ?? process.env["CLAUDE_MAILBOX_NAME"] ?? "").trim();
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(opts.name)}`,
{ headers: { "X-Mailbox": opts.name } },
`${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(opts.name, messages);
const text = formatMessagesForHook(name, messages);
if (text) process.stdout.write(text);
return;
}
console.log(JSON.stringify(out, null, 2));
} catch (err) {
if (opts.hook) return;
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);
}
});

View File

@@ -0,0 +1,63 @@
import { describe, it, expect, beforeAll } from "vitest";
import { spawnSync } from "node:child_process";
import { existsSync } from "node:fs";
import { resolve } from "node:path";
const cliPath = resolve(__dirname, "..", "dist", "cli.js");
function runCli(args: string[], env: Record<string, string | undefined> = {}): {
status: number;
stdout: string;
stderr: string;
} {
const r = spawnSync(process.execPath, [cliPath, ...args], {
encoding: "utf8",
env: { ...process.env, ...env },
});
return {
status: r.status ?? -1,
stdout: typeof r.stdout === "string" ? r.stdout : "",
stderr: typeof r.stderr === "string" ? r.stderr : "",
};
}
describe("`check --hook` CLI behavior", () => {
beforeAll(() => {
if (!existsSync(cliPath)) {
throw new Error(`CLI not built. Run \`npm run build\` first. Missing: ${cliPath}`);
}
});
it("exits 0 silently when no name resolved (no --name, no env)", () => {
const r = runCli(["check", "--hook"], { CLAUDE_MAILBOX_NAME: undefined });
expect(r.status).toBe(0);
expect(r.stdout).toBe("");
expect(r.stderr).toBe("");
});
it("emits daemon-not-reachable hint when name is set but daemon is down", () => {
const r = runCli(
["check", "--hook", "--url", "http://127.0.0.1:1"],
{ CLAUDE_MAILBOX_NAME: "alice" },
);
expect(r.status).toBe(0);
expect(r.stdout).toContain("[Claude-Mailbox] Daemon not reachable");
expect(r.stdout).toContain("http://127.0.0.1:1");
expect(r.stdout).toContain("claude-mailbox install-autostart");
});
it("`--name` arg wins over CLAUDE_MAILBOX_NAME env (visible via hint URL/contents)", () => {
const r = runCli(
["check", "--hook", "--name", "bob", "--url", "http://127.0.0.1:1"],
{ CLAUDE_MAILBOX_NAME: "alice" },
);
expect(r.status).toBe(0);
expect(r.stdout).toContain("[Claude-Mailbox] Daemon not reachable");
});
it("non-hook mode errors out when no name resolved", () => {
const r = runCli(["check"], { CLAUDE_MAILBOX_NAME: undefined });
expect(r.status).not.toBe(0);
expect(r.stderr).toContain("CLAUDE_MAILBOX_NAME");
});
});