@cleocode/adapters 2026.10.1 → 2026.10.2

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.
@@ -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
+ }