howtofixcli 0.7.24 → 0.7.26
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/account-credential.js +136 -0
- package/dist/acp.js +234 -0
- package/dist/activity.js +124 -0
- package/dist/agent-defs.js +237 -0
- package/dist/agent-limiter.js +68 -0
- package/dist/agents.js +554 -0
- package/dist/anim.js +361 -0
- package/dist/args.js +250 -0
- package/dist/at-file.js +28 -0
- package/dist/brand-extension.js +87 -0
- package/dist/brand.js +76 -0
- package/dist/browser.js +58 -0
- package/dist/cache-report.js +139 -0
- package/dist/checkpoint.js +437 -0
- package/dist/claims.js +167 -0
- package/dist/cli-auth.js +142 -0
- package/dist/cli-checkin.js +121 -0
- package/dist/cli-credentials.js +254 -0
- package/dist/clipboard.js +149 -0
- package/dist/collapse.js +129 -0
- package/dist/commands.js +381 -0
- package/dist/compaction-guard.js +81 -0
- package/dist/connect-prompt.js +22 -0
- package/dist/context-extension.js +17 -0
- package/dist/dcp/commands.js +172 -0
- package/dist/dcp/compress-state.js +139 -0
- package/dist/dcp/compress.js +329 -0
- package/dist/dcp/dcp-extension.js +295 -0
- package/dist/dcp/dedup.js +93 -0
- package/dist/dcp/ids.js +189 -0
- package/dist/dcp/index.js +25 -0
- package/dist/dcp/middle-prune.js +76 -0
- package/dist/dcp/prompts.js +68 -0
- package/dist/dcp/protected-content.js +221 -0
- package/dist/dcp/protected.js +161 -0
- package/dist/dcp/prune.js +418 -0
- package/dist/dcp/state.js +176 -0
- package/dist/errors.js +63 -0
- package/dist/extensions.js +48 -0
- package/dist/fatal.js +35 -0
- package/dist/feed.js +498 -0
- package/dist/fs-atomic.js +54 -0
- package/dist/gateway-errors.js +107 -0
- package/dist/git.js +230 -0
- package/dist/goal-extension.js +88 -0
- package/dist/goal.js +222 -0
- package/dist/hooks-extension.js +176 -0
- package/dist/hooks.js +443 -0
- package/dist/howtofix-account.js +834 -0
- package/dist/hud-line.js +158 -0
- package/dist/hwid.js +130 -0
- package/dist/interrupt.js +60 -0
- package/dist/json-events.js +50 -0
- package/dist/level-style.js +112 -0
- package/dist/loop-guard.js +95 -0
- package/dist/lsp-tools.js +32 -0
- package/dist/lsp.js +429 -0
- package/dist/main.js +573 -98
- package/dist/mcp-config.js +85 -0
- package/dist/mcp-lifecycle.js +147 -0
- package/dist/model-catalog.js +217 -0
- package/dist/model-facts.js +35 -0
- package/dist/model-resolve.js +65 -0
- package/dist/notes.js +78 -0
- package/dist/oauth-login.js +568 -0
- package/dist/paths.js +61 -0
- package/dist/permissions.js +298 -0
- package/dist/plan-mode.js +363 -0
- package/dist/preset-command.js +227 -0
- package/dist/preset-extension.js +35 -0
- package/dist/preset-mcp.js +292 -0
- package/dist/presets/mcp/rea/preset.json +28 -0
- package/dist/presets/mcp/rea/prompt.md +5 -0
- package/dist/presets/mcp/rea/skills/mcp-rea/SKILL.md +6 -0
- package/dist/presets/mcp/remotion/preset.json +27 -0
- package/dist/presets/mcp/remotion/prompt.md +7 -0
- package/dist/presets/mcp/remotion/skills/mcp-remotion/SKILL.md +6 -0
- package/dist/presets.js +727 -0
- package/dist/prompt-files.js +103 -0
- package/dist/prompt-text.js +41 -0
- package/dist/proxy.js +137 -0
- package/dist/quality/ast.js +196 -0
- package/dist/quality/cognitive.js +83 -0
- package/dist/quality/cyclomatic.js +30 -0
- package/dist/quality/halstead.js +83 -0
- package/dist/quality/index.js +18 -0
- package/dist/quality/length.js +3 -0
- package/dist/quality/maintainability.js +15 -0
- package/dist/quality/nesting.js +57 -0
- package/dist/quality/report.js +803 -0
- package/dist/quality/smells.js +67 -0
- package/dist/quality-tool.js +63 -0
- package/dist/read-only-commands.js +597 -0
- package/dist/repo-map.js +292 -0
- package/dist/roblox-connect.js +125 -0
- package/dist/rpc.js +91 -0
- package/dist/rtk.js +193 -0
- package/dist/sandbox.js +105 -0
- package/dist/schema.js +214 -0
- package/dist/serve.js +50 -0
- package/dist/session-lifecycle.js +19 -0
- package/dist/session-retention.js +111 -0
- package/dist/session.js +460 -0
- package/dist/sessions.js +100 -0
- package/dist/settings.js +148 -0
- package/dist/skills.js +35 -0
- package/dist/slop-tool.js +96 -0
- package/dist/spend-cap.js +43 -0
- package/dist/subagent.js +171 -0
- package/dist/tar.js +146 -0
- package/dist/theme.js +79 -0
- package/dist/thinking.js +45 -0
- package/dist/todo.js +114 -0
- package/dist/tool-loadout.js +102 -0
- package/dist/tool-render.js +769 -0
- package/dist/tools.js +176 -0
- package/dist/ui.js +4931 -0
- package/dist/update.js +551 -0
- package/dist/usage-segment.js +74 -0
- package/dist/web-tools.js +46 -0
- package/dist/web.js +265 -0
- package/dist/welcome.js +179 -0
- package/dist/workflow-engine.js +246 -0
- package/dist/workflow-progress.js +446 -0
- package/dist/workflow-view.js +195 -0
- package/dist/workflow.js +349 -0
- package/package.json +62 -15
- package/dist/chunk-27TRKD65.js +0 -2
- package/dist/chunk-3FYCFLNK.js +0 -1
- package/dist/chunk-3LZR7OEU.js +0 -1
- package/dist/chunk-56SEAGAQ.js +0 -1
- package/dist/chunk-5AWTL34S.js +0 -1
- package/dist/chunk-75P2SCOT.js +0 -1
- package/dist/chunk-7CHDNTH6.js +0 -1
- package/dist/chunk-7PHRNXLN.js +0 -34
- package/dist/chunk-AGGIYXN7.js +0 -2
- package/dist/chunk-AUDMRGWF.js +0 -189
- package/dist/chunk-C5E3SFOQ.js +0 -2
- package/dist/chunk-CMTKCUL5.js +0 -1
- package/dist/chunk-DAPXGSGD.js +0 -12
- package/dist/chunk-DOD7MUGX.js +0 -1
- package/dist/chunk-FPXVDP32.js +0 -8
- package/dist/chunk-GXB7RZHH.js +0 -1
- package/dist/chunk-I7KVLBMF.js +0 -2
- package/dist/chunk-ILDDDLRC.js +0 -1
- package/dist/chunk-JFYVRWJI.js +0 -2
- package/dist/chunk-LAHVHRCS.js +0 -2
- package/dist/chunk-LSJC73DG.js +0 -1
- package/dist/chunk-MNDKS6UH.js +0 -6
- package/dist/chunk-N345KWWK.js +0 -1
- package/dist/chunk-N3J2J7EQ.js +0 -5
- package/dist/chunk-ONZLNFQ2.js +0 -3
- package/dist/chunk-ORJ2QZBG.js +0 -3
- package/dist/chunk-PCALBZOO.js +0 -2
- package/dist/chunk-QPHSHL7H.js +0 -2
- package/dist/chunk-TKROS3BE.js +0 -5
- package/dist/chunk-TSEKPGK4.js +0 -1
- package/dist/chunk-UP7ENLEL.js +0 -117
- package/dist/chunk-UTYFLLSM.js +0 -1
- package/dist/chunk-V3R4FYTJ.js +0 -1
- package/dist/chunk-V656XVXV.js +0 -8
- package/dist/chunk-WKUJ7CFO.js +0 -1
- package/dist/chunk-WOT6VMZA.js +0 -1
- package/dist/chunk-WUPU6LTD.js +0 -4
- package/dist/chunk-WX3V5P7U.js +0 -5
- package/dist/chunk-XW6LMQGX.js +0 -1
- package/dist/chunk-YW7NORH3.js +0 -2
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
import { readMessageText, writeMessageText } from "./prompt-text.js";
|
|
2
|
+
import { HOOKS_FILE, checkToolInput, loadHooks, resetOnceHooks, runHooksFor } from "./hooks.js";
|
|
3
|
+
/** Read once per session; see the note above. */
|
|
4
|
+
let config = { hooks: {} };
|
|
5
|
+
/** Context a `SessionStart` hook contributed, kept for the session's prompts. */
|
|
6
|
+
let sessionContext;
|
|
7
|
+
export function createHooksExtension() {
|
|
8
|
+
return (hxf) => {
|
|
9
|
+
// Read the config HERE, in the factory, not in `session_start`.
|
|
10
|
+
//
|
|
11
|
+
// Measured: `session_start` does not fire in `-p` print mode — one-shot runs
|
|
12
|
+
// are exactly where a `PreToolUse` hook matters most (an unattended agent is
|
|
13
|
+
// the one you want policed), and loading in the lifecycle event left `config`
|
|
14
|
+
// empty there, so every hook silently did nothing. The factory runs in every
|
|
15
|
+
// mode. The read is one small synchronous file; it happens once per process.
|
|
16
|
+
const loaded = loadHooks(HOOKS_FILE);
|
|
17
|
+
config = loaded.config;
|
|
18
|
+
sessionContext = undefined;
|
|
19
|
+
// A resumed session re-arms its `once` hooks: the ledger is per process, and
|
|
20
|
+
// the session that just opened is a new one from the hook's point of view.
|
|
21
|
+
resetOnceHooks();
|
|
22
|
+
hxf.on("session_start", async (_event, ctx) => {
|
|
23
|
+
for (const diagnostic of loaded.diagnostics) {
|
|
24
|
+
ctx.ui.notify(`hooks.json: ${diagnostic.message}`, "warning");
|
|
25
|
+
}
|
|
26
|
+
const { outcome, ran } = await runHooksFor(config, "SessionStart", { hook_event_name: "SessionStart", cwd: ctx.cwd }, { cwd: ctx.cwd });
|
|
27
|
+
if (outcome.context)
|
|
28
|
+
sessionContext = outcome.context;
|
|
29
|
+
if (outcome.error)
|
|
30
|
+
ctx.ui.notify(`SessionStart hook: ${outcome.error}`, "warning");
|
|
31
|
+
if (ran && sessionContext) {
|
|
32
|
+
ctx.ui.notify(`SessionStart hooks added ${sessionContext.length} characters of context`, "info");
|
|
33
|
+
}
|
|
34
|
+
});
|
|
35
|
+
// `UserPromptSubmit` may block a prompt or add to it. The SDK's `input` event
|
|
36
|
+
// is the only place a prompt can be replaced before the run starts.
|
|
37
|
+
hxf.on("input", async (event, ctx) => {
|
|
38
|
+
if (!config.hooks.UserPromptSubmit)
|
|
39
|
+
return undefined;
|
|
40
|
+
const payload = {
|
|
41
|
+
hook_event_name: "UserPromptSubmit",
|
|
42
|
+
cwd: ctx.cwd,
|
|
43
|
+
prompt: event.text,
|
|
44
|
+
};
|
|
45
|
+
const { outcome } = await runHooksFor(config, "UserPromptSubmit", payload, { cwd: ctx.cwd });
|
|
46
|
+
if (outcome.error)
|
|
47
|
+
ctx.ui.notify(`UserPromptSubmit hook: ${outcome.error}`, "warning");
|
|
48
|
+
if (outcome.block) {
|
|
49
|
+
ctx.ui.notify(outcome.reason ?? "blocked by a hook", "warning");
|
|
50
|
+
return { action: "handled" };
|
|
51
|
+
}
|
|
52
|
+
if (!outcome.context)
|
|
53
|
+
return undefined;
|
|
54
|
+
return { action: "transform", text: `${event.text}\n\n${outcome.context}` };
|
|
55
|
+
});
|
|
56
|
+
// `PreToolUse` is the one that can refuse — or rewrite. It runs before the
|
|
57
|
+
// approval gate because a hook is the user's own policy and must not be
|
|
58
|
+
// overridable by an approval mode.
|
|
59
|
+
hxf.on("tool_call", async (event, ctx) => {
|
|
60
|
+
if (!config.hooks.PreToolUse)
|
|
61
|
+
return undefined;
|
|
62
|
+
const payload = {
|
|
63
|
+
hook_event_name: "PreToolUse",
|
|
64
|
+
cwd: ctx.cwd,
|
|
65
|
+
tool_name: event.toolName,
|
|
66
|
+
tool_input: event.input,
|
|
67
|
+
};
|
|
68
|
+
const { outcome } = await runHooksFor(config, "PreToolUse", payload, { cwd: ctx.cwd });
|
|
69
|
+
if (outcome.error)
|
|
70
|
+
ctx.ui.notify(`PreToolUse hook: ${outcome.error}`, "warning");
|
|
71
|
+
if (outcome.block) {
|
|
72
|
+
return { block: true, reason: outcome.reason ?? "blocked by a hook" };
|
|
73
|
+
}
|
|
74
|
+
// A rewrite replaces what the tool will actually do, so it is validated
|
|
75
|
+
// against the tool's own schema before it is used — the same schema the
|
|
76
|
+
// provider was given, read from the SDK's own registry. A rewrite that
|
|
77
|
+
// fails is dropped and the original arguments run: a guard must not be able
|
|
78
|
+
// to turn itself into a way to run something the model never asked for.
|
|
79
|
+
if (outcome.updatedInput) {
|
|
80
|
+
const tools = hxf.getAllTools();
|
|
81
|
+
const schema = tools.find((tool) => tool.name === event.toolName)?.parameters;
|
|
82
|
+
if (!schema) {
|
|
83
|
+
ctx.ui.notify(`PreToolUse hook rewrote \`${event.toolName}\`, but no schema is known for that tool — the rewrite was ignored`, "warning");
|
|
84
|
+
}
|
|
85
|
+
else {
|
|
86
|
+
const problem = checkToolInput(schema, outcome.updatedInput);
|
|
87
|
+
if (problem) {
|
|
88
|
+
ctx.ui.notify(`PreToolUse hook rewrite ignored — ${problem}`, "warning");
|
|
89
|
+
}
|
|
90
|
+
else {
|
|
91
|
+
// The SDK's documented way to change arguments: mutate in place.
|
|
92
|
+
Object.assign(event.input, outcome.updatedInput);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
return undefined;
|
|
97
|
+
});
|
|
98
|
+
// `PostToolUse` adds context to what the tool returned. The event is
|
|
99
|
+
// `tool_result`, and the result's `content` is replaced with the hook's text
|
|
100
|
+
// appended — `structuredContent` is returned alongside it so it is not
|
|
101
|
+
// dropped (the SDK drops it otherwise).
|
|
102
|
+
hxf.on("tool_result", async (event, ctx) => {
|
|
103
|
+
if (!config.hooks.PostToolUse)
|
|
104
|
+
return undefined;
|
|
105
|
+
const text = event.content.map((block) => (block.type === "text" ? block.text : "")).join("\n");
|
|
106
|
+
const payload = {
|
|
107
|
+
hook_event_name: "PostToolUse",
|
|
108
|
+
cwd: ctx.cwd,
|
|
109
|
+
tool_name: event.toolName,
|
|
110
|
+
tool_input: event.input,
|
|
111
|
+
tool_result: text,
|
|
112
|
+
tool_is_error: event.isError,
|
|
113
|
+
};
|
|
114
|
+
const { outcome } = await runHooksFor(config, "PostToolUse", payload, { cwd: ctx.cwd });
|
|
115
|
+
if (outcome.error)
|
|
116
|
+
ctx.ui.notify(`PostToolUse hook: ${outcome.error}`, "warning");
|
|
117
|
+
if (!outcome.context)
|
|
118
|
+
return undefined;
|
|
119
|
+
return {
|
|
120
|
+
content: [...event.content, { type: "text", text: `\n[hook] ${outcome.context}` }],
|
|
121
|
+
structuredContent: event.structuredContent,
|
|
122
|
+
};
|
|
123
|
+
});
|
|
124
|
+
// `SessionStart` context reaches the model through the same path the shared
|
|
125
|
+
// notes use: appended to the leading system message, which must stay at index
|
|
126
|
+
// 0 because that is where the provider reads the prompt from.
|
|
127
|
+
hxf.on("context_with_system", async (event) => {
|
|
128
|
+
if (!sessionContext)
|
|
129
|
+
return undefined;
|
|
130
|
+
const head = event.messages[0];
|
|
131
|
+
if (!head || head.role !== "system")
|
|
132
|
+
return undefined;
|
|
133
|
+
const combined = `${readMessageText(head.content)}\n\n${sessionContext}`;
|
|
134
|
+
const messages = event.messages.map((message) => (message === head ? { ...head, content: writeMessageText(head.content, combined) } : message));
|
|
135
|
+
return { messages };
|
|
136
|
+
});
|
|
137
|
+
// `PreCompact` observes a compaction before it happens — the moment context
|
|
138
|
+
// is about to be thrown away. Notification only: the SDK's compaction result
|
|
139
|
+
// is not something a shell command should be able to veto, and a hook that
|
|
140
|
+
// could would be a way to wedge a session at its most fragile point.
|
|
141
|
+
hxf.on("session_before_compact", async (event, ctx) => {
|
|
142
|
+
if (!config.hooks.PreCompact)
|
|
143
|
+
return;
|
|
144
|
+
const { outcome } = await runHooksFor(config, "PreCompact", {
|
|
145
|
+
hook_event_name: "PreCompact",
|
|
146
|
+
cwd: ctx.cwd,
|
|
147
|
+
compact_reason: event.reason,
|
|
148
|
+
}, { cwd: ctx.cwd });
|
|
149
|
+
if (outcome.error)
|
|
150
|
+
ctx.ui.notify(`PreCompact hook: ${outcome.error}`, "warning");
|
|
151
|
+
});
|
|
152
|
+
// `SessionEnd` for a session that is **replaced** (`/new`, `/resume`, a fork).
|
|
153
|
+
//
|
|
154
|
+
// Measured: this event does NOT fire when the process exits — `/quit` and
|
|
155
|
+
// double Ctrl+C produced zero events, even for a TypeScript extension writing
|
|
156
|
+
// synchronously. The quit path is therefore handled in `ui.ts`'s `shutdown()`,
|
|
157
|
+
// which runs the same hooks and awaits them. This handler covers the other
|
|
158
|
+
// moment the session ends: being swapped for another one.
|
|
159
|
+
hxf.on("session_shutdown", async (event, ctx) => {
|
|
160
|
+
if (!config.hooks.SessionEnd)
|
|
161
|
+
return;
|
|
162
|
+
const { outcome } = await runHooksFor(config, "SessionEnd", { hook_event_name: "SessionEnd", cwd: ctx.cwd, reason: event.reason }, { cwd: ctx.cwd });
|
|
163
|
+
if (outcome.error)
|
|
164
|
+
ctx.ui.notify(`SessionEnd hook: ${outcome.error}`, "warning");
|
|
165
|
+
});
|
|
166
|
+
// `Stop` is notification-only: it fires when the run will not continue on its
|
|
167
|
+
// own, which is the moment an integration wants to know about.
|
|
168
|
+
hxf.on("agent_settled", async (_event, ctx) => {
|
|
169
|
+
if (!config.hooks.Stop)
|
|
170
|
+
return;
|
|
171
|
+
const { outcome } = await runHooksFor(config, "Stop", { hook_event_name: "Stop", cwd: ctx.cwd }, { cwd: ctx.cwd });
|
|
172
|
+
if (outcome.error)
|
|
173
|
+
ctx.ui.notify(`Stop hook: ${outcome.error}`, "warning");
|
|
174
|
+
});
|
|
175
|
+
};
|
|
176
|
+
}
|
package/dist/hooks.js
ADDED
|
@@ -0,0 +1,443 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* User-configured hooks: shell commands that run at lifecycle points.
|
|
3
|
+
*
|
|
4
|
+
* Why this exists: every extension in this CLI is compiled into the bundle. A
|
|
5
|
+
* user who wants "run prettier after every edit" or "refuse any `rm -rf`" or
|
|
6
|
+
* "add our house rules to every prompt" has to edit the source and rebuild — or
|
|
7
|
+
* repeat themselves every session. Claude Code answers this with **hooks**: a
|
|
8
|
+
* JSON config naming a shell command per lifecycle point, and a stdin/stdout
|
|
9
|
+
* contract the command speaks. That is the right shape for this because the
|
|
10
|
+
* command can be anything the user already has — a script, `jq`, a linter — with
|
|
11
|
+
* no build step and no TypeScript.
|
|
12
|
+
*
|
|
13
|
+
* The SDK's extension API is the substrate (`hxf.on(...)` exposes 48 events), but
|
|
14
|
+
* it requires code in the bundle. This module is the declarative layer on top: a
|
|
15
|
+
* config file, mapped onto the SDK's own events.
|
|
16
|
+
*
|
|
17
|
+
* <AGENT_DIR>/hooks.json
|
|
18
|
+
*
|
|
19
|
+
* ## The contract (deliberately Claude Code's, so it is already familiar)
|
|
20
|
+
*
|
|
21
|
+
* The command receives a JSON payload on **stdin** and answers on **stdout**:
|
|
22
|
+
*
|
|
23
|
+
* - **exit 0** — allowed. `stdout` may carry JSON with a `hookSpecificOutput`
|
|
24
|
+
* block (see below). Plain text on stdout is added as context where the event
|
|
25
|
+
* has a place for it.
|
|
26
|
+
* - **exit 2** — blocked. `stderr` is the reason the model and the user are
|
|
27
|
+
* shown. This is Claude Code's convention and it is the useful one: a hook
|
|
28
|
+
* needs a way to say no that is not the same as "the hook crashed".
|
|
29
|
+
* - **any other exit** — the hook failed. It is reported and the action is
|
|
30
|
+
* allowed, because a broken linter must not become a broken session.
|
|
31
|
+
*
|
|
32
|
+
* `hookSpecificOutput` fields, matching Claude Code:
|
|
33
|
+
*
|
|
34
|
+
* ```json
|
|
35
|
+
* { "hookSpecificOutput": {
|
|
36
|
+
* "hookEventName": "PreToolUse",
|
|
37
|
+
* "permissionDecision": "deny",
|
|
38
|
+
* "permissionDecisionReason": "no rm -rf in this repo",
|
|
39
|
+
* "additionalContext": "…" } }
|
|
40
|
+
* ```
|
|
41
|
+
*
|
|
42
|
+
* ## What is supported, and what is not
|
|
43
|
+
*
|
|
44
|
+
* Supported: `PreToolUse` (block a call, or rewrite its arguments),
|
|
45
|
+
* `PostToolUse` (add context after one), `UserPromptSubmit` (add context to a
|
|
46
|
+
* prompt, or block it), `SessionStart` (add context once per session), `Stop`
|
|
47
|
+
* (notification when a run settles), `PreCompact` (observe a compaction),
|
|
48
|
+
* `SessionEnd` (observe a shutdown).
|
|
49
|
+
*
|
|
50
|
+
* Not supported, and named rather than silently ignored: `SubagentStop`,
|
|
51
|
+
* `Notification`, `PermissionRequest`, `PostToolUseFailure` do not map onto an
|
|
52
|
+
* event this CLI can act on yet. An unknown name is reported at load, never
|
|
53
|
+
* dropped quietly — a hook the user wrote that never fires is worse than a
|
|
54
|
+
* config error.
|
|
55
|
+
*
|
|
56
|
+
* ## Rules that are load-bearing
|
|
57
|
+
*
|
|
58
|
+
* - **A rewritten tool input is validated before it is used.** `updatedInput` is
|
|
59
|
+
* the most dangerous field in the contract: it replaces what the tool will
|
|
60
|
+
* actually do. Claude Code rejects a rewrite that fails the tool's schema, and
|
|
61
|
+
* so do we — an unvalidated rewrite turns a guard into a weapon. The schema
|
|
62
|
+
* comes from the SDK's own `getAllTools()`, so it is the same one the provider
|
|
63
|
+
* was given.
|
|
64
|
+
* - **A rewrite is applied only when nothing blocks.** A hook that denies wins
|
|
65
|
+
* over one that rewrites: refusing is the stronger statement.
|
|
66
|
+
* - **A hook is a subprocess on an interactive path, so it must be async and
|
|
67
|
+
* bounded.** Every spawn is `spawn` (never `execFileSync`) with a per-hook
|
|
68
|
+
* timeout, because a hung hook would otherwise freeze the TUI for as long as
|
|
69
|
+
* the process lives. On timeout the hook is killed and the action is allowed.
|
|
70
|
+
* - **A hook that cannot run is not a reason to refuse to work.** A missing
|
|
71
|
+
* config, unreadable file, bad JSON, or a command that does not exist all
|
|
72
|
+
* degrade to "no hooks" with a diagnostic.
|
|
73
|
+
* - **The config is read once per session**, not per tool call. Re-reading a file
|
|
74
|
+
* on every `tool_call` is a synchronous disk hit on the hot path.
|
|
75
|
+
*/
|
|
76
|
+
import { spawn } from "node:child_process";
|
|
77
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
78
|
+
import { join } from "node:path";
|
|
79
|
+
import { Value } from "typebox/value";
|
|
80
|
+
import { AGENT_DIR } from "./paths.js";
|
|
81
|
+
/** The user's hook config. Named here so the docs and the check script agree. */
|
|
82
|
+
export const HOOKS_FILE = join(AGENT_DIR, "hooks.json");
|
|
83
|
+
/**
|
|
84
|
+
* A hook config is a payload, not a program. Past this size it is skipped rather
|
|
85
|
+
* than truncated, for the same reason `APPEND_SYSTEM.md` is: half a config is a
|
|
86
|
+
* different config.
|
|
87
|
+
*/
|
|
88
|
+
const MAX_BYTES = 64 * 1024;
|
|
89
|
+
/** Claude Code's default. Long enough for a real linter, short enough to not wedge a turn. */
|
|
90
|
+
export const DEFAULT_TIMEOUT_S = 30;
|
|
91
|
+
/** The events this CLI can actually act on. Anything else is reported. */
|
|
92
|
+
export const SUPPORTED_EVENTS = ["PreToolUse", "PostToolUse", "UserPromptSubmit", "SessionStart", "Stop", "PreCompact", "SessionEnd"];
|
|
93
|
+
/**
|
|
94
|
+
* Validate a rewritten tool input against the tool's own schema.
|
|
95
|
+
*
|
|
96
|
+
* The reference is the SDK's own extension runtime: it exposes `typebox/value`
|
|
97
|
+
* as a **virtual module** for extensions (`core/extensions/virtual-modules.js`),
|
|
98
|
+
* and uses it to validate the same tool inputs. Writing a hand-rolled checker
|
|
99
|
+
* instead would be a second, weaker opinion on a question the toolchain already
|
|
100
|
+
* answers — and the failure mode is the worst kind: a rewrite that passes our
|
|
101
|
+
* check and then breaks the tool.
|
|
102
|
+
*
|
|
103
|
+
* `typebox` is a direct dependency of the SDK, and the schema it gives us
|
|
104
|
+
* (`ToolInfo.parameters`) is the very one the provider was handed, so this is
|
|
105
|
+
* the same validation the model's own call goes through.
|
|
106
|
+
*
|
|
107
|
+
* Returns `undefined` when the value is acceptable, or a reason it is not.
|
|
108
|
+
*/
|
|
109
|
+
export function checkToolInput(schema, value) {
|
|
110
|
+
if (value === null || typeof value !== "object" || Array.isArray(value)) {
|
|
111
|
+
return "the rewrite is not a JSON object";
|
|
112
|
+
}
|
|
113
|
+
if (schema === null || typeof schema !== "object")
|
|
114
|
+
return undefined;
|
|
115
|
+
try {
|
|
116
|
+
if (Value.Check(schema, value))
|
|
117
|
+
return undefined;
|
|
118
|
+
const first = [...Value.Errors(schema, value)][0];
|
|
119
|
+
if (!first)
|
|
120
|
+
return "the rewrite does not match the tool's schema";
|
|
121
|
+
const at = "instancePath" in first ? String(first.instancePath) : "";
|
|
122
|
+
const where = at && at !== "/" ? ` at \`${at}\`` : "";
|
|
123
|
+
return `the rewrite does not match the tool's schema${where}: ${first.message}`;
|
|
124
|
+
}
|
|
125
|
+
catch (error) {
|
|
126
|
+
// A schema TypeBox cannot walk (an exotic tool, a future shape) must not
|
|
127
|
+
// become a reason to refuse the call: the rewrite is dropped and the tool
|
|
128
|
+
// runs with its original arguments.
|
|
129
|
+
return `the tool's schema could not be checked: ${error instanceof Error ? error.message : String(error)}`;
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
/**
|
|
133
|
+
* Read and validate the config. Never throws: a broken config must leave the
|
|
134
|
+
* session working, with a diagnostic saying what was wrong.
|
|
135
|
+
*/
|
|
136
|
+
export function loadHooks(path = HOOKS_FILE) {
|
|
137
|
+
const diagnostics = [];
|
|
138
|
+
const empty = { hooks: {} };
|
|
139
|
+
try {
|
|
140
|
+
if (!existsSync(path))
|
|
141
|
+
return { config: empty, diagnostics };
|
|
142
|
+
const raw = readFileSync(path, "utf8");
|
|
143
|
+
if (Buffer.byteLength(raw, "utf8") > MAX_BYTES) {
|
|
144
|
+
diagnostics.push({ source: path, message: `skipped: larger than ${MAX_BYTES} bytes` });
|
|
145
|
+
return { config: empty, diagnostics };
|
|
146
|
+
}
|
|
147
|
+
const parsed = JSON.parse(raw);
|
|
148
|
+
const result = validateHooks(parsed);
|
|
149
|
+
for (const message of result.diagnostics)
|
|
150
|
+
diagnostics.push({ source: path, message });
|
|
151
|
+
return { config: result.config, diagnostics };
|
|
152
|
+
}
|
|
153
|
+
catch (error) {
|
|
154
|
+
diagnostics.push({
|
|
155
|
+
source: path,
|
|
156
|
+
message: `unreadable: ${error instanceof Error ? error.message : String(error)}`,
|
|
157
|
+
});
|
|
158
|
+
return { config: empty, diagnostics };
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
/**
|
|
162
|
+
* Validate a parsed config. Pure, so the shape rules are testable without a
|
|
163
|
+
* session or a file — including the two that matter most: an unsupported event
|
|
164
|
+
* name is *reported*, and a malformed entry is dropped rather than taking the
|
|
165
|
+
* whole config with it.
|
|
166
|
+
*/
|
|
167
|
+
export function validateHooks(parsed) {
|
|
168
|
+
const diagnostics = [];
|
|
169
|
+
const config = { hooks: {} };
|
|
170
|
+
if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
171
|
+
return { config, diagnostics: ["expected a JSON object with a `hooks` key"] };
|
|
172
|
+
}
|
|
173
|
+
const hooks = parsed.hooks;
|
|
174
|
+
if (hooks === undefined)
|
|
175
|
+
return { config, diagnostics: ["no `hooks` key"] };
|
|
176
|
+
if (hooks === null || typeof hooks !== "object" || Array.isArray(hooks)) {
|
|
177
|
+
return { config, diagnostics: ["`hooks` must be an object keyed by event name"] };
|
|
178
|
+
}
|
|
179
|
+
for (const [name, value] of Object.entries(hooks)) {
|
|
180
|
+
if (!SUPPORTED_EVENTS.includes(name)) {
|
|
181
|
+
diagnostics.push(`unknown event \`${name}\` — supported: ${SUPPORTED_EVENTS.join(", ")}`);
|
|
182
|
+
continue;
|
|
183
|
+
}
|
|
184
|
+
if (!Array.isArray(value)) {
|
|
185
|
+
diagnostics.push(`\`${name}\` must be an array of matchers`);
|
|
186
|
+
continue;
|
|
187
|
+
}
|
|
188
|
+
const matchers = [];
|
|
189
|
+
for (const entry of value) {
|
|
190
|
+
const matcher = validateMatcher(entry, name, diagnostics);
|
|
191
|
+
if (matcher)
|
|
192
|
+
matchers.push(matcher);
|
|
193
|
+
}
|
|
194
|
+
if (matchers.length)
|
|
195
|
+
config.hooks[name] = matchers;
|
|
196
|
+
}
|
|
197
|
+
return { config, diagnostics };
|
|
198
|
+
}
|
|
199
|
+
function validateMatcher(entry, event, diagnostics) {
|
|
200
|
+
if (entry === null || typeof entry !== "object" || Array.isArray(entry)) {
|
|
201
|
+
diagnostics.push(`\`${event}\`: a matcher must be an object`);
|
|
202
|
+
return undefined;
|
|
203
|
+
}
|
|
204
|
+
const record = entry;
|
|
205
|
+
const commands = Array.isArray(record.hooks) ? record.hooks : undefined;
|
|
206
|
+
if (!commands) {
|
|
207
|
+
diagnostics.push(`\`${event}\`: a matcher needs a \`hooks\` array`);
|
|
208
|
+
return undefined;
|
|
209
|
+
}
|
|
210
|
+
const hooks = [];
|
|
211
|
+
for (const command of commands) {
|
|
212
|
+
if (command === null || typeof command !== "object")
|
|
213
|
+
continue;
|
|
214
|
+
const c = command;
|
|
215
|
+
const type = typeof c.type === "string" ? c.type : "command";
|
|
216
|
+
if (type !== "command") {
|
|
217
|
+
diagnostics.push(`\`${event}\`: only \`type: "command"\` is supported, got \`${type}\``);
|
|
218
|
+
continue;
|
|
219
|
+
}
|
|
220
|
+
if (typeof c.command !== "string" || !c.command.trim()) {
|
|
221
|
+
diagnostics.push(`\`${event}\`: a hook needs a non-empty \`command\``);
|
|
222
|
+
continue;
|
|
223
|
+
}
|
|
224
|
+
const timeout = typeof c.timeout === "number" && Number.isFinite(c.timeout) && c.timeout > 0 ? c.timeout : undefined;
|
|
225
|
+
hooks.push({
|
|
226
|
+
type: "command",
|
|
227
|
+
command: c.command,
|
|
228
|
+
timeout,
|
|
229
|
+
...(c.async === true ? { async: true } : {}),
|
|
230
|
+
...(c.once === true ? { once: true } : {}),
|
|
231
|
+
});
|
|
232
|
+
}
|
|
233
|
+
if (!hooks.length)
|
|
234
|
+
return undefined;
|
|
235
|
+
const matcher = typeof record.matcher === "string" ? record.matcher : undefined;
|
|
236
|
+
return { matcher, hooks };
|
|
237
|
+
}
|
|
238
|
+
/**
|
|
239
|
+
* Which matchers fire for a tool. `undefined`, `""`, `*` and a `|`-separated
|
|
240
|
+
* list all work, because that is how Claude Code's `matcher` reads and a user
|
|
241
|
+
* arriving from there will write it that way. Case-insensitive: tool names are
|
|
242
|
+
* written `bash` here and `Bash` there.
|
|
243
|
+
*/
|
|
244
|
+
export function matchersFor(config, event, toolName) {
|
|
245
|
+
const matchers = config.hooks[event];
|
|
246
|
+
if (!matchers)
|
|
247
|
+
return [];
|
|
248
|
+
return matchers.filter((matcher) => {
|
|
249
|
+
const pattern = matcher.matcher?.trim();
|
|
250
|
+
if (!pattern || pattern === "*")
|
|
251
|
+
return true;
|
|
252
|
+
if (toolName === undefined)
|
|
253
|
+
return false;
|
|
254
|
+
const name = toolName.toLowerCase();
|
|
255
|
+
return pattern
|
|
256
|
+
.split("|")
|
|
257
|
+
.map((part) => part.trim().toLowerCase())
|
|
258
|
+
.filter(Boolean)
|
|
259
|
+
.includes(name);
|
|
260
|
+
});
|
|
261
|
+
}
|
|
262
|
+
/**
|
|
263
|
+
* Run one hook. Async and bounded: a hook that hangs is killed and reported, so
|
|
264
|
+
* a bad command cannot freeze the session.
|
|
265
|
+
*/
|
|
266
|
+
export function runHook(command, payload, options = { cwd: process.cwd() }) {
|
|
267
|
+
const timeoutS = options.timeoutS ?? DEFAULT_TIMEOUT_S;
|
|
268
|
+
return new Promise((resolve) => {
|
|
269
|
+
let stdout = "";
|
|
270
|
+
let stderr = "";
|
|
271
|
+
let timedOut = false;
|
|
272
|
+
let settled = false;
|
|
273
|
+
const child = spawn(command, {
|
|
274
|
+
cwd: options.cwd,
|
|
275
|
+
shell: true,
|
|
276
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
277
|
+
});
|
|
278
|
+
// A hook that exits without reading stdin closes the pipe under us, and the
|
|
279
|
+
// write then fails **asynchronously** — an unhandled `error` event on the
|
|
280
|
+
// socket, which takes the whole process down. `exit 0` and `exit 2` are both
|
|
281
|
+
// normal hooks and both do this, so the handler is not optional. (The
|
|
282
|
+
// try/catch around the `end()` call below does not catch it: by then the
|
|
283
|
+
// write has been queued.)
|
|
284
|
+
child.stdin?.on("error", () => { });
|
|
285
|
+
child.stdout?.on("error", () => { });
|
|
286
|
+
child.stderr?.on("error", () => { });
|
|
287
|
+
const timer = setTimeout(() => {
|
|
288
|
+
timedOut = true;
|
|
289
|
+
child.kill("SIGKILL");
|
|
290
|
+
}, timeoutS * 1000);
|
|
291
|
+
child.stdout?.on("data", (chunk) => {
|
|
292
|
+
stdout += chunk.toString("utf8");
|
|
293
|
+
});
|
|
294
|
+
child.stderr?.on("data", (chunk) => {
|
|
295
|
+
stderr += chunk.toString("utf8");
|
|
296
|
+
});
|
|
297
|
+
const finish = (code) => {
|
|
298
|
+
if (settled)
|
|
299
|
+
return;
|
|
300
|
+
settled = true;
|
|
301
|
+
clearTimeout(timer);
|
|
302
|
+
resolve({ code, stdout, stderr, timedOut });
|
|
303
|
+
};
|
|
304
|
+
child.on("error", (error) => {
|
|
305
|
+
stderr += error.message;
|
|
306
|
+
finish(null);
|
|
307
|
+
});
|
|
308
|
+
child.on("close", (code) => finish(code));
|
|
309
|
+
try {
|
|
310
|
+
child.stdin?.end(JSON.stringify(payload));
|
|
311
|
+
}
|
|
312
|
+
catch {
|
|
313
|
+
// A hook that closes stdin early is not an error; the exit code decides.
|
|
314
|
+
}
|
|
315
|
+
});
|
|
316
|
+
}
|
|
317
|
+
/**
|
|
318
|
+
* Read a hook's answer. Pure, so every branch is testable without spawning.
|
|
319
|
+
*
|
|
320
|
+
* The precedence is the point: an explicit `permissionDecision` beats the exit
|
|
321
|
+
* code, and `exit 2` beats stdout, because a hook that wrote a reason to stderr
|
|
322
|
+
* and exited 2 means it — that is the convention.
|
|
323
|
+
*/
|
|
324
|
+
export function interpretHookResult(result) {
|
|
325
|
+
const { code, stdout, stderr, timedOut } = result;
|
|
326
|
+
const trimmedErr = stderr.trim();
|
|
327
|
+
if (timedOut)
|
|
328
|
+
return { block: false, error: `timed out after ${DEFAULT_TIMEOUT_S}s and was killed` };
|
|
329
|
+
const parsed = parseJsonObject(stdout);
|
|
330
|
+
// `additionalContext` is the field Claude Code uses for "here is more context";
|
|
331
|
+
// it is honoured whatever the exit code, because a hook can both add context
|
|
332
|
+
// and refuse.
|
|
333
|
+
const specific = parsed?.hookSpecificOutput;
|
|
334
|
+
const context = typeof specific?.additionalContext === "string" ? specific.additionalContext : typeof parsed?.additionalContext === "string" ? parsed.additionalContext : undefined;
|
|
335
|
+
const decision = typeof specific?.permissionDecision === "string" ? specific.permissionDecision : undefined;
|
|
336
|
+
const decisionReason = typeof specific?.permissionDecisionReason === "string" ? specific.permissionDecisionReason : undefined;
|
|
337
|
+
// `updatedInput` replaces what the tool will actually do. It is read here and
|
|
338
|
+
// validated by the caller, which is the only place that has the tool's schema.
|
|
339
|
+
const rewritten = specific?.updatedInput;
|
|
340
|
+
const updatedInput = rewritten !== null && typeof rewritten === "object" && !Array.isArray(rewritten) ? rewritten : undefined;
|
|
341
|
+
if (decision === "deny" || decision === "block") {
|
|
342
|
+
return { block: true, reason: decisionReason || trimmedErr || "denied by a hook", context };
|
|
343
|
+
}
|
|
344
|
+
if (decision === "allow")
|
|
345
|
+
return { block: false, context, updatedInput };
|
|
346
|
+
if (code === 2)
|
|
347
|
+
return { block: true, reason: trimmedErr || "blocked by a hook", context };
|
|
348
|
+
if (code !== 0) {
|
|
349
|
+
return {
|
|
350
|
+
block: false,
|
|
351
|
+
context,
|
|
352
|
+
updatedInput,
|
|
353
|
+
error: `exited ${code === null ? "without a status" : code}${trimmedErr ? `: ${trimmedErr}` : ""}`,
|
|
354
|
+
};
|
|
355
|
+
}
|
|
356
|
+
// Exit 0 with plain text is the simple form: the text is the context.
|
|
357
|
+
if (!parsed && stdout.trim())
|
|
358
|
+
return { block: false, context: stdout.trim() };
|
|
359
|
+
return { block: false, context, updatedInput };
|
|
360
|
+
}
|
|
361
|
+
function parseJsonObject(text) {
|
|
362
|
+
const trimmed = text.trim();
|
|
363
|
+
if (!trimmed.startsWith("{"))
|
|
364
|
+
return undefined;
|
|
365
|
+
try {
|
|
366
|
+
const value = JSON.parse(trimmed);
|
|
367
|
+
if (value && typeof value === "object" && !Array.isArray(value)) {
|
|
368
|
+
return value;
|
|
369
|
+
}
|
|
370
|
+
return undefined;
|
|
371
|
+
}
|
|
372
|
+
catch {
|
|
373
|
+
return undefined;
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
/** Commands already run, for `once`. Session-scoped by design. */
|
|
377
|
+
const firedOnce = new Set();
|
|
378
|
+
/** Reset the `once` ledger. Called when a session starts, so a resumed session re-arms. */
|
|
379
|
+
export function resetOnceHooks() {
|
|
380
|
+
firedOnce.clear();
|
|
381
|
+
}
|
|
382
|
+
/**
|
|
383
|
+
* Run every matcher for an event and combine the outcomes. A block wins over
|
|
384
|
+
* context and over a rewrite: refusing is the stronger statement, and a hook that
|
|
385
|
+
* said no meant it. Contexts from every hook are kept, in order, so two hooks can
|
|
386
|
+
* each add a piece. A rewrite is last-writer-wins, because two hooks rewriting the
|
|
387
|
+
* same call cannot both be right.
|
|
388
|
+
*
|
|
389
|
+
* `async: true` and `once: true` are handled here rather than at each call site,
|
|
390
|
+
* so no event can forget them. An async hook is started and **not awaited**: it
|
|
391
|
+
* cannot contribute a decision, only a side effect.
|
|
392
|
+
*/
|
|
393
|
+
export async function runHooksFor(config, event, payload, options) {
|
|
394
|
+
const matchers = matchersFor(config, event, payload.tool_name);
|
|
395
|
+
if (!matchers.length)
|
|
396
|
+
return { outcome: { block: false }, ran: 0 };
|
|
397
|
+
const contexts = [];
|
|
398
|
+
let ran = 0;
|
|
399
|
+
let block;
|
|
400
|
+
let error;
|
|
401
|
+
let updatedInput;
|
|
402
|
+
for (const matcher of matchers) {
|
|
403
|
+
for (const hook of matcher.hooks) {
|
|
404
|
+
if (hook.once) {
|
|
405
|
+
const key = `${event}\u0000${hook.command}`;
|
|
406
|
+
if (firedOnce.has(key))
|
|
407
|
+
continue;
|
|
408
|
+
firedOnce.add(key);
|
|
409
|
+
}
|
|
410
|
+
ran += 1;
|
|
411
|
+
if (hook.async) {
|
|
412
|
+
// Started, never awaited. A rejection is swallowed on purpose: an async
|
|
413
|
+
// hook has no channel to report on, and an unhandled rejection would take
|
|
414
|
+
// the process down for a log line.
|
|
415
|
+
void runHook(hook.command, payload, { cwd: options.cwd, timeoutS: hook.timeout }).catch(() => { });
|
|
416
|
+
continue;
|
|
417
|
+
}
|
|
418
|
+
const result = await runHook(hook.command, payload, {
|
|
419
|
+
cwd: options.cwd,
|
|
420
|
+
timeoutS: hook.timeout,
|
|
421
|
+
});
|
|
422
|
+
const outcome = interpretHookResult(result);
|
|
423
|
+
if (outcome.context)
|
|
424
|
+
contexts.push(outcome.context);
|
|
425
|
+
if (outcome.error)
|
|
426
|
+
error = outcome.error;
|
|
427
|
+
if (outcome.updatedInput)
|
|
428
|
+
updatedInput = outcome.updatedInput;
|
|
429
|
+
if (outcome.block && !block)
|
|
430
|
+
block = outcome;
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
if (block) {
|
|
434
|
+
return {
|
|
435
|
+
outcome: { block: true, reason: block.reason, context: contexts.join("\n\n") || undefined },
|
|
436
|
+
ran,
|
|
437
|
+
};
|
|
438
|
+
}
|
|
439
|
+
return {
|
|
440
|
+
outcome: { block: false, context: contexts.join("\n\n") || undefined, error, updatedInput },
|
|
441
|
+
ran,
|
|
442
|
+
};
|
|
443
|
+
}
|