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

@@ -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" };
}