@haposoft/cafekit 0.18.2 → 0.18.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -1
- package/bin/__tests__/adv-skill.test.js +371 -0
- package/bin/__tests__/agent-authority.test.js +38 -0
- package/bin/__tests__/agent-instructions.test.js +54 -0
- package/bin/__tests__/claude-plugin-build.test.js +151 -0
- package/bin/__tests__/codex-hooks.test.js +38 -6
- package/bin/__tests__/codex-implicit-policy.test.js +160 -0
- package/bin/__tests__/codex-native.test.js +15 -8
- package/bin/__tests__/codex-plugin-build.test.js +230 -0
- package/bin/__tests__/codex-plugin-context.test.js +135 -0
- package/bin/__tests__/codex-plugin-hooks.test.js +220 -0
- package/bin/__tests__/codex-runtime-layout.test.js +81 -0
- package/bin/__tests__/codex-setup-agents.test.js +216 -0
- package/bin/__tests__/legacy-hooks-retired.test.js +63 -13
- package/bin/__tests__/migrate-apply.test.js +373 -0
- package/bin/__tests__/migrate-cli.test.js +152 -0
- package/bin/__tests__/migrate-merged.test.js +269 -0
- package/bin/__tests__/migrate-plan.test.js +271 -0
- package/bin/__tests__/omp-bridge.test.js +81 -5
- package/bin/__tests__/omp-hooks.test.js +24 -0
- package/bin/__tests__/plugin-safety-keys.test.js +121 -0
- package/bin/__tests__/privacy-paths.test.js +184 -0
- package/bin/__tests__/usage-hook-retired.test.js +24 -6
- package/bin/__tests__/vibe-run.test.js +215 -0
- package/bin/__tests__/vibe-skill.test.js +549 -0
- package/bin/install.js +99 -1
- package/bin/lib/backup.js +43 -24
- package/bin/lib/codex-install.js +7 -0
- package/bin/lib/context.js +5 -0
- package/bin/lib/lock.js +98 -18
- package/bin/lib/managed-writer.js +16 -10
- package/bin/lib/migrate-apply.js +202 -0
- package/bin/lib/migrate-merged.js +342 -0
- package/bin/lib/migrate-plan.js +419 -0
- package/bin/lib/plugin-inventory.js +33 -0
- package/bin/phases/claude-runtime.js +5 -3
- package/bin/phases/codex-hooks.js +38 -0
- package/bin/phases/codex-runtime.js +106 -74
- package/bin/phases/copy-payload.js +49 -1
- package/bin/phases/omp-runtime.js +4 -26
- package/bin/phases/root-config.js +5 -2
- package/package.json +1 -1
- package/src/claude/CLAUDE.md +1 -1
- package/src/claude/agents/advisor.md +55 -0
- package/src/claude/agents/debugger.md +2 -3
- package/src/claude/agents/deployer.md +2 -1
- package/src/claude/agents/docs-keeper.md +12 -0
- package/src/claude/agents/inspector.md +1 -1
- package/src/claude/agents/project-manager.md +2 -2
- package/src/claude/agents/researcher.md +0 -1
- package/src/claude/agents/spec-maker.md +4 -1
- package/src/claude/hooks/__tests__/plugin-context.test.js +96 -0
- package/src/claude/hooks/__tests__/plugin-options.test.js +141 -0
- package/src/claude/hooks/__tests__/plugin-state-isolation.test.js +125 -0
- package/src/claude/hooks/__tests__/precompact.test.js +7 -1
- package/src/claude/hooks/__tests__/privacy-block.test.js +33 -9
- package/src/claude/hooks/__tests__/setup-statusline.test.js +179 -0
- package/src/claude/hooks/__tests__/spec-gate.test.js +86 -1
- package/src/claude/hooks/agent.cjs +5 -2
- package/src/claude/hooks/inspect-block.cjs +3 -1
- package/src/claude/hooks/lib/hook-state-dir.cjs +19 -1
- package/src/claude/hooks/lib/plugin-options.cjs +47 -0
- package/src/claude/hooks/lib/privacy-command-analysis.cjs +130 -11
- package/src/claude/hooks/lib/privacy-paths.cjs +370 -10
- package/src/claude/hooks/lib/spec-touch.cjs +14 -1
- package/src/claude/hooks/plugin-context.cjs +80 -0
- package/src/claude/hooks/privacy-block.cjs +2 -1
- package/src/claude/hooks/rules.cjs +5 -2
- package/src/claude/hooks/session.cjs +4 -1
- package/src/claude/hooks/spec-state.cjs +5 -4
- package/src/claude/hooks/state.cjs +5 -1
- package/src/claude/migration-manifest.json +6 -1
- package/src/claude/plugin/commands/setup-statusline.md +12 -0
- package/src/claude/plugin/core.md +71 -0
- package/src/claude/rules/manage-docs.md +1 -2
- package/src/claude/rules/review-audit-self-decision.md +1 -1
- package/src/claude/rules/skill-workflow-routing.md +1 -1
- package/src/claude/rules/state-sync.md +14 -0
- package/src/claude/rules/workflow.md +1 -1
- package/src/claude/scripts/generate-skill-catalog.cjs +5 -0
- package/src/claude/scripts/provenance.cjs +40 -16
- package/src/claude/scripts/setup-statusline.cjs +88 -0
- package/src/claude/skills/adv/SKILL.md +139 -0
- package/src/claude/skills/adv/references/optional-modes.md +41 -0
- package/src/claude/skills/adv/references/ultra-mode.md +29 -0
- package/src/claude/skills/brainstorm/SKILL.md +1 -1
- package/src/claude/skills/code-review/SKILL.md +4 -1
- package/src/claude/skills/code-review/references/pr-fix-reply.md +42 -0
- package/src/claude/skills/develop/SKILL.md +5 -3
- package/src/claude/skills/develop/references/quality-gate.md +1 -1
- package/src/claude/skills/fix/SKILL.md +2 -2
- package/src/claude/skills/git/SKILL.md +4 -2
- package/src/claude/skills/git/references/merge-pr.md +125 -0
- package/src/claude/skills/specs/SKILL.md +2 -2
- package/src/claude/skills/specs/references/review.md +1 -1
- package/src/claude/skills/specs/references/templates.md +2 -0
- package/src/claude/skills/vibe/SKILL.md +69 -0
- package/src/claude/skills/vibe/references/pipeline-skill-integration.md +34 -0
- package/src/claude/skills/vibe/references/report-templates.md +84 -0
- package/src/claude/skills/vibe/references/subagent-orchestration.md +23 -0
- package/src/codex/AGENTS.md +1 -1
- package/src/codex/hooks/inspect-block.cjs +7 -4
- package/src/codex/hooks/lib/hook-context.cjs +118 -4
- package/src/codex/hooks/lib/plugin-options.cjs +2 -0
- package/src/codex/hooks/lib/privacy-state.cjs +18 -1
- package/src/codex/hooks/lib/state-store.cjs +5 -4
- package/src/codex/hooks/precompact.cjs +9 -8
- package/src/codex/hooks/privacy-approval.cjs +2 -2
- package/src/codex/hooks/privacy-block.cjs +5 -4
- package/src/codex/hooks/rules.cjs +12 -6
- package/src/codex/hooks/session.cjs +22 -10
- package/src/codex/plugin/core.md +76 -0
- package/src/codex/plugin/hooks/plugin-context.cjs +63 -0
- package/src/codex/plugin/skills/setup-agents/SKILL.md +17 -0
- package/src/codex/scripts/setup-agents.cjs +124 -0
- package/src/common/AGENTS.md +1 -1
- package/src/omp/extensions/{cafekit-bridge.mjs → cafekit-bridge.js} +73 -5
- package/src/omp/hooks/privacy-block.cjs +2 -1
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const { test, before, after } = require('node:test');
|
|
4
|
+
const assert = require('node:assert/strict');
|
|
5
|
+
const fs = require('node:fs');
|
|
6
|
+
const os = require('node:os');
|
|
7
|
+
const path = require('node:path');
|
|
8
|
+
const crypto = require('node:crypto');
|
|
9
|
+
const { spawnSync } = require('node:child_process');
|
|
10
|
+
|
|
11
|
+
const BUILD = path.resolve(__dirname, '../../scripts/build-codex-plugin.mjs');
|
|
12
|
+
const INSTALL = path.resolve(__dirname, '../install.js');
|
|
13
|
+
const UID = typeof process.getuid === 'function' ? process.getuid() : 'user';
|
|
14
|
+
let lib;
|
|
15
|
+
let work;
|
|
16
|
+
let tree;
|
|
17
|
+
let handlers;
|
|
18
|
+
|
|
19
|
+
function walk(root) {
|
|
20
|
+
if (!fs.existsSync(root)) return [];
|
|
21
|
+
return fs.readdirSync(root, { withFileTypes: true }).flatMap((entry) => {
|
|
22
|
+
const full = path.join(root, entry.name);
|
|
23
|
+
return entry.isDirectory() ? [`${full}/`, ...walk(full)] : [full];
|
|
24
|
+
}).sort();
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function git(cwd, ...args) {
|
|
28
|
+
const run = spawnSync('git', ['-c', 'user.email=t@t', '-c', 'user.name=t', ...args], { cwd, encoding: 'utf8' });
|
|
29
|
+
assert.equal(run.status, 0, run.stderr);
|
|
30
|
+
return run.stdout.trim();
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function repo(name, setup = () => {}) {
|
|
34
|
+
const dir = path.join(work, name);
|
|
35
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
36
|
+
git(dir, 'init', '-q');
|
|
37
|
+
fs.writeFileSync(path.join(dir, 'README.md'), '# demo\n');
|
|
38
|
+
fs.writeFileSync(path.join(dir, '.env'), 'EXAMPLE=placeholder\n');
|
|
39
|
+
setup(dir);
|
|
40
|
+
git(dir, 'add', '-A');
|
|
41
|
+
git(dir, 'commit', '-qm', 'fixture');
|
|
42
|
+
return dir;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function packet(dir, status) {
|
|
46
|
+
fs.mkdirSync(path.join(dir, 'specs/demo'), { recursive: true });
|
|
47
|
+
fs.writeFileSync(path.join(dir, 'specs/demo/plan.md'), '# Demo\nSpecs-Contract: process-first-ready-v1\n\n## Tasks\n| # | Task | Status |\n|---|---|---|\n| 01 | Demo | pending |\n');
|
|
48
|
+
fs.writeFileSync(path.join(dir, 'specs/demo/task-01-demo.md'), `# Task 01 — Demo\n\nStatus: ${status}\n\n## Dependencies\n- none\n\n## Verification Plan\n- Command: \`test -f README.md\`\n\n## Receipt\n`);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function handler(event, script) {
|
|
52
|
+
const found = handlers.find((h) => h.event === event && h.command.includes(`/hooks/${script}"`));
|
|
53
|
+
assert.ok(found, `no ${event} handler for ${script}`);
|
|
54
|
+
return found.command;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// Runs one plugin hook through its generated launcher, the way Codex starts it.
|
|
58
|
+
function run(event, script, cwd, payload = {}, env = {}) {
|
|
59
|
+
const result = spawnSync('sh', ['-c', handler(event, script)], {
|
|
60
|
+
cwd,
|
|
61
|
+
input: JSON.stringify({ session_id: 'plugin-hooks-test', hook_event_name: event, cwd, ...payload }),
|
|
62
|
+
encoding: 'utf8',
|
|
63
|
+
timeout: 20000,
|
|
64
|
+
env: { ...process.env, HOME: path.join(work, 'home'), PLUGIN_ROOT: tree, CLAUDE_PLUGIN_ROOT: tree, PLUGIN_DATA: path.join(work, 'data'), CLAUDE_PLUGIN_DATA: path.join(work, 'data'), ...env },
|
|
65
|
+
});
|
|
66
|
+
return result;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const ALL = [
|
|
70
|
+
['SessionStart', 'session.cjs', { source: 'startup' }],
|
|
71
|
+
['UserPromptSubmit', 'rules.cjs', { prompt: 'hello' }],
|
|
72
|
+
['PreToolUse', 'privacy-block.cjs', { tool_name: 'Bash', tool_input: { command: 'ls' } }],
|
|
73
|
+
['PreToolUse', 'inspect-block.cjs', { tool_name: 'Bash', tool_input: { command: 'ls' } }],
|
|
74
|
+
['PostToolUse', 'spec-state.cjs', { tool_name: 'apply_patch', tool_input: { command: '' }, tool_response: {} }],
|
|
75
|
+
['PreCompact', 'precompact.cjs', { trigger: 'manual' }],
|
|
76
|
+
['Stop', 'spec-gate.cjs', { stop_hook_active: false }],
|
|
77
|
+
['Stop', 'state.cjs', { stop_hook_active: false }],
|
|
78
|
+
];
|
|
79
|
+
|
|
80
|
+
before(async () => {
|
|
81
|
+
lib = await import(BUILD);
|
|
82
|
+
work = fs.mkdtempSync(path.join(os.tmpdir(), 'ck-codex-plugin-hooks-'));
|
|
83
|
+
tree = lib.build(path.join(work, 'plugin', 'cf-codex'));
|
|
84
|
+
const { hooks } = JSON.parse(fs.readFileSync(path.join(tree, 'hooks/hooks.json'), 'utf8'));
|
|
85
|
+
handlers = Object.entries(hooks).flatMap(([event, groups]) => groups.flatMap((g) => g.hooks.map((h) => ({ event, ...h }))));
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
after(() => fs.rmSync(work, { recursive: true, force: true }));
|
|
89
|
+
|
|
90
|
+
test('gate-blocks-in-payload-repo: Stop blocks a done task without a receipt in the payload repository', () => {
|
|
91
|
+
const dir = repo('gate', (d) => packet(d, 'done'));
|
|
92
|
+
const result = run('Stop', 'spec-gate.cjs', dir, { stop_hook_active: false });
|
|
93
|
+
assert.equal(result.status, 0, result.stderr);
|
|
94
|
+
assert.match(result.stdout, /"decision":"block"/);
|
|
95
|
+
assert.match(result.stdout, /task-01-demo\.md/);
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
test('privacy-uses-payload-repo: reading the payload repository .env is denied and the approval is keyed to it', () => {
|
|
99
|
+
const dir = repo('privacy');
|
|
100
|
+
const data = path.join(work, 'privacy-data');
|
|
101
|
+
const result = run('PreToolUse', 'privacy-block.cjs', dir, { tool_name: 'Bash', tool_input: { command: 'cat .env' } }, { PLUGIN_DATA: data, CLAUDE_PLUGIN_DATA: data });
|
|
102
|
+
assert.match(result.stdout, /"permissionDecision":"deny"/);
|
|
103
|
+
const key = crypto.createHash('sha256').update(fs.realpathSync(dir)).digest('hex').slice(0, 16);
|
|
104
|
+
assert.ok(walk(path.join(data, 'projects', key, 'privacy')).some((p) => !p.endsWith('/')), 'no pending approval stored for this repository');
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
test('nothing-written-outside-data: every hook leaves the repository, the plugin tree and its parent unchanged', () => {
|
|
108
|
+
const dir = repo('clean', (d) => fs.mkdirSync(path.join(d, '.codex')) || fs.writeFileSync(path.join(d, '.codex/runtime.json'), '{}\n'));
|
|
109
|
+
const data = path.join(work, 'clean-data');
|
|
110
|
+
// Git's own .git/ files (an index refresh, a lock left by a timed-out git call) are not
|
|
111
|
+
// CafeKit state, so the working tree is compared and git must report it clean.
|
|
112
|
+
const worktree = () => walk(dir).filter((p) => !p.startsWith(path.join(dir, '.git') + path.sep) && p !== `${path.join(dir, '.git')}/`);
|
|
113
|
+
const before = [worktree(), walk(path.join(work, 'plugin'))];
|
|
114
|
+
for (const [event, script, payload] of ALL) {
|
|
115
|
+
const result = run(event, script, dir, payload, { PLUGIN_DATA: data, CLAUDE_PLUGIN_DATA: data });
|
|
116
|
+
assert.equal(result.status, 0, `${script}: ${result.stderr}`);
|
|
117
|
+
}
|
|
118
|
+
assert.deepEqual([worktree(), walk(path.join(work, 'plugin'))], before);
|
|
119
|
+
assert.equal(git(dir, 'status', '--porcelain'), '', 'the hooks left changes in the repository');
|
|
120
|
+
assert.ok(walk(data).some((p) => !p.endsWith('/')), 'no state under PLUGIN_DATA');
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
test('tmp-root-0700: without PLUGIN_DATA, state goes to a private per-user temp directory', () => {
|
|
124
|
+
const dir = repo('tmp');
|
|
125
|
+
const tmp = fs.mkdtempSync(path.join(work, 'tmp-'));
|
|
126
|
+
for (const [event, script, payload] of ALL) run(event, script, dir, payload, { PLUGIN_DATA: '', CLAUDE_PLUGIN_DATA: '', TMPDIR: tmp });
|
|
127
|
+
const root = path.join(tmp, `cafekit-codex-plugin-${UID}`);
|
|
128
|
+
assert.ok(fs.lstatSync(root).isDirectory());
|
|
129
|
+
assert.equal(fs.statSync(root).mode & 0o777, 0o700);
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
test('foreign-tmp-fail-closed: a temp state root this user does not safely own makes privacy deny', () => {
|
|
133
|
+
const dir = repo('foreign');
|
|
134
|
+
const tmp = fs.mkdtempSync(path.join(work, 'tmp-foreign-'));
|
|
135
|
+
const elsewhere = fs.mkdtempSync(path.join(work, 'elsewhere-'));
|
|
136
|
+
fs.symlinkSync(elsewhere, path.join(tmp, `cafekit-codex-plugin-${UID}`));
|
|
137
|
+
const result = run('PreToolUse', 'privacy-block.cjs', dir, { tool_name: 'Bash', tool_input: { command: 'cat .env' } }, { PLUGIN_DATA: '', CLAUDE_PLUGIN_DATA: '', TMPDIR: tmp });
|
|
138
|
+
assert.match(result.stdout, /"permissionDecision":"deny"/);
|
|
139
|
+
assert.deepEqual(walk(elsewhere), [], 'state was written through the planted symlink');
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
test('no-npx-notice: the plugin session hook prints no npx update notice and keeps the update cache', () => {
|
|
143
|
+
const dir = repo('notice', (d) => {
|
|
144
|
+
fs.mkdirSync(path.join(d, '.codex'));
|
|
145
|
+
fs.writeFileSync(path.join(d, '.codex/cafekit.json'), JSON.stringify({ version: '0.0.1' }));
|
|
146
|
+
});
|
|
147
|
+
const cache = path.join(dir, '.codex/.cafekit-update-cache.json');
|
|
148
|
+
const bytes = JSON.stringify({ ts: Date.now(), latest: '99.0.0' });
|
|
149
|
+
fs.writeFileSync(cache, bytes);
|
|
150
|
+
const result = run('SessionStart', 'session.cjs', dir, { source: 'startup' });
|
|
151
|
+
assert.equal(result.status, 0, result.stderr);
|
|
152
|
+
assert.doesNotMatch(result.stdout, /npx @haposoft\/cafekit/);
|
|
153
|
+
assert.equal(fs.readFileSync(cache, 'utf8'), bytes);
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
test('defaults-without-runtime: without .codex/runtime.json the rules hook still injects the repository paths', () => {
|
|
157
|
+
const dir = repo('defaults');
|
|
158
|
+
const result = run('UserPromptSubmit', 'rules.cjs', dir, { prompt: 'hello', session_id: 'defaults-session' });
|
|
159
|
+
assert.equal(result.status, 0, result.stderr);
|
|
160
|
+
assert.ok(result.stdout.includes(path.join(fs.realpathSync(dir), 'specs')), result.stdout);
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
function npxRepo(name) {
|
|
164
|
+
const dir = repo(name);
|
|
165
|
+
const install = spawnSync('node', [INSTALL, '--platform', 'codex', '-y'], { cwd: dir, encoding: 'utf8', env: { ...process.env, HOME: path.join(work, `home-${name}`) } });
|
|
166
|
+
assert.equal(install.status, 0, install.stderr || install.stdout);
|
|
167
|
+
return dir;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
test('silent-beside-npx: with the npx Codex hooks active in the repository, plugin hooks stand down and say so once', () => {
|
|
171
|
+
const dir = npxRepo('beside');
|
|
172
|
+
const session = run('SessionStart', 'session.cjs', dir, { source: 'startup' });
|
|
173
|
+
assert.equal(session.status, 0, session.stderr);
|
|
174
|
+
assert.match(session.stdout, /CafeKit plugin is idle/);
|
|
175
|
+
assert.match(session.stdout, /\$cf-/, 'the notice names the duplicate skills');
|
|
176
|
+
assert.match(session.stdout, /trusted in \/hooks/, 'the notice says the gates depend on trusting the npx hooks');
|
|
177
|
+
assert.ok(session.stdout.includes('npx @haposoft/cafekit@latest migrate'), 'the notice names the migrate command with @latest');
|
|
178
|
+
const privacy = run('PreToolUse', 'privacy-block.cjs', dir, { tool_name: 'Bash', tool_input: { command: 'cat .env' } });
|
|
179
|
+
assert.equal(privacy.status, 0);
|
|
180
|
+
assert.equal(privacy.stdout, '', 'the plugin hook acted beside the npx hooks');
|
|
181
|
+
const rules = run('UserPromptSubmit', 'rules.cjs', dir, { prompt: 'hi', session_id: 'beside-rules' });
|
|
182
|
+
assert.equal(rules.stdout, '');
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
test('planted-hooks-json-keeps-gates: a hooks.json without real npx hooks behind it does not switch the plugin off', () => {
|
|
186
|
+
const cases = {
|
|
187
|
+
planted: { description: 'x /.codex/hooks/ x', hooks: {} },
|
|
188
|
+
foreignMachine: { hooks: { PreToolUse: [{ hooks: [{ type: 'command', command: "node '/Users/someone-else/project/.codex/hooks/privacy-block.cjs'" }] }] } },
|
|
189
|
+
relativeScript: { hooks: { PreToolUse: [{ hooks: [{ type: 'command', command: "node './.codex/hooks/noop.cjs'" }] }] } },
|
|
190
|
+
};
|
|
191
|
+
// Absolute paths that still reach the repository's own hooks only through `..` or a
|
|
192
|
+
// symlinked alias; on Linux /proc/self/cwd does the same through the hook's cwd.
|
|
193
|
+
const alias = path.join(work, 'planted-alias-link');
|
|
194
|
+
const command = (script) => ({ hooks: { PreToolUse: [{ hooks: [{ type: 'command', command: `node '${script}'` }] }] } });
|
|
195
|
+
cases.dotDot = (d) => command(`${fs.realpathSync(d)}/.codex/hooks/../hooks/noop.cjs`);
|
|
196
|
+
cases.symlinkAlias = (d) => { fs.symlinkSync(fs.realpathSync(d), alias); return command(`${alias}/.codex/hooks/noop.cjs`); };
|
|
197
|
+
for (const [name, entry] of Object.entries(cases)) {
|
|
198
|
+
const dir = repo(`planted-${name}`, (d) => {
|
|
199
|
+
const config = typeof entry === 'function' ? entry(d) : entry;
|
|
200
|
+
fs.mkdirSync(path.join(d, '.codex/hooks'), { recursive: true });
|
|
201
|
+
fs.writeFileSync(path.join(d, '.codex/hooks/noop.cjs'), '\n');
|
|
202
|
+
fs.writeFileSync(path.join(d, '.codex/hooks.json'), JSON.stringify(config));
|
|
203
|
+
});
|
|
204
|
+
const result = run('PreToolUse', 'privacy-block.cjs', dir, { tool_name: 'Bash', tool_input: { command: 'cat .env' } });
|
|
205
|
+
assert.match(result.stdout, /"permissionDecision":"deny"/, `${name}: the plugin stood down`);
|
|
206
|
+
}
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
test('npx-installed-tree: npx-installed hooks keep their behavior, even with a plugin root inherited from the parent', () => {
|
|
210
|
+
const dir = npxRepo('npx');
|
|
211
|
+
for (const inherited of [false, true]) {
|
|
212
|
+
const env = { ...process.env, HOME: path.join(work, 'home-npx') };
|
|
213
|
+
delete env.PLUGIN_ROOT; delete env.CLAUDE_PLUGIN_ROOT; delete env.PLUGIN_DATA; delete env.CLAUDE_PLUGIN_DATA;
|
|
214
|
+
if (inherited) Object.assign(env, { PLUGIN_ROOT: tree, CLAUDE_PLUGIN_ROOT: tree });
|
|
215
|
+
const payload = { session_id: `npx-${inherited}`, hook_event_name: 'PreToolUse', cwd: dir, tool_name: 'Bash', tool_input: { command: 'cat .env' } };
|
|
216
|
+
const result = spawnSync('node', [path.join(dir, '.codex/hooks/privacy-block.cjs')], { cwd: dir, input: JSON.stringify(payload), encoding: 'utf8', env });
|
|
217
|
+
assert.match(result.stdout, /"permissionDecision":"deny"/, `inherited=${inherited}`);
|
|
218
|
+
assert.ok(fs.existsSync(path.join(dir, '.codex/hooks/.privacy')), 'npx privacy state left its project location');
|
|
219
|
+
}
|
|
220
|
+
});
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const { test } = require('node:test');
|
|
4
|
+
const assert = require('node:assert/strict');
|
|
5
|
+
const fs = require('node:fs');
|
|
6
|
+
const path = require('node:path');
|
|
7
|
+
|
|
8
|
+
const SRC = path.resolve(__dirname, '../../src');
|
|
9
|
+
const SHIM = /^(?:['"]use strict['"];?\s*)?module\.exports\s*=\s*require\(['"]\.\.\/\.\.\/\.\.\/claude\/hooks\/lib\/([^'"/]+)['"]\);?\s*$/;
|
|
10
|
+
|
|
11
|
+
function layout() {
|
|
12
|
+
return require('../phases/codex-runtime').codexRuntimeLayout();
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const destinations = (entries) => entries.map((entry) => entry.dest);
|
|
16
|
+
|
|
17
|
+
test('shims-resolved: every shim lib ships as the real Claude library, never as the shim', () => {
|
|
18
|
+
const libDir = path.join(SRC, 'codex/hooks/lib');
|
|
19
|
+
const shims = fs.readdirSync(libDir).filter((name) => (fs.readFileSync(path.join(libDir, name), 'utf8').match(SHIM) || [])[1] === name);
|
|
20
|
+
assert.equal(shims.length, 8, `expected 8 shim libraries, found ${shims.join(', ')}`);
|
|
21
|
+
const entries = layout();
|
|
22
|
+
for (const name of shims) {
|
|
23
|
+
const shipped = entries.filter((entry) => entry.dest === `hooks/lib/${name}`);
|
|
24
|
+
assert.equal(shipped.length, 1, `hooks/lib/${name} shipped ${shipped.length} times`);
|
|
25
|
+
assert.equal(shipped[0].src, path.join(SRC, 'claude/hooks/lib', name));
|
|
26
|
+
}
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
test('hooks-copied: every non-shim Codex hook file ships from the Codex source', () => {
|
|
30
|
+
const entries = layout();
|
|
31
|
+
const hooksDir = path.join(SRC, 'codex/hooks');
|
|
32
|
+
for (const name of fs.readdirSync(hooksDir).filter((n) => n !== 'lib')) {
|
|
33
|
+
const entry = entries.find((e) => e.dest === `hooks/${name}`);
|
|
34
|
+
assert.ok(entry, `hooks/${name} missing`);
|
|
35
|
+
assert.equal(entry.src, path.join(hooksDir, name));
|
|
36
|
+
}
|
|
37
|
+
const libDir = path.join(hooksDir, 'lib');
|
|
38
|
+
for (const name of fs.readdirSync(libDir).filter((n) => !SHIM.test(fs.readFileSync(path.join(libDir, n), 'utf8')))) {
|
|
39
|
+
const entry = entries.find((e) => e.dest === `hooks/lib/${name}`);
|
|
40
|
+
assert.ok(entry, `hooks/lib/${name} missing`);
|
|
41
|
+
assert.equal(entry.src, path.join(libDir, name));
|
|
42
|
+
}
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
test('groups-installed: every layout group is one the installer writes', () => {
|
|
46
|
+
const { LAYOUT_GROUPS } = require('../phases/codex-runtime');
|
|
47
|
+
const known = LAYOUT_GROUPS.map(([name]) => name);
|
|
48
|
+
for (const entry of layout()) assert.ok(known.includes(entry.group), `${entry.dest} has unknown group ${entry.group}`);
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
test('rules-composed: Claude rules minus Codex overrides, plus Codex rules, with the right transform', () => {
|
|
52
|
+
const entries = layout().filter((entry) => entry.dest.startsWith('rules/'));
|
|
53
|
+
const overrides = fs.readdirSync(path.join(SRC, 'codex/rules'));
|
|
54
|
+
const claudeRules = fs.readdirSync(path.join(SRC, 'claude/rules')).filter((name) => !overrides.includes(name));
|
|
55
|
+
assert.deepEqual(destinations(entries).sort(), [...claudeRules, ...overrides].map((name) => `rules/${name}`).sort());
|
|
56
|
+
for (const entry of entries) {
|
|
57
|
+
const override = overrides.includes(path.basename(entry.dest));
|
|
58
|
+
assert.equal(entry.src, path.join(SRC, override ? 'codex/rules' : 'claude/rules', path.basename(entry.dest)));
|
|
59
|
+
assert.equal(entry.transform, override ? null : 'codex-body', `${entry.dest} transform`);
|
|
60
|
+
}
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
test('styles-and-schema: output styles carry the Codex transform and the schema ships once', () => {
|
|
64
|
+
const entries = layout();
|
|
65
|
+
const styles = entries.filter((entry) => entry.dest.startsWith('output-styles/'));
|
|
66
|
+
assert.deepEqual(destinations(styles).sort(), fs.readdirSync(path.join(SRC, 'claude/output-styles')).map((n) => `output-styles/${n}`).sort());
|
|
67
|
+
assert.ok(styles.every((entry) => entry.transform === 'codex-body'));
|
|
68
|
+
const schema = entries.filter((entry) => entry.dest === 'runtime.schema.json');
|
|
69
|
+
assert.equal(schema.length, 1);
|
|
70
|
+
assert.equal(schema[0].src, path.join(SRC, 'claude/runtime.schema.json'));
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
test('sources-exist: every layout source is a regular file and every destination is unique and relative', () => {
|
|
74
|
+
const entries = layout();
|
|
75
|
+
assert.equal(new Set(destinations(entries)).size, entries.length, 'duplicate destination');
|
|
76
|
+
for (const entry of entries) {
|
|
77
|
+
assert.ok(fs.lstatSync(entry.src).isFile(), `${entry.src} is not a regular file`);
|
|
78
|
+
assert.ok(!path.isAbsolute(entry.dest) && !entry.dest.split('/').includes('..'), `${entry.dest} escapes the runtime root`);
|
|
79
|
+
assert.equal(typeof entry.group, 'string');
|
|
80
|
+
}
|
|
81
|
+
});
|
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// Codex loads no agents from a plugin, so the plugin ships them as TOML with an
|
|
4
|
+
// ownership marker and a script copies them into the user's Codex agents directory.
|
|
5
|
+
// The script replaces only files whose marker hash still matches their body.
|
|
6
|
+
|
|
7
|
+
const { test, before, after } = require('node:test');
|
|
8
|
+
const assert = require('node:assert/strict');
|
|
9
|
+
const crypto = require('node:crypto');
|
|
10
|
+
const fs = require('node:fs');
|
|
11
|
+
const os = require('node:os');
|
|
12
|
+
const path = require('node:path');
|
|
13
|
+
const { spawnSync } = require('node:child_process');
|
|
14
|
+
|
|
15
|
+
const SCRIPTS = path.resolve(__dirname, '../../scripts');
|
|
16
|
+
const SRC = path.resolve(__dirname, '../../src');
|
|
17
|
+
const { convertCodexAgentContent, codexAgentName } = require('../lib/codex-install.js');
|
|
18
|
+
const { CODEX_POLICY_FILE, CODEX_EXPLICIT_ONLY_POLICY } = require('../phases/copy-payload.js');
|
|
19
|
+
const MARKER = /^# CafeKit agent: plugin=(\S+) version=(\S+) sha256=([0-9a-f]{64})\n/;
|
|
20
|
+
const REAL_AGENTS = path.join(os.homedir(), '.codex', 'agents');
|
|
21
|
+
let work;
|
|
22
|
+
let tree;
|
|
23
|
+
let agentNames;
|
|
24
|
+
let realBefore;
|
|
25
|
+
|
|
26
|
+
const sha256 = (text) => crypto.createHash('sha256').update(text).digest('hex');
|
|
27
|
+
|
|
28
|
+
function realState() {
|
|
29
|
+
try {
|
|
30
|
+
const stat = fs.statSync(REAL_AGENTS);
|
|
31
|
+
return `${stat.mtimeMs}:${fs.readdirSync(REAL_AGENTS).sort().join(',')}`;
|
|
32
|
+
} catch {
|
|
33
|
+
return 'absent';
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
before(async () => {
|
|
38
|
+
realBefore = realState();
|
|
39
|
+
work = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'ck-codex-agents-')));
|
|
40
|
+
const lib = await import(path.join(SCRIPTS, 'build-codex-plugin.mjs'));
|
|
41
|
+
const claudeLib = await import(path.join(SCRIPTS, 'build-claude-plugin.mjs'));
|
|
42
|
+
agentNames = claudeLib.inScopeAgents();
|
|
43
|
+
tree = lib.build(path.join(work, 'plugin', 'cf-codex'));
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
after(() => {
|
|
47
|
+
fs.rmSync(work, { recursive: true, force: true });
|
|
48
|
+
assert.equal(realState(), realBefore, 'the real ~/.codex/agents changed');
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
let caseCount = 0;
|
|
52
|
+
function setup() {
|
|
53
|
+
const dir = path.join(work, `case-${++caseCount}`);
|
|
54
|
+
const home = path.join(dir, 'home');
|
|
55
|
+
const repo = path.join(dir, 'repo');
|
|
56
|
+
fs.mkdirSync(home, { recursive: true });
|
|
57
|
+
fs.mkdirSync(repo, { recursive: true });
|
|
58
|
+
spawnSync('git', ['init', '-q'], { cwd: repo });
|
|
59
|
+
return { home, repo, agents: path.join(home, '.codex', 'agents') };
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function setupAgents({ home, repo }, args = [], codexHome) {
|
|
63
|
+
const env = { ...process.env, HOME: home };
|
|
64
|
+
delete env.CODEX_HOME;
|
|
65
|
+
if (codexHome !== undefined) env.CODEX_HOME = codexHome;
|
|
66
|
+
return spawnSync('node', [path.join(tree, 'scripts/setup-agents.cjs'), ...args], { cwd: repo, encoding: 'utf8', env });
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function shipped(name) {
|
|
70
|
+
return fs.readFileSync(path.join(tree, 'agents', name), 'utf8');
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// An older CafeKit release of the same agent: a valid marker over a different body.
|
|
74
|
+
function olderOwn(name) {
|
|
75
|
+
const body = `name = "${name.replace(/\.toml$/, '')}"\ndescription = "an older release"\ndeveloper_instructions = "old"\n`;
|
|
76
|
+
return `# CafeKit agent: plugin=cf version=0.0.1 sha256=${sha256(body)}\n${body}`;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
test('agents-built-with-hash-marker: every in-scope agent ships as TOML whose marker hashes its body', () => {
|
|
80
|
+
const pkg = JSON.parse(fs.readFileSync(path.resolve(__dirname, '../../package.json'), 'utf8'));
|
|
81
|
+
const files = fs.readdirSync(path.join(tree, 'agents')).sort();
|
|
82
|
+
assert.deepEqual(files, agentNames.map((agent) => `${codexAgentName(agent)}.toml`).sort());
|
|
83
|
+
for (const agent of agentNames) {
|
|
84
|
+
const text = shipped(`${codexAgentName(agent)}.toml`);
|
|
85
|
+
const [marker, plugin, version, digest] = text.match(MARKER) || [];
|
|
86
|
+
assert.ok(marker, `${agent} has no marker`);
|
|
87
|
+
assert.equal(plugin, 'cf');
|
|
88
|
+
assert.equal(version, pkg.version);
|
|
89
|
+
const body = text.slice(marker.length);
|
|
90
|
+
assert.equal(sha256(body), digest, agent);
|
|
91
|
+
assert.equal(body, convertCodexAgentContent(fs.readFileSync(path.join(SRC, 'claude/agents', `${agent}.md`), 'utf8'), `${agent}.md`), `${agent} differs from the npx conversion`);
|
|
92
|
+
}
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
test('write-when-absent: an empty agents directory receives every shipped agent', () => {
|
|
96
|
+
const ctx = setup();
|
|
97
|
+
const result = setupAgents(ctx);
|
|
98
|
+
assert.equal(result.status, 0, result.stderr);
|
|
99
|
+
const files = fs.readdirSync(path.join(tree, 'agents')).sort();
|
|
100
|
+
assert.deepEqual(fs.readdirSync(ctx.agents).sort(), files);
|
|
101
|
+
for (const file of files) assert.equal(fs.readFileSync(path.join(ctx.agents, file), 'utf8'), shipped(file), file);
|
|
102
|
+
assert.equal(result.stdout.match(/^written: /gm).length, files.length);
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
test('replace-own-unmodified: an unedited agent from an older release is replaced', () => {
|
|
106
|
+
const ctx = setup();
|
|
107
|
+
fs.mkdirSync(ctx.agents, { recursive: true });
|
|
108
|
+
fs.writeFileSync(path.join(ctx.agents, 'test_runner.toml'), olderOwn('test_runner.toml'));
|
|
109
|
+
const result = setupAgents(ctx);
|
|
110
|
+
assert.equal(result.status, 0, result.stderr);
|
|
111
|
+
assert.equal(fs.readFileSync(path.join(ctx.agents, 'test_runner.toml'), 'utf8'), shipped('test_runner.toml'));
|
|
112
|
+
assert.match(result.stdout, /^replaced: .*test_runner\.toml$/m);
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
test('keep-modified-own: an agent whose body was edited under the marker keeps its bytes', () => {
|
|
116
|
+
const ctx = setup();
|
|
117
|
+
fs.mkdirSync(ctx.agents, { recursive: true });
|
|
118
|
+
const edited = `${shipped('code_auditor.toml')}# my own note\n`;
|
|
119
|
+
fs.writeFileSync(path.join(ctx.agents, 'code_auditor.toml'), edited);
|
|
120
|
+
const result = setupAgents(ctx);
|
|
121
|
+
assert.equal(result.status, 0, result.stderr);
|
|
122
|
+
assert.equal(sha256(fs.readFileSync(path.join(ctx.agents, 'code_auditor.toml'), 'utf8')), sha256(edited));
|
|
123
|
+
assert.match(result.stdout, /^kept \(modified\): .*code_auditor\.toml$/m);
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
test('keep-foreign: a file CafeKit did not install keeps its bytes', () => {
|
|
127
|
+
const ctx = setup();
|
|
128
|
+
fs.mkdirSync(ctx.agents, { recursive: true });
|
|
129
|
+
const foreign = 'name = "debugger"\ndescription = "mine"\ndeveloper_instructions = "mine"\n';
|
|
130
|
+
fs.writeFileSync(path.join(ctx.agents, 'debugger.toml'), foreign);
|
|
131
|
+
const result = setupAgents(ctx);
|
|
132
|
+
assert.equal(result.status, 0, result.stderr);
|
|
133
|
+
assert.equal(fs.readFileSync(path.join(ctx.agents, 'debugger.toml'), 'utf8'), foreign);
|
|
134
|
+
assert.match(result.stdout, /^kept \(not installed by CafeKit\): .*debugger\.toml$/m);
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
test('mode-preserved: replacing an owned agent keeps its file mode', () => {
|
|
138
|
+
const ctx = setup();
|
|
139
|
+
fs.mkdirSync(ctx.agents, { recursive: true });
|
|
140
|
+
const file = path.join(ctx.agents, 'implementer.toml');
|
|
141
|
+
fs.writeFileSync(file, olderOwn('implementer.toml'), { mode: 0o600 });
|
|
142
|
+
fs.chmodSync(file, 0o600);
|
|
143
|
+
const result = setupAgents(ctx);
|
|
144
|
+
assert.equal(result.status, 0, result.stderr);
|
|
145
|
+
assert.equal(fs.readFileSync(file, 'utf8'), shipped('implementer.toml'));
|
|
146
|
+
assert.equal(fs.statSync(file).mode & 0o777, 0o600);
|
|
147
|
+
assert.deepEqual(fs.readdirSync(ctx.agents).filter((f) => f.endsWith('.tmp')), [], 'a temp file was left behind');
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
test('codex-home-honored: an absolute CODEX_HOME receives the agents and a relative one is refused', () => {
|
|
151
|
+
const ctx = setup();
|
|
152
|
+
const codexHome = path.join(ctx.home, 'custom-codex-home');
|
|
153
|
+
const result = setupAgents(ctx, [], codexHome);
|
|
154
|
+
assert.equal(result.status, 0, result.stderr);
|
|
155
|
+
assert.deepEqual(fs.readdirSync(path.join(codexHome, 'agents')).sort(), fs.readdirSync(path.join(tree, 'agents')).sort());
|
|
156
|
+
assert.ok(!fs.existsSync(path.join(ctx.home, '.codex')), 'agents also went to ~/.codex');
|
|
157
|
+
const refused = setup();
|
|
158
|
+
const relative = setupAgents(refused, [], 'relative-home');
|
|
159
|
+
assert.equal(relative.status, 1);
|
|
160
|
+
assert.match(relative.stderr, /CODEX_HOME must be an absolute path/);
|
|
161
|
+
assert.ok(!fs.existsSync(path.join(refused.repo, 'relative-home')) && !fs.existsSync(path.join(refused.home, '.codex')), 'a refused CODEX_HOME still wrote');
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
test('unreadable-kept: a file it cannot read is kept and the other agents still install', { skip: process.getuid?.() === 0 }, () => {
|
|
165
|
+
const ctx = setup();
|
|
166
|
+
fs.mkdirSync(ctx.agents, { recursive: true });
|
|
167
|
+
const locked = path.join(ctx.agents, 'debugger.toml');
|
|
168
|
+
fs.writeFileSync(locked, 'mine\n');
|
|
169
|
+
fs.chmodSync(locked, 0o000);
|
|
170
|
+
try {
|
|
171
|
+
const result = setupAgents(ctx);
|
|
172
|
+
assert.equal(result.status, 0, result.stderr);
|
|
173
|
+
assert.match(result.stdout, /^kept \(unreadable\): .*debugger\.toml$/m);
|
|
174
|
+
assert.equal(fs.readdirSync(ctx.agents).length, fs.readdirSync(path.join(tree, 'agents')).length);
|
|
175
|
+
} finally {
|
|
176
|
+
fs.chmodSync(locked, 0o600);
|
|
177
|
+
}
|
|
178
|
+
assert.equal(fs.readFileSync(locked, 'utf8'), 'mine\n');
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
test('symlink-kept: a symlinked agent stays a symlink and its target is untouched', () => {
|
|
182
|
+
const ctx = setup();
|
|
183
|
+
fs.mkdirSync(ctx.agents, { recursive: true });
|
|
184
|
+
const elsewhere = path.join(ctx.home, 'dotfiles-researcher.toml');
|
|
185
|
+
fs.writeFileSync(elsewhere, olderOwn('researcher.toml'));
|
|
186
|
+
const before = fs.readFileSync(elsewhere, 'utf8');
|
|
187
|
+
fs.symlinkSync(elsewhere, path.join(ctx.agents, 'researcher.toml'));
|
|
188
|
+
const result = setupAgents(ctx);
|
|
189
|
+
assert.equal(result.status, 0, result.stderr);
|
|
190
|
+
assert.ok(fs.lstatSync(path.join(ctx.agents, 'researcher.toml')).isSymbolicLink());
|
|
191
|
+
assert.equal(fs.readlinkSync(path.join(ctx.agents, 'researcher.toml')), elsewhere);
|
|
192
|
+
assert.equal(fs.readFileSync(elsewhere, 'utf8'), before);
|
|
193
|
+
assert.match(result.stdout, /^kept \(symlink\): .*researcher\.toml$/m);
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
test('repo-collision-reported: an agent of the same name in the repository is reported', () => {
|
|
197
|
+
const ctx = setup();
|
|
198
|
+
fs.mkdirSync(path.join(ctx.repo, '.codex/agents'), { recursive: true });
|
|
199
|
+
fs.writeFileSync(path.join(ctx.repo, '.codex/agents/test_runner.toml'), 'name = "test_runner"\n');
|
|
200
|
+
const result = setupAgents(ctx);
|
|
201
|
+
assert.equal(result.status, 0, result.stderr);
|
|
202
|
+
assert.match(result.stdout, /^collision: .*\.codex\/agents\/test_runner\.toml/m);
|
|
203
|
+
assert.equal((result.stdout.match(/^collision: /gm) || []).length, 1);
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
test('skill-explicit-no-target: the skill is explicit-only and the script refuses any argument', () => {
|
|
207
|
+
const skill = fs.readFileSync(path.join(tree, 'skills/setup-agents/SKILL.md'), 'utf8');
|
|
208
|
+
assert.match(skill, /^---\nname: cf-setup-agents\n/);
|
|
209
|
+
assert.match(skill, /\{cafekit:setup-agents\}/);
|
|
210
|
+
assert.equal(fs.readFileSync(path.join(tree, 'skills/setup-agents', CODEX_POLICY_FILE), 'utf8'), CODEX_EXPLICIT_ONLY_POLICY);
|
|
211
|
+
const ctx = setup();
|
|
212
|
+
const target = path.join(ctx.home, 'elsewhere');
|
|
213
|
+
const result = setupAgents(ctx, [target]);
|
|
214
|
+
assert.equal(result.status, 2);
|
|
215
|
+
assert.ok(!fs.existsSync(target) && !fs.existsSync(ctx.agents), 'the script wrote with an argument');
|
|
216
|
+
});
|
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
// review authority and the scaffold guard) are gone from every runtime. Each case pins one way
|
|
5
5
|
// they could survive: a fresh install, an upgrade on Claude (settings prune matches by substring,
|
|
6
6
|
// so a user's own hook with the same file name must stay), an upgrade on Codex (whose tracker
|
|
7
|
-
// keys are `.codex/…` and whose handlers come in three shapes), an upgrade on omp, an omp bridge
|
|
7
|
+
// keys are `.codex/…` and whose handlers come in three shapes), an upgrade on omp (hash-checked like Codex), an omp bridge
|
|
8
8
|
// that still lists the removed gates, and the source lists themselves.
|
|
9
9
|
|
|
10
10
|
const assert = require('node:assert/strict');
|
|
@@ -21,7 +21,7 @@ const SRC = path.join(PACKAGE_ROOT, 'src');
|
|
|
21
21
|
const readJson = (file) => JSON.parse(fs.readFileSync(file, 'utf8'));
|
|
22
22
|
const MANIFEST = readJson(path.join(SRC, 'claude/migration-manifest.json'));
|
|
23
23
|
const SOURCE_SETTINGS = readJson(path.join(SRC, 'claude/settings/settings.json'));
|
|
24
|
-
const SOURCE_BRIDGE_PATH = path.join(SRC, 'omp/extensions/cafekit-bridge.
|
|
24
|
+
const SOURCE_BRIDGE_PATH = path.join(SRC, 'omp/extensions/cafekit-bridge.js');
|
|
25
25
|
|
|
26
26
|
const NAMES = [
|
|
27
27
|
'completion-authority.cjs',
|
|
@@ -70,7 +70,7 @@ test('fresh installs ship and register none of the five legacy hooks', () => {
|
|
|
70
70
|
const claude = commands(readJson(path.join(root, '.claude', 'settings.json')));
|
|
71
71
|
const codex = commands(readJson(path.join(root, '.codex', 'hooks.json')));
|
|
72
72
|
for (const command of [...claude, ...codex]) assert.deepEqual(mentionsName(command), [], command);
|
|
73
|
-
const bridge = fs.readFileSync(path.join(root, '.omp', 'extensions', 'cafekit-bridge.
|
|
73
|
+
const bridge = fs.readFileSync(path.join(root, '.omp', 'extensions', 'cafekit-bridge.js'), 'utf8');
|
|
74
74
|
assert.deepEqual(mentionsName(bridge), []);
|
|
75
75
|
});
|
|
76
76
|
});
|
|
@@ -166,9 +166,10 @@ test('omp upgrade removes the legacy hooks and their records', () => {
|
|
|
166
166
|
const manifestPath = path.join(root, '.omp', 'cafekit-manifest.json');
|
|
167
167
|
const manifest = readJson(manifestPath);
|
|
168
168
|
// omp records keys relative to the project root (recordRoot '.').
|
|
169
|
+
const shipped = '// shipped by an older CafeKit\n';
|
|
169
170
|
for (const name of NAMES) {
|
|
170
|
-
fs.writeFileSync(path.join(root, '.omp', 'hooks', name),
|
|
171
|
-
manifest.files[`.omp/hooks/${name}`] = { sha256: '
|
|
171
|
+
fs.writeFileSync(path.join(root, '.omp', 'hooks', name), shipped);
|
|
172
|
+
manifest.files[`.omp/hooks/${name}`] = { sha256: crypto.createHash('sha256').update(shipped).digest('hex') };
|
|
172
173
|
}
|
|
173
174
|
fs.writeFileSync(manifestPath, JSON.stringify(manifest, null, 2));
|
|
174
175
|
|
|
@@ -181,28 +182,77 @@ test('omp upgrade removes the legacy hooks and their records', () => {
|
|
|
181
182
|
});
|
|
182
183
|
});
|
|
183
184
|
|
|
184
|
-
test('omp upgrade
|
|
185
|
+
test('omp upgrade preserves edited legacy hooks and prunes only absent records', () => {
|
|
185
186
|
inTempProject((root) => {
|
|
186
187
|
install(root, ['omp']);
|
|
187
|
-
const
|
|
188
|
+
const manifestPath = path.join(root, '.omp', 'cafekit-manifest.json');
|
|
189
|
+
const manifest = readJson(manifestPath);
|
|
190
|
+
// A record whose file is already gone must leave the manifest too.
|
|
191
|
+
for (const name of NAMES) {
|
|
192
|
+
if (name !== 'completion-authority-state.cjs') fs.writeFileSync(path.join(root, '.omp', 'hooks', name), '// user edit\n');
|
|
193
|
+
manifest.files[`.omp/hooks/${name}`] = { sha256: '0'.repeat(64) };
|
|
194
|
+
}
|
|
195
|
+
fs.writeFileSync(manifestPath, JSON.stringify(manifest, null, 2));
|
|
196
|
+
|
|
197
|
+
const upgrade = install(root, ['omp']);
|
|
198
|
+
assert.match(`${upgrade.stdout}\n${upgrade.stderr}`, new RegExp(`Preserved ${NAMES.length - 1} obsolete runtime item`));
|
|
199
|
+
const after = readJson(manifestPath);
|
|
200
|
+
for (const name of NAMES) {
|
|
201
|
+
if (name === 'completion-authority-state.cjs') {
|
|
202
|
+
assert.equal(after.files[`.omp/hooks/${name}`], undefined, `record ${name}`);
|
|
203
|
+
continue;
|
|
204
|
+
}
|
|
205
|
+
assert.equal(fs.readFileSync(path.join(root, '.omp', 'hooks', name), 'utf8'), '// user edit\n', name);
|
|
206
|
+
assert.equal(after.files[`.omp/hooks/${name}`].sha256, '0'.repeat(64), `record ${name}`);
|
|
207
|
+
}
|
|
208
|
+
});
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
test('omp upgrade removes a pristine old .mjs bridge, which omp never loads', () => {
|
|
212
|
+
inTempProject((root) => {
|
|
213
|
+
install(root, ['omp']);
|
|
214
|
+
const oldPath = path.join(root, '.omp', 'extensions', 'cafekit-bridge.mjs');
|
|
188
215
|
const manifestPath = path.join(root, '.omp', 'cafekit-manifest.json');
|
|
189
216
|
const old = fs.readFileSync(SOURCE_BRIDGE_PATH, 'utf8')
|
|
190
|
-
.replace("'inspect-block.cjs']", "'inspect-block.cjs', 'task-scaffold-guard.cjs']")
|
|
191
|
-
|
|
192
|
-
.replace("'spec-gate.cjs', 'state.cjs'", "'spec-gate.cjs', 'completion-authority.cjs', 'state.cjs'");
|
|
193
|
-
assert.deepEqual(mentionsName(old).sort(), ['completion-authority.cjs', 'task-scaffold-guard.cjs'], 'the fixture lists the removed gates');
|
|
194
|
-
fs.writeFileSync(bridgePath, old);
|
|
217
|
+
.replace("'inspect-block.cjs']", "'inspect-block.cjs', 'task-scaffold-guard.cjs']");
|
|
218
|
+
fs.writeFileSync(oldPath, old);
|
|
195
219
|
const manifest = readJson(manifestPath);
|
|
196
220
|
manifest.files['.omp/extensions/cafekit-bridge.mjs'] = { sha256: crypto.createHash('sha256').update(old).digest('hex') };
|
|
197
221
|
fs.writeFileSync(manifestPath, JSON.stringify(manifest, null, 2));
|
|
198
222
|
|
|
199
223
|
install(root, ['omp']);
|
|
200
|
-
|
|
224
|
+
assert.equal(fs.existsSync(oldPath), false, 'the pristine .mjs bridge is removed');
|
|
225
|
+
assert.equal(readJson(manifestPath).files['.omp/extensions/cafekit-bridge.mjs'], undefined, 'and its record');
|
|
226
|
+
const installed = fs.readFileSync(path.join(root, '.omp', 'extensions', 'cafekit-bridge.js'), 'utf8');
|
|
201
227
|
assert.deepEqual(mentionsName(installed), []);
|
|
202
228
|
assert.equal(installed, fs.readFileSync(SOURCE_BRIDGE_PATH, 'utf8'));
|
|
203
229
|
});
|
|
204
230
|
});
|
|
205
231
|
|
|
232
|
+
test('omp upgrade keeps an edited, untracked or symlinked old .mjs bridge and says so', () => {
|
|
233
|
+
for (const shape of ['edited', 'untracked', 'symlink']) {
|
|
234
|
+
inTempProject((root) => {
|
|
235
|
+
install(root, ['omp']);
|
|
236
|
+
const oldPath = path.join(root, '.omp', 'extensions', 'cafekit-bridge.mjs');
|
|
237
|
+
const manifestPath = path.join(root, '.omp', 'cafekit-manifest.json');
|
|
238
|
+
const manifest = readJson(manifestPath);
|
|
239
|
+
if (shape === 'symlink') {
|
|
240
|
+
const target = path.join(root, 'my-bridge.mjs');
|
|
241
|
+
fs.writeFileSync(target, '// mine\n');
|
|
242
|
+
fs.symlinkSync(target, oldPath);
|
|
243
|
+
} else {
|
|
244
|
+
fs.writeFileSync(oldPath, '// edited by the user\n');
|
|
245
|
+
if (shape === 'edited') manifest.files['.omp/extensions/cafekit-bridge.mjs'] = { sha256: '0'.repeat(64) };
|
|
246
|
+
}
|
|
247
|
+
fs.writeFileSync(manifestPath, JSON.stringify(manifest, null, 2));
|
|
248
|
+
|
|
249
|
+
const result = install(root, ['omp']);
|
|
250
|
+
assert.equal(fs.lstatSync(oldPath).isFile() || fs.lstatSync(oldPath).isSymbolicLink(), true, `${shape} .mjs is kept`);
|
|
251
|
+
assert.match(`${result.stdout}\n${result.stderr}`, /Preserved \d+ obsolete runtime item/, `${shape} is reported`);
|
|
252
|
+
});
|
|
253
|
+
}
|
|
254
|
+
});
|
|
255
|
+
|
|
206
256
|
test('no source ships, registers or bridges the five legacy hooks', () => {
|
|
207
257
|
for (const name of NAMES) {
|
|
208
258
|
assert.equal(fs.existsSync(path.join(SRC, 'claude/hooks', name)), false, `claude ${name}`);
|