@cleocode/adapters 2026.10.3 → 2026.10.5-rc.ci.672
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 +2095 -1269
- package/dist/index.js.map +3 -3
- package/dist/providers/claude-code/adapter.d.ts.map +1 -1
- package/dist/providers/claude-code/hooks.d.ts +43 -17
- package/dist/providers/claude-code/hooks.d.ts.map +1 -1
- package/dist/providers/claude-code/install.d.ts +42 -14
- package/dist/providers/claude-code/install.d.ts.map +1 -1
- package/dist/providers/claude-code/paths.d.ts +77 -0
- package/dist/providers/claude-code/paths.d.ts.map +1 -1
- package/dist/providers/codex/install.d.ts.map +1 -1
- package/dist/providers/cursor/install.d.ts.map +1 -1
- package/dist/providers/gemini-cli/install.d.ts.map +1 -1
- package/dist/providers/kimi/install.d.ts.map +1 -1
- package/dist/providers/openai-sdk/install.d.ts.map +1 -1
- package/dist/providers/opencode/install.d.ts.map +1 -1
- package/dist/providers/pi/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/dist/providers/shared/project-instruction-file.d.ts +30 -0
- package/dist/providers/shared/project-instruction-file.d.ts.map +1 -0
- package/package.json +9 -5
- package/src/__tests__/claude-code-adapter.test.ts +11 -6
- package/src/heavy-command-hook.ts +21 -0
- package/src/index.ts +12 -0
- package/src/providers/claude-code/__tests__/hooks-install.test.ts +20 -17
- package/src/providers/claude-code/__tests__/no-user-global-write.test.ts +293 -0
- package/src/providers/claude-code/adapter.ts +3 -1
- package/src/providers/claude-code/hooks.ts +138 -51
- package/src/providers/claude-code/install.ts +123 -52
- package/src/providers/claude-code/paths.ts +159 -1
- package/src/providers/codex/install.ts +7 -6
- package/src/providers/cursor/install.ts +26 -12
- package/src/providers/gemini-cli/install.ts +4 -2
- package/src/providers/kimi/install.ts +4 -2
- package/src/providers/openai-sdk/install.ts +5 -2
- package/src/providers/opencode/install.ts +4 -2
- package/src/providers/pi/install.ts +4 -2
- package/src/providers/shared/__tests__/heavy-command-hook-delivery.test.ts +580 -0
- package/src/providers/shared/__tests__/heavy-command-hook-install.test.ts +20 -0
- package/src/providers/shared/heavy-command-hook-delivery.ts +620 -0
- package/src/providers/shared/heavy-command-hook-install.ts +401 -32
- package/src/providers/shared/project-instruction-file.ts +47 -0
|
@@ -37,6 +37,7 @@ import {
|
|
|
37
37
|
readFileSync,
|
|
38
38
|
realpathSync,
|
|
39
39
|
rmSync,
|
|
40
|
+
statSync,
|
|
40
41
|
writeFileSync,
|
|
41
42
|
} from 'node:fs';
|
|
42
43
|
import { homedir, tmpdir } from 'node:os';
|
|
@@ -100,14 +101,70 @@ export const HEAVY_TOOL_WORDS: readonly string[] = Object.freeze([
|
|
|
100
101
|
*/
|
|
101
102
|
export type HeavyHookSyncResult = 'installed' | 'updated' | 'removed' | 'unchanged' | 'skipped';
|
|
102
103
|
|
|
104
|
+
/**
|
|
105
|
+
* `path` through the native realpath, which returns the case stored on disk,
|
|
106
|
+
* so a differently cased spelling of the home directory on a case-insensitive
|
|
107
|
+
* volume still matches it (T13227 review).
|
|
108
|
+
*/
|
|
103
109
|
function canonical(path: string): string {
|
|
104
110
|
try {
|
|
105
|
-
return realpathSync(path);
|
|
111
|
+
return realpathSync.native(path);
|
|
106
112
|
} catch {
|
|
107
113
|
return resolve(path);
|
|
108
114
|
}
|
|
109
115
|
}
|
|
110
116
|
|
|
117
|
+
/**
|
|
118
|
+
* A config path cannot be created because something on the way to it exists
|
|
119
|
+
* but is not a directory (T13124: a stray empty `<project>/.codex` file made
|
|
120
|
+
* `mkdirSync` throw `EEXIST` and aborted the Codex install). Nothing is
|
|
121
|
+
* written; the delivery reports the provider as `blocked`.
|
|
122
|
+
*/
|
|
123
|
+
export class HeavyHookPathBlockedError extends Error {
|
|
124
|
+
/** The existing path that is not a directory. */
|
|
125
|
+
readonly path: string;
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* @param path - the existing non-directory path.
|
|
129
|
+
* @param target - the config file that would have lived under it.
|
|
130
|
+
*/
|
|
131
|
+
constructor(path: string, target: string) {
|
|
132
|
+
super(`${path} exists but is not a directory, so ${target} cannot be created under it`);
|
|
133
|
+
this.name = 'HeavyHookPathBlockedError';
|
|
134
|
+
this.path = path;
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* The nearest existing path at or above `dir` when it is NOT a directory, or
|
|
140
|
+
* `null` when the nearest existing one is a directory (so `mkdir -p dir`
|
|
141
|
+
* can succeed). Errors other than "does not exist" return `null` and are left
|
|
142
|
+
* to the write that follows.
|
|
143
|
+
*
|
|
144
|
+
* @param dir - the directory a config file needs.
|
|
145
|
+
*/
|
|
146
|
+
export function nonDirectoryAncestor(dir: string): string | null {
|
|
147
|
+
let cur = resolve(dir);
|
|
148
|
+
for (;;) {
|
|
149
|
+
try {
|
|
150
|
+
return statSync(cur).isDirectory() ? null : cur;
|
|
151
|
+
} catch (err) {
|
|
152
|
+
const code = (err as NodeJS.ErrnoException).code;
|
|
153
|
+
if (code !== 'ENOENT' && code !== 'ENOTDIR') return null;
|
|
154
|
+
}
|
|
155
|
+
const parent = dirname(cur);
|
|
156
|
+
if (parent === cur) return null;
|
|
157
|
+
cur = parent;
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/** Create `dirname(target)`, or throw {@link HeavyHookPathBlockedError} when a file is in the way. */
|
|
162
|
+
function ensureParentDir(target: string): void {
|
|
163
|
+
const blocked = nonDirectoryAncestor(dirname(target));
|
|
164
|
+
if (blocked !== null) throw new HeavyHookPathBlockedError(blocked, target);
|
|
165
|
+
mkdirSync(dirname(target), { recursive: true });
|
|
166
|
+
}
|
|
167
|
+
|
|
111
168
|
/**
|
|
112
169
|
* Whether `projectDir` is the user's home directory. There,
|
|
113
170
|
* `<home>/.claude/settings.local.json`, `<home>/.codex/hooks.json` and
|
|
@@ -268,7 +325,7 @@ export function heavyCommandHookEntry(provider: 'claude-code' | 'codex'): Record
|
|
|
268
325
|
}
|
|
269
326
|
|
|
270
327
|
/** Whether one hook object is CLEO's heavy-command hook. */
|
|
271
|
-
function isHeavyHookObject(hook: unknown): boolean {
|
|
328
|
+
export function isHeavyHookObject(hook: unknown): boolean {
|
|
272
329
|
return (
|
|
273
330
|
isPlainObject(hook) &&
|
|
274
331
|
typeof hook.command === 'string' &&
|
|
@@ -348,7 +405,7 @@ export async function syncJsonHeavyCommandHook(
|
|
|
348
405
|
});
|
|
349
406
|
return removed ? 'removed' : 'unchanged';
|
|
350
407
|
}
|
|
351
|
-
|
|
408
|
+
ensureParentDir(configPath);
|
|
352
409
|
clearOlderCleoMarkers();
|
|
353
410
|
let result: HeavyHookSyncResult = 'unchanged';
|
|
354
411
|
await updateJsonConfigFile(configPath, (config) => {
|
|
@@ -370,12 +427,26 @@ export async function syncJsonHeavyCommandHook(
|
|
|
370
427
|
}
|
|
371
428
|
|
|
372
429
|
/** Claude Code's per-machine settings file, relative to the project. */
|
|
373
|
-
const CLAUDE_LOCAL_SETTINGS = '.claude/settings.local.json';
|
|
430
|
+
export const CLAUDE_LOCAL_SETTINGS = '.claude/settings.local.json';
|
|
374
431
|
|
|
375
|
-
/** First line of the `info/exclude` block CLEO adds (and alone may remove). */
|
|
432
|
+
/** First line of the `info/exclude` block CLEO adds for `settings.local.json` (and alone may remove). */
|
|
376
433
|
export const LOCAL_SETTINGS_EXCLUDE_MARKER =
|
|
377
434
|
'# cleo-hook: keep the per-machine heavy-command hook settings out of git (T12983)';
|
|
378
435
|
|
|
436
|
+
/**
|
|
437
|
+
* First line of the `info/exclude` block CLEO adds for any other hook file it
|
|
438
|
+
* writes: Codex's `.codex/hooks.json` and the opencode plugin (T13124, gh#1805:
|
|
439
|
+
* an untracked plugin file widened `cleo verify`'s evidence scope).
|
|
440
|
+
*/
|
|
441
|
+
export const HOOK_FILE_EXCLUDE_MARKER =
|
|
442
|
+
'# cleo-hook: keep a per-machine heavy-command hook file out of git (T13124)';
|
|
443
|
+
|
|
444
|
+
/** Codex's project hook config, relative to the project. */
|
|
445
|
+
export const CODEX_HOOKS_FILE = '.codex/hooks.json';
|
|
446
|
+
|
|
447
|
+
/** The opencode plugin, relative to the project. */
|
|
448
|
+
export const OPENCODE_PLUGIN_FILE = `.opencode/plugins/${OPENCODE_HEAVY_COMMAND_PLUGIN}`;
|
|
449
|
+
|
|
379
450
|
/** Run git in `projectDir`; its trimmed stdout, or `null` on any failure. */
|
|
380
451
|
function gitOutput(projectDir: string, args: readonly string[]): string | null {
|
|
381
452
|
try {
|
|
@@ -397,54 +468,340 @@ function excludeFile(projectDir: string): string | null {
|
|
|
397
468
|
return isAbsolute(path) ? path : join(projectDir, path);
|
|
398
469
|
}
|
|
399
470
|
|
|
400
|
-
/** The `info/exclude` line for
|
|
401
|
-
function excludeLine(projectDir: string): string {
|
|
471
|
+
/** The `info/exclude` line for a project file (relative to the repo root). */
|
|
472
|
+
function excludeLine(projectDir: string, relPath: string): string {
|
|
402
473
|
const prefix = gitOutput(projectDir, ['rev-parse', '--show-prefix']) ?? '';
|
|
403
|
-
return `/${prefix}${
|
|
474
|
+
return `/${prefix}${relPath}`;
|
|
404
475
|
}
|
|
405
476
|
|
|
406
477
|
/**
|
|
407
|
-
* Keep
|
|
408
|
-
*
|
|
409
|
-
*
|
|
410
|
-
*
|
|
478
|
+
* Keep a hook file CLEO wrote out of git when nothing ignores it yet: add a
|
|
479
|
+
* marked block naming this project's file to the repository's `info/exclude`,
|
|
480
|
+
* never to a tracked `.gitignore`. A file git already tracks is left alone (an
|
|
481
|
+
* exclude cannot untrack it). Keyed on the exact path line, so a second CLEO
|
|
411
482
|
* project in a subdirectory of the same repository gets its own line.
|
|
412
483
|
* Idempotent; does nothing outside a git work tree.
|
|
413
484
|
*
|
|
414
485
|
* @param projectDir - the project root.
|
|
486
|
+
* @param relPath - the file, relative to the project (forward slashes).
|
|
487
|
+
* @param marker - the block's first line.
|
|
415
488
|
* @returns whether a block was added.
|
|
416
489
|
*/
|
|
417
|
-
export function
|
|
490
|
+
export function excludeHookFileFromGit(
|
|
491
|
+
projectDir: string,
|
|
492
|
+
relPath: string,
|
|
493
|
+
marker: string = HOOK_FILE_EXCLUDE_MARKER,
|
|
494
|
+
): boolean {
|
|
418
495
|
const exclude = excludeFile(projectDir);
|
|
419
496
|
if (exclude === null) return false;
|
|
420
|
-
if (gitOutput(projectDir, ['check-ignore',
|
|
421
|
-
|
|
497
|
+
if (gitOutput(projectDir, ['check-ignore', relPath]) !== null) return false;
|
|
498
|
+
if (gitOutput(projectDir, ['ls-files', '--error-unmatch', relPath]) !== null) return false;
|
|
499
|
+
const line = excludeLine(projectDir, relPath);
|
|
422
500
|
const text = existsSync(exclude) ? readFileSync(exclude, 'utf-8') : '';
|
|
423
501
|
if (text.split('\n').includes(line)) return false;
|
|
424
|
-
const block = `${
|
|
502
|
+
const block = `${marker}\n${line}\n`;
|
|
425
503
|
mkdirSync(dirname(exclude), { recursive: true });
|
|
426
504
|
writeFileSync(exclude, text === '' || text.endsWith('\n') ? text + block : `${text}\n${block}`);
|
|
427
505
|
return true;
|
|
428
506
|
}
|
|
429
507
|
|
|
430
508
|
/**
|
|
431
|
-
* Remove the block {@link
|
|
432
|
-
*
|
|
509
|
+
* Remove the block {@link excludeHookFileFromGit} added for this project's
|
|
510
|
+
* file, and only that: the exact path line and the CLEO marker right above it.
|
|
433
511
|
*
|
|
434
512
|
* @param projectDir - the project root.
|
|
513
|
+
* @param relPath - the file, relative to the project (forward slashes).
|
|
435
514
|
* @returns whether a block was removed.
|
|
436
515
|
*/
|
|
437
|
-
export function
|
|
516
|
+
export function unexcludeHookFileFromGit(projectDir: string, relPath: string): boolean {
|
|
438
517
|
const exclude = excludeFile(projectDir);
|
|
439
518
|
if (exclude === null || !existsSync(exclude)) return false;
|
|
440
519
|
const lines = readFileSync(exclude, 'utf-8').split('\n');
|
|
441
|
-
const at = lines.indexOf(excludeLine(projectDir));
|
|
442
|
-
|
|
520
|
+
const at = lines.indexOf(excludeLine(projectDir, relPath));
|
|
521
|
+
const above = lines[at - 1];
|
|
522
|
+
if (at < 1 || (above !== LOCAL_SETTINGS_EXCLUDE_MARKER && above !== HOOK_FILE_EXCLUDE_MARKER)) {
|
|
523
|
+
return false;
|
|
524
|
+
}
|
|
443
525
|
lines.splice(at - 1, 2);
|
|
444
526
|
writeFileSync(exclude, lines.join('\n'));
|
|
445
527
|
return true;
|
|
446
528
|
}
|
|
447
529
|
|
|
530
|
+
/**
|
|
531
|
+
* Whether git sees a hook file as an untracked change: inside a work tree,
|
|
532
|
+
* not ignored and not tracked. Such a file shows up in `git status` and
|
|
533
|
+
* widens `cleo verify`'s evidence scope (gh#1805).
|
|
534
|
+
*
|
|
535
|
+
* @param projectDir - the project root.
|
|
536
|
+
* @param relPath - the file, relative to the project (forward slashes).
|
|
537
|
+
*/
|
|
538
|
+
export function hookFileVisibleToGit(projectDir: string, relPath: string): boolean {
|
|
539
|
+
if (gitOutput(projectDir, ['rev-parse', '--is-inside-work-tree']) !== 'true') return false;
|
|
540
|
+
if (gitOutput(projectDir, ['check-ignore', relPath]) !== null) return false;
|
|
541
|
+
return gitOutput(projectDir, ['ls-files', '--error-unmatch', relPath]) === null;
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
/**
|
|
545
|
+
* Keep `.claude/settings.local.json` out of git when nothing ignores it yet
|
|
546
|
+
* (Claude Code ignores the file only when it creates it itself). See
|
|
547
|
+
* {@link excludeHookFileFromGit}.
|
|
548
|
+
*
|
|
549
|
+
* @param projectDir - the project root.
|
|
550
|
+
* @returns whether a block was added.
|
|
551
|
+
*/
|
|
552
|
+
export function excludeLocalSettingsFromGit(projectDir: string): boolean {
|
|
553
|
+
return excludeHookFileFromGit(projectDir, CLAUDE_LOCAL_SETTINGS, LOCAL_SETTINGS_EXCLUDE_MARKER);
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
/**
|
|
557
|
+
* Remove the block {@link excludeLocalSettingsFromGit} added for this
|
|
558
|
+
* project, and only that.
|
|
559
|
+
*
|
|
560
|
+
* @param projectDir - the project root.
|
|
561
|
+
* @returns whether a block was removed.
|
|
562
|
+
*/
|
|
563
|
+
export function unexcludeLocalSettingsFromGit(projectDir: string): boolean {
|
|
564
|
+
return unexcludeHookFileFromGit(projectDir, CLAUDE_LOCAL_SETTINGS);
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
/**
|
|
568
|
+
* A hook config CLEO will not add its per-machine hook to, because it is the
|
|
569
|
+
* project's own: Codex's `.codex/hooks.json` is committable, unlike Claude
|
|
570
|
+
* Code's `settings.local.json`. Writing there would put a per-machine hook
|
|
571
|
+
* into a team file, and excluding it would hide the team's file from git.
|
|
572
|
+
* Nothing is written; the delivery reports `needs-consent` with the entry to
|
|
573
|
+
* add by hand.
|
|
574
|
+
*/
|
|
575
|
+
export class HeavyHookSharedConfigError extends Error {
|
|
576
|
+
/** The project's own config file. */
|
|
577
|
+
readonly path: string;
|
|
578
|
+
|
|
579
|
+
/**
|
|
580
|
+
* @param path - the config file.
|
|
581
|
+
* @param why - what makes it the project's own.
|
|
582
|
+
*/
|
|
583
|
+
constructor(path: string, why: string) {
|
|
584
|
+
super(`${path} ${why}, so CLEO does not write or hide it`);
|
|
585
|
+
this.name = 'HeavyHookSharedConfigError';
|
|
586
|
+
this.path = path;
|
|
587
|
+
}
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
/**
|
|
591
|
+
* Whether a parsed hooks config is one CLEO created: nothing but a `hooks`
|
|
592
|
+
* map whose every hook object is CLEO's own (`# cleo-hook`), with at least
|
|
593
|
+
* one. An empty `{}` is the user's, not CLEO's.
|
|
594
|
+
*/
|
|
595
|
+
function cleoCreatedConfig(config: unknown): boolean {
|
|
596
|
+
if (!isPlainObject(config) || Object.keys(config).some((k) => k !== 'hooks')) return false;
|
|
597
|
+
if (!isPlainObject(config.hooks)) return false;
|
|
598
|
+
let mine = 0;
|
|
599
|
+
for (const groups of Object.values(config.hooks)) {
|
|
600
|
+
if (!Array.isArray(groups)) return false;
|
|
601
|
+
for (const group of groups) {
|
|
602
|
+
if (!isPlainObject(group) || !Array.isArray(group.hooks)) return false;
|
|
603
|
+
for (const hook of group.hooks) {
|
|
604
|
+
if (
|
|
605
|
+
!isPlainObject(hook) ||
|
|
606
|
+
typeof hook.command !== 'string' ||
|
|
607
|
+
!hook.command.includes(CLEO_HOOK_MARKER)
|
|
608
|
+
) {
|
|
609
|
+
return false;
|
|
610
|
+
}
|
|
611
|
+
mine++;
|
|
612
|
+
}
|
|
613
|
+
}
|
|
614
|
+
}
|
|
615
|
+
return mine > 0;
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
/**
|
|
619
|
+
* Why Codex's `.codex/hooks.json` in `projectDir` is the project's own config
|
|
620
|
+
* that CLEO must not write or hide, or `null` when CLEO may: the file is
|
|
621
|
+
* absent, or is one CLEO created and git does not track (review MED-3). An
|
|
622
|
+
* unreadable file is left to the sync, which refuses it.
|
|
623
|
+
*
|
|
624
|
+
* @param projectDir - the project root.
|
|
625
|
+
* @param checkGit - also ask git whether the file is tracked (one spawn);
|
|
626
|
+
* `false` reads the file only (the session briefing).
|
|
627
|
+
*/
|
|
628
|
+
export function codexHooksSharedReason(projectDir: string, checkGit = true): string | null {
|
|
629
|
+
const file = join(projectDir, CODEX_HOOKS_FILE);
|
|
630
|
+
// Tracking first: a committed file is the team's whatever it holds, even
|
|
631
|
+
// empty (review MED-3a).
|
|
632
|
+
if (
|
|
633
|
+
checkGit &&
|
|
634
|
+
gitOutput(projectDir, ['ls-files', '--error-unmatch', CODEX_HOOKS_FILE]) !== null
|
|
635
|
+
) {
|
|
636
|
+
return 'is tracked by git (a shared team config)';
|
|
637
|
+
}
|
|
638
|
+
if (!existsSync(file)) return null;
|
|
639
|
+
let config: unknown;
|
|
640
|
+
try {
|
|
641
|
+
config = JSON.parse(readFileSync(file, 'utf-8'));
|
|
642
|
+
} catch {
|
|
643
|
+
// Empty or not valid JSON: CLEO never writes such a file, so it is the user's.
|
|
644
|
+
return "is the project's own Codex hook config (empty or not valid JSON)";
|
|
645
|
+
}
|
|
646
|
+
return cleoCreatedConfig(config) ? null : "is the project's own Codex hook config";
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
/**
|
|
650
|
+
* Whether the committed (`HEAD`) version of Codex's `.codex/hooks.json` holds
|
|
651
|
+
* CLEO's hook: the team adopted it, so mode `off` on one machine must not
|
|
652
|
+
* remove it from the tracked file (review MED-3b).
|
|
653
|
+
*
|
|
654
|
+
* @param projectDir - the project root.
|
|
655
|
+
*/
|
|
656
|
+
export function committedCodexHookHoldsCleo(projectDir: string): boolean {
|
|
657
|
+
const committed = gitOutput(projectDir, ['show', `HEAD:./${CODEX_HOOKS_FILE}`]);
|
|
658
|
+
if (committed === null || committed === '') return false;
|
|
659
|
+
try {
|
|
660
|
+
const config: unknown = JSON.parse(committed);
|
|
661
|
+
if (!isPlainObject(config)) return false;
|
|
662
|
+
const groups = isPlainObject(config.hooks) ? config.hooks.PreToolUse : undefined;
|
|
663
|
+
return (
|
|
664
|
+
Array.isArray(groups) &&
|
|
665
|
+
groups.some(
|
|
666
|
+
(g) => isPlainObject(g) && Array.isArray(g.hooks) && g.hooks.some(isHeavyHookObject),
|
|
667
|
+
)
|
|
668
|
+
);
|
|
669
|
+
} catch {
|
|
670
|
+
return false;
|
|
671
|
+
}
|
|
672
|
+
}
|
|
673
|
+
|
|
674
|
+
/** Structural equality of two parsed JSON values (key order ignored). */
|
|
675
|
+
function jsonEqual(a: unknown, b: unknown): boolean {
|
|
676
|
+
if (Array.isArray(a) || Array.isArray(b)) {
|
|
677
|
+
return (
|
|
678
|
+
Array.isArray(a) &&
|
|
679
|
+
Array.isArray(b) &&
|
|
680
|
+
a.length === b.length &&
|
|
681
|
+
a.every((v, i) => jsonEqual(v, b[i]))
|
|
682
|
+
);
|
|
683
|
+
}
|
|
684
|
+
if (isPlainObject(a) && isPlainObject(b)) {
|
|
685
|
+
const keys = Object.keys(a);
|
|
686
|
+
return (
|
|
687
|
+
keys.length === Object.keys(b).length &&
|
|
688
|
+
keys.every((k) => Object.hasOwn(b, k) && jsonEqual(a[k], b[k]))
|
|
689
|
+
);
|
|
690
|
+
}
|
|
691
|
+
return a === b;
|
|
692
|
+
}
|
|
693
|
+
|
|
694
|
+
/**
|
|
695
|
+
* Mode `off` on a TRACKED `hooks.json` whose only change from `HEAD` is
|
|
696
|
+
* CLEO's hook: write `HEAD`'s exact bytes back, so the file returns to its
|
|
697
|
+
* committed state instead of being re-serialised with CLEO's formatting
|
|
698
|
+
* (review LOW-3). Returns whether it did.
|
|
699
|
+
*
|
|
700
|
+
* @param projectDir - the project root.
|
|
701
|
+
*/
|
|
702
|
+
export function restoreCommittedCodexHooks(projectDir: string): boolean {
|
|
703
|
+
if (gitOutput(projectDir, ['ls-files', '--error-unmatch', CODEX_HOOKS_FILE]) === null)
|
|
704
|
+
return false;
|
|
705
|
+
const file = join(projectDir, CODEX_HOOKS_FILE);
|
|
706
|
+
let committed: string;
|
|
707
|
+
let working: unknown;
|
|
708
|
+
try {
|
|
709
|
+
committed = execFileSync('git', ['-C', projectDir, 'show', `HEAD:./${CODEX_HOOKS_FILE}`], {
|
|
710
|
+
encoding: 'utf-8',
|
|
711
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
712
|
+
timeout: 5000,
|
|
713
|
+
});
|
|
714
|
+
working = JSON.parse(readFileSync(file, 'utf-8'));
|
|
715
|
+
} catch {
|
|
716
|
+
return false;
|
|
717
|
+
}
|
|
718
|
+
if (!isPlainObject(working)) return false;
|
|
719
|
+
const hooks = hookMap(working);
|
|
720
|
+
if (!placeHeavyHook(hooks, null).changed) return false;
|
|
721
|
+
try {
|
|
722
|
+
if (!jsonEqual(working, JSON.parse(committed))) return false;
|
|
723
|
+
} catch {
|
|
724
|
+
return false;
|
|
725
|
+
}
|
|
726
|
+
writeFileSync(file, committed);
|
|
727
|
+
return true;
|
|
728
|
+
}
|
|
729
|
+
|
|
730
|
+
/**
|
|
731
|
+
* Whether CLEO's own marked `info/exclude` block names this project file
|
|
732
|
+
* (it hides the file from git).
|
|
733
|
+
*
|
|
734
|
+
* @param projectDir - the project root.
|
|
735
|
+
* @param relPath - the file, relative to the project (forward slashes).
|
|
736
|
+
*/
|
|
737
|
+
export function excludedByCleo(projectDir: string, relPath: string): boolean {
|
|
738
|
+
const exclude = excludeFile(projectDir);
|
|
739
|
+
if (exclude === null || !existsSync(exclude)) return false;
|
|
740
|
+
const lines = readFileSync(exclude, 'utf-8').split('\n');
|
|
741
|
+
const at = lines.indexOf(excludeLine(projectDir, relPath));
|
|
742
|
+
const above = lines[at - 1];
|
|
743
|
+
return at >= 1 && (above === LOCAL_SETTINGS_EXCLUDE_MARKER || above === HOOK_FILE_EXCLUDE_MARKER);
|
|
744
|
+
}
|
|
745
|
+
|
|
746
|
+
/**
|
|
747
|
+
* The exact `PreToolUse` entry to add to Codex's `.codex/hooks.json` by hand,
|
|
748
|
+
* for a project whose own config CLEO does not write (`needs-consent`).
|
|
749
|
+
*/
|
|
750
|
+
export function codexHookSnippet(): string {
|
|
751
|
+
return JSON.stringify(heavyCommandHookEntry('codex'));
|
|
752
|
+
}
|
|
753
|
+
|
|
754
|
+
/**
|
|
755
|
+
* Whether git tracks a project file and it has uncommitted changes.
|
|
756
|
+
*
|
|
757
|
+
* @param projectDir - the project root.
|
|
758
|
+
* @param relPath - the file, relative to the project (forward slashes).
|
|
759
|
+
*/
|
|
760
|
+
export function trackedFileModified(projectDir: string, relPath: string): boolean {
|
|
761
|
+
if (gitOutput(projectDir, ['ls-files', '--error-unmatch', relPath]) === null) return false;
|
|
762
|
+
return (gitOutput(projectDir, ['diff', '--name-only', 'HEAD', '--', relPath]) ?? '') !== '';
|
|
763
|
+
}
|
|
764
|
+
|
|
765
|
+
/**
|
|
766
|
+
* Codex: sync the hook in `<project>/.codex/hooks.json` and keep that file out
|
|
767
|
+
* of git (T13124), as {@link syncClaudeCodeHeavyCommandHook} does for Claude
|
|
768
|
+
* Code's settings. A shared `hooks.json` (tracked, or holding hooks that are
|
|
769
|
+
* not CLEO's) is never written: {@link HeavyHookSharedConfigError}. Mode `off`
|
|
770
|
+
* still removes CLEO's own hook object from it.
|
|
771
|
+
*
|
|
772
|
+
* @param projectDir - the project root.
|
|
773
|
+
* @param mode - the resolved hook mode.
|
|
774
|
+
* @returns what changed in `hooks.json`.
|
|
775
|
+
* @throws {HeavyHookSharedConfigError} when `hooks.json` is shared.
|
|
776
|
+
*/
|
|
777
|
+
export async function syncCodexHeavyCommandHook(
|
|
778
|
+
projectDir: string,
|
|
779
|
+
mode: HeavyCommandHookMode,
|
|
780
|
+
): Promise<HeavyHookSyncResult> {
|
|
781
|
+
const file = join(projectDir, CODEX_HOOKS_FILE);
|
|
782
|
+
if (mode !== 'off') {
|
|
783
|
+
const shared = codexHooksSharedReason(projectDir);
|
|
784
|
+
if (shared !== null) {
|
|
785
|
+
// A file CLEO once created and excluded is now the user's: stop hiding
|
|
786
|
+
// it from git (review MED-3c). Only CLEO's own marked block goes.
|
|
787
|
+
unexcludeHookFileFromGit(projectDir, CODEX_HOOKS_FILE);
|
|
788
|
+
throw new HeavyHookSharedConfigError(file, shared);
|
|
789
|
+
}
|
|
790
|
+
} else if (committedCodexHookHoldsCleo(projectDir)) {
|
|
791
|
+
throw new HeavyHookSharedConfigError(
|
|
792
|
+
file,
|
|
793
|
+
"holds CLEO's hook as the team committed it (removing it is a team change)",
|
|
794
|
+
);
|
|
795
|
+
} else if (restoreCommittedCodexHooks(projectDir)) {
|
|
796
|
+
unexcludeHookFileFromGit(projectDir, CODEX_HOOKS_FILE);
|
|
797
|
+
return 'removed';
|
|
798
|
+
}
|
|
799
|
+
const result = await syncJsonHeavyCommandHook(file, 'codex', mode);
|
|
800
|
+
if (mode === 'off') unexcludeHookFileFromGit(projectDir, CODEX_HOOKS_FILE);
|
|
801
|
+
else excludeHookFileFromGit(projectDir, CODEX_HOOKS_FILE);
|
|
802
|
+
return result;
|
|
803
|
+
}
|
|
804
|
+
|
|
448
805
|
/**
|
|
449
806
|
* Claude Code: sync the hook in `<project>/.claude/settings.local.json`, keep
|
|
450
807
|
* that file out of git, and remove a hook an earlier build put in the shared
|
|
@@ -465,11 +822,17 @@ export async function syncClaudeCodeHeavyCommandHook(
|
|
|
465
822
|
);
|
|
466
823
|
if (mode === 'off') unexcludeLocalSettingsFromGit(projectDir);
|
|
467
824
|
else excludeLocalSettingsFromGit(projectDir);
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
825
|
+
try {
|
|
826
|
+
await syncJsonHeavyCommandHook(
|
|
827
|
+
join(projectDir, '.claude', 'settings.json'),
|
|
828
|
+
'claude-code',
|
|
829
|
+
'off',
|
|
830
|
+
);
|
|
831
|
+
} catch {
|
|
832
|
+
// Legacy cleanup only (T13124): a shared settings.json that is not valid
|
|
833
|
+
// JSON is the user's to fix, and Claude Code cannot load a hook from it
|
|
834
|
+
// anyway. The local install above already succeeded.
|
|
835
|
+
}
|
|
473
836
|
return result;
|
|
474
837
|
}
|
|
475
838
|
|
|
@@ -537,7 +900,8 @@ export function opencodeHeavyCommandPluginSource(): string {
|
|
|
537
900
|
|
|
538
901
|
/**
|
|
539
902
|
* Write (or, mode `off`, delete) the opencode plugin in
|
|
540
|
-
* `<projectDir>/.opencode/plugins
|
|
903
|
+
* `<projectDir>/.opencode/plugins/`, and keep it out of git (T13124).
|
|
904
|
+
* Rewritten only when its content changed.
|
|
541
905
|
*
|
|
542
906
|
* @param projectDir - the project root.
|
|
543
907
|
* @param mode - the resolved hook mode.
|
|
@@ -547,16 +911,21 @@ export function syncOpencodeHeavyCommandPlugin(
|
|
|
547
911
|
projectDir: string,
|
|
548
912
|
mode: HeavyCommandHookMode,
|
|
549
913
|
): HeavyHookSyncResult {
|
|
550
|
-
const pluginPath = join(projectDir,
|
|
914
|
+
const pluginPath = join(projectDir, OPENCODE_PLUGIN_FILE);
|
|
551
915
|
const exists = existsSync(pluginPath);
|
|
552
916
|
if (mode === 'off') {
|
|
917
|
+
unexcludeHookFileFromGit(projectDir, OPENCODE_PLUGIN_FILE);
|
|
553
918
|
if (!exists) return 'unchanged';
|
|
554
919
|
rmSync(pluginPath, { force: true });
|
|
555
920
|
return 'removed';
|
|
556
921
|
}
|
|
557
922
|
const source = opencodeHeavyCommandPluginSource();
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
923
|
+
const current = exists && readFileSync(pluginPath, 'utf-8') === source;
|
|
924
|
+
if (!current) {
|
|
925
|
+
ensureParentDir(pluginPath);
|
|
926
|
+
writeFileSync(pluginPath, source, 'utf-8');
|
|
927
|
+
}
|
|
928
|
+
// An untracked plugin file widened `cleo verify`'s evidence scope (gh#1805).
|
|
929
|
+
excludeHookFileFromGit(projectDir, OPENCODE_PLUGIN_FILE);
|
|
930
|
+
return current ? 'unchanged' : exists ? 'updated' : 'installed';
|
|
562
931
|
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A provider's PROJECT instruction file, skipped (not failed) at a project
|
|
3
|
+
* rooted at the home directory (T13227).
|
|
4
|
+
*
|
|
5
|
+
* Providers load CLAUDE.md / AGENTS.md / GEMINI.md from the working directory
|
|
6
|
+
* up through every ancestor, so a file at `$HOME` reaches every session under
|
|
7
|
+
* it: CAAMP refuses it with `HomeInstructionFileError`. An adapter's install
|
|
8
|
+
* reports that step as `skipped` and carries on with the rest, the way the
|
|
9
|
+
* heavy-command hook already skips the home directory (T12983).
|
|
10
|
+
*
|
|
11
|
+
* @task T13227
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import {
|
|
15
|
+
type EnsureProviderInstructionFileOptions,
|
|
16
|
+
type EnsureProviderInstructionFileResult,
|
|
17
|
+
ensureProviderInstructionFile,
|
|
18
|
+
HomeInstructionFileError,
|
|
19
|
+
} from '@cleocode/caamp';
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Ensure a provider's project instruction file, or `null` when the project is
|
|
23
|
+
* the home directory and the file was refused (nothing written).
|
|
24
|
+
*
|
|
25
|
+
* @param providerId - CAAMP provider id.
|
|
26
|
+
* @param projectDir - the project root.
|
|
27
|
+
* @param options - references, content, scope.
|
|
28
|
+
* @returns the CAAMP result, or `null` when skipped at `$HOME`.
|
|
29
|
+
*
|
|
30
|
+
* @example
|
|
31
|
+
* ```typescript
|
|
32
|
+
* const r = await ensureProjectInstructionFile('codex', projectDir, { scope: 'project' });
|
|
33
|
+
* if (r === null) details.instructionFile = 'skipped';
|
|
34
|
+
* ```
|
|
35
|
+
*/
|
|
36
|
+
export async function ensureProjectInstructionFile(
|
|
37
|
+
providerId: string,
|
|
38
|
+
projectDir: string,
|
|
39
|
+
options: EnsureProviderInstructionFileOptions,
|
|
40
|
+
): Promise<EnsureProviderInstructionFileResult | null> {
|
|
41
|
+
try {
|
|
42
|
+
return await ensureProviderInstructionFile(providerId, projectDir, options);
|
|
43
|
+
} catch (err) {
|
|
44
|
+
if (err instanceof HomeInstructionFileError) return null;
|
|
45
|
+
throw err;
|
|
46
|
+
}
|
|
47
|
+
}
|