@cleocode/adapters 2026.10.6 → 2026.10.7
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/heavy-command-hook.js +979 -0
- package/dist/heavy-command-hook.js.map +6 -0
- package/dist/index.js +179 -9
- package/dist/index.js.map +1 -1
- package/dist/providers/shared/heavy-command-hook-delivery.d.ts.map +1 -1
- package/dist/providers/shared/heavy-command-hook-install.d.ts +23 -0
- package/dist/providers/shared/heavy-command-hook-install.d.ts.map +1 -1
- package/package.json +5 -5
- package/src/providers/shared/__tests__/heavy-command-hook-delivery.test.ts +16 -0
- package/src/providers/shared/__tests__/heavy-command-hook-install.test.ts +40 -0
- package/src/providers/shared/heavy-command-hook-delivery.ts +23 -0
- package/src/providers/shared/heavy-command-hook-install.ts +85 -7
|
@@ -0,0 +1,979 @@
|
|
|
1
|
+
// packages/adapters/src/providers/shared/heavy-command-hook-delivery.ts
|
|
2
|
+
import { execFileSync as execFileSync2, spawnSync } from "node:child_process";
|
|
3
|
+
import { existsSync as existsSync2, lstatSync, readFileSync as readFileSync2, statSync as statSync2 } from "node:fs";
|
|
4
|
+
import { homedir as homedir2 } from "node:os";
|
|
5
|
+
import { dirname as dirname2, join as join2 } from "node:path";
|
|
6
|
+
import { JsonConfigParseError } from "@cleocode/caamp";
|
|
7
|
+
import { findOnPath } from "@cleocode/paths";
|
|
8
|
+
|
|
9
|
+
// packages/adapters/src/providers/shared/heavy-command-hook-install.ts
|
|
10
|
+
import { execFileSync } from "node:child_process";
|
|
11
|
+
import {
|
|
12
|
+
existsSync,
|
|
13
|
+
mkdirSync,
|
|
14
|
+
readdirSync,
|
|
15
|
+
readFileSync,
|
|
16
|
+
realpathSync,
|
|
17
|
+
rmSync,
|
|
18
|
+
statSync,
|
|
19
|
+
writeFileSync
|
|
20
|
+
} from "node:fs";
|
|
21
|
+
import { homedir, tmpdir } from "node:os";
|
|
22
|
+
import { dirname, isAbsolute, join, resolve } from "node:path";
|
|
23
|
+
import { updateJsonConfigFile } from "@cleocode/caamp";
|
|
24
|
+
|
|
25
|
+
// packages/adapters/src/providers/shared/hook-config.ts
|
|
26
|
+
var CLEO_HOOK_MARKER = "# cleo-hook";
|
|
27
|
+
function isPlainObject(value) {
|
|
28
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
29
|
+
}
|
|
30
|
+
function hookMap(settings) {
|
|
31
|
+
const existing = settings.hooks;
|
|
32
|
+
if (existing === void 0) {
|
|
33
|
+
const created = {};
|
|
34
|
+
settings.hooks = created;
|
|
35
|
+
return created;
|
|
36
|
+
}
|
|
37
|
+
if (!isPlainObject(existing)) {
|
|
38
|
+
throw new Error('settings.json "hooks" is not an object; not modifying it');
|
|
39
|
+
}
|
|
40
|
+
return existing;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// packages/adapters/src/providers/shared/heavy-command-hook-install.ts
|
|
44
|
+
var HEAVY_COMMAND_HOOK_ID = "cleo hook heavy-command";
|
|
45
|
+
var HEAVY_COMMAND_HOOK_TIMEOUT_SEC = 20;
|
|
46
|
+
var OPENCODE_HEAVY_COMMAND_PLUGIN = "cleo-heavy-command.js";
|
|
47
|
+
var HEAVY_TOOL_WORDS = Object.freeze([
|
|
48
|
+
"vitest",
|
|
49
|
+
"jest",
|
|
50
|
+
"mocha",
|
|
51
|
+
"ava",
|
|
52
|
+
"tap",
|
|
53
|
+
"playwright",
|
|
54
|
+
"pytest",
|
|
55
|
+
"rspec",
|
|
56
|
+
"phpunit",
|
|
57
|
+
"tsc",
|
|
58
|
+
"tsup",
|
|
59
|
+
"turbo",
|
|
60
|
+
"vite",
|
|
61
|
+
"esbuild",
|
|
62
|
+
"webpack",
|
|
63
|
+
"rollup",
|
|
64
|
+
"next",
|
|
65
|
+
"nx",
|
|
66
|
+
"biome",
|
|
67
|
+
"eslint",
|
|
68
|
+
"svelte-check",
|
|
69
|
+
"npm",
|
|
70
|
+
"pnpm",
|
|
71
|
+
"yarn",
|
|
72
|
+
"bun",
|
|
73
|
+
"npx",
|
|
74
|
+
"pnpx",
|
|
75
|
+
"bunx",
|
|
76
|
+
"cargo",
|
|
77
|
+
"go"
|
|
78
|
+
]);
|
|
79
|
+
function canonical(path) {
|
|
80
|
+
try {
|
|
81
|
+
return realpathSync.native(path);
|
|
82
|
+
} catch {
|
|
83
|
+
return resolve(path);
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
var HeavyHookPathBlockedError = class extends Error {
|
|
87
|
+
/** The existing path that is not a directory. */
|
|
88
|
+
path;
|
|
89
|
+
/**
|
|
90
|
+
* @param path - the existing non-directory path.
|
|
91
|
+
* @param target - the config file that would have lived under it.
|
|
92
|
+
*/
|
|
93
|
+
constructor(path, target) {
|
|
94
|
+
super(`${path} exists but is not a directory, so ${target} cannot be created under it`);
|
|
95
|
+
this.name = "HeavyHookPathBlockedError";
|
|
96
|
+
this.path = path;
|
|
97
|
+
}
|
|
98
|
+
};
|
|
99
|
+
function nonDirectoryAncestor(dir) {
|
|
100
|
+
let cur = resolve(dir);
|
|
101
|
+
for (; ; ) {
|
|
102
|
+
try {
|
|
103
|
+
return statSync(cur).isDirectory() ? null : cur;
|
|
104
|
+
} catch (err) {
|
|
105
|
+
const code = err.code;
|
|
106
|
+
if (code !== "ENOENT" && code !== "ENOTDIR") return null;
|
|
107
|
+
}
|
|
108
|
+
const parent = dirname(cur);
|
|
109
|
+
if (parent === cur) return null;
|
|
110
|
+
cur = parent;
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
function ensureParentDir(target) {
|
|
114
|
+
const blocked = nonDirectoryAncestor(dirname(target));
|
|
115
|
+
if (blocked !== null) throw new HeavyHookPathBlockedError(blocked, target);
|
|
116
|
+
mkdirSync(dirname(target), { recursive: true });
|
|
117
|
+
}
|
|
118
|
+
function isUserHomeDir(projectDir, home = homedir()) {
|
|
119
|
+
return canonical(projectDir) === canonical(home);
|
|
120
|
+
}
|
|
121
|
+
var OLDER_CLEO_HOOK_ANSWER = JSON.stringify({
|
|
122
|
+
hookSpecificOutput: {
|
|
123
|
+
hookEventName: "PreToolUse",
|
|
124
|
+
additionalContext: "[cleo] This may be a heavy command, but the cleo on PATH predates cleo hook and cleo run, so it runs outside the machine-wide resource budget. Upgrade CLEO to govern it."
|
|
125
|
+
}
|
|
126
|
+
});
|
|
127
|
+
var OLDER_CLEO_MARKER_PREFIX = "cleo-hook-unsupported-";
|
|
128
|
+
function clearOlderCleoMarkers(dir = process.env.XDG_RUNTIME_DIR || tmpdir()) {
|
|
129
|
+
let removed = 0;
|
|
130
|
+
let names;
|
|
131
|
+
try {
|
|
132
|
+
names = readdirSync(dir);
|
|
133
|
+
} catch {
|
|
134
|
+
return 0;
|
|
135
|
+
}
|
|
136
|
+
for (const name of names) {
|
|
137
|
+
if (!name.startsWith(OLDER_CLEO_MARKER_PREFIX)) continue;
|
|
138
|
+
try {
|
|
139
|
+
rmSync(join(dir, name), { force: true });
|
|
140
|
+
removed++;
|
|
141
|
+
} catch {
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
return removed;
|
|
145
|
+
}
|
|
146
|
+
function shellWord(text) {
|
|
147
|
+
return `'${text.replace(/'/g, `'\\''`)}'`;
|
|
148
|
+
}
|
|
149
|
+
function heavyCommandGuardScript(provider) {
|
|
150
|
+
const words = HEAVY_TOOL_WORDS.join("|");
|
|
151
|
+
const old = `printf '%s\\n' '${OLDER_CLEO_HOOK_ANSWER}'`;
|
|
152
|
+
return [
|
|
153
|
+
"command -v cleo >/dev/null 2>&1 || exit 0",
|
|
154
|
+
'in="$(cat)"',
|
|
155
|
+
"set -f",
|
|
156
|
+
"hit=",
|
|
157
|
+
// `[\]` (not `\\`) matches a backslash: it survives fish's quoting under Codex.
|
|
158
|
+
`for w in $(printf '%s' "$in" | sed 's/[\\][nrt]/ /g; s/[^A-Za-z0-9_./@+-]/ /g'); do case "\${w##*/}" in ${words}) hit=1; break;; esac; done`,
|
|
159
|
+
'[ -n "$hit" ] || exit 0',
|
|
160
|
+
'p="$(command -v cleo)"',
|
|
161
|
+
`d="\${XDG_RUNTIME_DIR:-\${TMPDIR:-/tmp}}"`,
|
|
162
|
+
`k="$d/${OLDER_CLEO_MARKER_PREFIX}$(printf '%s|%s' "$p" "$PWD" | cksum | tr -c '0-9' '-')"`,
|
|
163
|
+
`if [ -f "$k" ] && [ ! -L "$k" ] && [ -O "$k" ] && ! [ "$p" -nt "$k" ] && [ -n "$(find "$k" -mmin -60 2>/dev/null)" ]; then ${old}; exit 0; fi`,
|
|
164
|
+
'e="$(mktemp 2>/dev/null)" || e=',
|
|
165
|
+
// Only the older-CLEO signature (exit 127, "Unknown command hook") writes the
|
|
166
|
+
// marker; a crash or kill of a current CLEO fails open and asks again next time.
|
|
167
|
+
`if out="$(printf '%s' "$in" | ${HEAVY_COMMAND_HOOK_ID} --provider ${provider} 2>"\${e:-/dev/null}")"; then case "$out" in "{"*) printf '%s\\n' "$out";; esac; else rc=$?; if [ "$rc" = 127 ] && [ -n "$e" ] && grep -q 'Unknown command' "$e" 2>/dev/null; then t="$(mktemp "$k.XXXXXX" 2>/dev/null)" && mv -f "$t" "$k"; ${old}; fi; fi`,
|
|
168
|
+
'[ -z "$e" ] || rm -f "$e"',
|
|
169
|
+
"exit 0"
|
|
170
|
+
].join("; ");
|
|
171
|
+
}
|
|
172
|
+
function heavyCommandHookCommand(provider) {
|
|
173
|
+
const script = heavyCommandGuardScript(provider);
|
|
174
|
+
const line = provider === "codex" ? `/bin/sh -c ${shellWord(script)}` : script;
|
|
175
|
+
return `${line} ${CLEO_HOOK_MARKER}`;
|
|
176
|
+
}
|
|
177
|
+
function heavyCommandHookObject(provider) {
|
|
178
|
+
return {
|
|
179
|
+
type: "command",
|
|
180
|
+
command: heavyCommandHookCommand(provider),
|
|
181
|
+
timeout: HEAVY_COMMAND_HOOK_TIMEOUT_SEC
|
|
182
|
+
};
|
|
183
|
+
}
|
|
184
|
+
function heavyCommandHookEntry(provider) {
|
|
185
|
+
return { matcher: "Bash", hooks: [heavyCommandHookObject(provider)] };
|
|
186
|
+
}
|
|
187
|
+
function isHeavyHookObject(hook) {
|
|
188
|
+
return isPlainObject(hook) && typeof hook.command === "string" && hook.command.includes(CLEO_HOOK_MARKER) && hook.command.includes(HEAVY_COMMAND_HOOK_ID);
|
|
189
|
+
}
|
|
190
|
+
function placeHeavyHook(hooks, desired, event = "PreToolUse", isMine = isHeavyHookObject) {
|
|
191
|
+
const groups = hooks[event];
|
|
192
|
+
if (groups === void 0) return { changed: false, found: false };
|
|
193
|
+
if (!Array.isArray(groups)) {
|
|
194
|
+
throw new Error(`hooks "${event}" is not an array; not modifying it`);
|
|
195
|
+
}
|
|
196
|
+
let changed = false;
|
|
197
|
+
let found = false;
|
|
198
|
+
const next = [];
|
|
199
|
+
for (const group of groups) {
|
|
200
|
+
if (!isPlainObject(group) || !Array.isArray(group.hooks)) {
|
|
201
|
+
next.push(group);
|
|
202
|
+
continue;
|
|
203
|
+
}
|
|
204
|
+
const kept = [];
|
|
205
|
+
let groupChanged = false;
|
|
206
|
+
for (const hook of group.hooks) {
|
|
207
|
+
if (!isMine(hook)) {
|
|
208
|
+
kept.push(hook);
|
|
209
|
+
} else if (desired !== null && !found) {
|
|
210
|
+
found = true;
|
|
211
|
+
if (JSON.stringify(hook) !== JSON.stringify(desired)) groupChanged = true;
|
|
212
|
+
kept.push(desired);
|
|
213
|
+
} else {
|
|
214
|
+
groupChanged = true;
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
if (!groupChanged) {
|
|
218
|
+
next.push(group);
|
|
219
|
+
continue;
|
|
220
|
+
}
|
|
221
|
+
changed = true;
|
|
222
|
+
if (kept.length > 0) next.push({ ...group, hooks: kept });
|
|
223
|
+
}
|
|
224
|
+
if (changed) hooks[event] = next;
|
|
225
|
+
return { changed, found };
|
|
226
|
+
}
|
|
227
|
+
async function syncJsonHeavyCommandHook(configPath, provider, mode) {
|
|
228
|
+
if (mode === "off") {
|
|
229
|
+
if (!existsSync(configPath)) return "unchanged";
|
|
230
|
+
const removed = await updateJsonConfigFile(configPath, (config) => {
|
|
231
|
+
if (config.hooks === void 0) return false;
|
|
232
|
+
return placeHeavyHook(hookMap(config), null).changed;
|
|
233
|
+
});
|
|
234
|
+
return removed ? "removed" : "unchanged";
|
|
235
|
+
}
|
|
236
|
+
ensureParentDir(configPath);
|
|
237
|
+
clearOlderCleoMarkers();
|
|
238
|
+
let result = "unchanged";
|
|
239
|
+
await updateJsonConfigFile(configPath, (config) => {
|
|
240
|
+
const hooks = hookMap(config);
|
|
241
|
+
const placed = placeHeavyHook(hooks, heavyCommandHookObject(provider));
|
|
242
|
+
if (placed.found) {
|
|
243
|
+
if (placed.changed) result = "updated";
|
|
244
|
+
return placed.changed;
|
|
245
|
+
}
|
|
246
|
+
const existing = hooks.PreToolUse;
|
|
247
|
+
hooks.PreToolUse = [
|
|
248
|
+
...Array.isArray(existing) ? existing : [],
|
|
249
|
+
heavyCommandHookEntry(provider)
|
|
250
|
+
];
|
|
251
|
+
result = "installed";
|
|
252
|
+
return true;
|
|
253
|
+
});
|
|
254
|
+
return result;
|
|
255
|
+
}
|
|
256
|
+
var ASK_ENFORCE_HOOK_ID = "cleo hook ask-enforce";
|
|
257
|
+
function askEnforceHookObject(provider) {
|
|
258
|
+
const script = `command -v cleo >/dev/null 2>&1 || exit 0; out="$(${ASK_ENFORCE_HOOK_ID} --provider ${provider} 2>/dev/null)"; case "$out" in "{"*) printf '%s\\n' "$out";; esac; exit 0`;
|
|
259
|
+
const line = provider === "codex" ? `/bin/sh -c ${shellWord(script)}` : script;
|
|
260
|
+
return {
|
|
261
|
+
type: "command",
|
|
262
|
+
command: `${line} ${CLEO_HOOK_MARKER}`,
|
|
263
|
+
timeout: HEAVY_COMMAND_HOOK_TIMEOUT_SEC
|
|
264
|
+
};
|
|
265
|
+
}
|
|
266
|
+
function isAskEnforceHookObject(hook) {
|
|
267
|
+
return isPlainObject(hook) && typeof hook.command === "string" && hook.command.includes(CLEO_HOOK_MARKER) && hook.command.includes(ASK_ENFORCE_HOOK_ID);
|
|
268
|
+
}
|
|
269
|
+
async function syncJsonAskEnforceHook(configPath, provider, install) {
|
|
270
|
+
const mine = (hook) => isAskEnforceHookObject(hook);
|
|
271
|
+
if (!install) {
|
|
272
|
+
if (!existsSync(configPath)) return "unchanged";
|
|
273
|
+
const removed = await updateJsonConfigFile(configPath, (config) => {
|
|
274
|
+
if (config.hooks === void 0) return false;
|
|
275
|
+
return placeHeavyHook(hookMap(config), null, "Stop", mine).changed;
|
|
276
|
+
});
|
|
277
|
+
return removed ? "removed" : "unchanged";
|
|
278
|
+
}
|
|
279
|
+
ensureParentDir(configPath);
|
|
280
|
+
let result = "unchanged";
|
|
281
|
+
await updateJsonConfigFile(configPath, (config) => {
|
|
282
|
+
const hooks = hookMap(config);
|
|
283
|
+
const placed = placeHeavyHook(hooks, askEnforceHookObject(provider), "Stop", mine);
|
|
284
|
+
if (placed.found) {
|
|
285
|
+
if (placed.changed) result = "updated";
|
|
286
|
+
return placed.changed;
|
|
287
|
+
}
|
|
288
|
+
const existing = hooks.Stop;
|
|
289
|
+
hooks.Stop = [
|
|
290
|
+
...Array.isArray(existing) ? existing : [],
|
|
291
|
+
{ hooks: [askEnforceHookObject(provider)] }
|
|
292
|
+
];
|
|
293
|
+
result = "installed";
|
|
294
|
+
return true;
|
|
295
|
+
});
|
|
296
|
+
return result;
|
|
297
|
+
}
|
|
298
|
+
var CLAUDE_LOCAL_SETTINGS = ".claude/settings.local.json";
|
|
299
|
+
var LOCAL_SETTINGS_EXCLUDE_MARKER = "# cleo-hook: keep the per-machine heavy-command hook settings out of git (T12983)";
|
|
300
|
+
var HOOK_FILE_EXCLUDE_MARKER = "# cleo-hook: keep a per-machine heavy-command hook file out of git (T13124)";
|
|
301
|
+
var CODEX_HOOKS_FILE = ".codex/hooks.json";
|
|
302
|
+
var OPENCODE_PLUGIN_FILE = `.opencode/plugins/${OPENCODE_HEAVY_COMMAND_PLUGIN}`;
|
|
303
|
+
function gitOutput(projectDir, args) {
|
|
304
|
+
try {
|
|
305
|
+
return execFileSync("git", ["-C", projectDir, ...args], {
|
|
306
|
+
encoding: "utf-8",
|
|
307
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
308
|
+
timeout: 5e3
|
|
309
|
+
}).trim();
|
|
310
|
+
} catch {
|
|
311
|
+
return null;
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
function excludeFile(projectDir) {
|
|
315
|
+
if (gitOutput(projectDir, ["rev-parse", "--is-inside-work-tree"]) !== "true") return null;
|
|
316
|
+
const path = gitOutput(projectDir, ["rev-parse", "--git-path", "info/exclude"]);
|
|
317
|
+
if (path === null || path === "") return null;
|
|
318
|
+
return isAbsolute(path) ? path : join(projectDir, path);
|
|
319
|
+
}
|
|
320
|
+
function excludeLine(projectDir, relPath) {
|
|
321
|
+
const prefix = gitOutput(projectDir, ["rev-parse", "--show-prefix"]) ?? "";
|
|
322
|
+
return `/${prefix}${relPath}`;
|
|
323
|
+
}
|
|
324
|
+
function excludeHookFileFromGit(projectDir, relPath, marker = HOOK_FILE_EXCLUDE_MARKER) {
|
|
325
|
+
const exclude = excludeFile(projectDir);
|
|
326
|
+
if (exclude === null) return false;
|
|
327
|
+
if (gitOutput(projectDir, ["check-ignore", relPath]) !== null) return false;
|
|
328
|
+
if (gitOutput(projectDir, ["ls-files", "--error-unmatch", relPath]) !== null) return false;
|
|
329
|
+
const line = excludeLine(projectDir, relPath);
|
|
330
|
+
const text = existsSync(exclude) ? readFileSync(exclude, "utf-8") : "";
|
|
331
|
+
if (text.split("\n").includes(line)) return false;
|
|
332
|
+
const block = `${marker}
|
|
333
|
+
${line}
|
|
334
|
+
`;
|
|
335
|
+
mkdirSync(dirname(exclude), { recursive: true });
|
|
336
|
+
writeFileSync(exclude, text === "" || text.endsWith("\n") ? text + block : `${text}
|
|
337
|
+
${block}`);
|
|
338
|
+
return true;
|
|
339
|
+
}
|
|
340
|
+
function unexcludeHookFileFromGit(projectDir, relPath) {
|
|
341
|
+
const exclude = excludeFile(projectDir);
|
|
342
|
+
if (exclude === null || !existsSync(exclude)) return false;
|
|
343
|
+
const lines = readFileSync(exclude, "utf-8").split("\n");
|
|
344
|
+
const at = lines.indexOf(excludeLine(projectDir, relPath));
|
|
345
|
+
const above = lines[at - 1];
|
|
346
|
+
if (at < 1 || above !== LOCAL_SETTINGS_EXCLUDE_MARKER && above !== HOOK_FILE_EXCLUDE_MARKER) {
|
|
347
|
+
return false;
|
|
348
|
+
}
|
|
349
|
+
lines.splice(at - 1, 2);
|
|
350
|
+
writeFileSync(exclude, lines.join("\n"));
|
|
351
|
+
return true;
|
|
352
|
+
}
|
|
353
|
+
function hookFileVisibleToGit(projectDir, relPath) {
|
|
354
|
+
if (gitOutput(projectDir, ["rev-parse", "--is-inside-work-tree"]) !== "true") return false;
|
|
355
|
+
if (gitOutput(projectDir, ["check-ignore", relPath]) !== null) return false;
|
|
356
|
+
return gitOutput(projectDir, ["ls-files", "--error-unmatch", relPath]) === null;
|
|
357
|
+
}
|
|
358
|
+
function excludeLocalSettingsFromGit(projectDir) {
|
|
359
|
+
return excludeHookFileFromGit(projectDir, CLAUDE_LOCAL_SETTINGS, LOCAL_SETTINGS_EXCLUDE_MARKER);
|
|
360
|
+
}
|
|
361
|
+
function unexcludeLocalSettingsFromGit(projectDir) {
|
|
362
|
+
return unexcludeHookFileFromGit(projectDir, CLAUDE_LOCAL_SETTINGS);
|
|
363
|
+
}
|
|
364
|
+
var HeavyHookSharedConfigError = class extends Error {
|
|
365
|
+
/** The project's own config file. */
|
|
366
|
+
path;
|
|
367
|
+
/**
|
|
368
|
+
* @param path - the config file.
|
|
369
|
+
* @param why - what makes it the project's own.
|
|
370
|
+
*/
|
|
371
|
+
constructor(path, why) {
|
|
372
|
+
super(`${path} ${why}, so CLEO does not write or hide it`);
|
|
373
|
+
this.name = "HeavyHookSharedConfigError";
|
|
374
|
+
this.path = path;
|
|
375
|
+
}
|
|
376
|
+
};
|
|
377
|
+
function cleoCreatedConfig(config) {
|
|
378
|
+
if (!isPlainObject(config) || Object.keys(config).some((k) => k !== "hooks")) return false;
|
|
379
|
+
if (!isPlainObject(config.hooks)) return false;
|
|
380
|
+
let mine = 0;
|
|
381
|
+
for (const groups of Object.values(config.hooks)) {
|
|
382
|
+
if (!Array.isArray(groups)) return false;
|
|
383
|
+
for (const group of groups) {
|
|
384
|
+
if (!isPlainObject(group) || !Array.isArray(group.hooks)) return false;
|
|
385
|
+
for (const hook of group.hooks) {
|
|
386
|
+
if (!isPlainObject(hook) || typeof hook.command !== "string" || !hook.command.includes(CLEO_HOOK_MARKER)) {
|
|
387
|
+
return false;
|
|
388
|
+
}
|
|
389
|
+
mine++;
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
return mine > 0;
|
|
394
|
+
}
|
|
395
|
+
function codexHooksSharedReason(projectDir, checkGit = true) {
|
|
396
|
+
const file = join(projectDir, CODEX_HOOKS_FILE);
|
|
397
|
+
if (checkGit && gitOutput(projectDir, ["ls-files", "--error-unmatch", CODEX_HOOKS_FILE]) !== null) {
|
|
398
|
+
return "is tracked by git (a shared team config)";
|
|
399
|
+
}
|
|
400
|
+
if (!existsSync(file)) return null;
|
|
401
|
+
let config;
|
|
402
|
+
try {
|
|
403
|
+
config = JSON.parse(readFileSync(file, "utf-8"));
|
|
404
|
+
} catch {
|
|
405
|
+
return "is the project's own Codex hook config (empty or not valid JSON)";
|
|
406
|
+
}
|
|
407
|
+
return cleoCreatedConfig(config) ? null : "is the project's own Codex hook config";
|
|
408
|
+
}
|
|
409
|
+
function committedCodexHookHoldsCleo(projectDir) {
|
|
410
|
+
const committed = gitOutput(projectDir, ["show", `HEAD:./${CODEX_HOOKS_FILE}`]);
|
|
411
|
+
if (committed === null || committed === "") return false;
|
|
412
|
+
try {
|
|
413
|
+
const config = JSON.parse(committed);
|
|
414
|
+
if (!isPlainObject(config)) return false;
|
|
415
|
+
const groups = isPlainObject(config.hooks) ? config.hooks.PreToolUse : void 0;
|
|
416
|
+
return Array.isArray(groups) && groups.some(
|
|
417
|
+
(g) => isPlainObject(g) && Array.isArray(g.hooks) && g.hooks.some(isHeavyHookObject)
|
|
418
|
+
);
|
|
419
|
+
} catch {
|
|
420
|
+
return false;
|
|
421
|
+
}
|
|
422
|
+
}
|
|
423
|
+
function jsonEqual(a, b) {
|
|
424
|
+
if (Array.isArray(a) || Array.isArray(b)) {
|
|
425
|
+
return Array.isArray(a) && Array.isArray(b) && a.length === b.length && a.every((v, i) => jsonEqual(v, b[i]));
|
|
426
|
+
}
|
|
427
|
+
if (isPlainObject(a) && isPlainObject(b)) {
|
|
428
|
+
const keys = Object.keys(a);
|
|
429
|
+
return keys.length === Object.keys(b).length && keys.every((k) => Object.hasOwn(b, k) && jsonEqual(a[k], b[k]));
|
|
430
|
+
}
|
|
431
|
+
return a === b;
|
|
432
|
+
}
|
|
433
|
+
function restoreCommittedCodexHooks(projectDir) {
|
|
434
|
+
if (gitOutput(projectDir, ["ls-files", "--error-unmatch", CODEX_HOOKS_FILE]) === null)
|
|
435
|
+
return false;
|
|
436
|
+
const file = join(projectDir, CODEX_HOOKS_FILE);
|
|
437
|
+
let committed;
|
|
438
|
+
let working;
|
|
439
|
+
try {
|
|
440
|
+
committed = execFileSync("git", ["-C", projectDir, "show", `HEAD:./${CODEX_HOOKS_FILE}`], {
|
|
441
|
+
encoding: "utf-8",
|
|
442
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
443
|
+
timeout: 5e3
|
|
444
|
+
});
|
|
445
|
+
working = JSON.parse(readFileSync(file, "utf-8"));
|
|
446
|
+
} catch {
|
|
447
|
+
return false;
|
|
448
|
+
}
|
|
449
|
+
if (!isPlainObject(working)) return false;
|
|
450
|
+
const hooks = hookMap(working);
|
|
451
|
+
if (!placeHeavyHook(hooks, null).changed) return false;
|
|
452
|
+
try {
|
|
453
|
+
if (!jsonEqual(working, JSON.parse(committed))) return false;
|
|
454
|
+
} catch {
|
|
455
|
+
return false;
|
|
456
|
+
}
|
|
457
|
+
writeFileSync(file, committed);
|
|
458
|
+
return true;
|
|
459
|
+
}
|
|
460
|
+
function excludedByCleo(projectDir, relPath) {
|
|
461
|
+
const exclude = excludeFile(projectDir);
|
|
462
|
+
if (exclude === null || !existsSync(exclude)) return false;
|
|
463
|
+
const lines = readFileSync(exclude, "utf-8").split("\n");
|
|
464
|
+
const at = lines.indexOf(excludeLine(projectDir, relPath));
|
|
465
|
+
const above = lines[at - 1];
|
|
466
|
+
return at >= 1 && (above === LOCAL_SETTINGS_EXCLUDE_MARKER || above === HOOK_FILE_EXCLUDE_MARKER);
|
|
467
|
+
}
|
|
468
|
+
function codexHookSnippet() {
|
|
469
|
+
return JSON.stringify(heavyCommandHookEntry("codex"));
|
|
470
|
+
}
|
|
471
|
+
function trackedFileModified(projectDir, relPath) {
|
|
472
|
+
if (gitOutput(projectDir, ["ls-files", "--error-unmatch", relPath]) === null) return false;
|
|
473
|
+
return (gitOutput(projectDir, ["diff", "--name-only", "HEAD", "--", relPath]) ?? "") !== "";
|
|
474
|
+
}
|
|
475
|
+
async function syncCodexHeavyCommandHook(projectDir, mode) {
|
|
476
|
+
const file = join(projectDir, CODEX_HOOKS_FILE);
|
|
477
|
+
if (mode !== "off") {
|
|
478
|
+
const shared = codexHooksSharedReason(projectDir);
|
|
479
|
+
if (shared !== null) {
|
|
480
|
+
unexcludeHookFileFromGit(projectDir, CODEX_HOOKS_FILE);
|
|
481
|
+
throw new HeavyHookSharedConfigError(file, shared);
|
|
482
|
+
}
|
|
483
|
+
} else if (committedCodexHookHoldsCleo(projectDir)) {
|
|
484
|
+
throw new HeavyHookSharedConfigError(
|
|
485
|
+
file,
|
|
486
|
+
"holds CLEO's hook as the team committed it (removing it is a team change)"
|
|
487
|
+
);
|
|
488
|
+
} else if (restoreCommittedCodexHooks(projectDir)) {
|
|
489
|
+
unexcludeHookFileFromGit(projectDir, CODEX_HOOKS_FILE);
|
|
490
|
+
return "removed";
|
|
491
|
+
}
|
|
492
|
+
const result = await syncJsonHeavyCommandHook(file, "codex", mode);
|
|
493
|
+
if (mode === "off") unexcludeHookFileFromGit(projectDir, CODEX_HOOKS_FILE);
|
|
494
|
+
else excludeHookFileFromGit(projectDir, CODEX_HOOKS_FILE);
|
|
495
|
+
return result;
|
|
496
|
+
}
|
|
497
|
+
async function syncClaudeCodeHeavyCommandHook(projectDir, mode) {
|
|
498
|
+
const result = await syncJsonHeavyCommandHook(
|
|
499
|
+
join(projectDir, CLAUDE_LOCAL_SETTINGS),
|
|
500
|
+
"claude-code",
|
|
501
|
+
mode
|
|
502
|
+
);
|
|
503
|
+
if (mode === "off") unexcludeLocalSettingsFromGit(projectDir);
|
|
504
|
+
else excludeLocalSettingsFromGit(projectDir);
|
|
505
|
+
try {
|
|
506
|
+
await syncJsonHeavyCommandHook(
|
|
507
|
+
join(projectDir, ".claude", "settings.json"),
|
|
508
|
+
"claude-code",
|
|
509
|
+
"off"
|
|
510
|
+
);
|
|
511
|
+
} catch {
|
|
512
|
+
}
|
|
513
|
+
return result;
|
|
514
|
+
}
|
|
515
|
+
function opencodeHeavyCommandPluginSource() {
|
|
516
|
+
return [
|
|
517
|
+
"// CLEO heavy-command plugin for OpenCode (generated by @cleocode/adapters, T12983).",
|
|
518
|
+
"// Routes heavy shell commands (tests, builds, typechecks, installs) through",
|
|
519
|
+
"// `cleo run`, the machine-wide resource budget. Opt out with",
|
|
520
|
+
'// CLEO_HEAVY_COMMAND_HOOK=off, or set resources.heavyCommandHook to "off" and',
|
|
521
|
+
"// run `cleo upgrade` (which deletes this file). Fails open: on any error the",
|
|
522
|
+
"// command runs unchanged.",
|
|
523
|
+
"",
|
|
524
|
+
"import { execFile } from 'node:child_process';",
|
|
525
|
+
"",
|
|
526
|
+
`const HEAVY = /(?:^|[^A-Za-z0-9_.@+-])(?:${HEAVY_TOOL_WORDS.join("|")})(?=$|[^A-Za-z0-9_.@+-])/;`,
|
|
527
|
+
"const contexts = new Map();",
|
|
528
|
+
"",
|
|
529
|
+
"function ask(payload) {",
|
|
530
|
+
" return new Promise((done) => {",
|
|
531
|
+
" const child = execFile(",
|
|
532
|
+
" 'cleo',",
|
|
533
|
+
" ['hook', 'heavy-command', '--provider', 'opencode'],",
|
|
534
|
+
` { timeout: ${HEAVY_COMMAND_HOOK_TIMEOUT_SEC * 1e3} },`,
|
|
535
|
+
" (err, stdout) => done(err ? '' : stdout),",
|
|
536
|
+
" );",
|
|
537
|
+
" child.on('error', () => done(''));",
|
|
538
|
+
" child.stdin?.end(JSON.stringify(payload));",
|
|
539
|
+
" });",
|
|
540
|
+
"}",
|
|
541
|
+
"",
|
|
542
|
+
"export const CleoHeavyCommand = async ({ directory }) => ({",
|
|
543
|
+
" 'tool.execute.before': async (input, output) => {",
|
|
544
|
+
" if (input?.tool !== 'bash' || typeof output?.args?.command !== 'string') return;",
|
|
545
|
+
" if (process.env.CLEO_HEAVY_COMMAND_HOOK === 'off') return;",
|
|
546
|
+
" if (!HEAVY.test(output.args.command)) return;",
|
|
547
|
+
" try {",
|
|
548
|
+
" const text = await ask({ tool_name: 'bash', tool_input: output.args, cwd: directory });",
|
|
549
|
+
" const answer = JSON.parse(text || '{}');",
|
|
550
|
+
" if (typeof answer.command === 'string') output.args.command = answer.command;",
|
|
551
|
+
" if (typeof answer.timeout === 'number') output.args.timeout = answer.timeout;",
|
|
552
|
+
" if (typeof answer.context === 'string' && input.callID) contexts.set(input.callID, answer.context);",
|
|
553
|
+
" } catch {",
|
|
554
|
+
" // Fail open.",
|
|
555
|
+
" }",
|
|
556
|
+
" },",
|
|
557
|
+
" 'tool.execute.after': async (input, output) => {",
|
|
558
|
+
" const context = input?.callID ? contexts.get(input.callID) : undefined;",
|
|
559
|
+
" if (context === undefined) return;",
|
|
560
|
+
" contexts.delete(input.callID);",
|
|
561
|
+
" if (typeof output?.output === 'string') output.output += '\\n\\n' + context;",
|
|
562
|
+
" },",
|
|
563
|
+
"});",
|
|
564
|
+
""
|
|
565
|
+
].join("\n");
|
|
566
|
+
}
|
|
567
|
+
function syncOpencodeHeavyCommandPlugin(projectDir, mode) {
|
|
568
|
+
const pluginPath = join(projectDir, OPENCODE_PLUGIN_FILE);
|
|
569
|
+
const exists = existsSync(pluginPath);
|
|
570
|
+
if (mode === "off") {
|
|
571
|
+
unexcludeHookFileFromGit(projectDir, OPENCODE_PLUGIN_FILE);
|
|
572
|
+
if (!exists) return "unchanged";
|
|
573
|
+
rmSync(pluginPath, { force: true });
|
|
574
|
+
return "removed";
|
|
575
|
+
}
|
|
576
|
+
const source = opencodeHeavyCommandPluginSource();
|
|
577
|
+
const current = exists && readFileSync(pluginPath, "utf-8") === source;
|
|
578
|
+
if (!current) {
|
|
579
|
+
ensureParentDir(pluginPath);
|
|
580
|
+
writeFileSync(pluginPath, source, "utf-8");
|
|
581
|
+
}
|
|
582
|
+
excludeHookFileFromGit(projectDir, OPENCODE_PLUGIN_FILE);
|
|
583
|
+
return current ? "unchanged" : exists ? "updated" : "installed";
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
// packages/adapters/src/providers/shared/heavy-command-hook-delivery.ts
|
|
587
|
+
var HEAVY_HOOK_DELIVERY_PROVIDERS = Object.freeze([
|
|
588
|
+
"claude-code",
|
|
589
|
+
"codex",
|
|
590
|
+
"opencode",
|
|
591
|
+
"kimi"
|
|
592
|
+
]);
|
|
593
|
+
var HEAVY_HOOK_FIX_COMMAND = "cleo doctor heavy-command-hook --fix";
|
|
594
|
+
var CODEX_SHARED_REMEDY = "if the team wants Codex governed here, add CLEO's PreToolUse entry (the snippet) to .codex/hooks.json yourself and commit it deliberately; CLEO never writes or hides a project's own hook config";
|
|
595
|
+
var KIMI_REASON = "Kimi reads hooks only from its global ~/.kimi/config.toml, and CLEO never writes user-global configs";
|
|
596
|
+
var KIMI_REMEDY = "add a [[hooks]] entry for PreToolUse that runs `cleo hook heavy-command --provider kimi` to ~/.kimi/config.toml yourself";
|
|
597
|
+
function homeOf(env) {
|
|
598
|
+
return env.HOME || homedir2();
|
|
599
|
+
}
|
|
600
|
+
function heavyHookTarget(provider, projectDir, env = process.env) {
|
|
601
|
+
switch (provider) {
|
|
602
|
+
case "claude-code":
|
|
603
|
+
return join2(projectDir, CLAUDE_LOCAL_SETTINGS);
|
|
604
|
+
case "codex":
|
|
605
|
+
return join2(projectDir, CODEX_HOOKS_FILE);
|
|
606
|
+
case "opencode":
|
|
607
|
+
return join2(projectDir, OPENCODE_PLUGIN_FILE);
|
|
608
|
+
case "kimi":
|
|
609
|
+
return join2(homeOf(env), ".kimi", "config.toml");
|
|
610
|
+
}
|
|
611
|
+
}
|
|
612
|
+
function detectHeavyHookProvider(provider, projectDir, env = process.env) {
|
|
613
|
+
const home = homeOf(env);
|
|
614
|
+
const signals = provider === "claude-code" ? [
|
|
615
|
+
[
|
|
616
|
+
env.CLAUDECODE === "1" || Boolean(env.CLAUDE_CODE_ENTRYPOINT),
|
|
617
|
+
"running inside Claude Code"
|
|
618
|
+
],
|
|
619
|
+
[() => existsSync2(join2(projectDir, ".claude")), "the project has .claude/"],
|
|
620
|
+
[() => findOnPath("claude", { env }) !== null, "`claude` is on PATH"],
|
|
621
|
+
[() => existsSync2(env.CLAUDE_CONFIG_DIR || join2(home, ".claude")), "~/.claude exists"]
|
|
622
|
+
] : provider === "codex" ? [
|
|
623
|
+
[() => existsSync2(join2(projectDir, ".codex")), "the project has .codex"],
|
|
624
|
+
[() => findOnPath("codex", { env }) !== null, "`codex` is on PATH"],
|
|
625
|
+
[() => existsSync2(env.CODEX_HOME || join2(home, ".codex")), "~/.codex exists"]
|
|
626
|
+
] : provider === "opencode" ? [
|
|
627
|
+
[() => existsSync2(join2(projectDir, ".opencode")), "the project has .opencode/"],
|
|
628
|
+
[
|
|
629
|
+
() => existsSync2(join2(projectDir, "opencode.json")) || existsSync2(join2(projectDir, "opencode.jsonc")),
|
|
630
|
+
"the project has opencode.json"
|
|
631
|
+
],
|
|
632
|
+
[() => findOnPath("opencode", { env }) !== null, "`opencode` is on PATH"],
|
|
633
|
+
[
|
|
634
|
+
() => existsSync2(join2(env.XDG_CONFIG_HOME || join2(home, ".config"), "opencode")),
|
|
635
|
+
"~/.config/opencode exists"
|
|
636
|
+
]
|
|
637
|
+
] : [
|
|
638
|
+
[() => findOnPath("kimi", { env }) !== null, "`kimi` is on PATH"],
|
|
639
|
+
[() => existsSync2(join2(home, ".kimi")), "~/.kimi exists"]
|
|
640
|
+
];
|
|
641
|
+
for (const [test, why] of signals) {
|
|
642
|
+
if (typeof test === "boolean" ? test : test()) return { detected: true, why };
|
|
643
|
+
}
|
|
644
|
+
if (provider !== "kimi" && existsSync2(heavyHookTarget(provider, projectDir, env))) {
|
|
645
|
+
return { detected: true, why: "its hook config exists" };
|
|
646
|
+
}
|
|
647
|
+
return {
|
|
648
|
+
detected: false,
|
|
649
|
+
why: `${provider} is not in use here (no config directory or CLI found)`
|
|
650
|
+
};
|
|
651
|
+
}
|
|
652
|
+
function errorText(err) {
|
|
653
|
+
return err instanceof Error ? err.message : String(err);
|
|
654
|
+
}
|
|
655
|
+
async function syncAskEnforceHook(provider, target, mode, env) {
|
|
656
|
+
const install = mode !== "off" && env.CLEO_ASK_ENFORCE?.trim().toLowerCase() !== "off";
|
|
657
|
+
try {
|
|
658
|
+
await syncJsonAskEnforceHook(target, provider, install);
|
|
659
|
+
} catch {
|
|
660
|
+
}
|
|
661
|
+
}
|
|
662
|
+
async function syncProvider(provider, projectDir, mode, env) {
|
|
663
|
+
const target = heavyHookTarget(provider, projectDir, env);
|
|
664
|
+
if (provider === "kimi") {
|
|
665
|
+
return mode === "off" ? { provider, status: "unchanged", target } : { provider, status: "unsupported", target, reason: KIMI_REASON, remedy: KIMI_REMEDY };
|
|
666
|
+
}
|
|
667
|
+
try {
|
|
668
|
+
const result = provider === "claude-code" ? await syncClaudeCodeHeavyCommandHook(projectDir, mode) : provider === "codex" ? await syncCodexHeavyCommandHook(projectDir, mode) : syncOpencodeHeavyCommandPlugin(projectDir, mode);
|
|
669
|
+
if (provider !== "opencode") await syncAskEnforceHook(provider, target, mode, env);
|
|
670
|
+
return { provider, status: result, target };
|
|
671
|
+
} catch (err) {
|
|
672
|
+
if (err instanceof HeavyHookSharedConfigError) {
|
|
673
|
+
return {
|
|
674
|
+
provider,
|
|
675
|
+
status: "needs-consent",
|
|
676
|
+
target,
|
|
677
|
+
reason: err.message,
|
|
678
|
+
...mode === "off" ? {
|
|
679
|
+
remedy: "the team committed CLEO's hook in .codex/hooks.json; remove it there in a team change if the team agrees"
|
|
680
|
+
} : { remedy: CODEX_SHARED_REMEDY, snippet: codexHookSnippet() }
|
|
681
|
+
};
|
|
682
|
+
}
|
|
683
|
+
if (err instanceof HeavyHookPathBlockedError) {
|
|
684
|
+
return {
|
|
685
|
+
provider,
|
|
686
|
+
status: "blocked",
|
|
687
|
+
target,
|
|
688
|
+
reason: err.message,
|
|
689
|
+
remedy: `remove or rename ${err.path} (it is not a directory), then run: ${HEAVY_HOOK_FIX_COMMAND}`
|
|
690
|
+
};
|
|
691
|
+
}
|
|
692
|
+
return {
|
|
693
|
+
provider,
|
|
694
|
+
status: "failed",
|
|
695
|
+
target,
|
|
696
|
+
reason: errorText(err),
|
|
697
|
+
remedy: err instanceof JsonConfigParseError ? `fix the JSON in ${err.filePath} (it was left untouched), then run: ${HEAVY_HOOK_FIX_COMMAND}` : `run: ${HEAVY_HOOK_FIX_COMMAND}`
|
|
698
|
+
};
|
|
699
|
+
}
|
|
700
|
+
}
|
|
701
|
+
async function syncProjectHeavyCommandHooks(projectDir, mode, options = {}) {
|
|
702
|
+
const env = options.env ?? process.env;
|
|
703
|
+
const providers = options.providers ?? HEAVY_HOOK_DELIVERY_PROVIDERS;
|
|
704
|
+
const home = isUserHomeDir(projectDir, homeOf(env));
|
|
705
|
+
const outcomes = [];
|
|
706
|
+
for (const provider of providers) {
|
|
707
|
+
const target = heavyHookTarget(provider, projectDir, env);
|
|
708
|
+
if (home) {
|
|
709
|
+
outcomes.push({
|
|
710
|
+
provider,
|
|
711
|
+
status: "skipped",
|
|
712
|
+
target,
|
|
713
|
+
reason: "the project is your home directory, whose provider configs are user-global"
|
|
714
|
+
});
|
|
715
|
+
continue;
|
|
716
|
+
}
|
|
717
|
+
const detection = detectHeavyHookProvider(provider, projectDir, env);
|
|
718
|
+
if (mode !== "off" && !detection.detected) {
|
|
719
|
+
outcomes.push({ provider, status: "skipped", target, reason: detection.why });
|
|
720
|
+
continue;
|
|
721
|
+
}
|
|
722
|
+
outcomes.push(await syncProvider(provider, projectDir, mode, env));
|
|
723
|
+
}
|
|
724
|
+
return outcomes;
|
|
725
|
+
}
|
|
726
|
+
function jsonHookPresence(file, provider) {
|
|
727
|
+
let raw;
|
|
728
|
+
try {
|
|
729
|
+
raw = readFileSync2(file, "utf-8");
|
|
730
|
+
} catch (err) {
|
|
731
|
+
const code = err.code;
|
|
732
|
+
return code === "ENOENT" || code === "ENOTDIR" ? { kind: "absent" } : { kind: "unreadable", error: errorText(err) };
|
|
733
|
+
}
|
|
734
|
+
if (raw.trim() === "") return { kind: "absent" };
|
|
735
|
+
let config;
|
|
736
|
+
try {
|
|
737
|
+
config = JSON.parse(raw);
|
|
738
|
+
} catch (err) {
|
|
739
|
+
return { kind: "unreadable", error: errorText(err) };
|
|
740
|
+
}
|
|
741
|
+
if (!isPlainObject(config))
|
|
742
|
+
return { kind: "unreadable", error: "top-level value is not an object" };
|
|
743
|
+
const hooks = config.hooks;
|
|
744
|
+
const groups = isPlainObject(hooks) ? hooks.PreToolUse : void 0;
|
|
745
|
+
if (!Array.isArray(groups)) return { kind: "absent" };
|
|
746
|
+
const expected = JSON.stringify(heavyCommandHookObject(provider));
|
|
747
|
+
let found = { kind: "absent" };
|
|
748
|
+
for (const group of groups) {
|
|
749
|
+
if (!isPlainObject(group) || !Array.isArray(group.hooks)) continue;
|
|
750
|
+
for (const hook of group.hooks) {
|
|
751
|
+
if (!isHeavyHookObject(hook)) continue;
|
|
752
|
+
if (JSON.stringify(hook) === expected) return { kind: "present", current: true };
|
|
753
|
+
found = { kind: "present", current: false };
|
|
754
|
+
}
|
|
755
|
+
}
|
|
756
|
+
return found;
|
|
757
|
+
}
|
|
758
|
+
function presenceOf(provider, projectDir, target) {
|
|
759
|
+
if (provider !== "opencode") {
|
|
760
|
+
const local = jsonHookPresence(target, provider);
|
|
761
|
+
if (provider !== "claude-code" || local.kind !== "absent") return local;
|
|
762
|
+
const legacy = jsonHookPresence(join2(projectDir, ".claude", "settings.json"), provider);
|
|
763
|
+
return legacy.kind === "present" ? { kind: "present", current: false } : { kind: "absent" };
|
|
764
|
+
}
|
|
765
|
+
if (!existsSync2(target)) return { kind: "absent" };
|
|
766
|
+
try {
|
|
767
|
+
return {
|
|
768
|
+
kind: "present",
|
|
769
|
+
current: readFileSync2(target, "utf-8") === opencodeHeavyCommandPluginSource()
|
|
770
|
+
};
|
|
771
|
+
} catch (err) {
|
|
772
|
+
return { kind: "unreadable", error: errorText(err) };
|
|
773
|
+
}
|
|
774
|
+
}
|
|
775
|
+
var HOOK_FILES = {
|
|
776
|
+
"claude-code": CLAUDE_LOCAL_SETTINGS,
|
|
777
|
+
codex: CODEX_HOOKS_FILE,
|
|
778
|
+
opencode: OPENCODE_PLUGIN_FILE
|
|
779
|
+
};
|
|
780
|
+
function inspectProvider(provider, projectDir, mode, env, gitChecks) {
|
|
781
|
+
const target = heavyHookTarget(provider, projectDir, env);
|
|
782
|
+
const { detected, why } = detectHeavyHookProvider(provider, projectDir, env);
|
|
783
|
+
const base = { provider, detected, target };
|
|
784
|
+
if (provider === "kimi") {
|
|
785
|
+
if (!detected || mode === "off") return { ...base, state: "not-detected", detail: why };
|
|
786
|
+
let manual = false;
|
|
787
|
+
try {
|
|
788
|
+
manual = readFileSync2(target, "utf-8").includes(HEAVY_COMMAND_HOOK_ID);
|
|
789
|
+
} catch {
|
|
790
|
+
}
|
|
791
|
+
return manual ? { ...base, state: "installed", detail: `a manual entry in ${target} runs the hook` } : { ...base, state: "unsupported", detail: `${why}; ${KIMI_REASON}`, remedy: KIMI_REMEDY };
|
|
792
|
+
}
|
|
793
|
+
const presence = presenceOf(provider, projectDir, target);
|
|
794
|
+
if (presence.kind === "unreadable") {
|
|
795
|
+
return {
|
|
796
|
+
...base,
|
|
797
|
+
state: "unreadable",
|
|
798
|
+
detail: `${target} cannot be read or parsed (${presence.error})`,
|
|
799
|
+
remedy: `fix ${target}, then run: ${HEAVY_HOOK_FIX_COMMAND}`
|
|
800
|
+
};
|
|
801
|
+
}
|
|
802
|
+
if (mode === "off") {
|
|
803
|
+
return presence.kind === "present" ? {
|
|
804
|
+
...base,
|
|
805
|
+
state: "outdated",
|
|
806
|
+
detail: "resources.heavyCommandHook is off, but the hook is still installed",
|
|
807
|
+
remedy: `run: ${HEAVY_HOOK_FIX_COMMAND} (removes it)`
|
|
808
|
+
} : { ...base, state: "disabled", detail: "resources.heavyCommandHook is off" };
|
|
809
|
+
}
|
|
810
|
+
if (presence.kind === "present") {
|
|
811
|
+
if (!presence.current) {
|
|
812
|
+
return {
|
|
813
|
+
...base,
|
|
814
|
+
state: "outdated",
|
|
815
|
+
detail: `the hook in ${target} (or a legacy location) was written by another CLEO build`,
|
|
816
|
+
remedy: `run: ${HEAVY_HOOK_FIX_COMMAND}`
|
|
817
|
+
};
|
|
818
|
+
}
|
|
819
|
+
if (!gitChecks) return { ...base, state: "installed", detail: `installed in ${target}` };
|
|
820
|
+
const shared2 = provider === "codex" ? codexHooksSharedReason(projectDir, true) : null;
|
|
821
|
+
if (shared2 !== null && excludedByCleo(projectDir, CODEX_HOOKS_FILE)) {
|
|
822
|
+
return {
|
|
823
|
+
...base,
|
|
824
|
+
state: "outdated",
|
|
825
|
+
detail: `CLEO's info/exclude line hides ${target}, which ${shared2}`,
|
|
826
|
+
remedy: `run: ${HEAVY_HOOK_FIX_COMMAND} (removes CLEO's exclude line)`
|
|
827
|
+
};
|
|
828
|
+
}
|
|
829
|
+
if (shared2 !== null) {
|
|
830
|
+
const modified = trackedFileModified(projectDir, CODEX_HOOKS_FILE);
|
|
831
|
+
if (modified || hookFileVisibleToGit(projectDir, CODEX_HOOKS_FILE)) {
|
|
832
|
+
return {
|
|
833
|
+
...base,
|
|
834
|
+
state: "outdated",
|
|
835
|
+
detail: `CLEO's per-machine hook sits in ${target}, which ${shared2}, as ${modified ? "an uncommitted change" : "an untracked file"}: one \`git commit -a\` ships it to the team, and it widens evidence scope`,
|
|
836
|
+
remedy: "remove CLEO's entry from .codex/hooks.json, or commit it deliberately if the team wants Codex governed"
|
|
837
|
+
};
|
|
838
|
+
}
|
|
839
|
+
return { ...base, state: "installed", detail: `installed in ${target}, which ${shared2}` };
|
|
840
|
+
}
|
|
841
|
+
if (hookFileVisibleToGit(projectDir, HOOK_FILES[provider])) {
|
|
842
|
+
return {
|
|
843
|
+
...base,
|
|
844
|
+
state: "outdated",
|
|
845
|
+
detail: `${target} is installed but git sees it as an untracked file (it widens evidence scope)`,
|
|
846
|
+
remedy: `run: ${HEAVY_HOOK_FIX_COMMAND} (adds it to the repository's info/exclude)`
|
|
847
|
+
};
|
|
848
|
+
}
|
|
849
|
+
return { ...base, state: "installed", detail: `installed in ${target}` };
|
|
850
|
+
}
|
|
851
|
+
if (!detected) return { ...base, state: "not-detected", detail: why };
|
|
852
|
+
const shared = provider === "codex" ? codexHooksSharedReason(projectDir, gitChecks) : null;
|
|
853
|
+
if (shared !== null && gitChecks && excludedByCleo(projectDir, CODEX_HOOKS_FILE)) {
|
|
854
|
+
return {
|
|
855
|
+
...base,
|
|
856
|
+
state: "outdated",
|
|
857
|
+
detail: `CLEO's info/exclude line hides ${target}, which ${shared}`,
|
|
858
|
+
remedy: `run: ${HEAVY_HOOK_FIX_COMMAND} (removes CLEO's exclude line)`
|
|
859
|
+
};
|
|
860
|
+
}
|
|
861
|
+
if (shared !== null) {
|
|
862
|
+
return {
|
|
863
|
+
...base,
|
|
864
|
+
state: "needs-consent",
|
|
865
|
+
detail: `${target} ${shared}, so CLEO does not write or hide it; Codex runs ungoverned here until the team adds the entry (${why})`,
|
|
866
|
+
remedy: CODEX_SHARED_REMEDY,
|
|
867
|
+
snippet: codexHookSnippet()
|
|
868
|
+
};
|
|
869
|
+
}
|
|
870
|
+
const blocked = nonDirectoryAncestor(dirname2(target));
|
|
871
|
+
if (blocked !== null) {
|
|
872
|
+
return {
|
|
873
|
+
...base,
|
|
874
|
+
state: "blocked",
|
|
875
|
+
detail: `${blocked} exists but is not a directory, so the hook cannot be installed (${why})`,
|
|
876
|
+
remedy: `remove or rename ${blocked}, then run: ${HEAVY_HOOK_FIX_COMMAND}`
|
|
877
|
+
};
|
|
878
|
+
}
|
|
879
|
+
return {
|
|
880
|
+
...base,
|
|
881
|
+
state: "missing",
|
|
882
|
+
detail: `${why}, but ${target} has no heavy-command hook: agent-run tests and builds bypass the machine-wide budget`,
|
|
883
|
+
remedy: `run: ${HEAVY_HOOK_FIX_COMMAND}`
|
|
884
|
+
};
|
|
885
|
+
}
|
|
886
|
+
function inspectProjectHeavyCommandHooks(projectDir, mode, options = {}) {
|
|
887
|
+
const env = options.env ?? process.env;
|
|
888
|
+
const providers = options.providers ?? HEAVY_HOOK_DELIVERY_PROVIDERS;
|
|
889
|
+
if (isUserHomeDir(projectDir, homeOf(env))) {
|
|
890
|
+
return providers.map((provider) => ({
|
|
891
|
+
provider,
|
|
892
|
+
detected: false,
|
|
893
|
+
state: "not-detected",
|
|
894
|
+
target: heavyHookTarget(provider, projectDir, env),
|
|
895
|
+
detail: "the project is your home directory; CLEO installs this hook per project only"
|
|
896
|
+
}));
|
|
897
|
+
}
|
|
898
|
+
const gitChecks = options.gitChecks ?? true;
|
|
899
|
+
return providers.map((provider) => inspectProvider(provider, projectDir, mode, env, gitChecks));
|
|
900
|
+
}
|
|
901
|
+
var OLDER_CLEO_MARKER_TTL_MS = 60 * 60 * 1e3;
|
|
902
|
+
function olderCleoMarkerPath(cleoPath, dir, env) {
|
|
903
|
+
try {
|
|
904
|
+
const key = execFileSync2(
|
|
905
|
+
"/bin/sh",
|
|
906
|
+
["-c", `printf '%s|%s' "$1" "$2" | cksum | tr -c '0-9' '-'`, "sh", cleoPath, dir],
|
|
907
|
+
{ encoding: "utf-8", stdio: ["ignore", "pipe", "ignore"], timeout: 5e3 }
|
|
908
|
+
);
|
|
909
|
+
const base = env.XDG_RUNTIME_DIR || env.TMPDIR || "/tmp";
|
|
910
|
+
return join2(base, `${OLDER_CLEO_MARKER_PREFIX}${key}`);
|
|
911
|
+
} catch {
|
|
912
|
+
return null;
|
|
913
|
+
}
|
|
914
|
+
}
|
|
915
|
+
function hasOlderCleoMarker(cleoPath, dir, env) {
|
|
916
|
+
const marker = olderCleoMarkerPath(cleoPath, dir, env);
|
|
917
|
+
if (marker === null) return false;
|
|
918
|
+
try {
|
|
919
|
+
const st = lstatSync(marker);
|
|
920
|
+
if (!st.isFile()) return false;
|
|
921
|
+
if (Date.now() - st.mtimeMs > OLDER_CLEO_MARKER_TTL_MS) return false;
|
|
922
|
+
return statSync2(cleoPath).mtimeMs <= st.mtimeMs;
|
|
923
|
+
} catch {
|
|
924
|
+
return false;
|
|
925
|
+
}
|
|
926
|
+
}
|
|
927
|
+
function probeHeavyHookCli(projectDir, options = {}) {
|
|
928
|
+
const env = options.env ?? process.env;
|
|
929
|
+
const path = findOnPath("cleo", { env });
|
|
930
|
+
if (path === null) {
|
|
931
|
+
return {
|
|
932
|
+
state: "missing",
|
|
933
|
+
path: null,
|
|
934
|
+
detail: "no `cleo` on PATH, so the hook lets every heavy command run ungoverned",
|
|
935
|
+
remedy: "install CLEO so `cleo` is on PATH: npm i -g @cleocode/cleo"
|
|
936
|
+
};
|
|
937
|
+
}
|
|
938
|
+
const older = {
|
|
939
|
+
state: "older",
|
|
940
|
+
path,
|
|
941
|
+
detail: `${path} predates \`cleo hook\` and \`cleo run\`, so the hook lets every heavy command run ungoverned`,
|
|
942
|
+
remedy: `upgrade the cleo at ${path} (npm i -g @cleocode/cleo, or your version manager), then run: ${HEAVY_HOOK_FIX_COMMAND}`
|
|
943
|
+
};
|
|
944
|
+
if (hasOlderCleoMarker(path, projectDir, env)) return older;
|
|
945
|
+
const probe = spawnSync(path, ["hook", "heavy-command", "--provider", "claude-code"], {
|
|
946
|
+
cwd: projectDir,
|
|
947
|
+
env: { ...env },
|
|
948
|
+
input: "{}",
|
|
949
|
+
encoding: "utf-8",
|
|
950
|
+
timeout: 2e4
|
|
951
|
+
});
|
|
952
|
+
if (probe.status === 0) {
|
|
953
|
+
return { state: "current", path, detail: `${path} answers \`cleo hook heavy-command\`` };
|
|
954
|
+
}
|
|
955
|
+
if (probe.status === 127 && /Unknown command/.test(probe.stderr ?? "")) return older;
|
|
956
|
+
const outcome = probe.status !== null ? `exit ${probe.status}` : probe.error?.message ?? `signal ${probe.signal ?? "none"}`;
|
|
957
|
+
return {
|
|
958
|
+
state: "unknown",
|
|
959
|
+
path,
|
|
960
|
+
detail: `${path} did not answer \`cleo hook heavy-command\` (${outcome}); the hook fails open, so heavy commands run ungoverned while it does not`,
|
|
961
|
+
remedy: `run \`${path} hook heavy-command < /dev/null\` in ${projectDir} to see why it fails (a broken version-manager shim, say)`
|
|
962
|
+
};
|
|
963
|
+
}
|
|
964
|
+
var heavyHookDeliveryApi = Object.freeze({
|
|
965
|
+
syncProjectHeavyCommandHooks,
|
|
966
|
+
inspectProjectHeavyCommandHooks,
|
|
967
|
+
probeHeavyHookCli
|
|
968
|
+
});
|
|
969
|
+
export {
|
|
970
|
+
HEAVY_HOOK_DELIVERY_PROVIDERS,
|
|
971
|
+
HEAVY_HOOK_FIX_COMMAND,
|
|
972
|
+
detectHeavyHookProvider,
|
|
973
|
+
heavyHookDeliveryApi,
|
|
974
|
+
heavyHookTarget,
|
|
975
|
+
inspectProjectHeavyCommandHooks,
|
|
976
|
+
probeHeavyHookCli,
|
|
977
|
+
syncProjectHeavyCommandHooks
|
|
978
|
+
};
|
|
979
|
+
//# sourceMappingURL=heavy-command-hook.js.map
|