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.
226 lines
7.8 KiB
TypeScript
226 lines
7.8 KiB
TypeScript
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 });
|
|
}
|
|
});
|
|
});
|