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.
This commit is contained in:
Mika Kuns
2026-05-19 10:09:30 +02:00
parent a5a2895725
commit 66967167bc
4 changed files with 465 additions and 3 deletions

View File

@@ -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 <cwd>/.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 <mailbox> --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=<token>
```
`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.

View File

@@ -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 <name>", "Mailbox name (also sent as X-Mailbox)")
.option("--url <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 <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(

129
node/src/hook.ts Normal file
View File

@@ -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<string, ClaudeHookGroup[]>;
[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" };
}

225
node/tests/hook.test.ts Normal file
View File

@@ -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<string, unknown> = {};
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<string, unknown> = {};
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<string, unknown> = {
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<string, unknown>).PostToolUse).toBeDefined();
});
it("adds a new empty-matcher group when only non-empty matchers exist", () => {
const s: Record<string, unknown> = {
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<string, unknown> = {};
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<string, unknown> = {
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<string, unknown> = {
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<string, unknown> = {};
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 });
}
});
});