diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json new file mode 100644 index 0000000..918fbff --- /dev/null +++ b/.claude-plugin/marketplace.json @@ -0,0 +1,14 @@ +{ + "name": "claude-mailbox", + "owner": { + "name": "Mika Kuns" + }, + "description": "Plugins for the Claude-Mailbox project.", + "plugins": [ + { + "name": "claude-mailbox", + "source": "./plugin", + "description": "Auto-checks the local Claude-Mailbox daemon before every prompt and injects pending messages." + } + ] +} diff --git a/README.md b/README.md index 0486ba1..c4f94b6 100644 --- a/README.md +++ b/README.md @@ -104,6 +104,28 @@ claude-mailbox uninstall-service [--purge] The .NET and Node builds are wire-compatible (same port, same `X-Mailbox` header, same MCP tool names, same SQLite schema), so a `.mcp.json` configured against one works against the other. +## Use from Claude Code (plugin) + +Easiest path for colleagues — install the marketplace once, then the plugin: + +``` +/plugin marketplace add https://git.kuns.dev/releases/ClaudeMailbox +/plugin install claude-mailbox@claude-mailbox +``` + +The plugin only wires the `UserPromptSubmit` hook. **The daemon binary is a separate prerequisite** — install it via the npm package above (or the bootstrap one-liner) and run `claude-mailbox install-autostart` so it starts on boot. + +Then set a per-machine mailbox name: + +```sh +setx CLAUDE_MAILBOX_NAME alice # Windows +export CLAUDE_MAILBOX_NAME=alice # macOS / Linux (in ~/.zshrc or ~/.bashrc) +``` + +Restart Claude Code and unread messages will appear in context before every prompt. If the daemon is not reachable, the hook emits a one-line setup hint instead of staying silent — so a missing daemon is loud, not invisible. + +See [`plugin/README.md`](./plugin/README.md) for the full walkthrough. + ## Use from a Claude session Drop this into your project's `.mcp.json` (one per session, different `X-Mailbox` values): diff --git a/node/src/cli.ts b/node/src/cli.ts index 92c81a8..f2135d6 100644 --- a/node/src/cli.ts +++ b/node/src/cli.ts @@ -127,29 +127,45 @@ program program .command("check") - .description("Pull pending messages and mark delivered.") - .requiredOption("--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 ", "Mailbox name (also sent as X-Mailbox). Falls back to $CLAUDE_MAILBOX_NAME.") .option("--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); } }); diff --git a/node/tests/cli-hook.test.ts b/node/tests/cli-hook.test.ts new file mode 100644 index 0000000..23ce365 --- /dev/null +++ b/node/tests/cli-hook.test.ts @@ -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 = {}): { + 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"); + }); +}); diff --git a/plugin/.claude-plugin/plugin.json b/plugin/.claude-plugin/plugin.json new file mode 100644 index 0000000..c621e95 --- /dev/null +++ b/plugin/.claude-plugin/plugin.json @@ -0,0 +1,11 @@ +{ + "name": "claude-mailbox", + "version": "0.1.0", + "description": "Auto-checks the local Claude-Mailbox daemon before every prompt and injects pending messages into the conversation context.", + "author": { + "name": "Mika Kuns" + }, + "homepage": "https://git.kuns.dev/releases/ClaudeMailbox", + "license": "MIT", + "keywords": ["mailbox", "ipc", "coordination", "mcp"] +} diff --git a/plugin/README.md b/plugin/README.md new file mode 100644 index 0000000..e08fbcd --- /dev/null +++ b/plugin/README.md @@ -0,0 +1,68 @@ +# claude-mailbox plugin + +Lets Claude Code automatically pull unread messages from a local `claude-mailbox` daemon before every prompt and inject them into the conversation context. + +## Three-step setup + +The Claude Code plugin itself is just the glue — you still need the daemon binary on PATH and a mailbox name. + +### 1. Install the daemon (one-time, per machine) + +```sh +npm config set @kuns:registry=https://git.kuns.dev/api/packages/releases/npm/ +npm install -g @kuns/claude-mailbox +claude-mailbox install-autostart # registers per-OS autostart (no admin needed by default) +``` + +Verify with: + +```sh +claude-mailbox status # expect: Running +``` + +### 2. Install the plugin + +In Claude Code: + +``` +/plugin marketplace add https://git.kuns.dev/releases/ClaudeMailbox +/plugin install claude-mailbox@claude-mailbox +``` + +### 3. Choose a mailbox name + +Set the env var Claude Code will inherit (any unique name — your username, machine name, whatever): + +```sh +# Windows (PowerShell, persistent) +setx CLAUDE_MAILBOX_NAME alice + +# macOS / Linux (add to ~/.zshrc or ~/.bashrc) +export CLAUDE_MAILBOX_NAME=alice +``` + +Restart Claude Code so the new env var is picked up. + +## What happens at runtime + +Before every prompt the plugin runs `claude-mailbox check --hook`, which: + +- prints unread mailbox messages in a Claude-friendly format and marks them delivered, +- stays **silent** when the inbox is empty or `CLAUDE_MAILBOX_NAME` is not set, +- emits a one-line setup hint when the daemon is unreachable (so missing step 1 is loud). + +## Sending yourself a message (smoke test) + +```sh +claude-mailbox send --from probe --to alice --body "hello from the CLI" +``` + +Then start a new Claude Code session — the message should appear in context before Claude's first reply. + +## Uninstall + +``` +/plugin uninstall claude-mailbox@claude-mailbox +npm uninstall -g @kuns/claude-mailbox +claude-mailbox uninstall-autostart # if you used it +``` diff --git a/plugin/hooks/hooks.json b/plugin/hooks/hooks.json new file mode 100644 index 0000000..68c2d1d --- /dev/null +++ b/plugin/hooks/hooks.json @@ -0,0 +1,14 @@ +{ + "hooks": { + "UserPromptSubmit": [ + { + "hooks": [ + { + "type": "command", + "command": "claude-mailbox check --hook" + } + ] + } + ] + } +}