@haposoft/cafekit 0.18.0 → 0.18.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.
- package/README.md +12 -1
- package/bin/__tests__/claude-settings.test.js +123 -0
- package/bin/__tests__/codex-ask-guard.test.js +123 -0
- package/bin/__tests__/codex-ask-projection.test.js +51 -0
- package/bin/__tests__/codex-hooks-ownership.test.js +80 -1
- package/bin/__tests__/codex-hooks.test.js +938 -237
- package/bin/__tests__/codex-native-hook-boundaries.test.js +136 -0
- package/bin/__tests__/codex-native.test.js +222 -149
- package/bin/__tests__/codex-projection-parity.test.js +314 -0
- package/bin/__tests__/codex-quiet-hooks.test.js +170 -0
- package/bin/__tests__/codex-research-projection.test.js +70 -0
- package/bin/__tests__/codex-specs-projection.test.js +76 -0
- package/bin/__tests__/develop-contract.test.js +102 -1362
- package/bin/__tests__/docs-sync-on-demand.test.js +25 -20
- package/bin/__tests__/inspect-paths.test.js +183 -0
- package/bin/__tests__/installer-safety.test.js +335 -0
- package/bin/__tests__/legacy-authority-decoupled.test.js +14 -19
- package/bin/__tests__/legacy-hooks-retired.test.js +18 -8
- package/bin/__tests__/omp-bridge.test.js +66 -12
- package/bin/__tests__/omp-hooks.test.js +11 -5
- package/bin/__tests__/orca-session.test.js +7 -0
- package/bin/__tests__/package-inventory.test.js +1 -395
- package/bin/__tests__/privacy-paths.test.js +317 -0
- package/bin/__tests__/spec-narrowing.test.js +30 -134
- package/bin/__tests__/specs-v2-execution-closeout.test.js +0 -372
- package/bin/__tests__/test-runtime-dependency-closure.cjs +1 -2
- package/bin/__tests__/usage-hook-retired.test.js +5 -3
- package/bin/__tests__/validator-grounding.test.js +17 -2929
- package/bin/install.js +25 -0
- package/bin/lib/codex-install.js +134 -12
- package/bin/lib/context.js +1 -1
- package/bin/lib/managed-writer.js +2 -3
- package/bin/phases/claude-runtime.js +91 -25
- package/bin/phases/claude-settings.js +22 -1
- package/bin/phases/codex-hooks.js +96 -33
- package/bin/phases/codex-runtime.js +45 -49
- package/bin/phases/copy-payload.js +11 -28
- package/bin/phases/omp-runtime.js +6 -3
- package/package.json +2 -3
- package/src/claude/CLAUDE.md +4 -0
- package/src/claude/agents/code-auditor.md +9 -6
- package/src/claude/agents/debugger.md +3 -9
- package/src/claude/agents/docs-keeper.md +4 -7
- package/src/claude/agents/git-ops.md +2 -4
- package/src/claude/agents/implementer.md +6 -7
- package/src/claude/agents/inspector.md +2 -0
- package/src/claude/agents/project-manager.md +2 -2
- package/src/claude/agents/spec-maker.md +4 -4
- package/src/claude/agents/test-runner.md +8 -11
- package/src/claude/agents/ui-ux-designer.md +0 -1
- package/src/claude/gitignore +3 -0
- package/src/claude/hooks/__tests__/installed-root.test.js +111 -0
- package/src/claude/hooks/__tests__/precompact.test.js +206 -10
- package/src/claude/hooks/__tests__/privacy-block.test.js +27 -0
- package/src/claude/hooks/__tests__/session-writeenv.test.js +25 -0
- package/src/claude/hooks/__tests__/spec-gate.test.js +311 -340
- package/src/claude/hooks/__tests__/spec-state-touched.test.js +230 -8
- package/src/claude/hooks/__tests__/state.test.js +207 -7
- package/src/claude/hooks/__tests__/update-check.test.js +88 -0
- package/src/claude/hooks/agent.cjs +7 -6
- package/src/claude/hooks/inspect-block.cjs +15 -63
- package/src/claude/hooks/lib/inspect-paths.cjs +304 -0
- package/src/claude/hooks/lib/parser.cjs +1 -1
- package/src/claude/hooks/lib/privacy-command-analysis.cjs +191 -17
- package/src/claude/hooks/lib/privacy-paths.cjs +313 -0
- package/src/claude/hooks/lib/project-root.cjs +15 -0
- package/src/claude/hooks/lib/rules-reservation.cjs +36 -0
- package/src/claude/hooks/lib/spec-touch.cjs +67 -0
- package/src/claude/hooks/lib/update-check.cjs +77 -0
- package/src/claude/hooks/precompact.cjs +8 -15
- package/src/claude/hooks/privacy-block.cjs +23 -205
- package/src/claude/hooks/rules.cjs +6 -28
- package/src/claude/hooks/session.cjs +34 -117
- package/src/claude/hooks/spec-gate.cjs +17 -89
- package/src/claude/hooks/spec-state.cjs +89 -96
- package/src/claude/hooks/state.cjs +96 -51
- package/src/claude/migration-manifest.json +20 -8
- package/src/claude/output-styles/coding-level-0-eli5.md +9 -9
- package/src/claude/output-styles/coding-level-1-junior.md +3 -3
- package/src/claude/output-styles/coding-level-2-mid.md +3 -3
- package/src/claude/output-styles/coding-level-3-senior.md +2 -2
- package/src/claude/output-styles/coding-level-4-lead.md +5 -5
- package/src/claude/output-styles/coding-level-5-god.md +6 -6
- package/src/claude/rules/ai-dev-rules.md +1 -2
- package/src/claude/rules/hook-protocols.md +1 -1
- package/src/claude/rules/workflow.md +1 -1
- package/src/claude/runtime.json +0 -3
- package/src/claude/scripts/generate-skill-catalog.cjs +2 -2
- package/src/claude/scripts/provenance.cjs +14 -0
- package/src/claude/scripts/spec-receipt.cjs +0 -114
- package/src/claude/scripts/spec-resolver.cjs +41 -457
- package/src/claude/scripts/workflow-policy.cjs +3 -1801
- package/src/claude/settings/settings.json +1 -1
- package/src/claude/skills/agent-browser/SKILL.md +1 -1
- package/src/claude/skills/ai-multimodal/scripts/check_setup.py +8 -68
- package/src/claude/skills/ai-multimodal/scripts/gemini_batch_process.py +6 -28
- package/src/claude/skills/chrome-devtools/SKILL.md +14 -31
- package/src/claude/skills/code-review/SKILL.md +25 -108
- package/src/claude/skills/code-review/references/spec-compliance-review.md +4 -7
- package/src/claude/skills/develop/SKILL.md +22 -70
- package/src/claude/skills/develop/references/subagent-patterns.md +0 -6
- package/src/claude/skills/git/SKILL.md +1 -1
- package/src/claude/skills/git/references/worktree-blueprint.md +1 -1
- package/src/claude/skills/research/SKILL.md +24 -55
- package/src/claude/skills/sync/SKILL.md +12 -34
- package/src/claude/skills/sync/references/rebind-and-audit.md +2 -2
- package/src/claude/skills/test/SKILL.md +39 -123
- package/src/claude/skills/test/references/execution-strategy.md +11 -22
- package/src/claude/skills/ui-ux/SKILL.md +1 -1
- package/src/claude/skills/ui-ux-pro-max/SKILL.md +24 -79
- package/src/claude/skills/web-testing/SKILL.md +2 -20
- package/src/codex/AGENTS.md +8 -0
- package/src/codex/hooks/ask-guard.cjs +54 -0
- package/src/codex/hooks/inspect-block.cjs +11 -7
- package/src/codex/hooks/lib/inspect-paths.cjs +1 -199
- package/src/codex/hooks/lib/privacy-paths.cjs +1 -0
- package/src/codex/hooks/lib/privacy-state.cjs +98 -16
- package/src/codex/hooks/lib/rules-reservation.cjs +38 -0
- package/src/codex/hooks/lib/secret-keywords.cjs +1 -0
- package/src/codex/hooks/lib/spec-receipt.cjs +2 -30
- package/src/codex/hooks/lib/spec-session-touch.cjs +100 -0
- package/src/codex/hooks/lib/spec-touch.cjs +3 -0
- package/src/codex/hooks/lib/spec-utils.cjs +6 -45
- package/src/codex/hooks/lib/update-check.cjs +1 -0
- package/src/codex/hooks/precompact.cjs +39 -0
- package/src/codex/hooks/privacy-approval.cjs +28 -17
- package/src/codex/hooks/privacy-block.cjs +9 -215
- package/src/codex/hooks/rules.cjs +4 -30
- package/src/codex/hooks/secret-output-guardrail.cjs +1 -1
- package/src/codex/hooks/session.cjs +47 -11
- package/src/codex/hooks/spec-gate.cjs +32 -74
- package/src/codex/hooks/spec-state.cjs +68 -46
- package/src/codex/hooks/state.cjs +25 -4
- package/src/codex/hooks.json +39 -7
- package/src/codex/rules/hook-protocols.md +13 -6
- package/src/common/AGENTS.md +4 -10
- package/src/omp/extensions/cafekit-bridge.mjs +12 -4
- package/src/omp/hooks/privacy-block.cjs +24 -206
- package/bin/__tests__/change-firewall.semantic-firewall.test.js +0 -2133
- package/bin/__tests__/finalizer-pass-gate.semantic-firewall.test.js +0 -342
- package/bin/__tests__/release-preflight.semantic-firewall.test.js +0 -586
- package/bin/__tests__/review-receipt-c2.semantic-firewall.test.js +0 -378
- package/bin/__tests__/specs-v2-policy-and-scaffold.test.js +0 -665
- package/bin/__tests__/specs-v2-validator-grounder-contract.test.js +0 -1132
- package/src/claude/hooks/lib/context.cjs +0 -527
- package/src/claude/hooks/lib/detect.cjs +0 -474
- package/src/claude/scripts/change-firewall.cjs +0 -1748
- package/src/claude/scripts/spec-authoring-digest.cjs +0 -151
- package/src/claude/scripts/spec-authoring-validation.cjs +0 -126
- package/src/claude/scripts/spec-final-state.cjs +0 -290
- package/src/claude/scripts/spec-ground.cjs +0 -650
- package/src/claude/scripts/spec-readiness.cjs +0 -425
- package/src/claude/scripts/spec-scaffold.cjs +0 -2317
- package/src/claude/scripts/spec-semantic-model.cjs +0 -396
- package/src/claude/scripts/validate-spec-output.cjs +0 -3914
- package/src/claude/skills/code-review/references/verification-gate.md +0 -41
- package/src/claude/skills/specs/templates/design.md +0 -82
- package/src/claude/skills/specs/templates/requirements-init.md +0 -8
- package/src/claude/skills/specs/templates/requirements.md +0 -42
- package/src/claude/skills/specs/templates/spec-state.json +0 -56
- package/src/claude/skills/specs/templates/task.md +0 -47
- package/src/codex/rules/state-sync.md +0 -60
|
@@ -47,29 +47,34 @@ function makeStale(root) {
|
|
|
47
47
|
execSync('git -c user.email=t@t -c user.name=t add -A && git -c user.email=t@t -c user.name=t commit -qm fixture', { cwd: root });
|
|
48
48
|
}
|
|
49
49
|
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
50
|
+
for (const [runtime, folder, config] of [['claude', '.claude', 'settings.json'], ['codex', '.codex', 'hooks.json']]) {
|
|
51
|
+
test(`fresh ${runtime} install runs no docs-sync at SessionStart`, () => {
|
|
52
|
+
inTempProject((root) => {
|
|
53
|
+
install(root, [runtime]);
|
|
54
|
+
const settings = JSON.parse(fs.readFileSync(path.join(root, folder, config), 'utf8'));
|
|
55
|
+
assert.equal(commands(settings, 'SessionStart').some((c) => c.includes('docs-sync.cjs')), false);
|
|
56
|
+
assert.equal(fs.existsSync(path.join(root, folder, 'hooks', 'docs-sync.cjs')), true, 'the hook stays installed for on-demand use');
|
|
57
|
+
});
|
|
56
58
|
});
|
|
57
|
-
});
|
|
58
59
|
|
|
59
|
-
test(
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
60
|
+
test(`${runtime} upgrade drops the managed SessionStart docs-sync entry`, () => {
|
|
61
|
+
inTempProject((root) => {
|
|
62
|
+
install(root, [runtime]);
|
|
63
|
+
const settingsPath = path.join(root, folder, config);
|
|
64
|
+
const settings = JSON.parse(fs.readFileSync(settingsPath, 'utf8'));
|
|
65
|
+
const kept = commands(settings, 'SessionStart');
|
|
66
|
+
const command = runtime === 'claude'
|
|
67
|
+
? 'node "$CLAUDE_PROJECT_DIR/.claude/hooks/docs-sync.cjs"'
|
|
68
|
+
: 'node ".codex/hooks/docs-sync.cjs"';
|
|
69
|
+
settings.hooks.SessionStart[0].hooks.push({ type: 'command', command });
|
|
70
|
+
fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2));
|
|
71
|
+
install(root, [runtime]);
|
|
72
|
+
const after = commands(JSON.parse(fs.readFileSync(settingsPath, 'utf8')), 'SessionStart');
|
|
73
|
+
assert.equal(after.some((c) => c.includes('docs-sync.cjs')), false);
|
|
74
|
+
for (const command of kept) assert.ok(after.includes(command), `kept: ${command}`);
|
|
75
|
+
});
|
|
71
76
|
});
|
|
72
|
-
}
|
|
77
|
+
}
|
|
73
78
|
|
|
74
79
|
test('the docs skill runs docs-sync on demand', () => {
|
|
75
80
|
for (const rel of ['SKILL.md', 'references/update-workflow.md']) {
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const assert = require('node:assert/strict');
|
|
4
|
+
const fs = require('node:fs');
|
|
5
|
+
const os = require('node:os');
|
|
6
|
+
const path = require('node:path');
|
|
7
|
+
const { spawnSync } = require('node:child_process');
|
|
8
|
+
const { test } = require('node:test');
|
|
9
|
+
const SOURCE = path.resolve(__dirname, '../../src');
|
|
10
|
+
|
|
11
|
+
// Invoke installed-layout hooks with stdin JSON: denial must be real, not a crash.
|
|
12
|
+
function fixture(host, run, variant = '') {
|
|
13
|
+
const temp = fs.mkdtempSync(path.join(os.tmpdir(), 'ck-inspect-'));
|
|
14
|
+
const root = path.join(temp, 'build/app');
|
|
15
|
+
const link = path.join(temp, 'project-link');
|
|
16
|
+
const hooks = path.join(root, `.${host}/hooks`);
|
|
17
|
+
try {
|
|
18
|
+
fs.mkdirSync(root, { recursive: true });
|
|
19
|
+
fs.symlinkSync(root, link, 'dir');
|
|
20
|
+
fs.cpSync(path.join(SOURCE, `${host}/hooks`), hooks, { recursive: true });
|
|
21
|
+
if (host === 'codex') {
|
|
22
|
+
for (const name of ['runtime-path-safety', 'privacy-command-analysis', 'privacy-paths', 'secret-keywords', 'inspect-paths']) {
|
|
23
|
+
const source = path.join(SOURCE, `claude/hooks/lib/${name}.cjs`);
|
|
24
|
+
// Before the shared lib exists, exercise the original native implementation.
|
|
25
|
+
if (fs.existsSync(source)) fs.copyFileSync(source, path.join(hooks, `lib/${name}.cjs`));
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
const cwd = variant === 'nested' ? path.join(root, 'packages/x') : root;
|
|
29
|
+
fs.mkdirSync(cwd, { recursive: true });
|
|
30
|
+
const projectRoot = variant === 'symlink' ? link : root;
|
|
31
|
+
run({ root, temp, cwd, hook(tool_name, tool_input, payload = {}) {
|
|
32
|
+
const log = path.join(hooks, '.logs/hook-log.jsonl');
|
|
33
|
+
const before = fs.existsSync(log) ? fs.readFileSync(log, 'utf8') : '';
|
|
34
|
+
const result = spawnSync(process.execPath, [path.join(hooks, 'inspect-block.cjs')], {
|
|
35
|
+
cwd, encoding: 'utf8', input: JSON.stringify({ cwd, tool_name, tool_input, ...payload }),
|
|
36
|
+
env: { ...process.env, CLAUDE_PROJECT_DIR: projectRoot },
|
|
37
|
+
});
|
|
38
|
+
const after = fs.existsSync(log) ? fs.readFileSync(log, 'utf8') : '';
|
|
39
|
+
assert.doesNotMatch(after.slice(before.length), /"status":"crash"/, result.stderr);
|
|
40
|
+
return result;
|
|
41
|
+
} });
|
|
42
|
+
} finally { fs.rmSync(temp, { recursive: true, force: true }); }
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const cases = [];
|
|
46
|
+
function add(name, tool, input, deny = false, variant = '', payload = {}) {
|
|
47
|
+
cases.push({ name, tool, input, deny, variant, payload });
|
|
48
|
+
}
|
|
49
|
+
function shell(command, deny = false) { add(command, 'Bash', { command }, deny); }
|
|
50
|
+
add('Read node_modules', 'Read', { file_path: 'node_modules/x/index.js' }, true);
|
|
51
|
+
add('Read absolute dist', 'Read', ({ root }) => ({ file_path: `${root}/dist/a.js` }), true);
|
|
52
|
+
add('Glob broad', 'Glob', { pattern: '**/*' }, true);
|
|
53
|
+
add('Glob dist', 'Glob', { pattern: '*.js', path: 'dist' }, true);
|
|
54
|
+
add('Grep path', 'Grep', { pattern: 'foo', path: 'node_modules' }, true);
|
|
55
|
+
add('Grep glob', 'Grep', { pattern: 'foo', glob: 'dist/**' }, true);
|
|
56
|
+
for (const command of [
|
|
57
|
+
'cat dist/index.js', 'nl -ba dist/a.js', 'sed -n 1,5p build/x',
|
|
58
|
+
'grep -rn foo node_modules', 'rg foo dist', 'find ./dist', 'ls node_modules',
|
|
59
|
+
'npm exec cat ./dist/index.js', 'npx cat dist/a.js', 'npm exec -- cat dist/a',
|
|
60
|
+
'FOO=1 cat dist/a', 'env FOO=1 cat dist/a', 'sudo -u root cat dist/a',
|
|
61
|
+
'vite build --outDir ./dist $(cat dist/index.js)', 'echo `cat dist/a`', 'echo "$(cat dist/a)"',
|
|
62
|
+
]) shell(command, true);
|
|
63
|
+
add('Bash tilde path', 'Bash', ({ root }) => ({ command: `cat ~/${path.relative(os.homedir(), root)}/dist/a` }), true);
|
|
64
|
+
add('missing tool name', undefined, { path: './dist/index.js' }, true);
|
|
65
|
+
add('exec_command reader', 'exec_command', { cmd: 'cat dist/index.js' }, true);
|
|
66
|
+
for (const tool of ['Edit', 'Write', 'MultiEdit']) {
|
|
67
|
+
add(`${tool} write`, tool, ({ root }) => ({ file_path: `${root}/scripts/build/release.js` }));
|
|
68
|
+
}
|
|
69
|
+
add('NotebookEdit write', 'NotebookEdit', { notebook_path: 'dist/a.ipynb' });
|
|
70
|
+
add('patch package main', 'apply_patch', { command: '*** Begin Patch\n*** Update File: package.json\n+"main": "./dist/index.js"\n*** End Patch' });
|
|
71
|
+
add('patch Add File', 'apply_patch', { command: '*** Begin Patch\n*** Add File: src/build/config.ts\n+x\n*** End Patch' });
|
|
72
|
+
for (const pattern of ['target', 'node_modules']) add(`Grep regex ${pattern}`, 'Grep', { pattern });
|
|
73
|
+
for (const command of [
|
|
74
|
+
'.agents/skills/.venv/bin/python3 scripts/search.py q', '.claude/skills/.venv/bin/python3 scripts/x.py',
|
|
75
|
+
'python3 -m venv .venv', 'source .venv/bin/activate && pytest',
|
|
76
|
+
'node_modules/.bin/eslint src', 'npx eslint dist/', 'python3 scripts/x.py build/out',
|
|
77
|
+
'tsc --outDir ./build', 'vite build --outDir ./dist', 'npm run build -- --outDir ./dist',
|
|
78
|
+
'cargo build --target-dir ./target', 'cd build && cmake ..', 'tree -I node_modules',
|
|
79
|
+
'grep -rn foo . --exclude-dir node_modules', "rg --glob '!**/node_modules/**' foo",
|
|
80
|
+
'find . -path ./node_modules -prune -o -print', 'echo concat vendor', "echo '$(cat dist/a)'",
|
|
81
|
+
"git commit -F - <<'EOF'\nls node_modules cleanup\nEOF",
|
|
82
|
+
]) shell(command);
|
|
83
|
+
add('Grep excluded glob', 'Grep', { pattern: 'foo', glob: '!dist/**' });
|
|
84
|
+
for (const variant of ['', 'nested', 'symlink']) {
|
|
85
|
+
add(`project above blocked segment ${variant || 'root'}`, 'Read', ({ root }) => ({ file_path: `${fs.realpathSync(root)}/src/a.js` }), false, variant);
|
|
86
|
+
}
|
|
87
|
+
add('Read ordinary', 'Read', { file_path: 'src/a.js' });
|
|
88
|
+
shell('cat src/a.js');
|
|
89
|
+
|
|
90
|
+
// Wrapper options, nested substitutions, and structured paths.
|
|
91
|
+
for (const command of [
|
|
92
|
+
'time -p env -u FOO BAR=1 command -- cat dist/a', 'nice -n 5 nohup cat dist/a',
|
|
93
|
+
'xargs -I {} cat dist/a', 'pnpm exec -- cat dist/a', 'pnpx --yes cat dist/a',
|
|
94
|
+
'yarn exec -- cat dist/a', 'bunx --bun cat dist/a', 'npx -p cat -- cat dist/a',
|
|
95
|
+
'echo "$(echo $(cat dist/a))"', 'echo ok; cat dist/a', 'echo ok\ncat dist/a',
|
|
96
|
+
'echo ok | cat dist/a', 'rg -e foo dist/a', 'awk \'{print $1}\' dist/a',
|
|
97
|
+
'less dist/a', 'more dist/a', 'bat dist/a', 'wc dist/a', 'du dist', 'file dist/a', 'stat dist/a',
|
|
98
|
+
'cat -n dist/a', 'wc -c dist/a', 'du -s dist', 'ls -L dist',
|
|
99
|
+
'echo $( (cat dist/a) )', 'env -S "cat dist/a"', 'env --split-string="cat" dist/a',
|
|
100
|
+
'npm exec -c "cat dist/a"', 'npx --call="cat dist/a"',
|
|
101
|
+
'cat src/a <<\\EOF\nprose\nEOF\ncat dist/a', "cat src/a <<E'OF'\nprose\nEOF\ncat dist/a",
|
|
102
|
+
]) shell(command, true);
|
|
103
|
+
for (const command of [
|
|
104
|
+
'grep --include node_modules foo src', 'rg -e node_modules src',
|
|
105
|
+
'rg --exclude-dir=node_modules foo src', 'tree -P dist',
|
|
106
|
+
'echo "cat dist/a"', "echo '`cat dist/a`'", 'cat src/a.js <<EOF\ncat dist/a\nEOF',
|
|
107
|
+
]) shell(command);
|
|
108
|
+
add('nested paths', 'mcp__files__read', { operations: [{ paths: ['dist/a'] }] }, true);
|
|
109
|
+
add('notebook path reader', 'Read', { notebook_path: 'dist/a.ipynb' }, true);
|
|
110
|
+
add('unknown command reader', 'mcp__shell', { command: 'cat dist/a' }, true);
|
|
111
|
+
add('missing name command reader', undefined, { command: 'cat dist/a' }, true);
|
|
112
|
+
add('write skips command too', 'Write', { file_path: 'dist/a', command: 'cat dist/a' });
|
|
113
|
+
|
|
114
|
+
// Output redirections are writes; search option values are not patterns.
|
|
115
|
+
for (const command of [
|
|
116
|
+
"cat > dist/index.html <<'EOF'\n<html></html>\nEOF",
|
|
117
|
+
'ls src > build/files.txt', 'wc -l src/a.ts > coverage/summary.txt',
|
|
118
|
+
'grep x src 2> build/err', 'ls src >> build/x', 'ls src 2>&1', 'ls src>build/x',
|
|
119
|
+
'rg --max-depth 2 target src', 'rg -j 4 build src', 'rg -M 200 dist src',
|
|
120
|
+
'rg --sort path target src', 'grep -rn -d skip build src',
|
|
121
|
+
]) shell(command);
|
|
122
|
+
for (const command of ['cat < dist/a', 'cat <dist/a']) shell(command, true);
|
|
123
|
+
|
|
124
|
+
add('Grep glob:"**/*.ts", path:"src"', 'Grep', { pattern: 'x', glob: '**/*.ts', path: 'src' });
|
|
125
|
+
add('Glob pattern:"**/*.ts", path:"src"', 'Glob', { pattern: '**/*.ts', path: 'src' });
|
|
126
|
+
shell("rg -g '**/*.ts' x src");
|
|
127
|
+
add('Glob broad without path', 'Glob', { pattern: '**/*' }, true);
|
|
128
|
+
add('Grep glob:"**/*.ts", path:"."', 'Grep', { pattern: 'x', glob: '**/*.ts', path: '.' }, true);
|
|
129
|
+
add('Glob broad absolute project root', 'Glob', ({ root }) => ({ pattern: '**/*.ts', path: root }), true);
|
|
130
|
+
add('Glob broad path:".."', 'Glob', { pattern: '**/*', path: '..' }, true);
|
|
131
|
+
add('Glob broad filesystem root', 'Glob', { pattern: '**/*', path: '/' }, true);
|
|
132
|
+
add('Glob broad home', 'Glob', { pattern: '**/*', path: '~' }, true);
|
|
133
|
+
add('Glob broad normalized project root', 'Glob', { pattern: '**/*', path: 'src/..' }, true);
|
|
134
|
+
add('Glob broad blocked directory', 'Glob', { pattern: '**/*.ts', path: 'node_modules' }, true);
|
|
135
|
+
shell("rg --glob '**/*' symbol .", true);
|
|
136
|
+
shell("rg --glob '**/*' symbol; cat src/file.ts", true);
|
|
137
|
+
shell("cd ..; rg --glob '**/*' symbol src", true);
|
|
138
|
+
shell("env -C .. rg --glob '**/*' symbol src", true);
|
|
139
|
+
for (const command of [
|
|
140
|
+
"rg -E utf8 -g '**/*' x", "rg --ignore-file .gitignore -g '**/*' x",
|
|
141
|
+
"rg --engine pcre2 -g '**/*' x", "rg --type-add 'web:*.html' -g '**/*' x",
|
|
142
|
+
"echo . | xargs rg -g '**/*' x src", "env -C.. rg -g '**/*' x src",
|
|
143
|
+
"builtin cd .. && rg -g '**/*' x src",
|
|
144
|
+
]) shell(command, true);
|
|
145
|
+
add('Bash broad glob with non-string session cwd', 'Bash', { command: "rg -g '**/*' x src" }, true, '', { cwd: 1 });
|
|
146
|
+
add('Bash broad glob with non-string input cwd', 'Bash', { command: "rg -g '**/*' x src", cwd: 1 }, true);
|
|
147
|
+
for (const key of ['cwd', 'workdir']) {
|
|
148
|
+
add(`Bash broad glob with ${key}:".."`, 'Bash', { command: "rg -g '**/*' x src", [key]: '..' }, true);
|
|
149
|
+
}
|
|
150
|
+
for (const flag of [
|
|
151
|
+
'--json', '-S', '--smart-case', '-N', '--no-heading', '--heading', '--column', '--vimgrep',
|
|
152
|
+
'--follow', '--no-ignore', '--no-ignore-dot', '--no-ignore-exclude', '--no-ignore-files',
|
|
153
|
+
'--no-ignore-global', '--no-ignore-parent', '--no-ignore-vcs', '--no-ignore-messages',
|
|
154
|
+
'--no-ignore-file-case-insensitive', '--count-matches', '-b', '--trim',
|
|
155
|
+
'-C3', '-A2', '-m5', '-tts',
|
|
156
|
+
]) shell(`rg -g '**/*.ts' ${flag} x src`);
|
|
157
|
+
for (const command of [
|
|
158
|
+
"eval 'cd ..'; rg -g '**/*' x src", "source setup.sh; rg -g '**/*' x src",
|
|
159
|
+
". setup.sh; rg -g '**/*' x src", "env -iC.. rg -g '**/*' x src",
|
|
160
|
+
]) shell(command, true);
|
|
161
|
+
|
|
162
|
+
for (const host of ['claude', 'codex']) {
|
|
163
|
+
for (const entry of cases) {
|
|
164
|
+
if (host === 'codex' && entry.payload.cwd === 1) continue;
|
|
165
|
+
test(`inspect-paths ${host}: ${entry.name}`, () => fixture(host, (context) => {
|
|
166
|
+
const input = typeof entry.input === 'function' ? entry.input(context) : entry.input;
|
|
167
|
+
const result = context.hook(entry.tool, input, entry.payload);
|
|
168
|
+
assert.equal(result.status, entry.deny ? 2 : 0, result.stderr || result.stdout);
|
|
169
|
+
if (entry.deny) {
|
|
170
|
+
if (host === 'claude') assert.equal(JSON.parse(result.stdout).hookSpecificOutput.permissionDecision, 'deny');
|
|
171
|
+
assert.match(result.stderr, /SCOPE LIMIT EXCEEDED/);
|
|
172
|
+
} else assert.equal(result.stdout, '');
|
|
173
|
+
}, entry.variant));
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
test('inspect-paths lib blocks broad glob with non-string context.cwd', () => {
|
|
178
|
+
const { inspectInput } = require('../../src/claude/hooks/lib/inspect-paths.cjs');
|
|
179
|
+
const result = inspectInput('Bash', { command: "rg -g '**/*' x src" }, {
|
|
180
|
+
cwd: 1, projectRoot: process.cwd(),
|
|
181
|
+
});
|
|
182
|
+
assert.equal(result.broadGlob, '**/*');
|
|
183
|
+
});
|
|
@@ -97,6 +97,341 @@ function inTempProject(run) {
|
|
|
97
97
|
}
|
|
98
98
|
}
|
|
99
99
|
|
|
100
|
+
function runCodexInstaller(root) {
|
|
101
|
+
return spawnSync(process.execPath, [INSTALLER, '--platform', 'codex', '--yes'], {
|
|
102
|
+
cwd: root, encoding: 'utf8', env: { ...process.env, PATH: '/usr/bin:/bin' }
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function rootFileGit(root, args) {
|
|
107
|
+
const result = spawnSync('git', args, { cwd: root, encoding: 'utf8' });
|
|
108
|
+
assert.equal(result.status, 0, `${result.stdout}\n${result.stderr}`);
|
|
109
|
+
return result.stdout;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function commitRootFile(root, file) {
|
|
113
|
+
rootFileGit(root, ['init', '-q']);
|
|
114
|
+
const content = file === 'AGENTS.md' ? upsertManagedCoreBlock('', SHARED_TEMPLATE) : TEMPLATE;
|
|
115
|
+
fs.writeFileSync(path.join(root, file), `${content}\n## Project notes\nKeep these project notes.\n`);
|
|
116
|
+
rootFileGit(root, ['add', '--', file]);
|
|
117
|
+
rootFileGit(root, ['-c', 'user.name=CafeKit Fixture', '-c', 'user.email=cafekit-fixture@example.invalid',
|
|
118
|
+
'commit', '--no-gpg-sign', '-qm', 'root file fixture']);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function runRootFileInstaller(root, platform, args = [], env = {}) {
|
|
122
|
+
return spawnSync(process.execPath,
|
|
123
|
+
[INSTALLER, '--platform', platform, '--yes', '--without-document-skills', ...args],
|
|
124
|
+
{ cwd: root, encoding: 'utf8', env: { ...process.env, PATH: '/usr/bin:/bin', ...env } });
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
for (const [file, platform, args] of [
|
|
128
|
+
['AGENTS.md', 'claude', []], ['CLAUDE.md', 'claude', []],
|
|
129
|
+
['AGENTS.md', 'codex', []], ['AGENTS.md', 'omp', []],
|
|
130
|
+
['AGENTS.md', 'claude', ['--dry-run']],
|
|
131
|
+
]) {
|
|
132
|
+
test(`missing tracked ${file} stops ${platform} installation${args.length ? ' in dry-run' : ''} before writes`, () => {
|
|
133
|
+
inTempProject(root => {
|
|
134
|
+
commitRootFile(root, file);
|
|
135
|
+
fs.unlinkSync(path.join(root, file));
|
|
136
|
+
const before = fs.readdirSync(root).filter(name => name !== '.cafekit.lock');
|
|
137
|
+
const result = runRootFileInstaller(root, platform, args);
|
|
138
|
+
const output = `${result.stdout}\n${result.stderr}`;
|
|
139
|
+
assert.notEqual(result.status, 0, output);
|
|
140
|
+
assert.ok(output.includes(`git checkout -- ${file}`), output);
|
|
141
|
+
assert.ok(output.includes(`git rm ${file}`), output);
|
|
142
|
+
assert.deepEqual(fs.readdirSync(root).filter(name => name !== '.cafekit.lock'), before);
|
|
143
|
+
for (const name of ['AGENTS.md', 'CLAUDE.md', '.claude', '.codex', '.agents', '.omp', '.cafekit-backup']) {
|
|
144
|
+
assert.equal(fs.existsSync(path.join(root, name)), false, name);
|
|
145
|
+
}
|
|
146
|
+
});
|
|
147
|
+
});
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
for (const assumeUnchanged of [false, true]) {
|
|
151
|
+
test(`missing skip-worktree AGENTS.md permits installation${assumeUnchanged ? ' with assume-unchanged' : ''}`, () => {
|
|
152
|
+
inTempProject(root => {
|
|
153
|
+
commitRootFile(root, 'AGENTS.md');
|
|
154
|
+
rootFileGit(root, ['update-index', '--skip-worktree', '--', 'AGENTS.md']);
|
|
155
|
+
if (assumeUnchanged) rootFileGit(root, ['update-index', '--assume-unchanged', '--', 'AGENTS.md']);
|
|
156
|
+
assert.match(rootFileGit(root, ['ls-files', '-v', '--', 'AGENTS.md']), assumeUnchanged ? /^s / : /^S /);
|
|
157
|
+
fs.unlinkSync(path.join(root, 'AGENTS.md'));
|
|
158
|
+
const result = runRootFileInstaller(root, 'claude');
|
|
159
|
+
assert.equal(result.status, 0, `${result.stdout}\n${result.stderr}`);
|
|
160
|
+
assert.ok(fs.existsSync(path.join(root, 'AGENTS.md')));
|
|
161
|
+
});
|
|
162
|
+
});
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
test('a tracked dangling CLAUDE.md symlink is present for the root preflight', () => {
|
|
166
|
+
inTempProject(root => {
|
|
167
|
+
rootFileGit(root, ['init', '-q']);
|
|
168
|
+
fs.mkdirSync(path.join(root, 'notes'));
|
|
169
|
+
fs.symlinkSync('notes/claude.md', path.join(root, 'CLAUDE.md'));
|
|
170
|
+
rootFileGit(root, ['add', '--', 'CLAUDE.md']);
|
|
171
|
+
rootFileGit(root, ['-c', 'user.name=CafeKit Fixture', '-c', 'user.email=cafekit-fixture@example.invalid',
|
|
172
|
+
'commit', '--no-gpg-sign', '-qm', 'root symlink fixture']);
|
|
173
|
+
const result = runRootFileInstaller(root, 'claude');
|
|
174
|
+
const output = `${result.stdout}\n${result.stderr}`;
|
|
175
|
+
assert.notEqual(result.status, 0, output);
|
|
176
|
+
assert.doesNotMatch(output, /tracked by git but missing/);
|
|
177
|
+
assert.match(output, /Refusing to follow symlinked managed path/);
|
|
178
|
+
assert.equal(fs.readlinkSync(path.join(root, 'CLAUDE.md')), 'notes/claude.md');
|
|
179
|
+
assert.equal(fs.existsSync(path.join(root, 'notes/claude.md')), false);
|
|
180
|
+
assert.deepEqual(fs.readdirSync(path.join(root, 'notes')), []);
|
|
181
|
+
});
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
test('uncertain git ownership warns and permits a fresh root file', () => {
|
|
185
|
+
inTempProject(root => {
|
|
186
|
+
commitRootFile(root, 'AGENTS.md');
|
|
187
|
+
fs.unlinkSync(path.join(root, 'AGENTS.md'));
|
|
188
|
+
const result = runRootFileInstaller(root, 'claude', [], {
|
|
189
|
+
GIT_TEST_ASSUME_DIFFERENT_OWNER: '1', GIT_CONFIG_GLOBAL: '/dev/null', GIT_CONFIG_NOSYSTEM: '1',
|
|
190
|
+
});
|
|
191
|
+
assert.equal(result.status, 0, `${result.stdout}\n${result.stderr}`);
|
|
192
|
+
assert.match(`${result.stdout}\n${result.stderr}`, /Could not confirm git state for AGENTS\.md; installing a fresh copy\./);
|
|
193
|
+
assert.ok(fs.existsSync(path.join(root, 'AGENTS.md')));
|
|
194
|
+
});
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
test('Codex installation leaves a missing tracked CLAUDE.md alone', () => {
|
|
198
|
+
inTempProject(root => {
|
|
199
|
+
commitRootFile(root, 'CLAUDE.md');
|
|
200
|
+
fs.unlinkSync(path.join(root, 'CLAUDE.md'));
|
|
201
|
+
const result = runRootFileInstaller(root, 'codex');
|
|
202
|
+
assert.equal(result.status, 0, `${result.stdout}\n${result.stderr}`);
|
|
203
|
+
assert.equal(fs.existsSync(path.join(root, 'CLAUDE.md')), false);
|
|
204
|
+
});
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
test('a root file removed from the git index can be freshly installed', () => {
|
|
208
|
+
inTempProject(root => {
|
|
209
|
+
commitRootFile(root, 'AGENTS.md');
|
|
210
|
+
rootFileGit(root, ['rm', '--', 'AGENTS.md']);
|
|
211
|
+
const result = runRootFileInstaller(root, 'claude');
|
|
212
|
+
assert.equal(result.status, 0, `${result.stdout}\n${result.stderr}`);
|
|
213
|
+
assert.ok(fs.existsSync(path.join(root, 'AGENTS.md')));
|
|
214
|
+
assert.doesNotMatch(`${result.stdout}\n${result.stderr}`, /Could not confirm git state/);
|
|
215
|
+
});
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
test('a project outside git installs root files without a git-state warning', () => {
|
|
219
|
+
inTempProject(root => {
|
|
220
|
+
const result = runRootFileInstaller(root, 'claude');
|
|
221
|
+
assert.equal(result.status, 0, `${result.stdout}\n${result.stderr}`);
|
|
222
|
+
assert.ok(fs.existsSync(path.join(root, 'AGENTS.md')));
|
|
223
|
+
assert.doesNotMatch(`${result.stdout}\n${result.stderr}`, /Could not confirm git state/);
|
|
224
|
+
});
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
test('an existing tracked root file retains notes after its managed marker', () => {
|
|
228
|
+
inTempProject(root => {
|
|
229
|
+
commitRootFile(root, 'AGENTS.md');
|
|
230
|
+
const result = runRootFileInstaller(root, 'claude');
|
|
231
|
+
assert.equal(result.status, 0, `${result.stdout}\n${result.stderr}`);
|
|
232
|
+
assert.match(fs.readFileSync(path.join(root, 'AGENTS.md'), 'utf8'), /CAFEKIT CORE END -->[\s\S]*Keep these project notes\./);
|
|
233
|
+
});
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
const NOTES_HOME = 'Project notes (commands, files to leave unchanged, slow or expensive operations) belong in the root AGENTS.md after the CAFEKIT CORE END marker';
|
|
237
|
+
const normalizeWhitespace = text => text.replace(/\s+/g, ' ').trim();
|
|
238
|
+
|
|
239
|
+
test('the installed Claude block points project notes to the shared root with a quoted import', () => {
|
|
240
|
+
inTempProject(root => {
|
|
241
|
+
const result = runRootFileInstaller(root, 'claude');
|
|
242
|
+
assert.equal(result.status, 0, `${result.stdout}\n${result.stderr}`);
|
|
243
|
+
const body = normalizeWhitespace(managedBody(fs.readFileSync(path.join(root, 'CLAUDE.md'), 'utf8'), START, END));
|
|
244
|
+
assert.ok(body.includes(`${NOTES_HOME}; Claude Code reads them through \`@AGENTS.md\`.`), body);
|
|
245
|
+
assert.doesNotMatch(body, /@AGENTS\.md\./);
|
|
246
|
+
assert.doesNotMatch(body, /Codex|\.codex|\$cf-/i);
|
|
247
|
+
});
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
test('the installed Codex block points project notes to the shared root directly', () => {
|
|
251
|
+
inTempProject(root => {
|
|
252
|
+
const result = runRootFileInstaller(root, 'codex');
|
|
253
|
+
assert.equal(result.status, 0, `${result.stdout}\n${result.stderr}`);
|
|
254
|
+
const body = normalizeWhitespace(managedBody(fs.readFileSync(path.join(root, 'AGENTS.md'), 'utf8'), CODEX_START, CODEX_END));
|
|
255
|
+
assert.ok(body.includes(`${NOTES_HOME}; Codex reads AGENTS.md directly.`), body);
|
|
256
|
+
});
|
|
257
|
+
});
|
|
258
|
+
|
|
259
|
+
test('the package README preserves root ownership and names one project-notes home for both hosts', () => {
|
|
260
|
+
const readme = normalizeWhitespace(fs.readFileSync(path.join(__dirname, '../../README.md'), 'utf8'));
|
|
261
|
+
const note = `${NOTES_HOME}; Claude Code reads them through \`@AGENTS.md\` and Codex reads AGENTS.md directly.`;
|
|
262
|
+
const ownership = 'CafeKit owns only its marked block inside the project-root `CLAUDE.md`; project instructions outside that block are preserved across installs and upgrades.';
|
|
263
|
+
assert.ok(readme.includes(`${ownership} ${note}`));
|
|
264
|
+
assert.ok(readme.includes(`Claude statusline. ${note}`));
|
|
265
|
+
});
|
|
266
|
+
|
|
267
|
+
for (const host of ['claude', 'codex']) {
|
|
268
|
+
for (const state of ['pristine file', 'modified file', 'untracked file', 'external symlink', 'dangling symlink', 'pristine directory', 'directory with an extra file', 'directory with a modified file', 'directory with a symlink']) {
|
|
269
|
+
test(`${host} obsolete cleanup safely handles a ${state}`, () => {
|
|
270
|
+
const outside = fs.mkdtempSync(path.join(os.tmpdir(), 'cafekit-obsolete-outside-'));
|
|
271
|
+
try {
|
|
272
|
+
inTempProject(root => {
|
|
273
|
+
const run = () => spawnSync(process.execPath, [INSTALLER, '--platform', host, '--yes', '--force-overwrite'], {
|
|
274
|
+
cwd: root, encoding: 'utf8', env: { ...process.env, PATH: '/usr/bin:/bin' }
|
|
275
|
+
});
|
|
276
|
+
const first = run();
|
|
277
|
+
assert.equal(first.status, 0, first.stderr);
|
|
278
|
+
const folder = `.${host}`;
|
|
279
|
+
const manifestPath = path.join(root, folder, 'cafekit-manifest.json');
|
|
280
|
+
const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8'));
|
|
281
|
+
const directory = state.includes('directory');
|
|
282
|
+
const relative = directory
|
|
283
|
+
? (host === 'codex' ? '.agents/skills/generate-graph' : '.claude/skills/generate-graph')
|
|
284
|
+
: `${folder}/hooks/usage.cjs`;
|
|
285
|
+
const target = path.join(root, relative);
|
|
286
|
+
const keyFor = relative => host === 'codex' ? relative : relative.slice('.claude/'.length);
|
|
287
|
+
const records = {};
|
|
288
|
+
const record = (relative, bytes) => {
|
|
289
|
+
const key = keyFor(relative);
|
|
290
|
+
records[key] = { sha256: sha256(bytes) };
|
|
291
|
+
manifest.files[key] = records[key];
|
|
292
|
+
};
|
|
293
|
+
const external = path.join(outside, 'target.txt');
|
|
294
|
+
if (state !== 'dangling symlink') fs.writeFileSync(external, 'outside bytes\n');
|
|
295
|
+
fs.mkdirSync(path.dirname(target), { recursive: true });
|
|
296
|
+
if (directory) {
|
|
297
|
+
fs.mkdirSync(path.join(target, 'nested'), { recursive: true });
|
|
298
|
+
fs.writeFileSync(path.join(target, 'tracked.txt'), state.includes('modified') ? 'changed\n' : 'installed\n');
|
|
299
|
+
fs.writeFileSync(path.join(target, 'nested/second.txt'), 'second\n');
|
|
300
|
+
record(`${relative}/tracked.txt`, 'installed\n');
|
|
301
|
+
record(`${relative}/nested/second.txt`, 'second\n');
|
|
302
|
+
if (state.includes('extra')) fs.writeFileSync(path.join(target, 'foreign.txt'), 'user bytes\n');
|
|
303
|
+
if (state.includes('symlink')) {
|
|
304
|
+
fs.symlinkSync(external, path.join(target, 'linked.txt'));
|
|
305
|
+
record(`${relative}/linked.txt`, 'outside bytes\n');
|
|
306
|
+
}
|
|
307
|
+
} else if (state.includes('symlink')) {
|
|
308
|
+
fs.symlinkSync(external, target);
|
|
309
|
+
record(relative, 'outside bytes\n');
|
|
310
|
+
} else {
|
|
311
|
+
fs.writeFileSync(target, state === 'modified file' ? 'changed\n' : 'installed\n');
|
|
312
|
+
if (state !== 'untracked file') record(relative, 'installed\n');
|
|
313
|
+
}
|
|
314
|
+
fs.writeFileSync(manifestPath, JSON.stringify(manifest));
|
|
315
|
+
const again = run();
|
|
316
|
+
assert.equal(again.status, 0, `${again.stdout}\n${again.stderr}`);
|
|
317
|
+
const preserved = !state.startsWith('pristine');
|
|
318
|
+
assert.equal(fs.existsSync(target) || state === 'dangling symlink' && fs.lstatSync(target).isSymbolicLink(), preserved);
|
|
319
|
+
const after = JSON.parse(fs.readFileSync(manifestPath, 'utf8'));
|
|
320
|
+
for (const [key, value] of Object.entries(records)) {
|
|
321
|
+
if (preserved) assert.deepEqual(after.files[key], value, key);
|
|
322
|
+
else assert.equal(after.files[key], undefined, key);
|
|
323
|
+
}
|
|
324
|
+
if (state.includes('symlink')) {
|
|
325
|
+
const link = directory ? path.join(target, 'linked.txt') : target;
|
|
326
|
+
assert.ok(fs.lstatSync(link).isSymbolicLink());
|
|
327
|
+
assert.equal(fs.readlinkSync(link), external);
|
|
328
|
+
}
|
|
329
|
+
if (state !== 'dangling symlink') assert.equal(fs.readFileSync(external, 'utf8'), 'outside bytes\n');
|
|
330
|
+
else assert.equal(fs.existsSync(external), false);
|
|
331
|
+
const warnings = `${again.stdout}\n${again.stderr}`.split('\n').filter(line => /Preserved \d+ obsolete runtime/.test(line));
|
|
332
|
+
assert.equal(warnings.length, preserved ? 1 : 0);
|
|
333
|
+
if (preserved) {
|
|
334
|
+
assert.match(warnings[0], /Preserved 1 obsolete runtime/);
|
|
335
|
+
assert.match(warnings[0], /manually/i);
|
|
336
|
+
}
|
|
337
|
+
});
|
|
338
|
+
} finally {
|
|
339
|
+
fs.rmSync(outside, { recursive: true, force: true });
|
|
340
|
+
}
|
|
341
|
+
});
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
test('combined install emits one aggregate warning for preserved obsolete runtime items', () => {
|
|
346
|
+
inTempProject(root => {
|
|
347
|
+
assert.equal(runCombinedInstaller(root).status, 0);
|
|
348
|
+
for (const host of ['claude', 'codex']) {
|
|
349
|
+
fs.writeFileSync(path.join(root, `.${host}/hooks/usage.cjs`), 'user bytes\n');
|
|
350
|
+
}
|
|
351
|
+
const again = runCombinedInstaller(root);
|
|
352
|
+
assert.equal(again.status, 0, again.stderr);
|
|
353
|
+
const warnings = `${again.stdout}\n${again.stderr}`.split('\n').filter(line => /Preserved \d+ obsolete runtime/.test(line));
|
|
354
|
+
assert.equal(warnings.length, 1);
|
|
355
|
+
assert.match(warnings[0], /Preserved 2 obsolete runtime/);
|
|
356
|
+
assert.match(warnings[0], /manually/i);
|
|
357
|
+
for (const host of ['claude', 'codex']) assert.equal(fs.readFileSync(path.join(root, `.${host}/hooks/usage.cjs`), 'utf8'), 'user bytes\n');
|
|
358
|
+
});
|
|
359
|
+
});
|
|
360
|
+
|
|
361
|
+
test('combined install removes pristine obsolete files after each host tracker is available', () => {
|
|
362
|
+
inTempProject(root => {
|
|
363
|
+
assert.equal(runCombinedInstaller(root).status, 0);
|
|
364
|
+
for (const host of ['claude', 'codex']) {
|
|
365
|
+
const target = path.join(root, `.${host}/hooks/usage.cjs`);
|
|
366
|
+
fs.writeFileSync(target, 'installed bytes\n');
|
|
367
|
+
const manifestPath = path.join(root, `.${host}/cafekit-manifest.json`);
|
|
368
|
+
const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8'));
|
|
369
|
+
const key = host === 'codex' ? '.codex/hooks/usage.cjs' : 'hooks/usage.cjs';
|
|
370
|
+
manifest.files[key] = { sha256: sha256('installed bytes\n') };
|
|
371
|
+
fs.writeFileSync(manifestPath, JSON.stringify(manifest));
|
|
372
|
+
}
|
|
373
|
+
const again = runCombinedInstaller(root);
|
|
374
|
+
assert.equal(again.status, 0, again.stderr);
|
|
375
|
+
for (const host of ['claude', 'codex']) {
|
|
376
|
+
assert.equal(fs.existsSync(path.join(root, `.${host}/hooks/usage.cjs`)), false, host);
|
|
377
|
+
const manifest = JSON.parse(fs.readFileSync(path.join(root, `.${host}/cafekit-manifest.json`), 'utf8'));
|
|
378
|
+
assert.equal(manifest.files[host === 'codex' ? '.codex/hooks/usage.cjs' : 'hooks/usage.cjs'], undefined, host);
|
|
379
|
+
}
|
|
380
|
+
assert.doesNotMatch(again.stdout + again.stderr, /Preserved \d+ obsolete runtime/);
|
|
381
|
+
});
|
|
382
|
+
});
|
|
383
|
+
|
|
384
|
+
test('identical Codex reinstall does not report hook helpers or rules updated', () => {
|
|
385
|
+
inTempProject(root => {
|
|
386
|
+
const first = runCodexInstaller(root);
|
|
387
|
+
assert.equal(first.status, 0, first.stderr);
|
|
388
|
+
const second = runCodexInstaller(root);
|
|
389
|
+
assert.equal(second.status, 0, second.stderr);
|
|
390
|
+
const labels = ['Codex native hooks', 'Codex privacy command analyzer', 'Codex privacy path analyzer',
|
|
391
|
+
'Codex secret keyword matcher', 'Codex runtime path safety', 'Codex inspection path analyzer',
|
|
392
|
+
'Codex spec touch helpers', 'Codex update check', 'Codex native rule overrides', 'rules/ directory'];
|
|
393
|
+
const updates = `${second.stdout}\n${second.stderr}`.split('\n')
|
|
394
|
+
.filter(line => line.includes('↻') && labels.some(label => line.includes(label)));
|
|
395
|
+
assert.deepEqual(updates, []);
|
|
396
|
+
});
|
|
397
|
+
});
|
|
398
|
+
|
|
399
|
+
test('installed Codex hook libraries match canonical bytes and contain no source shims', () => {
|
|
400
|
+
inTempProject(root => {
|
|
401
|
+
const result = runCodexInstaller(root);
|
|
402
|
+
assert.equal(result.status, 0, result.stderr);
|
|
403
|
+
const sourceRoot = path.join(__dirname, '../../src');
|
|
404
|
+
const libs = path.join(sourceRoot, 'codex/hooks/lib');
|
|
405
|
+
const shim = /^(?:['"]use strict['"];?\s*)?module\.exports\s*=\s*require\(['"]\.\.\/\.\.\/\.\.\/claude\/hooks\/lib\/([^'"]+)['"]\);?\s*$/;
|
|
406
|
+
for (const name of fs.readdirSync(libs)) {
|
|
407
|
+
const source = fs.readFileSync(path.join(libs, name), 'utf8');
|
|
408
|
+
const match = source.match(shim);
|
|
409
|
+
if (match) assert.equal(match[1], name);
|
|
410
|
+
const expected = path.join(sourceRoot, match ? 'claude/hooks/lib' : 'codex/hooks/lib', name);
|
|
411
|
+
const installed = fs.readFileSync(path.join(root, '.codex/hooks/lib', name));
|
|
412
|
+
assert.doesNotMatch(installed.toString(), shim, name);
|
|
413
|
+
assert.deepEqual(installed, fs.readFileSync(expected), name);
|
|
414
|
+
}
|
|
415
|
+
});
|
|
416
|
+
});
|
|
417
|
+
|
|
418
|
+
test('installed Claude runtime omits retired usage configuration', () => {
|
|
419
|
+
inTempProject(root => {
|
|
420
|
+
const result = runCombinedInstaller(root);
|
|
421
|
+
assert.equal(result.status, 0, result.stderr);
|
|
422
|
+
const runtime = JSON.parse(fs.readFileSync(path.join(root, '.claude/runtime.json'), 'utf8'));
|
|
423
|
+
assert.equal(Object.hasOwn(runtime, 'usage'), false);
|
|
424
|
+
});
|
|
425
|
+
});
|
|
426
|
+
|
|
427
|
+
test('multimodal setup scripts keep local resolution without the missing resolver', () => {
|
|
428
|
+
for (const name of ['check_setup.py', 'gemini_batch_process.py']) {
|
|
429
|
+
const content = fs.readFileSync(path.join(__dirname, '../../src/claude/skills/ai-multimodal/scripts', name), 'utf8');
|
|
430
|
+
assert.doesNotMatch(content, /resolve_env|CENTRALIZED_RESOLVER_AVAILABLE/, name);
|
|
431
|
+
assert.ok(content.includes("os.getenv('GEMINI_API_KEY')"), name);
|
|
432
|
+
}
|
|
433
|
+
});
|
|
434
|
+
|
|
100
435
|
test('legacy managed root gitignore migrates A13 plan patterns and preserves user content', () => {
|
|
101
436
|
inTempProject((root) => {
|
|
102
437
|
fs.writeFileSync('.gitignore', [
|
|
@@ -19,7 +19,6 @@ const LEGACY_FILES = [
|
|
|
19
19
|
'semantic-review-authority.cjs',
|
|
20
20
|
'task-scaffold-guard.cjs',
|
|
21
21
|
];
|
|
22
|
-
const MIGRATION = /Legacy spec\.json closeout is no longer supported/;
|
|
23
22
|
|
|
24
23
|
function inTempProject(run) {
|
|
25
24
|
const root = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'cafekit-decoupled-')));
|
|
@@ -122,26 +121,24 @@ function checkProcessFirst(kind) {
|
|
|
122
121
|
});
|
|
123
122
|
}
|
|
124
123
|
|
|
125
|
-
|
|
126
|
-
|
|
124
|
+
// CafeKit no longer reads spec.json packets: a leftover one, closed or mid-execution, never
|
|
125
|
+
// blocks Stop on its own, and beside a process-first packet the decision is that packet's alone.
|
|
126
|
+
function checkLegacyIgnored(kind) {
|
|
127
|
+
for (const legacy of [{ status: 'done' }, { status: 'completed' }, { status: 'in_progress', doneTask: true }]) {
|
|
127
128
|
inTempProject((root) => {
|
|
128
|
-
writeLegacy(root,
|
|
129
|
+
writeLegacy(root, legacy);
|
|
129
130
|
removeLegacyFiles(root);
|
|
130
|
-
assert.
|
|
131
|
+
assert.equal(stopGate(root, kind), '', `${kind} ${legacy.status} legacy packet alone`);
|
|
132
|
+
writeProcessFirst(root, { receipt: false });
|
|
133
|
+
const reason = blockReason(stopGate(root, kind));
|
|
134
|
+
assert.match(reason, /missing_receipt/);
|
|
135
|
+
assert.doesNotMatch(reason, /task-R1-01-legacy\.md/);
|
|
136
|
+
writeProcessFirst(root, { receipt: true });
|
|
137
|
+
assert.equal(stopGate(root, kind), '', `${kind} ${legacy.status} legacy packet beside a proven process-first packet`);
|
|
131
138
|
});
|
|
132
139
|
}
|
|
133
140
|
}
|
|
134
141
|
|
|
135
|
-
function checkLegacyMidExecution(kind) {
|
|
136
|
-
inTempProject((root) => {
|
|
137
|
-
writeLegacy(root, { status: 'in_progress', doneTask: true });
|
|
138
|
-
removeLegacyFiles(root);
|
|
139
|
-
const reason = blockReason(stopGate(root, kind));
|
|
140
|
-
assert.doesNotMatch(reason, MIGRATION);
|
|
141
|
-
assert.match(reason, /task-R1-01-legacy\.md/);
|
|
142
|
-
});
|
|
143
|
-
}
|
|
144
|
-
|
|
145
142
|
function checkSession(kind) {
|
|
146
143
|
inTempProject((root) => {
|
|
147
144
|
removeLegacyFiles(root);
|
|
@@ -151,9 +148,7 @@ function checkSession(kind) {
|
|
|
151
148
|
|
|
152
149
|
test('claude gate passes and blocks process-first with and without the legacy files', () => checkProcessFirst('claude'));
|
|
153
150
|
test('codex gate passes and blocks process-first with and without the legacy files', () => checkProcessFirst('codex'));
|
|
154
|
-
test('claude gate
|
|
155
|
-
test('codex gate
|
|
156
|
-
test('claude gate still receipt-checks a legacy 2.1 packet mid-execution', () => checkLegacyMidExecution('claude'));
|
|
157
|
-
test('codex gate still receipt-checks a legacy 2.1 packet mid-execution', () => checkLegacyMidExecution('codex'));
|
|
151
|
+
test('claude gate ignores a leftover legacy packet', () => checkLegacyIgnored('claude'));
|
|
152
|
+
test('codex gate ignores a leftover legacy packet', () => checkLegacyIgnored('codex'));
|
|
158
153
|
test('claude session starts without the legacy files', () => checkSession('claude'));
|
|
159
154
|
test('codex session starts without the legacy files', () => checkSession('codex'));
|