From b4aea57d9c84eb662b267194d007b58c571b37ec Mon Sep 17 00:00:00 2001 From: arkon Date: Sat, 24 Jan 2026 02:46:24 +0100 Subject: [PATCH] feat: add Claude Code hooks for desktop notifications Wire Claude Code's official hooks system (Notification, Stop) to POST back to Claudeman's new /api/hook-event endpoint, which broadcasts SSE events consumed by the existing NotificationManager for desktop alerts. Co-Authored-By: Claude Opus 4.5 --- CLAUDE.md | 65 +++++++++++--- src/hooks-config.ts | 75 +++++++++++++++++ src/types.ts | 15 ++++ src/web/public/app.js | 40 +++++++++ src/web/server.ts | 23 +++++ test/hooks-config.test.ts | 172 ++++++++++++++++++++++++++++++++++++++ 6 files changed, 378 insertions(+), 12 deletions(-) create mode 100644 src/hooks-config.ts create mode 100644 test/hooks-config.test.ts diff --git a/CLAUDE.md b/CLAUDE.md index 981f7ab2..903227fb 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -19,7 +19,7 @@ Claudeman is a Claude Code session manager with a web interface and autonomous R **Tech Stack**: TypeScript (ES2022/NodeNext, strict mode), Node.js, Fastify, Server-Sent Events, node-pty -**Key Dependencies**: fastify (REST API), node-pty (PTY spawning), ink/react (TUI), xterm.js (web terminal) +**Key Dependencies**: fastify (REST API), node-pty (PTY spawning), ink/react (TUI), xterm.js (web terminal), @modelcontextprotocol/sdk (MCP server for spawn protocol) **Requirements**: Node.js 18+, Claude CLI (`claude`) in PATH, GNU Screen (`apt install screen` / `brew install screen`) @@ -34,12 +34,13 @@ npm install **CRITICAL**: `npm run dev` runs CLI help, NOT the web server. Use `npx tsx src/index.ts web` for development. ```bash -npm run build # Compile TypeScript + copy static files to dist/web/ +npm run build # Compile TS + copy static files + templates + make bins executable npm run clean # Remove dist/ # Start web server (pick one): npx tsx src/index.ts web # Dev mode - no build needed (RECOMMENDED) npx tsx src/index.ts web -p 8080 # Dev mode with custom port +npx tsx src/index.ts web --https # Dev mode with self-signed TLS (enables browser notifications) npm run web # After npm run build (shorthand) node dist/index.js web # After npm run build claudeman web # After npm link @@ -68,7 +69,7 @@ npx vitest run -t "should create session" # By pattern # 3115: integration-flows.test.ts # 3120: session-cleanup.test.ts # 3125: ralph-integration.test.ts -# Unit tests (no port needed): respawn-controller, ralph-tracker, pty-interactive, task-queue, task, ralph-loop, session-manager, state-store, types, templates, ralph-config, spawn-detector, spawn-types, spawn-orchestrator +# Unit tests (no port needed): respawn-controller, ralph-tracker, pty-interactive, task-queue, task, ralph-loop, session-manager, state-store, types, templates, ralph-config, spawn-detector, spawn-types, spawn-orchestrator, hooks-config # Next available: 3127+ # Tests mock PTY - no real Claude CLI spawned @@ -86,6 +87,12 @@ npx vitest run -t "should create session" # By pattern npm run typecheck # Type check without building (or: npx tsc --noEmit) # Note: No ESLint/Prettier configured - rely on TypeScript strict mode +# MCP Server (for Claude Code to call spawn tools directly): +# Configure in Claude Code's MCP settings: +# command: "node", args: ["dist/mcp-server.js"] +# env: { CLAUDEMAN_API_URL: "http://localhost:3000", CLAUDEMAN_SESSION_ID: "" } +npx tsx src/mcp-server.ts # Dev mode (stdio transport) + # Debugging screen -ls # List GNU screen sessions screen -r # Attach to screen session (Ctrl+A D to detach) @@ -145,13 +152,16 @@ claudeman reset # Reset all state | `src/tui/App.tsx` | TUI main component: tabs, terminal viewport, status bar (Ink/React) | | `src/tui/components/*.tsx` | TUI components: StartScreen, TabBar, TerminalView, StatusBar, RalphPanel, HelpOverlay | | `src/tui/hooks/useSessionManager.ts` | TUI session state, screen polling, input handling | +| `src/hooks-config.ts` | Generates .claude/settings.local.json with Claude Code hooks for desktop notifications | | `src/types.ts` | All TypeScript interfaces | | `src/templates/claude-md.ts` | CLAUDE.md template generation with placeholder support | | `src/templates/case-template.md` | Default CLAUDE.md template for new cases (with placeholders) | | `src/spawn-types.ts` | Types, YAML parser, factory functions for spawn1337 protocol | -| `src/spawn-detector.ts` | Detects `` tags in terminal output (like ralph-tracker.ts) | +| `src/spawn-detector.ts` | Detects `` tags in terminal output (legacy, replaced by MCP) | | `src/spawn-orchestrator.ts` | Full agent lifecycle: spawn, monitor, budget, queue, cleanup | | `src/spawn-claude-md.ts` | Generates CLAUDE.md for spawned agent sessions | +| `src/mcp-server.ts` | MCP server binary (`claudeman-mcp`) exposing spawn tools to Claude Code | +| `src/tui/DirectAttach.ts` | Full-screen console attach with tab switching between sessions | ### Data Flow @@ -183,12 +193,26 @@ Steps can be skipped via config (`sendClear: false`, `sendInit: false`). Optiona Spawned agents are full-power Claude sessions running in their own screen sessions. They communicate via a filesystem-based message bus and signal completion via RalphTracker's `` mechanism. -**Protocol Flow:** +**Primary Interface: MCP Server** (`claudeman-mcp` binary). Claude Code calls spawn tools directly via MCP protocol, replacing the legacy terminal-tag-parsing approach (SpawnDetector). + +**MCP Tools:** +- `spawn_agent` - Spawn a new autonomous agent (builds task spec from parameters) +- `list_agents` - List all agents (active + completed + queued) +- `get_agent_status` - Get detailed agent status + progress +- `get_agent_result` - Read a completed agent's result +- `send_agent_message` - Send a message to a running agent +- `cancel_agent` - Cancel a running agent + +**MCP Environment Variables:** +- `CLAUDEMAN_API_URL` - Base URL for the Claudeman API (default: `http://localhost:3000`) +- `CLAUDEMAN_SESSION_ID` - Session ID of the calling Claude session + +**Protocol Flow (via MCP):** ``` -Parent outputs task.md - → SpawnDetector parses tag - → SpawnOrchestrator reads + parses task spec file (YAML frontmatter) - → Creates agent directory: ~/claudeman-cases/spawn-/ +Claude calls spawn_agent MCP tool + → MCP server builds task spec YAML + → POST /api/spawn/trigger with task spec + → SpawnOrchestrator creates agent directory: ~/claudeman-cases/spawn-/ → Spawns interactive Claude session in screen → Injects initial prompt via writeViaScreen() → Agent works autonomously, writes progress to spawn-comms/ @@ -196,7 +220,7 @@ Parent outputs task.md → Orchestrator reads result.md, notifies parent via SSE ``` -**Tag Patterns** (detected by `SpawnDetector`): +**Legacy Tag Patterns** (detected by `SpawnDetector`, still functional but superseded by MCP): - `path/to/task.md` - Spawn request - `` - Status query - `` - Cancel request @@ -241,7 +265,7 @@ Task instructions here... - Max spawn depth: 3 (prevents infinite recursion) - Default timeout: 30 minutes (max: 120) -**Session Integration:** Each session has a `SpawnDetector` (alongside RalphTracker) that forwards terminal data. Spawn events (`spawnRequested`, `spawnStatusRequested`, `spawnCancelRequested`, `spawnMessageToChild`) are emitted on the session and wired to the orchestrator in server.ts. +**Session Integration:** The MCP server communicates with the Claudeman API over HTTP. Legacy SpawnDetector (alongside RalphTracker) can still forward terminal data for tag-based spawning. Spawn events (`spawnRequested`, `spawnStatusRequested`, `spawnCancelRequested`, `spawnMessageToChild`) are emitted on the session and wired to the orchestrator in server.ts. **Agent Tree:** Agents can spawn children (up to `maxSpawnDepth`). Sessions track `parentAgentId` and `childAgentIds`. Cancelling a parent cascades to all children. @@ -440,7 +464,7 @@ Tab switch/new session fix: clear xterm → write buffer → resize PTY → Ctrl All events broadcast to `/api/events` with format: `{ type: string, sessionId?: string, data: any }`. -Event prefixes: `session:`, `task:`, `respawn:`, `spawn:`, `scheduled:`, `case:`, `screen:`, `init`. +Event prefixes: `session:`, `task:`, `respawn:`, `spawn:`, `hook:`, `scheduled:`, `case:`, `screen:`, `init`. Key events for frontend handling (see `app.js:handleSSEEvent()`): - `session:idle`, `session:working` - Status indicator updates @@ -450,6 +474,7 @@ Key events for frontend handling (see `app.js:handleSSEEvent()`): - `respawn:detectionUpdate` - Multi-layer idle detection status (confidence level, waiting state) - `spawn:queued`, `spawn:started`, `spawn:completed`, `spawn:failed`, `spawn:timeout`, `spawn:cancelled` - Agent lifecycle - `spawn:progress`, `spawn:message`, `spawn:budgetWarning`, `spawn:stateUpdate` - Agent monitoring +- `hook:idle_prompt`, `hook:permission_prompt`, `hook:stop` - Claude Code hooks (desktop notifications) ### Frontend (app.js) @@ -463,6 +488,18 @@ Vanilla JS + xterm.js. Key functions: - Client uses `requestAnimationFrame` to batch xterm.js writes - Prevents UI jank during high-throughput Claude output +### HTTPS & Browser Notifications + +**HTTPS**: The `--https` flag generates/reuses self-signed certificates in `~/.claudeman/certs/` (`server.key`, `server.crt`). Required for the Web Notification API in browsers. + +**Notification Layers** (in `app.js`, `NotificationManager` class): +1. In-app drawer with notification list +2. Tab title flashing with unread count (when tab unfocused) +3. Web Notification API (browser push notifications) +4. Audio alerts (critical level only) + +Notifications triggered for: session events, respawn updates, spawn agent lifecycle. Preferences persist to server-side `state.json` per session. + ### State Store Writes debounced (500ms) to `~/.claudeman/state.json`. The web server persists full session state via `persistSessionState()` on every meaningful change: @@ -538,6 +575,7 @@ TUI uses React JSX (`jsxImportSource: react`) for Ink components. - **SSE event**: Emit via `broadcast()` in server.ts, handle in `app.js:handleSSEEvent()` switch - **Session event**: Add to `SessionEvents` interface in `session.ts`, emit via `this.emit()`, subscribe in server.ts, handle in frontend - **Session setting**: Add field to `SessionState` in `types.ts`, include in `session.toState()`, call `this.persistSessionState(session)` in server.ts after the change +- **MCP tool**: Add tool definition in `mcp-server.ts` using `server.tool()`, use `apiRequest()` to call Claudeman REST API - **New test file**: Create `test/.test.ts`, pick unique port (next available: 3127+), add to port allocation comment above ### API Error Codes @@ -573,6 +611,7 @@ App.tsx │ ├── List navigation (↑/↓, Enter, a/d/D) │ ├── Case creation flow │ └── Tab switcher menu +├── DirectAttach.ts # Full-screen console attach with tab switching ├── TabBar.tsx # Session tabs (when attached) ├── TerminalView.tsx # Viewport into screen session ├── StatusBar.tsx # Bottom bar with status/tokens @@ -639,6 +678,7 @@ Long-running sessions are supported with automatic trimming: | GET | `/api/spawn/status` | Orchestrator status (counts, config) | | PUT | `/api/spawn/config` | Update orchestrator config | | POST | `/api/spawn/trigger` | Programmatic spawn (bypass terminal detection) | +| POST | `/api/hook-event` | Receive Claude Code hook callbacks (idle_prompt, permission_prompt, stop) | ## Keyboard Shortcuts (Web UI) @@ -699,6 +739,7 @@ Long-running sessions are supported with automatic trimming: | `~/.claudeman/state-inner.json` | Ralph loop/todo state per session (separate to reduce writes) | | `~/.claudeman/screens.json` | Screen session metadata (for recovery after restart) | | `~/.claudeman/settings.json` | User preferences (lastUsedCase, custom template path) | +| `~/.claudeman/certs/` | Self-signed TLS certificates for `--https` mode | **State lifecycle**: - Web server creates → session added to `state.json` + `screens.json` diff --git a/src/hooks-config.ts b/src/hooks-config.ts new file mode 100644 index 00000000..8dc7000f --- /dev/null +++ b/src/hooks-config.ts @@ -0,0 +1,75 @@ +/** + * @fileoverview Claude Code hooks configuration generator + * + * Generates .claude/settings.local.json with hook definitions that POST + * to Claudeman's /api/hook-event endpoint when Claude Code fires + * notification or stop hooks. Uses $CLAUDEMAN_API_URL and + * $CLAUDEMAN_SESSION_ID env vars (set on every managed session) so the + * config is static per case directory. + */ + +import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'; +import { join } from 'node:path'; + +import type { HookEventType } from './types.js'; + +/** + * Generates the hooks section for .claude/settings.local.json + * + * The curl commands reference env vars that are resolved at runtime by + * the shell, so the same config works for any session in the case dir. + */ +export function generateHooksConfig(): { hooks: Record } { + const curlCmd = (event: HookEventType) => + `curl -s -X POST $CLAUDEMAN_API_URL/api/hook-event ` + + `-H 'Content-Type: application/json' ` + + `-d '{"event":"${event}","sessionId":"'$CLAUDEMAN_SESSION_ID'"}' 2>/dev/null || true`; + + return { + hooks: { + Notification: [ + { + matcher: 'idle_prompt', + hooks: [{ type: 'command', command: curlCmd('idle_prompt'), timeout: 10000 }], + }, + { + matcher: 'permission_prompt', + hooks: [{ type: 'command', command: curlCmd('permission_prompt'), timeout: 10000 }], + }, + ], + Stop: [ + { + hooks: [{ type: 'command', command: curlCmd('stop'), timeout: 10000 }], + }, + ], + }, + }; +} + +/** + * Writes hooks config to .claude/settings.local.json in the given case path. + * Merges with existing file content, only touching the `hooks` key. + */ +export function writeHooksConfig(casePath: string): void { + const claudeDir = join(casePath, '.claude'); + if (!existsSync(claudeDir)) { + mkdirSync(claudeDir, { recursive: true }); + } + + const settingsPath = join(claudeDir, 'settings.local.json'); + let existing: Record = {}; + + if (existsSync(settingsPath)) { + try { + existing = JSON.parse(readFileSync(settingsPath, 'utf-8')); + } catch { + // If file is malformed, start fresh + existing = {}; + } + } + + const hooksConfig = generateHooksConfig(); + const merged = { ...existing, ...hooksConfig }; + + writeFileSync(settingsPath, JSON.stringify(merged, null, 2) + '\n'); +} diff --git a/src/types.ts b/src/types.ts index 8c801e34..406445cf 100644 --- a/src/types.ts +++ b/src/types.ts @@ -369,6 +369,21 @@ export interface QuickRunRequest { workingDir?: string; } +/** + * Hook event types triggered by Claude Code's hooks system + */ +export type HookEventType = 'idle_prompt' | 'permission_prompt' | 'stop'; + +/** + * Request body for the hook-event API endpoint + */ +export interface HookEventRequest { + /** Type of hook event that fired */ + event: HookEventType; + /** Session ID from CLAUDEMAN_SESSION_ID env var */ + sessionId: string; +} + // ========== API Response Types ========== /** diff --git a/src/web/public/app.js b/src/web/public/app.js index 5502b579..c8a7b30f 100644 --- a/src/web/public/app.js +++ b/src/web/public/app.js @@ -1092,6 +1092,46 @@ class ClaudemanApp { message: `Agent "${data.agentId}" finished successfully`, }); }); + + // Hook events (from Claude Code hooks system) + this.eventSource.addEventListener('hook:idle_prompt', (e) => { + const data = JSON.parse(e.data); + const session = this.sessions.get(data.sessionId); + this.notificationManager?.notify({ + urgency: 'warning', + category: 'hook-idle', + sessionId: data.sessionId, + sessionName: session?.name || data.sessionId, + title: 'Waiting for Input', + message: 'Claude is idle and waiting for a prompt', + }); + }); + + this.eventSource.addEventListener('hook:permission_prompt', (e) => { + const data = JSON.parse(e.data); + const session = this.sessions.get(data.sessionId); + this.notificationManager?.notify({ + urgency: 'critical', + category: 'hook-permission', + sessionId: data.sessionId, + sessionName: session?.name || data.sessionId, + title: 'Permission Required', + message: 'Claude needs tool approval to continue', + }); + }); + + this.eventSource.addEventListener('hook:stop', (e) => { + const data = JSON.parse(e.data); + const session = this.sessions.get(data.sessionId); + this.notificationManager?.notify({ + urgency: 'info', + category: 'hook-stop', + sessionId: data.sessionId, + sessionName: session?.name || data.sessionId, + title: 'Response Complete', + message: 'Claude has finished responding', + }); + }); } setConnectionStatus(status) { diff --git a/src/web/server.ts b/src/web/server.ts index 22bf90c2..dcb586c4 100644 --- a/src/web/server.ts +++ b/src/web/server.ts @@ -26,6 +26,7 @@ import { ScreenManager } from '../screen-manager.js'; import { getStore } from '../state-store.js'; import { generateClaudeMd } from '../templates/claude-md.js'; import { parseRalphLoopConfig, extractCompletionPhrase } from '../ralph-config.js'; +import { writeHooksConfig } from '../hooks-config.js'; import { v4 as uuidv4 } from 'uuid'; import { getErrorMessage, @@ -39,6 +40,7 @@ import { type QuickStartRequest, type CreateScheduledRunRequest, type QuickRunRequest, + type HookEventRequest, type ApiResponse, type SessionResponse, type QuickStartResponse, @@ -1081,6 +1083,9 @@ export class WebServer extends EventEmitter { // Write .mcp.json for Claude Code to discover spawn tools this.writeMcpConfig(casePath); + // Write .claude/settings.local.json with hooks for desktop notifications + writeHooksConfig(casePath); + this.broadcast('case:created', { name, path: casePath }); return { success: true, case: { name, path: casePath } }; @@ -1221,6 +1226,9 @@ export class WebServer extends EventEmitter { // Write .mcp.json for Claude Code to discover spawn tools this.writeMcpConfig(casePath); + // Write .claude/settings.local.json with hooks for desktop notifications + writeHooksConfig(casePath); + this.broadcast('case:created', { name: caseName, path: casePath }); } catch (err) { return { success: false, error: `Failed to create case: ${getErrorMessage(err)}` }; @@ -1447,6 +1455,21 @@ export class WebServer extends EventEmitter { } return { success: true, data: { agentId } }; }); + + // ========== Hook Events ========== + + this.app.post('/api/hook-event', async (req) => { + const { event, sessionId } = req.body as HookEventRequest; + const validEvents = ['idle_prompt', 'permission_prompt', 'stop'] as const; + if (!event || !validEvents.includes(event as typeof validEvents[number])) { + return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Invalid event type'); + } + if (!sessionId || !this.sessions.has(sessionId)) { + return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Session not found'); + } + this.broadcast(`hook:${event}`, { sessionId, timestamp: Date.now() }); + return { success: true }; + }); } /** Persists full session state including respawn config to state.json */ diff --git a/test/hooks-config.test.ts b/test/hooks-config.test.ts new file mode 100644 index 00000000..54ccfc2a --- /dev/null +++ b/test/hooks-config.test.ts @@ -0,0 +1,172 @@ +/** + * @fileoverview Tests for hooks config generation + * + * Tests the generation of .claude/settings.local.json with Claude Code + * hook definitions for desktop notifications. + */ + +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { existsSync, readFileSync, writeFileSync, mkdirSync, rmSync } from 'node:fs'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; +import { generateHooksConfig, writeHooksConfig } from '../src/hooks-config.js'; + +describe('generateHooksConfig', () => { + it('should return an object with hooks key', () => { + const config = generateHooksConfig(); + expect(config).toHaveProperty('hooks'); + }); + + it('should have Notification hooks array', () => { + const config = generateHooksConfig(); + expect(config.hooks.Notification).toBeInstanceOf(Array); + expect(config.hooks.Notification).toHaveLength(2); + }); + + it('should have Stop hooks array', () => { + const config = generateHooksConfig(); + expect(config.hooks.Stop).toBeInstanceOf(Array); + expect(config.hooks.Stop).toHaveLength(1); + }); + + it('should configure idle_prompt matcher', () => { + const config = generateHooksConfig(); + const notifHooks = config.hooks.Notification as Array<{ matcher?: string }>; + const idleHook = notifHooks.find(h => h.matcher === 'idle_prompt'); + expect(idleHook).toBeDefined(); + }); + + it('should configure permission_prompt matcher', () => { + const config = generateHooksConfig(); + const notifHooks = config.hooks.Notification as Array<{ matcher?: string }>; + const permHook = notifHooks.find(h => h.matcher === 'permission_prompt'); + expect(permHook).toBeDefined(); + }); + + it('should use env vars in curl commands (not hardcoded URLs)', () => { + const config = generateHooksConfig(); + const notifHooks = config.hooks.Notification as Array<{ hooks: Array<{ command: string }> }>; + const cmd = notifHooks[0].hooks[0].command; + expect(cmd).toContain('$CLAUDEMAN_API_URL'); + expect(cmd).toContain('$CLAUDEMAN_SESSION_ID'); + expect(cmd).not.toContain('localhost'); + }); + + it('should include || true for silent failure', () => { + const config = generateHooksConfig(); + const notifHooks = config.hooks.Notification as Array<{ hooks: Array<{ command: string }> }>; + expect(notifHooks[0].hooks[0].command).toContain('|| true'); + }); + + it('should set timeout to 10000ms', () => { + const config = generateHooksConfig(); + const notifHooks = config.hooks.Notification as Array<{ hooks: Array<{ timeout: number }> }>; + expect(notifHooks[0].hooks[0].timeout).toBe(10000); + }); + + it('should include correct event names in curl payloads', () => { + const config = generateHooksConfig(); + const notifHooks = config.hooks.Notification as Array<{ hooks: Array<{ command: string }> }>; + expect(notifHooks[0].hooks[0].command).toContain('"idle_prompt"'); + expect(notifHooks[1].hooks[0].command).toContain('"permission_prompt"'); + const stopHooks = config.hooks.Stop as Array<{ hooks: Array<{ command: string }> }>; + expect(stopHooks[0].hooks[0].command).toContain('"stop"'); + }); + + it('should set hook type to command', () => { + const config = generateHooksConfig(); + const notifHooks = config.hooks.Notification as Array<{ hooks: Array<{ type: string }> }>; + expect(notifHooks[0].hooks[0].type).toBe('command'); + const stopHooks = config.hooks.Stop as Array<{ hooks: Array<{ type: string }> }>; + expect(stopHooks[0].hooks[0].type).toBe('command'); + }); +}); + +describe('writeHooksConfig', () => { + const testDir = join(tmpdir(), 'claudeman-hooks-test-' + Date.now()); + + beforeEach(() => { + if (!existsSync(testDir)) { + mkdirSync(testDir, { recursive: true }); + } + }); + + afterEach(() => { + rmSync(testDir, { recursive: true, force: true }); + }); + + it('should create .claude directory if it does not exist', () => { + writeHooksConfig(testDir); + expect(existsSync(join(testDir, '.claude'))).toBe(true); + }); + + it('should create settings.local.json', () => { + writeHooksConfig(testDir); + const settingsPath = join(testDir, '.claude', 'settings.local.json'); + expect(existsSync(settingsPath)).toBe(true); + }); + + it('should write valid JSON', () => { + writeHooksConfig(testDir); + const settingsPath = join(testDir, '.claude', 'settings.local.json'); + const content = readFileSync(settingsPath, 'utf-8'); + expect(() => JSON.parse(content)).not.toThrow(); + }); + + it('should include hooks config in output', () => { + writeHooksConfig(testDir); + const settingsPath = join(testDir, '.claude', 'settings.local.json'); + const parsed = JSON.parse(readFileSync(settingsPath, 'utf-8')); + expect(parsed.hooks).toBeDefined(); + expect(parsed.hooks.Notification).toHaveLength(2); + expect(parsed.hooks.Stop).toHaveLength(1); + }); + + it('should merge with existing settings.local.json', () => { + const claudeDir = join(testDir, '.claude'); + mkdirSync(claudeDir, { recursive: true }); + writeFileSync( + join(claudeDir, 'settings.local.json'), + JSON.stringify({ existingKey: 'existingValue', permissions: { allow: ['Read'] } }, null, 2), + ); + + writeHooksConfig(testDir); + + const parsed = JSON.parse(readFileSync(join(claudeDir, 'settings.local.json'), 'utf-8')); + expect(parsed.existingKey).toBe('existingValue'); + expect(parsed.permissions).toEqual({ allow: ['Read'] }); + expect(parsed.hooks).toBeDefined(); + }); + + it('should overwrite existing hooks key', () => { + const claudeDir = join(testDir, '.claude'); + mkdirSync(claudeDir, { recursive: true }); + writeFileSync( + join(claudeDir, 'settings.local.json'), + JSON.stringify({ hooks: { oldHook: [] } }, null, 2), + ); + + writeHooksConfig(testDir); + + const parsed = JSON.parse(readFileSync(join(claudeDir, 'settings.local.json'), 'utf-8')); + expect(parsed.hooks.oldHook).toBeUndefined(); + expect(parsed.hooks.Notification).toBeDefined(); + }); + + it('should handle malformed existing settings.local.json', () => { + const claudeDir = join(testDir, '.claude'); + mkdirSync(claudeDir, { recursive: true }); + writeFileSync(join(claudeDir, 'settings.local.json'), 'not valid json{{{'); + + writeHooksConfig(testDir); + + const parsed = JSON.parse(readFileSync(join(claudeDir, 'settings.local.json'), 'utf-8')); + expect(parsed.hooks).toBeDefined(); + }); + + it('should end file with newline', () => { + writeHooksConfig(testDir); + const content = readFileSync(join(testDir, '.claude', 'settings.local.json'), 'utf-8'); + expect(content.endsWith('\n')).toBe(true); + }); +});