@cleocode/adapters 2026.10.3 → 2026.10.4
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.d.ts +12 -0
- package/dist/heavy-command-hook.d.ts.map +1 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +647 -49
- package/dist/index.js.map +3 -3
- package/dist/providers/codex/install.d.ts.map +1 -1
- package/dist/providers/shared/heavy-command-hook-delivery.d.ts +97 -0
- package/dist/providers/shared/heavy-command-hook-delivery.d.ts.map +1 -0
- package/dist/providers/shared/heavy-command-hook-install.d.ts +153 -7
- package/dist/providers/shared/heavy-command-hook-install.d.ts.map +1 -1
- package/package.json +9 -5
- package/src/heavy-command-hook.ts +21 -0
- package/src/index.ts +12 -0
- package/src/providers/codex/install.ts +3 -4
- package/src/providers/shared/__tests__/heavy-command-hook-delivery.test.ts +580 -0
- package/src/providers/shared/heavy-command-hook-delivery.ts +620 -0
- package/src/providers/shared/heavy-command-hook-install.ts +395 -31
|
@@ -0,0 +1,620 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Deliver CLEO's heavy-command hook to every provider a project uses (T13124).
|
|
3
|
+
*
|
|
4
|
+
* `cleo init`, `cleo upgrade`, `cleo doctor` and the session briefing reach the
|
|
5
|
+
* per-provider installers in {@link ./heavy-command-hook-install.js} through
|
|
6
|
+
* this module. It used to go through `AdapterManager.discover()`, which looks for
|
|
7
|
+
* manifests at `<project>/packages/adapters/<dir>/manifest.json`, a path that
|
|
8
|
+
* exists in no project, cleocode included. So the hook was never installed
|
|
9
|
+
* anywhere, and the per-adapter try/catch hid every failure.
|
|
10
|
+
*
|
|
11
|
+
* Each provider is handled on its own: a provider that cannot take the hook
|
|
12
|
+
* (a stray `.codex` file, a malformed settings file) gets a `blocked` or
|
|
13
|
+
* `failed` outcome with the reason and the exact remedy, and the other
|
|
14
|
+
* providers still install. Nothing here throws.
|
|
15
|
+
*
|
|
16
|
+
* A provider counts as in use when the project has its config directory, its
|
|
17
|
+
* CLI is on `PATH`, its user config directory exists, or the hook config
|
|
18
|
+
* already exists. The home directory is only READ for that check. CLEO writes
|
|
19
|
+
* project-level configs only, never a user-global one.
|
|
20
|
+
*
|
|
21
|
+
* @task T13124
|
|
22
|
+
* @epic T13121
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
import { execFileSync, spawnSync } from 'node:child_process';
|
|
26
|
+
import { existsSync, lstatSync, readFileSync, statSync } from 'node:fs';
|
|
27
|
+
import { homedir } from 'node:os';
|
|
28
|
+
import { dirname, join } from 'node:path';
|
|
29
|
+
import { JsonConfigParseError } from '@cleocode/caamp';
|
|
30
|
+
import type {
|
|
31
|
+
HeavyCommandHookMode,
|
|
32
|
+
HeavyCommandHookProvider,
|
|
33
|
+
HeavyHookCliProbe,
|
|
34
|
+
HeavyHookDeliveryApi,
|
|
35
|
+
HeavyHookDeliveryOptions,
|
|
36
|
+
HeavyHookDeliveryOutcome,
|
|
37
|
+
HeavyHookInspection,
|
|
38
|
+
} from '@cleocode/contracts';
|
|
39
|
+
import { findOnPath } from '@cleocode/paths';
|
|
40
|
+
import {
|
|
41
|
+
CLAUDE_LOCAL_SETTINGS,
|
|
42
|
+
CODEX_HOOKS_FILE,
|
|
43
|
+
codexHookSnippet,
|
|
44
|
+
codexHooksSharedReason,
|
|
45
|
+
excludedByCleo,
|
|
46
|
+
HEAVY_COMMAND_HOOK_ID,
|
|
47
|
+
HeavyHookPathBlockedError,
|
|
48
|
+
HeavyHookSharedConfigError,
|
|
49
|
+
heavyCommandHookObject,
|
|
50
|
+
hookFileVisibleToGit,
|
|
51
|
+
isHeavyHookObject,
|
|
52
|
+
isUserHomeDir,
|
|
53
|
+
nonDirectoryAncestor,
|
|
54
|
+
OLDER_CLEO_MARKER_PREFIX,
|
|
55
|
+
OPENCODE_PLUGIN_FILE,
|
|
56
|
+
opencodeHeavyCommandPluginSource,
|
|
57
|
+
syncClaudeCodeHeavyCommandHook,
|
|
58
|
+
syncCodexHeavyCommandHook,
|
|
59
|
+
syncOpencodeHeavyCommandPlugin,
|
|
60
|
+
trackedFileModified,
|
|
61
|
+
} from './heavy-command-hook-install.js';
|
|
62
|
+
import { isPlainObject } from './hook-config.js';
|
|
63
|
+
|
|
64
|
+
/** Every provider the delivery considers, in report order. */
|
|
65
|
+
export const HEAVY_HOOK_DELIVERY_PROVIDERS: readonly HeavyCommandHookProvider[] = Object.freeze([
|
|
66
|
+
'claude-code',
|
|
67
|
+
'codex',
|
|
68
|
+
'opencode',
|
|
69
|
+
'kimi',
|
|
70
|
+
]);
|
|
71
|
+
|
|
72
|
+
/** The focused remedy: installs or refreshes only this hook. */
|
|
73
|
+
export const HEAVY_HOOK_FIX_COMMAND = 'cleo doctor heavy-command-hook --fix';
|
|
74
|
+
|
|
75
|
+
type Env = Readonly<Record<string, string | undefined>>;
|
|
76
|
+
|
|
77
|
+
/** What to do when Codex's `hooks.json` is the project's own (review MED-3). */
|
|
78
|
+
const CODEX_SHARED_REMEDY =
|
|
79
|
+
"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";
|
|
80
|
+
|
|
81
|
+
/** Kimi has no project-level hook config, so CLEO cannot install for it. */
|
|
82
|
+
const KIMI_REASON =
|
|
83
|
+
'Kimi reads hooks only from its global ~/.kimi/config.toml, and CLEO never writes user-global configs';
|
|
84
|
+
const KIMI_REMEDY =
|
|
85
|
+
'add a [[hooks]] entry for PreToolUse that runs `cleo hook heavy-command --provider kimi` to ~/.kimi/config.toml yourself';
|
|
86
|
+
|
|
87
|
+
function homeOf(env: Env): string {
|
|
88
|
+
return env.HOME || homedir();
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* The file a provider's project-level hook lives in (Kimi: its global config,
|
|
93
|
+
* named for the report only; CLEO never writes it).
|
|
94
|
+
*
|
|
95
|
+
* @param provider - the harness.
|
|
96
|
+
* @param projectDir - the project root.
|
|
97
|
+
* @param env - environment (for Kimi's home directory).
|
|
98
|
+
* @returns the absolute path.
|
|
99
|
+
*/
|
|
100
|
+
export function heavyHookTarget(
|
|
101
|
+
provider: HeavyCommandHookProvider,
|
|
102
|
+
projectDir: string,
|
|
103
|
+
env: Env = process.env,
|
|
104
|
+
): string {
|
|
105
|
+
switch (provider) {
|
|
106
|
+
case 'claude-code':
|
|
107
|
+
return join(projectDir, CLAUDE_LOCAL_SETTINGS);
|
|
108
|
+
case 'codex':
|
|
109
|
+
return join(projectDir, CODEX_HOOKS_FILE);
|
|
110
|
+
case 'opencode':
|
|
111
|
+
return join(projectDir, OPENCODE_PLUGIN_FILE);
|
|
112
|
+
case 'kimi':
|
|
113
|
+
return join(homeOf(env), '.kimi', 'config.toml');
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** Whether a provider is in use, and the first signal that says so. */
|
|
118
|
+
export interface HeavyHookDetection {
|
|
119
|
+
/** The provider is in use on this machine or in this project. */
|
|
120
|
+
readonly detected: boolean;
|
|
121
|
+
/** The signal found, or what was checked when nothing was. */
|
|
122
|
+
readonly why: string;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Whether `provider` is in use here, from cheap read-only signals: its
|
|
127
|
+
* project config directory, its CLI on `PATH`, its user config directory, or
|
|
128
|
+
* an existing hook config.
|
|
129
|
+
*
|
|
130
|
+
* @param provider - the harness.
|
|
131
|
+
* @param projectDir - the project root.
|
|
132
|
+
* @param env - environment (`PATH`, `HOME`, `CODEX_HOME`, `XDG_CONFIG_HOME`, …).
|
|
133
|
+
* @returns whether it is in use, and why.
|
|
134
|
+
*/
|
|
135
|
+
export function detectHeavyHookProvider(
|
|
136
|
+
provider: HeavyCommandHookProvider,
|
|
137
|
+
projectDir: string,
|
|
138
|
+
env: Env = process.env,
|
|
139
|
+
): HeavyHookDetection {
|
|
140
|
+
const home = homeOf(env);
|
|
141
|
+
const signals: ReadonlyArray<readonly [boolean | (() => boolean), string]> =
|
|
142
|
+
provider === 'claude-code'
|
|
143
|
+
? [
|
|
144
|
+
[
|
|
145
|
+
env.CLAUDECODE === '1' || Boolean(env.CLAUDE_CODE_ENTRYPOINT),
|
|
146
|
+
'running inside Claude Code',
|
|
147
|
+
],
|
|
148
|
+
[() => existsSync(join(projectDir, '.claude')), 'the project has .claude/'],
|
|
149
|
+
[() => findOnPath('claude', { env }) !== null, '`claude` is on PATH'],
|
|
150
|
+
[() => existsSync(env.CLAUDE_CONFIG_DIR || join(home, '.claude')), '~/.claude exists'],
|
|
151
|
+
]
|
|
152
|
+
: provider === 'codex'
|
|
153
|
+
? [
|
|
154
|
+
[() => existsSync(join(projectDir, '.codex')), 'the project has .codex'],
|
|
155
|
+
[() => findOnPath('codex', { env }) !== null, '`codex` is on PATH'],
|
|
156
|
+
[() => existsSync(env.CODEX_HOME || join(home, '.codex')), '~/.codex exists'],
|
|
157
|
+
]
|
|
158
|
+
: provider === 'opencode'
|
|
159
|
+
? [
|
|
160
|
+
[() => existsSync(join(projectDir, '.opencode')), 'the project has .opencode/'],
|
|
161
|
+
[
|
|
162
|
+
() =>
|
|
163
|
+
existsSync(join(projectDir, 'opencode.json')) ||
|
|
164
|
+
existsSync(join(projectDir, 'opencode.jsonc')),
|
|
165
|
+
'the project has opencode.json',
|
|
166
|
+
],
|
|
167
|
+
[() => findOnPath('opencode', { env }) !== null, '`opencode` is on PATH'],
|
|
168
|
+
[
|
|
169
|
+
() => existsSync(join(env.XDG_CONFIG_HOME || join(home, '.config'), 'opencode')),
|
|
170
|
+
'~/.config/opencode exists',
|
|
171
|
+
],
|
|
172
|
+
]
|
|
173
|
+
: [
|
|
174
|
+
[() => findOnPath('kimi', { env }) !== null, '`kimi` is on PATH'],
|
|
175
|
+
[() => existsSync(join(home, '.kimi')), '~/.kimi exists'],
|
|
176
|
+
];
|
|
177
|
+
for (const [test, why] of signals) {
|
|
178
|
+
if (typeof test === 'boolean' ? test : test()) return { detected: true, why };
|
|
179
|
+
}
|
|
180
|
+
if (provider !== 'kimi' && existsSync(heavyHookTarget(provider, projectDir, env))) {
|
|
181
|
+
return { detected: true, why: 'its hook config exists' };
|
|
182
|
+
}
|
|
183
|
+
return {
|
|
184
|
+
detected: false,
|
|
185
|
+
why: `${provider} is not in use here (no config directory or CLI found)`,
|
|
186
|
+
};
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
function errorText(err: unknown): string {
|
|
190
|
+
return err instanceof Error ? err.message : String(err);
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/** Sync one provider; never throws. */
|
|
194
|
+
async function syncProvider(
|
|
195
|
+
provider: HeavyCommandHookProvider,
|
|
196
|
+
projectDir: string,
|
|
197
|
+
mode: HeavyCommandHookMode,
|
|
198
|
+
env: Env,
|
|
199
|
+
): Promise<HeavyHookDeliveryOutcome> {
|
|
200
|
+
const target = heavyHookTarget(provider, projectDir, env);
|
|
201
|
+
if (provider === 'kimi') {
|
|
202
|
+
return mode === 'off'
|
|
203
|
+
? { provider, status: 'unchanged', target }
|
|
204
|
+
: { provider, status: 'unsupported', target, reason: KIMI_REASON, remedy: KIMI_REMEDY };
|
|
205
|
+
}
|
|
206
|
+
try {
|
|
207
|
+
const result =
|
|
208
|
+
provider === 'claude-code'
|
|
209
|
+
? await syncClaudeCodeHeavyCommandHook(projectDir, mode)
|
|
210
|
+
: provider === 'codex'
|
|
211
|
+
? await syncCodexHeavyCommandHook(projectDir, mode)
|
|
212
|
+
: syncOpencodeHeavyCommandPlugin(projectDir, mode);
|
|
213
|
+
return { provider, status: result, target };
|
|
214
|
+
} catch (err) {
|
|
215
|
+
if (err instanceof HeavyHookSharedConfigError) {
|
|
216
|
+
return {
|
|
217
|
+
provider,
|
|
218
|
+
status: 'needs-consent',
|
|
219
|
+
target,
|
|
220
|
+
reason: err.message,
|
|
221
|
+
...(mode === 'off'
|
|
222
|
+
? {
|
|
223
|
+
remedy:
|
|
224
|
+
"the team committed CLEO's hook in .codex/hooks.json; remove it there in a team change if the team agrees",
|
|
225
|
+
}
|
|
226
|
+
: { remedy: CODEX_SHARED_REMEDY, snippet: codexHookSnippet() }),
|
|
227
|
+
};
|
|
228
|
+
}
|
|
229
|
+
if (err instanceof HeavyHookPathBlockedError) {
|
|
230
|
+
return {
|
|
231
|
+
provider,
|
|
232
|
+
status: 'blocked',
|
|
233
|
+
target,
|
|
234
|
+
reason: err.message,
|
|
235
|
+
remedy: `remove or rename ${err.path} (it is not a directory), then run: ${HEAVY_HOOK_FIX_COMMAND}`,
|
|
236
|
+
};
|
|
237
|
+
}
|
|
238
|
+
return {
|
|
239
|
+
provider,
|
|
240
|
+
status: 'failed',
|
|
241
|
+
target,
|
|
242
|
+
reason: errorText(err),
|
|
243
|
+
remedy:
|
|
244
|
+
err instanceof JsonConfigParseError
|
|
245
|
+
? `fix the JSON in ${err.filePath} (it was left untouched), then run: ${HEAVY_HOOK_FIX_COMMAND}`
|
|
246
|
+
: `run: ${HEAVY_HOOK_FIX_COMMAND}`,
|
|
247
|
+
};
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/**
|
|
252
|
+
* Install, refresh or (mode `off`) remove CLEO's heavy-command hook for every
|
|
253
|
+
* provider in use in `projectDir`, each independently: one provider's failure
|
|
254
|
+
* never stops another. Mode `off` cleans every provider, in use or not. The
|
|
255
|
+
* home directory is skipped (its provider configs are the user-global ones).
|
|
256
|
+
*
|
|
257
|
+
* @param projectDir - the project root.
|
|
258
|
+
* @param mode - the resolved hook mode (`resources.heavyCommandHook`).
|
|
259
|
+
* @param options - environment and provider list (tests).
|
|
260
|
+
* @returns one outcome per provider, in {@link HEAVY_HOOK_DELIVERY_PROVIDERS} order.
|
|
261
|
+
*/
|
|
262
|
+
export async function syncProjectHeavyCommandHooks(
|
|
263
|
+
projectDir: string,
|
|
264
|
+
mode: HeavyCommandHookMode,
|
|
265
|
+
options: HeavyHookDeliveryOptions = {},
|
|
266
|
+
): Promise<readonly HeavyHookDeliveryOutcome[]> {
|
|
267
|
+
const env = options.env ?? process.env;
|
|
268
|
+
const providers = options.providers ?? HEAVY_HOOK_DELIVERY_PROVIDERS;
|
|
269
|
+
const home = isUserHomeDir(projectDir, homeOf(env));
|
|
270
|
+
const outcomes: HeavyHookDeliveryOutcome[] = [];
|
|
271
|
+
for (const provider of providers) {
|
|
272
|
+
const target = heavyHookTarget(provider, projectDir, env);
|
|
273
|
+
if (home) {
|
|
274
|
+
outcomes.push({
|
|
275
|
+
provider,
|
|
276
|
+
status: 'skipped',
|
|
277
|
+
target,
|
|
278
|
+
reason: 'the project is your home directory, whose provider configs are user-global',
|
|
279
|
+
});
|
|
280
|
+
continue;
|
|
281
|
+
}
|
|
282
|
+
const detection = detectHeavyHookProvider(provider, projectDir, env);
|
|
283
|
+
if (mode !== 'off' && !detection.detected) {
|
|
284
|
+
outcomes.push({ provider, status: 'skipped', target, reason: detection.why });
|
|
285
|
+
continue;
|
|
286
|
+
}
|
|
287
|
+
outcomes.push(await syncProvider(provider, projectDir, mode, env));
|
|
288
|
+
}
|
|
289
|
+
return outcomes;
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
/** What a JSON hook config holds: CLEO's hook (current or not), or why it cannot say. */
|
|
293
|
+
type JsonHookPresence =
|
|
294
|
+
| { readonly kind: 'absent' }
|
|
295
|
+
| { readonly kind: 'present'; readonly current: boolean }
|
|
296
|
+
| { readonly kind: 'unreadable'; readonly error: string };
|
|
297
|
+
|
|
298
|
+
/** Find CLEO's heavy-command hook in a JSON hook config (`hooks.PreToolUse[].hooks[]`). */
|
|
299
|
+
function jsonHookPresence(file: string, provider: 'claude-code' | 'codex'): JsonHookPresence {
|
|
300
|
+
let raw: string;
|
|
301
|
+
try {
|
|
302
|
+
raw = readFileSync(file, 'utf-8');
|
|
303
|
+
} catch (err) {
|
|
304
|
+
const code = (err as NodeJS.ErrnoException).code;
|
|
305
|
+
return code === 'ENOENT' || code === 'ENOTDIR'
|
|
306
|
+
? { kind: 'absent' }
|
|
307
|
+
: { kind: 'unreadable', error: errorText(err) };
|
|
308
|
+
}
|
|
309
|
+
if (raw.trim() === '') return { kind: 'absent' };
|
|
310
|
+
let config: unknown;
|
|
311
|
+
try {
|
|
312
|
+
config = JSON.parse(raw);
|
|
313
|
+
} catch (err) {
|
|
314
|
+
return { kind: 'unreadable', error: errorText(err) };
|
|
315
|
+
}
|
|
316
|
+
if (!isPlainObject(config))
|
|
317
|
+
return { kind: 'unreadable', error: 'top-level value is not an object' };
|
|
318
|
+
const hooks = config.hooks;
|
|
319
|
+
const groups = isPlainObject(hooks) ? hooks.PreToolUse : undefined;
|
|
320
|
+
if (!Array.isArray(groups)) return { kind: 'absent' };
|
|
321
|
+
const expected = JSON.stringify(heavyCommandHookObject(provider));
|
|
322
|
+
let found: JsonHookPresence = { kind: 'absent' };
|
|
323
|
+
for (const group of groups) {
|
|
324
|
+
if (!isPlainObject(group) || !Array.isArray(group.hooks)) continue;
|
|
325
|
+
for (const hook of group.hooks) {
|
|
326
|
+
if (!isHeavyHookObject(hook)) continue;
|
|
327
|
+
if (JSON.stringify(hook) === expected) return { kind: 'present', current: true };
|
|
328
|
+
found = { kind: 'present', current: false };
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
return found;
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
/** The hook's presence in a provider's target. */
|
|
335
|
+
function presenceOf(
|
|
336
|
+
provider: 'claude-code' | 'codex' | 'opencode',
|
|
337
|
+
projectDir: string,
|
|
338
|
+
target: string,
|
|
339
|
+
): JsonHookPresence {
|
|
340
|
+
if (provider !== 'opencode') {
|
|
341
|
+
const local = jsonHookPresence(target, provider);
|
|
342
|
+
if (provider !== 'claude-code' || local.kind !== 'absent') return local;
|
|
343
|
+
// An earlier build wrote it to the shared settings.json; upgrade moves it.
|
|
344
|
+
const legacy = jsonHookPresence(join(projectDir, '.claude', 'settings.json'), provider);
|
|
345
|
+
return legacy.kind === 'present' ? { kind: 'present', current: false } : { kind: 'absent' };
|
|
346
|
+
}
|
|
347
|
+
if (!existsSync(target)) return { kind: 'absent' };
|
|
348
|
+
try {
|
|
349
|
+
return {
|
|
350
|
+
kind: 'present',
|
|
351
|
+
current: readFileSync(target, 'utf-8') === opencodeHeavyCommandPluginSource(),
|
|
352
|
+
};
|
|
353
|
+
} catch (err) {
|
|
354
|
+
return { kind: 'unreadable', error: errorText(err) };
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
/** Each provider's hook file, relative to the project (forward slashes). */
|
|
359
|
+
const HOOK_FILES: Readonly<Record<'claude-code' | 'codex' | 'opencode', string>> = {
|
|
360
|
+
'claude-code': CLAUDE_LOCAL_SETTINGS,
|
|
361
|
+
codex: CODEX_HOOKS_FILE,
|
|
362
|
+
opencode: OPENCODE_PLUGIN_FILE,
|
|
363
|
+
};
|
|
364
|
+
|
|
365
|
+
/** Inspect one provider; never throws. */
|
|
366
|
+
function inspectProvider(
|
|
367
|
+
provider: HeavyCommandHookProvider,
|
|
368
|
+
projectDir: string,
|
|
369
|
+
mode: HeavyCommandHookMode,
|
|
370
|
+
env: Env,
|
|
371
|
+
gitChecks: boolean,
|
|
372
|
+
): HeavyHookInspection {
|
|
373
|
+
const target = heavyHookTarget(provider, projectDir, env);
|
|
374
|
+
const { detected, why } = detectHeavyHookProvider(provider, projectDir, env);
|
|
375
|
+
const base = { provider, detected, target };
|
|
376
|
+
if (provider === 'kimi') {
|
|
377
|
+
if (!detected || mode === 'off') return { ...base, state: 'not-detected', detail: why };
|
|
378
|
+
// CLEO never writes ~/.kimi/config.toml, but reading it tells whether the
|
|
379
|
+
// user added the entry by hand.
|
|
380
|
+
let manual = false;
|
|
381
|
+
try {
|
|
382
|
+
manual = readFileSync(target, 'utf-8').includes(HEAVY_COMMAND_HOOK_ID);
|
|
383
|
+
} catch {
|
|
384
|
+
// No config, or unreadable: not installed by hand.
|
|
385
|
+
}
|
|
386
|
+
return manual
|
|
387
|
+
? { ...base, state: 'installed', detail: `a manual entry in ${target} runs the hook` }
|
|
388
|
+
: { ...base, state: 'unsupported', detail: `${why}; ${KIMI_REASON}`, remedy: KIMI_REMEDY };
|
|
389
|
+
}
|
|
390
|
+
const presence = presenceOf(provider, projectDir, target);
|
|
391
|
+
if (presence.kind === 'unreadable') {
|
|
392
|
+
return {
|
|
393
|
+
...base,
|
|
394
|
+
state: 'unreadable',
|
|
395
|
+
detail: `${target} cannot be read or parsed (${presence.error})`,
|
|
396
|
+
remedy: `fix ${target}, then run: ${HEAVY_HOOK_FIX_COMMAND}`,
|
|
397
|
+
};
|
|
398
|
+
}
|
|
399
|
+
if (mode === 'off') {
|
|
400
|
+
return presence.kind === 'present'
|
|
401
|
+
? {
|
|
402
|
+
...base,
|
|
403
|
+
state: 'outdated',
|
|
404
|
+
detail: 'resources.heavyCommandHook is off, but the hook is still installed',
|
|
405
|
+
remedy: `run: ${HEAVY_HOOK_FIX_COMMAND} (removes it)`,
|
|
406
|
+
}
|
|
407
|
+
: { ...base, state: 'disabled', detail: 'resources.heavyCommandHook is off' };
|
|
408
|
+
}
|
|
409
|
+
if (presence.kind === 'present') {
|
|
410
|
+
if (!presence.current) {
|
|
411
|
+
return {
|
|
412
|
+
...base,
|
|
413
|
+
state: 'outdated',
|
|
414
|
+
detail: `the hook in ${target} (or a legacy location) was written by another CLEO build`,
|
|
415
|
+
remedy: `run: ${HEAVY_HOOK_FIX_COMMAND}`,
|
|
416
|
+
};
|
|
417
|
+
}
|
|
418
|
+
if (!gitChecks) return { ...base, state: 'installed', detail: `installed in ${target}` };
|
|
419
|
+
// Codex's hooks.json may be a shared team config (review MED-3): CLEO's
|
|
420
|
+
// per-machine hook there must not ride along in a commit.
|
|
421
|
+
const shared = provider === 'codex' ? codexHooksSharedReason(projectDir, true) : null;
|
|
422
|
+
if (shared !== null && excludedByCleo(projectDir, CODEX_HOOKS_FILE)) {
|
|
423
|
+
return {
|
|
424
|
+
...base,
|
|
425
|
+
state: 'outdated',
|
|
426
|
+
detail: `CLEO's info/exclude line hides ${target}, which ${shared}`,
|
|
427
|
+
remedy: `run: ${HEAVY_HOOK_FIX_COMMAND} (removes CLEO's exclude line)`,
|
|
428
|
+
};
|
|
429
|
+
}
|
|
430
|
+
if (shared !== null) {
|
|
431
|
+
const modified = trackedFileModified(projectDir, CODEX_HOOKS_FILE);
|
|
432
|
+
if (modified || hookFileVisibleToGit(projectDir, CODEX_HOOKS_FILE)) {
|
|
433
|
+
return {
|
|
434
|
+
...base,
|
|
435
|
+
state: 'outdated',
|
|
436
|
+
detail: `CLEO's per-machine hook sits in ${target}, which ${shared}, as ${modified ? 'an uncommitted change' : 'an untracked file'}: one \`git commit -a\` ships it to the team, and it widens evidence scope`,
|
|
437
|
+
remedy:
|
|
438
|
+
"remove CLEO's entry from .codex/hooks.json, or commit it deliberately if the team wants Codex governed",
|
|
439
|
+
};
|
|
440
|
+
}
|
|
441
|
+
return { ...base, state: 'installed', detail: `installed in ${target}, which ${shared}` };
|
|
442
|
+
}
|
|
443
|
+
// A per-machine hook file git can see shows in `git status` and widens
|
|
444
|
+
// `cleo verify`'s evidence scope (gh#1805); the fix excludes it.
|
|
445
|
+
if (hookFileVisibleToGit(projectDir, HOOK_FILES[provider])) {
|
|
446
|
+
return {
|
|
447
|
+
...base,
|
|
448
|
+
state: 'outdated',
|
|
449
|
+
detail: `${target} is installed but git sees it as an untracked file (it widens evidence scope)`,
|
|
450
|
+
remedy: `run: ${HEAVY_HOOK_FIX_COMMAND} (adds it to the repository's info/exclude)`,
|
|
451
|
+
};
|
|
452
|
+
}
|
|
453
|
+
return { ...base, state: 'installed', detail: `installed in ${target}` };
|
|
454
|
+
}
|
|
455
|
+
if (!detected) return { ...base, state: 'not-detected', detail: why };
|
|
456
|
+
// The briefing (no git checks) still reads the file: a project's own
|
|
457
|
+
// hooks.json needs consent, it is not missing.
|
|
458
|
+
const shared = provider === 'codex' ? codexHooksSharedReason(projectDir, gitChecks) : null;
|
|
459
|
+
if (shared !== null && gitChecks && excludedByCleo(projectDir, CODEX_HOOKS_FILE)) {
|
|
460
|
+
return {
|
|
461
|
+
...base,
|
|
462
|
+
state: 'outdated',
|
|
463
|
+
detail: `CLEO's info/exclude line hides ${target}, which ${shared}`,
|
|
464
|
+
remedy: `run: ${HEAVY_HOOK_FIX_COMMAND} (removes CLEO's exclude line)`,
|
|
465
|
+
};
|
|
466
|
+
}
|
|
467
|
+
if (shared !== null) {
|
|
468
|
+
return {
|
|
469
|
+
...base,
|
|
470
|
+
state: 'needs-consent',
|
|
471
|
+
detail: `${target} ${shared}, so CLEO does not write or hide it; Codex runs ungoverned here until the team adds the entry (${why})`,
|
|
472
|
+
remedy: CODEX_SHARED_REMEDY,
|
|
473
|
+
snippet: codexHookSnippet(),
|
|
474
|
+
};
|
|
475
|
+
}
|
|
476
|
+
const blocked = nonDirectoryAncestor(dirname(target));
|
|
477
|
+
if (blocked !== null) {
|
|
478
|
+
return {
|
|
479
|
+
...base,
|
|
480
|
+
state: 'blocked',
|
|
481
|
+
detail: `${blocked} exists but is not a directory, so the hook cannot be installed (${why})`,
|
|
482
|
+
remedy: `remove or rename ${blocked}, then run: ${HEAVY_HOOK_FIX_COMMAND}`,
|
|
483
|
+
};
|
|
484
|
+
}
|
|
485
|
+
return {
|
|
486
|
+
...base,
|
|
487
|
+
state: 'missing',
|
|
488
|
+
detail: `${why}, but ${target} has no heavy-command hook: agent-run tests and builds bypass the machine-wide budget`,
|
|
489
|
+
remedy: `run: ${HEAVY_HOOK_FIX_COMMAND}`,
|
|
490
|
+
};
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
/**
|
|
494
|
+
* Read-only: the hook's state for every provider in `projectDir`. Never
|
|
495
|
+
* writes and never throws.
|
|
496
|
+
*
|
|
497
|
+
* @param projectDir - the project root.
|
|
498
|
+
* @param mode - the resolved hook mode (`resources.heavyCommandHook`).
|
|
499
|
+
* @param options - environment and provider list (tests).
|
|
500
|
+
* @returns one inspection per provider, in {@link HEAVY_HOOK_DELIVERY_PROVIDERS} order.
|
|
501
|
+
*/
|
|
502
|
+
export function inspectProjectHeavyCommandHooks(
|
|
503
|
+
projectDir: string,
|
|
504
|
+
mode: HeavyCommandHookMode,
|
|
505
|
+
options: HeavyHookDeliveryOptions = {},
|
|
506
|
+
): readonly HeavyHookInspection[] {
|
|
507
|
+
const env = options.env ?? process.env;
|
|
508
|
+
const providers = options.providers ?? HEAVY_HOOK_DELIVERY_PROVIDERS;
|
|
509
|
+
if (isUserHomeDir(projectDir, homeOf(env))) {
|
|
510
|
+
return providers.map((provider) => ({
|
|
511
|
+
provider,
|
|
512
|
+
detected: false,
|
|
513
|
+
state: 'not-detected' as const,
|
|
514
|
+
target: heavyHookTarget(provider, projectDir, env),
|
|
515
|
+
detail: 'the project is your home directory; CLEO installs this hook per project only',
|
|
516
|
+
}));
|
|
517
|
+
}
|
|
518
|
+
const gitChecks = options.gitChecks ?? true;
|
|
519
|
+
return providers.map((provider) => inspectProvider(provider, projectDir, mode, env, gitChecks));
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
/** How long the hook trusts its "this cleo predates `cleo hook`" marker. */
|
|
523
|
+
const OLDER_CLEO_MARKER_TTL_MS = 60 * 60 * 1000;
|
|
524
|
+
|
|
525
|
+
/**
|
|
526
|
+
* The marker file the hook command writes when `cleo` (at `cleoPath`, from
|
|
527
|
+
* directory `dir`) answered "Unknown command": the same key as the guard
|
|
528
|
+
* script (`printf '%s|%s' "$p" "$PWD" | cksum | tr -c '0-9' '-'`, in
|
|
529
|
+
* `$XDG_RUNTIME_DIR`, else `$TMPDIR`, else `/tmp`), or `null` when the key
|
|
530
|
+
* cannot be computed.
|
|
531
|
+
*/
|
|
532
|
+
function olderCleoMarkerPath(cleoPath: string, dir: string, env: Env): string | null {
|
|
533
|
+
try {
|
|
534
|
+
const key = execFileSync(
|
|
535
|
+
'/bin/sh',
|
|
536
|
+
['-c', `printf '%s|%s' "$1" "$2" | cksum | tr -c '0-9' '-'`, 'sh', cleoPath, dir],
|
|
537
|
+
{ encoding: 'utf-8', stdio: ['ignore', 'pipe', 'ignore'], timeout: 5000 },
|
|
538
|
+
);
|
|
539
|
+
const base = env.XDG_RUNTIME_DIR || env.TMPDIR || '/tmp';
|
|
540
|
+
return join(base, `${OLDER_CLEO_MARKER_PREFIX}${key}`);
|
|
541
|
+
} catch {
|
|
542
|
+
return null;
|
|
543
|
+
}
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
/** Whether the hook's own marker says this `cleo` predates `cleo hook` (the guard's validity rules). */
|
|
547
|
+
function hasOlderCleoMarker(cleoPath: string, dir: string, env: Env): boolean {
|
|
548
|
+
const marker = olderCleoMarkerPath(cleoPath, dir, env);
|
|
549
|
+
if (marker === null) return false;
|
|
550
|
+
try {
|
|
551
|
+
const st = lstatSync(marker);
|
|
552
|
+
if (!st.isFile()) return false;
|
|
553
|
+
if (Date.now() - st.mtimeMs > OLDER_CLEO_MARKER_TTL_MS) return false;
|
|
554
|
+
return statSync(cleoPath).mtimeMs <= st.mtimeMs;
|
|
555
|
+
} catch {
|
|
556
|
+
return false;
|
|
557
|
+
}
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
/**
|
|
561
|
+
* Whether the `cleo` on PATH, as the hook resolves it from `projectDir`, can
|
|
562
|
+
* answer the hook. A hook installed in front of an older CLEO (no `cleo hook`,
|
|
563
|
+
* so no `cleo run`) lets every command run ungoverned, so `cleo doctor` must
|
|
564
|
+
* say so. The check is the hook's own: its marker for this binary and
|
|
565
|
+
* directory, else one call to `cleo hook heavy-command` with an empty payload,
|
|
566
|
+
* where an older CLEO exits 127 with "Unknown command".
|
|
567
|
+
*
|
|
568
|
+
* @param projectDir - the project root (a version-manager shim may resolve a
|
|
569
|
+
* different `cleo` per directory).
|
|
570
|
+
* @param options - environment (tests).
|
|
571
|
+
* @returns the CLI's state.
|
|
572
|
+
*/
|
|
573
|
+
export function probeHeavyHookCli(
|
|
574
|
+
projectDir: string,
|
|
575
|
+
options: HeavyHookDeliveryOptions = {},
|
|
576
|
+
): HeavyHookCliProbe {
|
|
577
|
+
const env = options.env ?? process.env;
|
|
578
|
+
const path = findOnPath('cleo', { env });
|
|
579
|
+
if (path === null) {
|
|
580
|
+
return {
|
|
581
|
+
state: 'missing',
|
|
582
|
+
path: null,
|
|
583
|
+
detail: 'no `cleo` on PATH, so the hook lets every heavy command run ungoverned',
|
|
584
|
+
remedy: 'install CLEO so `cleo` is on PATH: npm i -g @cleocode/cleo',
|
|
585
|
+
};
|
|
586
|
+
}
|
|
587
|
+
const older: HeavyHookCliProbe = {
|
|
588
|
+
state: 'older',
|
|
589
|
+
path,
|
|
590
|
+
detail: `${path} predates \`cleo hook\` and \`cleo run\`, so the hook lets every heavy command run ungoverned`,
|
|
591
|
+
remedy: `upgrade the cleo at ${path} (npm i -g @cleocode/cleo, or your version manager), then run: ${HEAVY_HOOK_FIX_COMMAND}`,
|
|
592
|
+
};
|
|
593
|
+
if (hasOlderCleoMarker(path, projectDir, env)) return older;
|
|
594
|
+
const probe = spawnSync(path, ['hook', 'heavy-command', '--provider', 'claude-code'], {
|
|
595
|
+
cwd: projectDir,
|
|
596
|
+
env: { ...env },
|
|
597
|
+
input: '{}',
|
|
598
|
+
encoding: 'utf-8',
|
|
599
|
+
timeout: 20_000,
|
|
600
|
+
});
|
|
601
|
+
if (probe.status === 0) {
|
|
602
|
+
return { state: 'current', path, detail: `${path} answers \`cleo hook heavy-command\`` };
|
|
603
|
+
}
|
|
604
|
+
if (probe.status === 127 && /Unknown command/.test(probe.stderr ?? '')) return older;
|
|
605
|
+
return {
|
|
606
|
+
state: 'unknown',
|
|
607
|
+
path,
|
|
608
|
+
detail:
|
|
609
|
+
`${path} did not answer \`cleo hook heavy-command\` (${probe.error?.message ?? `exit ${probe.status ?? 'none'}`}); ` +
|
|
610
|
+
'the hook fails open, so heavy commands run ungoverned while it does not',
|
|
611
|
+
remedy: `run \`${path} hook heavy-command < /dev/null\` in ${projectDir} to see why it fails (a broken version-manager shim, say)`,
|
|
612
|
+
};
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
/** The delivery surface `@cleocode/core` loads at run time. */
|
|
616
|
+
export const heavyHookDeliveryApi: HeavyHookDeliveryApi = Object.freeze({
|
|
617
|
+
syncProjectHeavyCommandHooks,
|
|
618
|
+
inspectProjectHeavyCommandHooks,
|
|
619
|
+
probeHeavyHookCli,
|
|
620
|
+
});
|