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 <noreply@anthropic.com>
This commit is contained in:
arkon
2026-01-24 02:46:24 +01:00
co-authored by Claude Opus 4.5
parent f818fbb234
commit b4aea57d9c
6 changed files with 378 additions and 12 deletions
+53 -12
View File
@@ -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: "<id>" }
npx tsx src/mcp-server.ts # Dev mode (stdio transport)
# Debugging
screen -ls # List GNU screen sessions
screen -r <name> # 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 `<spawn1337>` tags in terminal output (like ralph-tracker.ts) |
| `src/spawn-detector.ts` | Detects `<spawn1337>` 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 `<promise>` 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 <spawn1337>task.md</spawn1337>
→ SpawnDetector parses tag
→ SpawnOrchestrator reads + parses task spec file (YAML frontmatter)
→ Creates agent directory: ~/claudeman-cases/spawn-<agentId>/
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-<agentId>/
→ 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 <spawn1337>task.md</spawn1337>
→ 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):
- `<spawn1337>path/to/task.md</spawn1337>` - Spawn request
- `<spawn1337-status agentId="id"/>` - Status query
- `<spawn1337-cancel agentId="id"/>` - 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/<name>.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`
+75
View File
@@ -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<string, unknown[]> } {
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<string, unknown> = {};
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');
}
+15
View File
@@ -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 ==========
/**
+40
View File
@@ -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) {
+23
View File
@@ -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 */
+172
View File
@@ -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);
});
});