Skip to content

Commit abb1fc0

Browse files
sestinjclaudecontinue[bot]
authored
feat: add hooks system for CLI event interception (#11029)
* feat: add hooks system for CLI event interception Adds a Claude Code-compatible hooks system that allows external handlers (shell commands, HTTP endpoints) to intercept and respond to CLI events. Supports 16 event types with regex-based matching, typed inputs/outputs, sync/async execution, and multi-source config merging. Co-Authored-By: Claude Opus 4.6 <[email protected]> * fix: register HookService in service container The fireHook.ts module references services.hooks, which requires the HookService to be registered in the service container and exposed in the services object. Co-Authored-By: Claude Opus 4.6 <[email protected]> * fix: resolve eslint errors in hooks files and services/index.ts Fix unused imports, import ordering, and negated condition lint errors. Co-Authored-By: Claude Opus 4.6 <[email protected]> * fix: handle EPIPE errors on hook stdin writes When a hook command exits before we finish writing to stdin, an EPIPE error is emitted. Add an error handler to suppress it gracefully. Co-Authored-By: Claude Opus 4.6 <[email protected]> * fix: skip hookRunner tests on Windows The hookRunner tests use /bin/sh syntax (>&2, single-quote echo, sleep) that is incompatible with Windows cmd.exe. Co-Authored-By: Claude Opus 4.6 <[email protected]> * fix: address cubic review - utf8 encoding, regex, EPIPE handling - Use setEncoding("utf8") on stdout/stderr instead of data.toString() to prevent multi-byte character corruption at buffer boundaries - Fix env var interpolation regex to use alternation instead of independent optionals, preventing unbalanced brace matching - Only suppress EPIPE errors on stdin, log other errors Co-Authored-By: Claude Opus 4.6 <[email protected]> * Remove AI slop from hookRunner.ts * Update AGENTS.md to document hooks system * Fix React hooks best practices in useService.ts * fix: correct indentation in useService.ts for prettier Co-Authored-By: Claude Opus 4.6 <[email protected]> * fix: remove invalid eslint-disable for react-hooks/exhaustive-deps The react-hooks eslint plugin is not configured in the CLI package, so the disable comment causes an eslint error. Co-Authored-By: Claude Opus 4.6 <[email protected]> --------- Co-authored-by: Claude Opus 4.6 <[email protected]> Co-authored-by: continue[bot] <230936708+continue[bot]@users.noreply.github.com>
1 parent ec7030d commit abb1fc0

File tree

10 files changed

+2289
-5
lines changed

10 files changed

+2289
-5
lines changed

extensions/cli/AGENTS.md

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,20 @@ This is a CLI tool for Continue Dev that provides an interactive AI-assisted dev
5050

5151
6. **MCP Integration** (`src/mcp.ts`): Model Context Protocol service for extended tool capabilities
5252

53+
7. **Hooks System** (`src/hooks/`): Event interception system for extending CLI behavior
54+
- `HookService.ts`: Service container integration, loads config and fires events
55+
- `hookConfig.ts`: Loads hooks from settings files, merges configs from multiple sources
56+
- `hookRunner.ts`: Executes hook handlers (command, HTTP) with exit code semantics
57+
- `fireHook.ts`: Convenience functions for firing events from integration points
58+
- `types.ts`: Claude Code-compatible type definitions for hook inputs/outputs
59+
- **Config locations** (lowest to highest precedence):
60+
- `~/.claude/settings.json`, `~/.continue/settings.json` (user-global)
61+
- `.claude/settings.json`, `.continue/settings.json` (project)
62+
- `.claude/settings.local.json`, `.continue/settings.local.json` (project-local)
63+
- **Exit code semantics**: 0 = proceed, 2 = block (stderr becomes feedback), other = non-blocking error
64+
- **JSON output**: Optional structured output with `hookSpecificOutput` for fine-grained control
65+
- **Hook types**: `command` (shell), `http` (POST request), `prompt`/`agent` (not yet implemented)
66+
5367
### Key Features
5468

5569
- **Streaming Responses**: Real-time AI response streaming (`streamChatResponse.ts`)
Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,128 @@
1+
/**
2+
* HookService — service container integration for the hooks system.
3+
*
4+
* Loads hook configuration from settings files and provides a `fireEvent`
5+
* method that integration points call to trigger hooks.
6+
*/
7+
8+
import { BaseService } from "../services/BaseService.js";
9+
import { logger } from "../util/logger.js";
10+
11+
import { loadHooksConfig } from "./hookConfig.js";
12+
import { runHooks } from "./hookRunner.js";
13+
import type { HookEventResult, HookInput, HooksConfig } from "./types.js";
14+
15+
// ---------------------------------------------------------------------------
16+
// Service state
17+
// ---------------------------------------------------------------------------
18+
19+
export interface HookServiceState {
20+
/** The merged hooks config from all settings files */
21+
config: HooksConfig;
22+
/** Whether all hooks are disabled (disableAllHooks: true) */
23+
disabled: boolean;
24+
/** Hooks that have already run their "once" execution */
25+
onceKeys: Set<string>;
26+
}
27+
28+
// ---------------------------------------------------------------------------
29+
// Service
30+
// ---------------------------------------------------------------------------
31+
32+
export class HookService extends BaseService<HookServiceState> {
33+
private cwd: string;
34+
35+
constructor() {
36+
super("hooks", {
37+
config: {},
38+
disabled: false,
39+
onceKeys: new Set(),
40+
});
41+
this.cwd = process.cwd();
42+
}
43+
44+
async doInitialize(cwd?: string): Promise<HookServiceState> {
45+
if (cwd) {
46+
this.cwd = cwd;
47+
}
48+
49+
const loaded = loadHooksConfig(this.cwd);
50+
51+
const eventCount = Object.keys(loaded.hooks).length;
52+
const handlerCount = Object.values(loaded.hooks).reduce(
53+
(sum, groups) =>
54+
sum + (groups?.reduce((s, g) => s + g.hooks.length, 0) ?? 0),
55+
0,
56+
);
57+
58+
if (handlerCount > 0) {
59+
logger.debug(
60+
`Hooks loaded: ${handlerCount} handler(s) across ${eventCount} event type(s)`,
61+
);
62+
}
63+
64+
return {
65+
config: loaded.hooks,
66+
disabled: loaded.disabled,
67+
onceKeys: new Set(),
68+
};
69+
}
70+
71+
/**
72+
* Fire a hook event and return the aggregated result.
73+
*
74+
* This is the main entry point used by all integration points.
75+
* Returns a no-op result if hooks are disabled.
76+
*/
77+
async fireEvent(input: HookInput): Promise<HookEventResult> {
78+
if (this.currentState.disabled) {
79+
return { blocked: false, results: [] };
80+
}
81+
82+
if (Object.keys(this.currentState.config).length === 0) {
83+
return { blocked: false, results: [] };
84+
}
85+
86+
try {
87+
const result = await runHooks(this.currentState.config, input, this.cwd);
88+
return result;
89+
} catch (error) {
90+
logger.warn(`Hook event ${input.hook_event_name} failed:`, error);
91+
// Hook errors should not break the main flow
92+
return { blocked: false, results: [] };
93+
}
94+
}
95+
96+
/**
97+
* Reload hooks config from disk (e.g., after config file changes).
98+
*/
99+
async reloadConfig(): Promise<void> {
100+
const loaded = loadHooksConfig(this.cwd);
101+
this.setState({
102+
config: loaded.hooks,
103+
disabled: loaded.disabled,
104+
});
105+
}
106+
107+
/**
108+
* Get the session-level common fields that go into every hook input.
109+
* Integration points should call this and spread it into their event-specific input.
110+
*/
111+
getCommonFields(
112+
sessionId: string,
113+
transcriptPath: string,
114+
permissionMode?: string,
115+
): {
116+
session_id: string;
117+
transcript_path: string;
118+
cwd: string;
119+
permission_mode?: string;
120+
} {
121+
return {
122+
session_id: sessionId,
123+
transcript_path: transcriptPath,
124+
cwd: this.cwd,
125+
permission_mode: permissionMode,
126+
};
127+
}
128+
}
Lines changed: 231 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,231 @@
1+
/**
2+
* Convenience functions for firing hook events from integration points.
3+
*
4+
* These functions build the hook input with common fields and delegate
5+
* to the HookService. They are safe to call even if the HookService
6+
* is not yet initialized (they return no-op results).
7+
*/
8+
9+
import { services } from "../services/index.js";
10+
import { getCurrentSession } from "../session.js";
11+
12+
import type {
13+
HookEventResult,
14+
NotificationInput,
15+
PostToolUseFailureInput,
16+
PostToolUseInput,
17+
PreCompactInput,
18+
PreToolUseInput,
19+
SessionEndInput,
20+
SessionEndReason,
21+
SessionStartInput,
22+
SessionStartSource,
23+
StopInput,
24+
UserPromptSubmitInput,
25+
} from "./types.js";
26+
27+
const NOOP_RESULT: HookEventResult = { blocked: false, results: [] };
28+
29+
function getCommonFields(): {
30+
session_id: string;
31+
transcript_path: string;
32+
cwd: string;
33+
permission_mode?: string;
34+
} {
35+
try {
36+
const session = getCurrentSession();
37+
const permissionMode = services.toolPermissions?.isReady()
38+
? services.toolPermissions.getState().currentMode
39+
: undefined;
40+
41+
return {
42+
session_id: session.sessionId,
43+
transcript_path: "", // We don't have a transcript file like Claude Code
44+
cwd: process.cwd(),
45+
permission_mode: permissionMode,
46+
};
47+
} catch {
48+
return {
49+
session_id: "",
50+
transcript_path: "",
51+
cwd: process.cwd(),
52+
};
53+
}
54+
}
55+
56+
function isHookServiceReady(): boolean {
57+
try {
58+
return services.hooks?.isReady() ?? false;
59+
} catch {
60+
return false;
61+
}
62+
}
63+
64+
// ---------------------------------------------------------------------------
65+
// Tool events
66+
// ---------------------------------------------------------------------------
67+
68+
export async function firePreToolUse(
69+
toolName: string,
70+
toolInput: unknown,
71+
toolUseId: string,
72+
): Promise<HookEventResult> {
73+
if (!isHookServiceReady()) return NOOP_RESULT;
74+
75+
const input: PreToolUseInput = {
76+
...getCommonFields(),
77+
hook_event_name: "PreToolUse",
78+
tool_name: toolName,
79+
tool_input: toolInput,
80+
tool_use_id: toolUseId,
81+
};
82+
83+
return services.hooks.fireEvent(input);
84+
}
85+
86+
export async function firePostToolUse(
87+
toolName: string,
88+
toolInput: unknown,
89+
toolResponse: unknown,
90+
toolUseId: string,
91+
): Promise<HookEventResult> {
92+
if (!isHookServiceReady()) return NOOP_RESULT;
93+
94+
const input: PostToolUseInput = {
95+
...getCommonFields(),
96+
hook_event_name: "PostToolUse",
97+
tool_name: toolName,
98+
tool_input: toolInput,
99+
tool_response: toolResponse,
100+
tool_use_id: toolUseId,
101+
};
102+
103+
return services.hooks.fireEvent(input);
104+
}
105+
106+
export async function firePostToolUseFailure(
107+
toolName: string,
108+
toolInput: unknown,
109+
toolUseId: string,
110+
error: string,
111+
isInterrupt?: boolean,
112+
): Promise<HookEventResult> {
113+
if (!isHookServiceReady()) return NOOP_RESULT;
114+
115+
const input: PostToolUseFailureInput = {
116+
...getCommonFields(),
117+
hook_event_name: "PostToolUseFailure",
118+
tool_name: toolName,
119+
tool_input: toolInput,
120+
tool_use_id: toolUseId,
121+
error,
122+
is_interrupt: isInterrupt,
123+
};
124+
125+
return services.hooks.fireEvent(input);
126+
}
127+
128+
// ---------------------------------------------------------------------------
129+
// Lifecycle events
130+
// ---------------------------------------------------------------------------
131+
132+
export async function fireUserPromptSubmit(
133+
prompt: string,
134+
): Promise<HookEventResult> {
135+
if (!isHookServiceReady()) return NOOP_RESULT;
136+
137+
const input: UserPromptSubmitInput = {
138+
...getCommonFields(),
139+
hook_event_name: "UserPromptSubmit",
140+
prompt,
141+
};
142+
143+
return services.hooks.fireEvent(input);
144+
}
145+
146+
export async function fireSessionStart(
147+
source: SessionStartSource,
148+
model?: string,
149+
): Promise<HookEventResult> {
150+
if (!isHookServiceReady()) return NOOP_RESULT;
151+
152+
const input: SessionStartInput = {
153+
...getCommonFields(),
154+
hook_event_name: "SessionStart",
155+
source,
156+
model,
157+
};
158+
159+
return services.hooks.fireEvent(input);
160+
}
161+
162+
export async function fireSessionEnd(
163+
reason: SessionEndReason,
164+
): Promise<HookEventResult> {
165+
if (!isHookServiceReady()) return NOOP_RESULT;
166+
167+
const input: SessionEndInput = {
168+
...getCommonFields(),
169+
hook_event_name: "SessionEnd",
170+
reason,
171+
};
172+
173+
return services.hooks.fireEvent(input);
174+
}
175+
176+
export async function fireStop(
177+
lastAssistantMessage?: string,
178+
): Promise<HookEventResult> {
179+
if (!isHookServiceReady()) return NOOP_RESULT;
180+
181+
const input: StopInput = {
182+
...getCommonFields(),
183+
hook_event_name: "Stop",
184+
stop_hook_active: true,
185+
last_assistant_message: lastAssistantMessage,
186+
};
187+
188+
return services.hooks.fireEvent(input);
189+
}
190+
191+
// ---------------------------------------------------------------------------
192+
// Notification event
193+
// ---------------------------------------------------------------------------
194+
195+
export async function fireNotification(
196+
message: string,
197+
notificationType: string,
198+
title?: string,
199+
): Promise<HookEventResult> {
200+
if (!isHookServiceReady()) return NOOP_RESULT;
201+
202+
const input: NotificationInput = {
203+
...getCommonFields(),
204+
hook_event_name: "Notification",
205+
message,
206+
title,
207+
notification_type: notificationType,
208+
};
209+
210+
return services.hooks.fireEvent(input);
211+
}
212+
213+
// ---------------------------------------------------------------------------
214+
// Compaction event
215+
// ---------------------------------------------------------------------------
216+
217+
export async function firePreCompact(
218+
trigger: "manual" | "auto",
219+
customInstructions: string | null = null,
220+
): Promise<HookEventResult> {
221+
if (!isHookServiceReady()) return NOOP_RESULT;
222+
223+
const input: PreCompactInput = {
224+
...getCommonFields(),
225+
hook_event_name: "PreCompact",
226+
trigger,
227+
custom_instructions: customInstructions,
228+
};
229+
230+
return services.hooks.fireEvent(input);
231+
}

0 commit comments

Comments
 (0)