@cleocode/adapters 2026.10.1 → 2026.10.3
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/index.js +1073 -262
- package/dist/index.js.map +3 -3
- package/dist/providers/claude-code/install.d.ts +6 -0
- package/dist/providers/claude-code/install.d.ts.map +1 -1
- package/dist/providers/claude-code/paths.d.ts +1 -78
- package/dist/providers/claude-code/paths.d.ts.map +1 -1
- package/dist/providers/codex/install.d.ts +8 -2
- package/dist/providers/codex/install.d.ts.map +1 -1
- package/dist/providers/kimi/install.d.ts +15 -0
- package/dist/providers/kimi/install.d.ts.map +1 -1
- package/dist/providers/opencode/install.d.ts +2 -0
- package/dist/providers/opencode/install.d.ts.map +1 -1
- package/dist/providers/shared/heavy-command-hook-install.d.ts +173 -0
- package/dist/providers/shared/heavy-command-hook-install.d.ts.map +1 -0
- package/dist/providers/shared/hook-config.d.ts +94 -0
- package/dist/providers/shared/hook-config.d.ts.map +1 -0
- package/package.json +5 -5
- package/src/providers/claude-code/install.ts +29 -1
- package/src/providers/claude-code/paths.ts +9 -131
- package/src/providers/codex/install.ts +27 -3
- package/src/providers/kimi/install.ts +15 -0
- package/src/providers/opencode/install.ts +24 -1
- package/src/providers/shared/__tests__/heavy-command-hook-install.test.ts +621 -0
- package/src/providers/shared/heavy-command-hook-install.ts +562 -0
- package/src/providers/shared/hook-config.ts +145 -0
|
@@ -0,0 +1,562 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Install CLEO's heavy-command hook into a project's provider configs (T12983).
|
|
3
|
+
*
|
|
4
|
+
* The hook (`cleo hook heavy-command`, in `@cleocode/cleo`) runs before every
|
|
5
|
+
* shell command an agent issues and routes heavy work (tests, builds,
|
|
6
|
+
* typechecks, installs) through `cleo run`, the machine-wide resource budget.
|
|
7
|
+
* Delivery per provider:
|
|
8
|
+
*
|
|
9
|
+
* - Claude Code: a `PreToolUse` / `Bash` hook in
|
|
10
|
+
* `<project>/.claude/settings.local.json`: project-level but per machine,
|
|
11
|
+
* which fits a per-machine budget. When git does not ignore that file yet,
|
|
12
|
+
* a marked block in the repository's `info/exclude` keeps it out of git.
|
|
13
|
+
* - Codex: the same hook in `<project>/.codex/hooks.json` (Codex asks the
|
|
14
|
+
* user to review and trust a new project hook in `/hooks` before it runs).
|
|
15
|
+
* - opencode: a `tool.execute.before` / `tool.execute.after` plugin at
|
|
16
|
+
* `<project>/.opencode/plugins/cleo-heavy-command.js`.
|
|
17
|
+
* - Kimi: not installed. Kimi reads hooks only from the global
|
|
18
|
+
* `~/.kimi/config.toml` and cannot rewrite input; `cleo hook heavy-command
|
|
19
|
+
* --provider kimi` speaks its protocol for a manual `[[hooks]]` entry.
|
|
20
|
+
*
|
|
21
|
+
* Project-level only: CLEO never writes this hook into a user-global config,
|
|
22
|
+
* and never into the home directory's provider configs. The hook command
|
|
23
|
+
* decides cheaply before it starts `cleo` (a tool name must appear as a whole
|
|
24
|
+
* word), guards against a missing or older `cleo`, and always exits 0. Only
|
|
25
|
+
* CLEO's own hook object is ever added, replaced or removed: a user hook that
|
|
26
|
+
* shares its matcher group stays. Mode `off` removes it again.
|
|
27
|
+
*
|
|
28
|
+
* @task T12983
|
|
29
|
+
* @epic T12978
|
|
30
|
+
*/
|
|
31
|
+
|
|
32
|
+
import { execFileSync } from 'node:child_process';
|
|
33
|
+
import {
|
|
34
|
+
existsSync,
|
|
35
|
+
mkdirSync,
|
|
36
|
+
readdirSync,
|
|
37
|
+
readFileSync,
|
|
38
|
+
realpathSync,
|
|
39
|
+
rmSync,
|
|
40
|
+
writeFileSync,
|
|
41
|
+
} from 'node:fs';
|
|
42
|
+
import { homedir, tmpdir } from 'node:os';
|
|
43
|
+
import { dirname, isAbsolute, join, resolve } from 'node:path';
|
|
44
|
+
import { updateJsonConfigFile } from '@cleocode/caamp';
|
|
45
|
+
import type { HeavyCommandHookMode } from '@cleocode/contracts';
|
|
46
|
+
import { CLEO_HOOK_MARKER, hookMap, isPlainObject } from './hook-config.js';
|
|
47
|
+
|
|
48
|
+
/** Identifies the heavy-command hook among CLEO's hook commands. */
|
|
49
|
+
export const HEAVY_COMMAND_HOOK_ID = 'cleo hook heavy-command';
|
|
50
|
+
|
|
51
|
+
/** Seconds a harness waits for the hook before it runs the command unchanged. */
|
|
52
|
+
export const HEAVY_COMMAND_HOOK_TIMEOUT_SEC = 20;
|
|
53
|
+
|
|
54
|
+
/** File name of the generated opencode plugin. */
|
|
55
|
+
export const OPENCODE_HEAVY_COMMAND_PLUGIN = 'cleo-heavy-command.js';
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Tool names that send a shell call on to `cleo hook`: a superset of the
|
|
59
|
+
* command words `run-class` treats as heavy (test runners, compilers,
|
|
60
|
+
* bundlers, linters, package managers and their exec runners, cargo, go).
|
|
61
|
+
* Matched as whole words only, so `cat test-notes.md` never starts `cleo`;
|
|
62
|
+
* recognition proper stays in `run-class` inside `cleo hook`.
|
|
63
|
+
*/
|
|
64
|
+
export const HEAVY_TOOL_WORDS: readonly string[] = Object.freeze([
|
|
65
|
+
'vitest',
|
|
66
|
+
'jest',
|
|
67
|
+
'mocha',
|
|
68
|
+
'ava',
|
|
69
|
+
'tap',
|
|
70
|
+
'playwright',
|
|
71
|
+
'pytest',
|
|
72
|
+
'rspec',
|
|
73
|
+
'phpunit',
|
|
74
|
+
'tsc',
|
|
75
|
+
'tsup',
|
|
76
|
+
'turbo',
|
|
77
|
+
'vite',
|
|
78
|
+
'esbuild',
|
|
79
|
+
'webpack',
|
|
80
|
+
'rollup',
|
|
81
|
+
'next',
|
|
82
|
+
'nx',
|
|
83
|
+
'biome',
|
|
84
|
+
'eslint',
|
|
85
|
+
'svelte-check',
|
|
86
|
+
'npm',
|
|
87
|
+
'pnpm',
|
|
88
|
+
'yarn',
|
|
89
|
+
'bun',
|
|
90
|
+
'npx',
|
|
91
|
+
'pnpx',
|
|
92
|
+
'bunx',
|
|
93
|
+
'cargo',
|
|
94
|
+
'go',
|
|
95
|
+
]);
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* What a sync did to one config file. `skipped`: the project is the user's
|
|
99
|
+
* home directory, whose provider configs are the user-global ones.
|
|
100
|
+
*/
|
|
101
|
+
export type HeavyHookSyncResult = 'installed' | 'updated' | 'removed' | 'unchanged' | 'skipped';
|
|
102
|
+
|
|
103
|
+
function canonical(path: string): string {
|
|
104
|
+
try {
|
|
105
|
+
return realpathSync(path);
|
|
106
|
+
} catch {
|
|
107
|
+
return resolve(path);
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Whether `projectDir` is the user's home directory. There,
|
|
113
|
+
* `<home>/.claude/settings.local.json`, `<home>/.codex/hooks.json` and
|
|
114
|
+
* `<home>/.opencode/` act as user-global configs, and CLEO installs this
|
|
115
|
+
* hook per project only, so the installers skip it.
|
|
116
|
+
*
|
|
117
|
+
* @param projectDir - the project root an installer was given.
|
|
118
|
+
* @param home - the home directory (injectable for tests).
|
|
119
|
+
*/
|
|
120
|
+
export function isUserHomeDir(projectDir: string, home: string = homedir()): boolean {
|
|
121
|
+
return canonical(projectDir) === canonical(home);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* The context line an older CLEO gets: its `cleo` has no `cleo hook` (and so
|
|
126
|
+
* no `cleo run`), so the command runs unchanged and ungoverned. A
|
|
127
|
+
* Claude-style `PreToolUse` answer; no single quotes (it is inlined into a
|
|
128
|
+
* single-quoted `sh` word).
|
|
129
|
+
*/
|
|
130
|
+
export const OLDER_CLEO_HOOK_ANSWER = JSON.stringify({
|
|
131
|
+
hookSpecificOutput: {
|
|
132
|
+
hookEventName: 'PreToolUse',
|
|
133
|
+
additionalContext:
|
|
134
|
+
'[cleo] This may be a heavy command, but the cleo on PATH predates cleo hook and ' +
|
|
135
|
+
'cleo run, so it runs outside the machine-wide resource budget. Upgrade CLEO to govern it.',
|
|
136
|
+
},
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
/** File-name prefix of the "this cleo has no `cleo hook`" markers in `$TMPDIR`. */
|
|
140
|
+
export const OLDER_CLEO_MARKER_PREFIX = 'cleo-hook-unsupported-';
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Delete this user's older-CLEO markers, so a CLEO that has just installed the
|
|
144
|
+
* hook is asked again at once instead of after the marker lapses.
|
|
145
|
+
*
|
|
146
|
+
* @param dir - the directory the hook command keeps markers in
|
|
147
|
+
* (`$XDG_RUNTIME_DIR`, else `$TMPDIR`).
|
|
148
|
+
* @returns how many markers were removed.
|
|
149
|
+
*/
|
|
150
|
+
export function clearOlderCleoMarkers(
|
|
151
|
+
dir: string = process.env.XDG_RUNTIME_DIR || tmpdir(),
|
|
152
|
+
): number {
|
|
153
|
+
let removed = 0;
|
|
154
|
+
let names: string[];
|
|
155
|
+
try {
|
|
156
|
+
names = readdirSync(dir);
|
|
157
|
+
} catch {
|
|
158
|
+
return 0;
|
|
159
|
+
}
|
|
160
|
+
for (const name of names) {
|
|
161
|
+
if (!name.startsWith(OLDER_CLEO_MARKER_PREFIX)) continue;
|
|
162
|
+
try {
|
|
163
|
+
rmSync(join(dir, name), { force: true });
|
|
164
|
+
removed++;
|
|
165
|
+
} catch {
|
|
166
|
+
// Someone else's marker, or already gone.
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
return removed;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/** Quote `text` as one `sh` word (also valid in zsh, bash and fish). */
|
|
173
|
+
function shellWord(text: string): string {
|
|
174
|
+
return `'${text.replace(/'/g, `'\\''`)}'`;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* The guard script both JSON configs run, a POSIX `sh` script that always
|
|
179
|
+
* exits 0 (exit 2 would block the agent's command):
|
|
180
|
+
*
|
|
181
|
+
* 1. no `cleo` on PATH: print nothing (harmless on a machine without CLEO);
|
|
182
|
+
* 2. no {@link HEAVY_TOOL_WORDS} word anywhere in the payload: print nothing
|
|
183
|
+
* without starting `cleo`. This is what most shell calls cost: one `sed`;
|
|
184
|
+
* 3. this `cleo` is known to lack `cleo hook`: print
|
|
185
|
+
* {@link OLDER_CLEO_HOOK_ANSWER} without starting `cleo`. The marker lives
|
|
186
|
+
* in `$XDG_RUNTIME_DIR` (else `$TMPDIR`, else `/tmp`), is keyed on the
|
|
187
|
+
* resolved `cleo` path AND the directory (a version-manager shim such as
|
|
188
|
+
* mise's resolves a different `cleo` per project), must be a regular file
|
|
189
|
+
* the user owns (never a symlink), and lapses when the binary is newer,
|
|
190
|
+
* after an hour, or when a newer CLEO installs the hook
|
|
191
|
+
* ({@link clearOlderCleoMarkers});
|
|
192
|
+
* 4. otherwise ask `cleo hook heavy-command` and print its answer when it is
|
|
193
|
+
* a JSON object. Only the older-CLEO signature (exit 127 with "Unknown
|
|
194
|
+
* command" on stderr) writes the marker (created with `mktemp`, moved into
|
|
195
|
+
* place) and prints the notice; any other failure, such as a current CLEO
|
|
196
|
+
* killed under memory pressure, fails open silently and asks again on the
|
|
197
|
+
* next call.
|
|
198
|
+
*/
|
|
199
|
+
function heavyCommandGuardScript(provider: 'claude-code' | 'codex'): string {
|
|
200
|
+
const words = HEAVY_TOOL_WORDS.join('|');
|
|
201
|
+
const old = `printf '%s\\n' '${OLDER_CLEO_HOOK_ANSWER}'`;
|
|
202
|
+
return [
|
|
203
|
+
'command -v cleo >/dev/null 2>&1 || exit 0',
|
|
204
|
+
'in="$(cat)"',
|
|
205
|
+
'set -f',
|
|
206
|
+
'hit=',
|
|
207
|
+
// `[\]` (not `\\`) matches a backslash: it survives fish's quoting under Codex.
|
|
208
|
+
`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`,
|
|
209
|
+
'[ -n "$hit" ] || exit 0',
|
|
210
|
+
'p="$(command -v cleo)"',
|
|
211
|
+
`d="\${XDG_RUNTIME_DIR:-\${TMPDIR:-/tmp}}"`,
|
|
212
|
+
`k="$d/${OLDER_CLEO_MARKER_PREFIX}$(printf '%s|%s' "$p" "$PWD" | cksum | tr -c '0-9' '-')"`,
|
|
213
|
+
`if [ -f "$k" ] && [ ! -L "$k" ] && [ -O "$k" ] && ! [ "$p" -nt "$k" ] && [ -n "$(find "$k" -mmin -60 2>/dev/null)" ]; then ${old}; exit 0; fi`,
|
|
214
|
+
'e="$(mktemp 2>/dev/null)" || e=',
|
|
215
|
+
// Only the older-CLEO signature (exit 127, "Unknown command hook") writes the
|
|
216
|
+
// marker; a crash or kill of a current CLEO fails open and asks again next time.
|
|
217
|
+
`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`,
|
|
218
|
+
'[ -z "$e" ] || rm -f "$e"',
|
|
219
|
+
'exit 0',
|
|
220
|
+
].join('; ');
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/**
|
|
224
|
+
* The hook command line for a JSON hook config, ending in the `# cleo-hook`
|
|
225
|
+
* marker. Claude Code runs hook commands through `sh -c`, so its line is the
|
|
226
|
+
* guard script itself. Codex runs them through the user's login shell
|
|
227
|
+
* (`$SHELL -lc`, which may be fish), so its line hands the script to
|
|
228
|
+
* `/bin/sh -c` as one quoted word.
|
|
229
|
+
*
|
|
230
|
+
* @param provider - the harness protocol to answer in.
|
|
231
|
+
* @returns the command line for the provider's config.
|
|
232
|
+
*
|
|
233
|
+
* @example
|
|
234
|
+
* ```ts
|
|
235
|
+
* heavyCommandHookCommand('claude-code');
|
|
236
|
+
* // command -v cleo >/dev/null 2>&1 || exit 0; in="$(cat)"; set -f; …; exit 0 # cleo-hook
|
|
237
|
+
* heavyCommandHookCommand('codex');
|
|
238
|
+
* // /bin/sh -c 'command -v cleo >/dev/null 2>&1 || exit 0; …' # cleo-hook
|
|
239
|
+
* ```
|
|
240
|
+
*/
|
|
241
|
+
export function heavyCommandHookCommand(provider: 'claude-code' | 'codex'): string {
|
|
242
|
+
const script = heavyCommandGuardScript(provider);
|
|
243
|
+
const line = provider === 'codex' ? `/bin/sh -c ${shellWord(script)}` : script;
|
|
244
|
+
return `${line} ${CLEO_HOOK_MARKER}`;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/**
|
|
248
|
+
* CLEO's hook object (one element of a matcher group's `hooks` array).
|
|
249
|
+
*
|
|
250
|
+
* @param provider - `claude-code` or `codex`.
|
|
251
|
+
*/
|
|
252
|
+
export function heavyCommandHookObject(provider: 'claude-code' | 'codex'): Record<string, unknown> {
|
|
253
|
+
return {
|
|
254
|
+
type: 'command',
|
|
255
|
+
command: heavyCommandHookCommand(provider),
|
|
256
|
+
timeout: HEAVY_COMMAND_HOOK_TIMEOUT_SEC,
|
|
257
|
+
};
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/**
|
|
261
|
+
* The `hooks.PreToolUse` matcher group CLEO adds when none of the user's
|
|
262
|
+
* groups holds its hook yet (Claude Code and Codex both match `Bash`).
|
|
263
|
+
*
|
|
264
|
+
* @param provider - `claude-code` or `codex`.
|
|
265
|
+
*/
|
|
266
|
+
export function heavyCommandHookEntry(provider: 'claude-code' | 'codex'): Record<string, unknown> {
|
|
267
|
+
return { matcher: 'Bash', hooks: [heavyCommandHookObject(provider)] };
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
/** Whether one hook object is CLEO's heavy-command hook. */
|
|
271
|
+
function isHeavyHookObject(hook: unknown): boolean {
|
|
272
|
+
return (
|
|
273
|
+
isPlainObject(hook) &&
|
|
274
|
+
typeof hook.command === 'string' &&
|
|
275
|
+
hook.command.includes(CLEO_HOOK_MARKER) &&
|
|
276
|
+
hook.command.includes(HEAVY_COMMAND_HOOK_ID)
|
|
277
|
+
);
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
/**
|
|
281
|
+
* Put `desired` in place of CLEO's first heavy-command hook object, drop any
|
|
282
|
+
* other copies, and drop a matcher group only when CLEO's hook was all it
|
|
283
|
+
* held. `desired: null` removes CLEO's hook everywhere.
|
|
284
|
+
*
|
|
285
|
+
* @returns whether the groups changed, and whether CLEO's hook was found.
|
|
286
|
+
*/
|
|
287
|
+
function placeHeavyHook(
|
|
288
|
+
hooks: Record<string, unknown>,
|
|
289
|
+
desired: Record<string, unknown> | null,
|
|
290
|
+
): { readonly changed: boolean; readonly found: boolean } {
|
|
291
|
+
const groups = hooks.PreToolUse;
|
|
292
|
+
if (groups === undefined) return { changed: false, found: false };
|
|
293
|
+
if (!Array.isArray(groups)) {
|
|
294
|
+
throw new Error('hooks "PreToolUse" is not an array; not modifying it');
|
|
295
|
+
}
|
|
296
|
+
let changed = false;
|
|
297
|
+
let found = false;
|
|
298
|
+
const next: unknown[] = [];
|
|
299
|
+
for (const group of groups) {
|
|
300
|
+
if (!isPlainObject(group) || !Array.isArray(group.hooks)) {
|
|
301
|
+
next.push(group);
|
|
302
|
+
continue;
|
|
303
|
+
}
|
|
304
|
+
const kept: unknown[] = [];
|
|
305
|
+
let groupChanged = false;
|
|
306
|
+
for (const hook of group.hooks) {
|
|
307
|
+
if (!isHeavyHookObject(hook)) {
|
|
308
|
+
kept.push(hook);
|
|
309
|
+
} else if (desired !== null && !found) {
|
|
310
|
+
found = true;
|
|
311
|
+
if (JSON.stringify(hook) !== JSON.stringify(desired)) groupChanged = true;
|
|
312
|
+
kept.push(desired);
|
|
313
|
+
} else {
|
|
314
|
+
groupChanged = true;
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
if (!groupChanged) {
|
|
318
|
+
next.push(group);
|
|
319
|
+
continue;
|
|
320
|
+
}
|
|
321
|
+
changed = true;
|
|
322
|
+
if (kept.length > 0) next.push({ ...group, hooks: kept });
|
|
323
|
+
}
|
|
324
|
+
if (changed) hooks.PreToolUse = next;
|
|
325
|
+
return { changed, found };
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
/**
|
|
329
|
+
* Add, refresh or (mode `off`) remove CLEO's heavy-command hook in a JSON hook
|
|
330
|
+
* config, through CAAMP's locked atomic writer. Only CLEO's own hook object is
|
|
331
|
+
* touched; a malformed file throws and is left as it is.
|
|
332
|
+
*
|
|
333
|
+
* @param configPath - the config file (created when missing, unless `off`).
|
|
334
|
+
* @param provider - `claude-code` or `codex`.
|
|
335
|
+
* @param mode - the resolved hook mode.
|
|
336
|
+
* @returns what changed.
|
|
337
|
+
*/
|
|
338
|
+
export async function syncJsonHeavyCommandHook(
|
|
339
|
+
configPath: string,
|
|
340
|
+
provider: 'claude-code' | 'codex',
|
|
341
|
+
mode: HeavyCommandHookMode,
|
|
342
|
+
): Promise<HeavyHookSyncResult> {
|
|
343
|
+
if (mode === 'off') {
|
|
344
|
+
if (!existsSync(configPath)) return 'unchanged';
|
|
345
|
+
const removed = await updateJsonConfigFile(configPath, (config) => {
|
|
346
|
+
if (config.hooks === undefined) return false;
|
|
347
|
+
return placeHeavyHook(hookMap(config), null).changed;
|
|
348
|
+
});
|
|
349
|
+
return removed ? 'removed' : 'unchanged';
|
|
350
|
+
}
|
|
351
|
+
mkdirSync(dirname(configPath), { recursive: true });
|
|
352
|
+
clearOlderCleoMarkers();
|
|
353
|
+
let result: HeavyHookSyncResult = 'unchanged';
|
|
354
|
+
await updateJsonConfigFile(configPath, (config) => {
|
|
355
|
+
const hooks = hookMap(config);
|
|
356
|
+
const placed = placeHeavyHook(hooks, heavyCommandHookObject(provider));
|
|
357
|
+
if (placed.found) {
|
|
358
|
+
if (placed.changed) result = 'updated';
|
|
359
|
+
return placed.changed;
|
|
360
|
+
}
|
|
361
|
+
const existing = hooks.PreToolUse;
|
|
362
|
+
hooks.PreToolUse = [
|
|
363
|
+
...(Array.isArray(existing) ? existing : []),
|
|
364
|
+
heavyCommandHookEntry(provider),
|
|
365
|
+
];
|
|
366
|
+
result = 'installed';
|
|
367
|
+
return true;
|
|
368
|
+
});
|
|
369
|
+
return result;
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
/** Claude Code's per-machine settings file, relative to the project. */
|
|
373
|
+
const CLAUDE_LOCAL_SETTINGS = '.claude/settings.local.json';
|
|
374
|
+
|
|
375
|
+
/** First line of the `info/exclude` block CLEO adds (and alone may remove). */
|
|
376
|
+
export const LOCAL_SETTINGS_EXCLUDE_MARKER =
|
|
377
|
+
'# cleo-hook: keep the per-machine heavy-command hook settings out of git (T12983)';
|
|
378
|
+
|
|
379
|
+
/** Run git in `projectDir`; its trimmed stdout, or `null` on any failure. */
|
|
380
|
+
function gitOutput(projectDir: string, args: readonly string[]): string | null {
|
|
381
|
+
try {
|
|
382
|
+
return execFileSync('git', ['-C', projectDir, ...args], {
|
|
383
|
+
encoding: 'utf-8',
|
|
384
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
385
|
+
timeout: 5000,
|
|
386
|
+
}).trim();
|
|
387
|
+
} catch {
|
|
388
|
+
return null;
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
/** The repository's `info/exclude` path for `projectDir`, or `null` outside a work tree. */
|
|
393
|
+
function excludeFile(projectDir: string): string | null {
|
|
394
|
+
if (gitOutput(projectDir, ['rev-parse', '--is-inside-work-tree']) !== 'true') return null;
|
|
395
|
+
const path = gitOutput(projectDir, ['rev-parse', '--git-path', 'info/exclude']);
|
|
396
|
+
if (path === null || path === '') return null;
|
|
397
|
+
return isAbsolute(path) ? path : join(projectDir, path);
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
/** The `info/exclude` line for this project's settings file (relative to the repo root). */
|
|
401
|
+
function excludeLine(projectDir: string): string {
|
|
402
|
+
const prefix = gitOutput(projectDir, ['rev-parse', '--show-prefix']) ?? '';
|
|
403
|
+
return `/${prefix}${CLAUDE_LOCAL_SETTINGS}`;
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
/**
|
|
407
|
+
* Keep `.claude/settings.local.json` out of git when nothing ignores it yet
|
|
408
|
+
* (Claude Code ignores the file only when it creates it itself). Adds a marked
|
|
409
|
+
* block naming this project's file to the repository's `info/exclude`, never
|
|
410
|
+
* to a tracked `.gitignore`. Keyed on the exact path line, so a second CLEO
|
|
411
|
+
* project in a subdirectory of the same repository gets its own line.
|
|
412
|
+
* Idempotent; does nothing outside a git work tree.
|
|
413
|
+
*
|
|
414
|
+
* @param projectDir - the project root.
|
|
415
|
+
* @returns whether a block was added.
|
|
416
|
+
*/
|
|
417
|
+
export function excludeLocalSettingsFromGit(projectDir: string): boolean {
|
|
418
|
+
const exclude = excludeFile(projectDir);
|
|
419
|
+
if (exclude === null) return false;
|
|
420
|
+
if (gitOutput(projectDir, ['check-ignore', CLAUDE_LOCAL_SETTINGS]) !== null) return false;
|
|
421
|
+
const line = excludeLine(projectDir);
|
|
422
|
+
const text = existsSync(exclude) ? readFileSync(exclude, 'utf-8') : '';
|
|
423
|
+
if (text.split('\n').includes(line)) return false;
|
|
424
|
+
const block = `${LOCAL_SETTINGS_EXCLUDE_MARKER}\n${line}\n`;
|
|
425
|
+
mkdirSync(dirname(exclude), { recursive: true });
|
|
426
|
+
writeFileSync(exclude, text === '' || text.endsWith('\n') ? text + block : `${text}\n${block}`);
|
|
427
|
+
return true;
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
/**
|
|
431
|
+
* Remove the block {@link excludeLocalSettingsFromGit} added for this
|
|
432
|
+
* project, and only that: the exact path line and the marker right above it.
|
|
433
|
+
*
|
|
434
|
+
* @param projectDir - the project root.
|
|
435
|
+
* @returns whether a block was removed.
|
|
436
|
+
*/
|
|
437
|
+
export function unexcludeLocalSettingsFromGit(projectDir: string): boolean {
|
|
438
|
+
const exclude = excludeFile(projectDir);
|
|
439
|
+
if (exclude === null || !existsSync(exclude)) return false;
|
|
440
|
+
const lines = readFileSync(exclude, 'utf-8').split('\n');
|
|
441
|
+
const at = lines.indexOf(excludeLine(projectDir));
|
|
442
|
+
if (at < 1 || lines[at - 1] !== LOCAL_SETTINGS_EXCLUDE_MARKER) return false;
|
|
443
|
+
lines.splice(at - 1, 2);
|
|
444
|
+
writeFileSync(exclude, lines.join('\n'));
|
|
445
|
+
return true;
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
/**
|
|
449
|
+
* Claude Code: sync the hook in `<project>/.claude/settings.local.json`, keep
|
|
450
|
+
* that file out of git, and remove a hook an earlier build put in the shared
|
|
451
|
+
* `<project>/.claude/settings.json`.
|
|
452
|
+
*
|
|
453
|
+
* @param projectDir - the project root.
|
|
454
|
+
* @param mode - the resolved hook mode.
|
|
455
|
+
* @returns what changed in `settings.local.json`.
|
|
456
|
+
*/
|
|
457
|
+
export async function syncClaudeCodeHeavyCommandHook(
|
|
458
|
+
projectDir: string,
|
|
459
|
+
mode: HeavyCommandHookMode,
|
|
460
|
+
): Promise<HeavyHookSyncResult> {
|
|
461
|
+
const result = await syncJsonHeavyCommandHook(
|
|
462
|
+
join(projectDir, CLAUDE_LOCAL_SETTINGS),
|
|
463
|
+
'claude-code',
|
|
464
|
+
mode,
|
|
465
|
+
);
|
|
466
|
+
if (mode === 'off') unexcludeLocalSettingsFromGit(projectDir);
|
|
467
|
+
else excludeLocalSettingsFromGit(projectDir);
|
|
468
|
+
await syncJsonHeavyCommandHook(
|
|
469
|
+
join(projectDir, '.claude', 'settings.json'),
|
|
470
|
+
'claude-code',
|
|
471
|
+
'off',
|
|
472
|
+
);
|
|
473
|
+
return result;
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
/**
|
|
477
|
+
* Source of the opencode plugin. opencode runs plugins in-process:
|
|
478
|
+
* `tool.execute.before` may replace `output.args`, and
|
|
479
|
+
* `tool.execute.after` may extend the tool's output. The plugin skips calls
|
|
480
|
+
* with no {@link HEAVY_TOOL_WORDS} word, asks
|
|
481
|
+
* `cleo hook heavy-command --provider opencode` for the rest, applies a
|
|
482
|
+
* rewrite and its longer timeout, and appends the hook's context line to the
|
|
483
|
+
* tool's output (opencode has no other channel back to the agent).
|
|
484
|
+
*/
|
|
485
|
+
export function opencodeHeavyCommandPluginSource(): string {
|
|
486
|
+
return [
|
|
487
|
+
'// CLEO heavy-command plugin for OpenCode (generated by @cleocode/adapters, T12983).',
|
|
488
|
+
'// Routes heavy shell commands (tests, builds, typechecks, installs) through',
|
|
489
|
+
'// `cleo run`, the machine-wide resource budget. Opt out with',
|
|
490
|
+
'// CLEO_HEAVY_COMMAND_HOOK=off, or set resources.heavyCommandHook to "off" and',
|
|
491
|
+
'// run `cleo upgrade` (which deletes this file). Fails open: on any error the',
|
|
492
|
+
'// command runs unchanged.',
|
|
493
|
+
'',
|
|
494
|
+
"import { execFile } from 'node:child_process';",
|
|
495
|
+
'',
|
|
496
|
+
`const HEAVY = /(?:^|[^A-Za-z0-9_.@+-])(?:${HEAVY_TOOL_WORDS.join('|')})(?=$|[^A-Za-z0-9_.@+-])/;`,
|
|
497
|
+
'const contexts = new Map();',
|
|
498
|
+
'',
|
|
499
|
+
'function ask(payload) {',
|
|
500
|
+
' return new Promise((done) => {',
|
|
501
|
+
' const child = execFile(',
|
|
502
|
+
" 'cleo',",
|
|
503
|
+
" ['hook', 'heavy-command', '--provider', 'opencode'],",
|
|
504
|
+
` { timeout: ${HEAVY_COMMAND_HOOK_TIMEOUT_SEC * 1000} },`,
|
|
505
|
+
" (err, stdout) => done(err ? '' : stdout),",
|
|
506
|
+
' );',
|
|
507
|
+
" child.on('error', () => done(''));",
|
|
508
|
+
' child.stdin?.end(JSON.stringify(payload));',
|
|
509
|
+
' });',
|
|
510
|
+
'}',
|
|
511
|
+
'',
|
|
512
|
+
'export const CleoHeavyCommand = async ({ directory }) => ({',
|
|
513
|
+
" 'tool.execute.before': async (input, output) => {",
|
|
514
|
+
" if (input?.tool !== 'bash' || typeof output?.args?.command !== 'string') return;",
|
|
515
|
+
" if (process.env.CLEO_HEAVY_COMMAND_HOOK === 'off') return;",
|
|
516
|
+
' if (!HEAVY.test(output.args.command)) return;',
|
|
517
|
+
' try {',
|
|
518
|
+
" const text = await ask({ tool_name: 'bash', tool_input: output.args, cwd: directory });",
|
|
519
|
+
" const answer = JSON.parse(text || '{}');",
|
|
520
|
+
" if (typeof answer.command === 'string') output.args.command = answer.command;",
|
|
521
|
+
" if (typeof answer.timeout === 'number') output.args.timeout = answer.timeout;",
|
|
522
|
+
" if (typeof answer.context === 'string' && input.callID) contexts.set(input.callID, answer.context);",
|
|
523
|
+
' } catch {',
|
|
524
|
+
' // Fail open.',
|
|
525
|
+
' }',
|
|
526
|
+
' },',
|
|
527
|
+
" 'tool.execute.after': async (input, output) => {",
|
|
528
|
+
' const context = input?.callID ? contexts.get(input.callID) : undefined;',
|
|
529
|
+
' if (context === undefined) return;',
|
|
530
|
+
' contexts.delete(input.callID);',
|
|
531
|
+
" if (typeof output?.output === 'string') output.output += '\\n\\n' + context;",
|
|
532
|
+
' },',
|
|
533
|
+
'});',
|
|
534
|
+
'',
|
|
535
|
+
].join('\n');
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
/**
|
|
539
|
+
* Write (or, mode `off`, delete) the opencode plugin in
|
|
540
|
+
* `<projectDir>/.opencode/plugins/`. Rewritten only when its content changed.
|
|
541
|
+
*
|
|
542
|
+
* @param projectDir - the project root.
|
|
543
|
+
* @param mode - the resolved hook mode.
|
|
544
|
+
* @returns what changed.
|
|
545
|
+
*/
|
|
546
|
+
export function syncOpencodeHeavyCommandPlugin(
|
|
547
|
+
projectDir: string,
|
|
548
|
+
mode: HeavyCommandHookMode,
|
|
549
|
+
): HeavyHookSyncResult {
|
|
550
|
+
const pluginPath = join(projectDir, '.opencode', 'plugins', OPENCODE_HEAVY_COMMAND_PLUGIN);
|
|
551
|
+
const exists = existsSync(pluginPath);
|
|
552
|
+
if (mode === 'off') {
|
|
553
|
+
if (!exists) return 'unchanged';
|
|
554
|
+
rmSync(pluginPath, { force: true });
|
|
555
|
+
return 'removed';
|
|
556
|
+
}
|
|
557
|
+
const source = opencodeHeavyCommandPluginSource();
|
|
558
|
+
if (exists && readFileSync(pluginPath, 'utf-8') === source) return 'unchanged';
|
|
559
|
+
mkdirSync(dirname(pluginPath), { recursive: true });
|
|
560
|
+
writeFileSync(pluginPath, source, 'utf-8');
|
|
561
|
+
return exists ? 'updated' : 'installed';
|
|
562
|
+
}
|