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