From 66967167bc540b336e7a586b0b1b4e500efbec2d Mon Sep 17 00:00:00 2001 From: Mika Kuns Date: Tue, 19 May 2026 10:09:30 +0200 Subject: [PATCH] feat(node): add Claude Code UserPromptSubmit hook for auto inbox-check Adds `install-hook` / `uninstall-hook` subcommands that idempotently patch ~/.claude/settings.json (or .claude/settings.json with --project), plus a `--hook` flag on `check` that emits human-readable output and stays silent on empty inbox or unreachable daemon. --- node/README.md | 34 +++++- node/src/cli.ts | 80 +++++++++++++- node/src/hook.ts | 129 +++++++++++++++++++++++ node/tests/hook.test.ts | 225 ++++++++++++++++++++++++++++++++++++++++ 4 files changed, 465 insertions(+), 3 deletions(-) create mode 100644 node/src/hook.ts create mode 100644 node/tests/hook.test.ts diff --git a/node/README.md b/node/README.md index 0c80450..876f542 100644 --- a/node/README.md +++ b/node/README.md @@ -17,4 +17,36 @@ Then: claude-mailbox install-autostart # registers per-OS autostart, no admin needed by default ``` -See the repository [README](../README.md) for the full architecture, MCP tool reference, and `.mcp.json` snippet. +See the [repository README](https://git.kuns.dev/releases/ClaudeMailbox/src/branch/main/README.md) for the full architecture, MCP tool reference, and `.mcp.json` snippet. + +## Claude Code hook (auto-check inbox) + +Register a `UserPromptSubmit` hook so Claude pulls pending mailbox messages before every prompt: + +```sh +claude-mailbox install-hook --name alice # patches ~/.claude/settings.json +claude-mailbox install-hook --name alice --project # patches /.claude/settings.json +claude-mailbox uninstall-hook # remove again +``` + +The hook is idempotent (running `install-hook` twice does nothing the second time) and only touches the `UserPromptSubmit` block — other hooks and settings are preserved. + +Under the hood the hook runs `claude-mailbox check --name --hook`, which: + +- prints unread messages in a Claude-friendly format, +- silently exits 0 if the inbox is empty or the daemon is unreachable (no context noise), +- marks the messages delivered so they aren't injected again next prompt. + +Cost: one local HTTP round-trip plus Node coldstart per prompt (~100ms on Windows). + +## Troubleshooting + +`npm install` returns `401 Unauthorized` +: The Gitea registry usually serves the `releases` scope publicly, but if your instance requires auth you'll need a read token: + + ```sh + npm config set //git.kuns.dev/api/packages/releases/npm/:_authToken= + ``` + +`gyp ERR! find VS` on Windows during install +: `better-sqlite3` ships prebuilt binaries for current Node LTS versions. If yours isn't covered, npm falls back to building from source and needs the Visual Studio Build Tools. Either install them or pin to a Node version with a matching prebuild. diff --git a/node/src/cli.ts b/node/src/cli.ts index b505169..92c81a8 100644 --- a/node/src/cli.ts +++ b/node/src/cli.ts @@ -1,11 +1,22 @@ #!/usr/bin/env node import { Command } from "commander"; -import { readFileSync } from "node:fs"; +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, + formatMessagesForHook, + readSettings, + settingsPathFor, + writeSettings, + type HookMessage, + type HookScope, +} from "./hook.js"; function readVersion(): string { try { @@ -119,15 +130,26 @@ program .description("Pull pending messages and mark delivered.") .requiredOption("--name ", "Mailbox name (also sent as X-Mailbox)") .option("--url ", "Daemon base URL", DEFAULT_URL) - .action(async (opts: { name: string; url: string }) => { + .option( + "--hook", + "Hook mode: human-readable output, silent on empty inbox or unreachable daemon (exit 0)", + ) + .action(async (opts: { name: string; url: string; hook?: boolean }) => { try { const out = await callJson( "POST", `${opts.url}/v1/check-inbox?name=${encodeURIComponent(opts.name)}`, { headers: { "X-Mailbox": opts.name } }, ); + if (opts.hook) { + const messages = (Array.isArray(out) ? out : []) as HookMessage[]; + const text = formatMessagesForHook(opts.name, messages); + if (text) process.stdout.write(text); + return; + } console.log(JSON.stringify(out, null, 2)); } catch (err) { + if (opts.hook) return; reportClientError(err, opts.url); } }); @@ -145,6 +167,60 @@ program } }); +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( diff --git a/node/src/hook.ts b/node/src/hook.ts new file mode 100644 index 0000000..7d3cc91 --- /dev/null +++ b/node/src/hook.ts @@ -0,0 +1,129 @@ +import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; +import { homedir } from "node:os"; +import { dirname, join } from "node:path"; + +export interface HookMessage { + id: number; + from: string; + body: string; + sentAt: string; +} + +export function formatMessagesForHook(name: string, messages: HookMessage[]): string { + if (messages.length === 0) return ""; + const header = + messages.length === 1 + ? `You have 1 new mailbox message for "${name}":` + : `You have ${messages.length} new mailbox messages for "${name}":`; + const lines: string[] = [header, ""]; + for (const m of messages) { + lines.push(`[#${m.id}] from ${m.from} (${m.sentAt}):`); + for (const bodyLine of m.body.split(/\r?\n/)) { + lines.push(` ${bodyLine}`); + } + lines.push(""); + } + return lines.join("\n").trimEnd() + "\n"; +} + +export type HookScope = "user" | "project"; + +export function settingsPathFor(scope: HookScope, cwd: string = process.cwd()): string { + if (scope === "user") return join(homedir(), ".claude", "settings.json"); + return join(cwd, ".claude", "settings.json"); +} + +interface ClaudeHookCommand { + type: "command"; + command: string; + timeout?: number; +} + +interface ClaudeHookGroup { + matcher?: string; + hooks: ClaudeHookCommand[]; +} + +interface ClaudeSettings { + hooks?: Record; + [key: string]: unknown; +} + +const HOOK_EVENT = "UserPromptSubmit"; + +export function buildHookCommand(name: string, url?: string): string { + const parts = ["claude-mailbox", "check", "--name", quoteIfNeeded(name), "--hook"]; + if (url) parts.push("--url", quoteIfNeeded(url)); + return parts.join(" "); +} + +function quoteIfNeeded(value: string): string { + if (/^[A-Za-z0-9._:/@\-]+$/.test(value)) return value; + return `"${value.replace(/(["\\])/g, "\\$1")}"`; +} + +function isOurHookCommand(command: string): boolean { + const c = command.trim(); + return /(^|\W)claude-mailbox\s+check\s/.test(c) && /(^|\s)--hook(\s|$)/.test(c); +} + +export function readSettings(path: string): ClaudeSettings { + if (!existsSync(path)) return {}; + const raw = readFileSync(path, "utf8"); + if (!raw.trim()) return {}; + return JSON.parse(raw) as ClaudeSettings; +} + +export function writeSettings(path: string, settings: ClaudeSettings): void { + mkdirSync(dirname(path), { recursive: true }); + writeFileSync(path, JSON.stringify(settings, null, 2) + "\n", "utf8"); +} + +export interface PatchResult { + changed: boolean; + reason: "added" | "already-present" | "removed" | "not-present"; +} + +export function applyInstall(settings: ClaudeSettings, command: string): PatchResult { + settings.hooks ??= {}; + settings.hooks[HOOK_EVENT] ??= []; + const groups = settings.hooks[HOOK_EVENT]; + + for (const group of groups) { + for (const hook of group.hooks) { + if (hook.command.trim() === command.trim()) { + return { changed: false, reason: "already-present" }; + } + } + } + + let target = groups.find((g) => (g.matcher ?? "") === ""); + if (!target) { + target = { matcher: "", hooks: [] }; + groups.push(target); + } + target.hooks.push({ type: "command", command }); + return { changed: true, reason: "added" }; +} + +export function applyUninstall(settings: ClaudeSettings): PatchResult { + const groups = settings.hooks?.[HOOK_EVENT]; + if (!groups || groups.length === 0) return { changed: false, reason: "not-present" }; + + let removed = false; + for (const group of groups) { + const before = group.hooks.length; + group.hooks = group.hooks.filter((h) => !isOurHookCommand(h.command)); + if (group.hooks.length !== before) removed = true; + } + + settings.hooks![HOOK_EVENT] = groups.filter((g) => g.hooks.length > 0); + if (settings.hooks![HOOK_EVENT].length === 0) { + delete settings.hooks![HOOK_EVENT]; + } + if (settings.hooks && Object.keys(settings.hooks).length === 0) { + delete settings.hooks; + } + + return removed ? { changed: true, reason: "removed" } : { changed: false, reason: "not-present" }; +} diff --git a/node/tests/hook.test.ts b/node/tests/hook.test.ts new file mode 100644 index 0000000..de46166 --- /dev/null +++ b/node/tests/hook.test.ts @@ -0,0 +1,225 @@ +import { describe, it, expect } from "vitest"; +import { mkdtempSync, readFileSync, rmSync, writeFileSync, mkdirSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { + applyInstall, + applyUninstall, + buildHookCommand, + formatMessagesForHook, + readSettings, + writeSettings, +} from "../src/hook.js"; + +describe("formatMessagesForHook", () => { + it("returns empty string for empty inbox", () => { + expect(formatMessagesForHook("bob", [])).toBe(""); + }); + + it("formats a single message", () => { + const out = formatMessagesForHook("bob", [ + { id: 1, from: "alice", body: "hi bob", sentAt: "2026-05-19T10:00:00.000Z" }, + ]); + expect(out).toContain("1 new mailbox message"); + expect(out).toContain("[#1] from alice (2026-05-19T10:00:00.000Z):"); + expect(out).toContain(" hi bob"); + }); + + it("formats multiple messages with plural header", () => { + const out = formatMessagesForHook("bob", [ + { id: 1, from: "alice", body: "one", sentAt: "2026-05-19T10:00:00.000Z" }, + { id: 2, from: "carol", body: "two", sentAt: "2026-05-19T10:01:00.000Z" }, + ]); + expect(out).toContain("2 new mailbox messages"); + expect(out).toContain("[#1] from alice"); + expect(out).toContain("[#2] from carol"); + }); + + it("preserves multi-line bodies with indentation", () => { + const out = formatMessagesForHook("bob", [ + { id: 1, from: "alice", body: "line1\nline2", sentAt: "2026-05-19T10:00:00.000Z" }, + ]); + expect(out).toContain(" line1"); + expect(out).toContain(" line2"); + }); +}); + +describe("buildHookCommand", () => { + it("builds a basic command", () => { + expect(buildHookCommand("alice")).toBe("claude-mailbox check --name alice --hook"); + }); + + it("appends --url when provided", () => { + expect(buildHookCommand("alice", "http://127.0.0.1:9000")).toBe( + "claude-mailbox check --name alice --hook --url http://127.0.0.1:9000", + ); + }); + + it("quotes names with spaces", () => { + const cmd = buildHookCommand("my mailbox"); + expect(cmd).toContain('--name "my mailbox"'); + }); +}); + +describe("applyInstall", () => { + it("creates hooks structure from empty settings", () => { + const s: Record = {}; + const r = applyInstall(s, "claude-mailbox check --name bob --hook"); + expect(r).toEqual({ changed: true, reason: "added" }); + expect(s).toMatchObject({ + hooks: { + UserPromptSubmit: [ + { + matcher: "", + hooks: [{ type: "command", command: "claude-mailbox check --name bob --hook" }], + }, + ], + }, + }); + }); + + it("is idempotent — does not duplicate the same command", () => { + const s: Record = {}; + applyInstall(s, "claude-mailbox check --name bob --hook"); + const r = applyInstall(s, "claude-mailbox check --name bob --hook"); + expect(r).toEqual({ changed: false, reason: "already-present" }); + const groups = (s.hooks as { UserPromptSubmit: { hooks: unknown[] }[] }).UserPromptSubmit; + expect(groups[0]!.hooks).toHaveLength(1); + }); + + it("preserves existing unrelated hooks", () => { + const s: Record = { + hooks: { + UserPromptSubmit: [ + { + matcher: "", + hooks: [{ type: "command", command: "echo something-else" }], + }, + ], + PostToolUse: [{ matcher: "Bash", hooks: [{ type: "command", command: "logger" }] }], + }, + }; + applyInstall(s, "claude-mailbox check --name bob --hook"); + const hooks = s.hooks as { UserPromptSubmit: { hooks: { command: string }[] }[] }; + expect(hooks.UserPromptSubmit[0]!.hooks).toHaveLength(2); + expect(hooks.UserPromptSubmit[0]!.hooks[0]!.command).toBe("echo something-else"); + expect(hooks.UserPromptSubmit[0]!.hooks[1]!.command).toBe( + "claude-mailbox check --name bob --hook", + ); + expect((s.hooks as Record).PostToolUse).toBeDefined(); + }); + + it("adds a new empty-matcher group when only non-empty matchers exist", () => { + const s: Record = { + hooks: { + UserPromptSubmit: [ + { matcher: "Bash", hooks: [{ type: "command", command: "echo bash-only" }] }, + ], + }, + }; + applyInstall(s, "claude-mailbox check --name bob --hook"); + const groups = (s.hooks as { UserPromptSubmit: { matcher?: string }[] }).UserPromptSubmit; + expect(groups).toHaveLength(2); + expect(groups[1]!.matcher).toBe(""); + }); +}); + +describe("applyUninstall", () => { + it("removes the hook and cleans up empty structures", () => { + const s: Record = {}; + applyInstall(s, "claude-mailbox check --name bob --hook"); + const r = applyUninstall(s); + expect(r).toEqual({ changed: true, reason: "removed" }); + expect(s.hooks).toBeUndefined(); + }); + + it("preserves unrelated hooks in the same group", () => { + const s: Record = { + hooks: { + UserPromptSubmit: [ + { + matcher: "", + hooks: [ + { type: "command", command: "echo something-else" }, + { type: "command", command: "claude-mailbox check --name bob --hook" }, + ], + }, + ], + }, + }; + applyUninstall(s); + const hooks = s.hooks as { UserPromptSubmit: { hooks: { command: string }[] }[] }; + expect(hooks.UserPromptSubmit[0]!.hooks).toHaveLength(1); + expect(hooks.UserPromptSubmit[0]!.hooks[0]!.command).toBe("echo something-else"); + }); + + it("returns not-present when there is nothing to remove", () => { + const s: Record = { + hooks: { UserPromptSubmit: [{ matcher: "", hooks: [{ type: "command", command: "x" }] }] }, + }; + const r = applyUninstall(s); + expect(r).toEqual({ changed: false, reason: "not-present" }); + }); + + it("removes hooks installed with --url arg", () => { + const s: Record = {}; + applyInstall(s, "claude-mailbox check --name bob --hook --url http://x"); + applyUninstall(s); + expect(s.hooks).toBeUndefined(); + }); +}); + +describe("readSettings / writeSettings roundtrip", () => { + it("survives an install → write → read cycle", () => { + const dir = mkdtempSync(join(tmpdir(), "claude-mailbox-hook-")); + try { + const path = join(dir, "settings.json"); + const s = readSettings(path); + expect(s).toEqual({}); + applyInstall(s, "claude-mailbox check --name bob --hook"); + writeSettings(path, s); + const reloaded = readSettings(path); + expect(reloaded.hooks?.UserPromptSubmit?.[0]?.hooks[0]?.command).toBe( + "claude-mailbox check --name bob --hook", + ); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + + it("creates parent .claude directory when missing", () => { + const dir = mkdtempSync(join(tmpdir(), "claude-mailbox-hook-")); + try { + const path = join(dir, "nested", ".claude", "settings.json"); + writeSettings(path, { hooks: {} }); + expect(readFileSync(path, "utf8")).toContain('"hooks"'); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + + it("preserves non-hook settings keys", () => { + const dir = mkdtempSync(join(tmpdir(), "claude-mailbox-hook-")); + try { + const path = join(dir, "settings.json"); + mkdirSync(dir, { recursive: true }); + writeFileSync( + path, + JSON.stringify({ model: "sonnet", permissions: { allow: ["Bash"] } }, null, 2), + ); + const s = readSettings(path); + applyInstall(s, "claude-mailbox check --name bob --hook"); + writeSettings(path, s); + const reloaded = readSettings(path) as { + model?: string; + permissions?: { allow?: string[] }; + hooks?: unknown; + }; + expect(reloaded.model).toBe("sonnet"); + expect(reloaded.permissions?.allow).toEqual(["Bash"]); + expect(reloaded.hooks).toBeDefined(); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); +});