mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-02 13:39:41 +02:00
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:
@@ -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`
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
@@ -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 ==========
|
||||
|
||||
/**
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 */
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user