@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.
Files changed (118) hide show
  1. package/README.md +1 -1
  2. package/bin/__tests__/adv-skill.test.js +371 -0
  3. package/bin/__tests__/agent-authority.test.js +38 -0
  4. package/bin/__tests__/agent-instructions.test.js +54 -0
  5. package/bin/__tests__/claude-plugin-build.test.js +151 -0
  6. package/bin/__tests__/codex-hooks.test.js +38 -6
  7. package/bin/__tests__/codex-implicit-policy.test.js +160 -0
  8. package/bin/__tests__/codex-native.test.js +15 -8
  9. package/bin/__tests__/codex-plugin-build.test.js +230 -0
  10. package/bin/__tests__/codex-plugin-context.test.js +135 -0
  11. package/bin/__tests__/codex-plugin-hooks.test.js +220 -0
  12. package/bin/__tests__/codex-runtime-layout.test.js +81 -0
  13. package/bin/__tests__/codex-setup-agents.test.js +216 -0
  14. package/bin/__tests__/legacy-hooks-retired.test.js +63 -13
  15. package/bin/__tests__/migrate-apply.test.js +373 -0
  16. package/bin/__tests__/migrate-cli.test.js +152 -0
  17. package/bin/__tests__/migrate-merged.test.js +269 -0
  18. package/bin/__tests__/migrate-plan.test.js +271 -0
  19. package/bin/__tests__/omp-bridge.test.js +81 -5
  20. package/bin/__tests__/omp-hooks.test.js +24 -0
  21. package/bin/__tests__/plugin-safety-keys.test.js +121 -0
  22. package/bin/__tests__/privacy-paths.test.js +184 -0
  23. package/bin/__tests__/usage-hook-retired.test.js +24 -6
  24. package/bin/__tests__/vibe-run.test.js +215 -0
  25. package/bin/__tests__/vibe-skill.test.js +549 -0
  26. package/bin/install.js +99 -1
  27. package/bin/lib/backup.js +43 -24
  28. package/bin/lib/codex-install.js +7 -0
  29. package/bin/lib/context.js +5 -0
  30. package/bin/lib/lock.js +98 -18
  31. package/bin/lib/managed-writer.js +16 -10
  32. package/bin/lib/migrate-apply.js +202 -0
  33. package/bin/lib/migrate-merged.js +342 -0
  34. package/bin/lib/migrate-plan.js +419 -0
  35. package/bin/lib/plugin-inventory.js +33 -0
  36. package/bin/phases/claude-runtime.js +5 -3
  37. package/bin/phases/codex-hooks.js +38 -0
  38. package/bin/phases/codex-runtime.js +106 -74
  39. package/bin/phases/copy-payload.js +49 -1
  40. package/bin/phases/omp-runtime.js +4 -26
  41. package/bin/phases/root-config.js +5 -2
  42. package/package.json +1 -1
  43. package/src/claude/CLAUDE.md +1 -1
  44. package/src/claude/agents/advisor.md +55 -0
  45. package/src/claude/agents/debugger.md +2 -3
  46. package/src/claude/agents/deployer.md +2 -1
  47. package/src/claude/agents/docs-keeper.md +12 -0
  48. package/src/claude/agents/inspector.md +1 -1
  49. package/src/claude/agents/project-manager.md +2 -2
  50. package/src/claude/agents/researcher.md +0 -1
  51. package/src/claude/agents/spec-maker.md +4 -1
  52. package/src/claude/hooks/__tests__/plugin-context.test.js +96 -0
  53. package/src/claude/hooks/__tests__/plugin-options.test.js +141 -0
  54. package/src/claude/hooks/__tests__/plugin-state-isolation.test.js +125 -0
  55. package/src/claude/hooks/__tests__/precompact.test.js +7 -1
  56. package/src/claude/hooks/__tests__/privacy-block.test.js +33 -9
  57. package/src/claude/hooks/__tests__/setup-statusline.test.js +179 -0
  58. package/src/claude/hooks/__tests__/spec-gate.test.js +86 -1
  59. package/src/claude/hooks/agent.cjs +5 -2
  60. package/src/claude/hooks/inspect-block.cjs +3 -1
  61. package/src/claude/hooks/lib/hook-state-dir.cjs +19 -1
  62. package/src/claude/hooks/lib/plugin-options.cjs +47 -0
  63. package/src/claude/hooks/lib/privacy-command-analysis.cjs +130 -11
  64. package/src/claude/hooks/lib/privacy-paths.cjs +370 -10
  65. package/src/claude/hooks/lib/spec-touch.cjs +14 -1
  66. package/src/claude/hooks/plugin-context.cjs +80 -0
  67. package/src/claude/hooks/privacy-block.cjs +2 -1
  68. package/src/claude/hooks/rules.cjs +5 -2
  69. package/src/claude/hooks/session.cjs +4 -1
  70. package/src/claude/hooks/spec-state.cjs +5 -4
  71. package/src/claude/hooks/state.cjs +5 -1
  72. package/src/claude/migration-manifest.json +6 -1
  73. package/src/claude/plugin/commands/setup-statusline.md +12 -0
  74. package/src/claude/plugin/core.md +71 -0
  75. package/src/claude/rules/manage-docs.md +1 -2
  76. package/src/claude/rules/review-audit-self-decision.md +1 -1
  77. package/src/claude/rules/skill-workflow-routing.md +1 -1
  78. package/src/claude/rules/state-sync.md +14 -0
  79. package/src/claude/rules/workflow.md +1 -1
  80. package/src/claude/scripts/generate-skill-catalog.cjs +5 -0
  81. package/src/claude/scripts/provenance.cjs +40 -16
  82. package/src/claude/scripts/setup-statusline.cjs +88 -0
  83. package/src/claude/skills/adv/SKILL.md +139 -0
  84. package/src/claude/skills/adv/references/optional-modes.md +41 -0
  85. package/src/claude/skills/adv/references/ultra-mode.md +29 -0
  86. package/src/claude/skills/brainstorm/SKILL.md +1 -1
  87. package/src/claude/skills/code-review/SKILL.md +4 -1
  88. package/src/claude/skills/code-review/references/pr-fix-reply.md +42 -0
  89. package/src/claude/skills/develop/SKILL.md +5 -3
  90. package/src/claude/skills/develop/references/quality-gate.md +1 -1
  91. package/src/claude/skills/fix/SKILL.md +2 -2
  92. package/src/claude/skills/git/SKILL.md +4 -2
  93. package/src/claude/skills/git/references/merge-pr.md +125 -0
  94. package/src/claude/skills/specs/SKILL.md +2 -2
  95. package/src/claude/skills/specs/references/review.md +1 -1
  96. package/src/claude/skills/specs/references/templates.md +2 -0
  97. package/src/claude/skills/vibe/SKILL.md +69 -0
  98. package/src/claude/skills/vibe/references/pipeline-skill-integration.md +34 -0
  99. package/src/claude/skills/vibe/references/report-templates.md +84 -0
  100. package/src/claude/skills/vibe/references/subagent-orchestration.md +23 -0
  101. package/src/codex/AGENTS.md +1 -1
  102. package/src/codex/hooks/inspect-block.cjs +7 -4
  103. package/src/codex/hooks/lib/hook-context.cjs +118 -4
  104. package/src/codex/hooks/lib/plugin-options.cjs +2 -0
  105. package/src/codex/hooks/lib/privacy-state.cjs +18 -1
  106. package/src/codex/hooks/lib/state-store.cjs +5 -4
  107. package/src/codex/hooks/precompact.cjs +9 -8
  108. package/src/codex/hooks/privacy-approval.cjs +2 -2
  109. package/src/codex/hooks/privacy-block.cjs +5 -4
  110. package/src/codex/hooks/rules.cjs +12 -6
  111. package/src/codex/hooks/session.cjs +22 -10
  112. package/src/codex/plugin/core.md +76 -0
  113. package/src/codex/plugin/hooks/plugin-context.cjs +63 -0
  114. package/src/codex/plugin/skills/setup-agents/SKILL.md +17 -0
  115. package/src/codex/scripts/setup-agents.cjs +124 -0
  116. package/src/common/AGENTS.md +1 -1
  117. package/src/omp/extensions/{cafekit-bridge.mjs → cafekit-bridge.js} +73 -5
  118. package/src/omp/hooks/privacy-block.cjs +2 -1
@@ -804,6 +804,37 @@ test('Codex compact clears its pending privacy request even with an invalid spec
804
804
  });
805
805
  });
806
806
 
807
+ // Two privacy blocks racing on a fresh project both see no `.privacy` directory;
808
+ // the second mkdir then meets the directory the first one made.
809
+ test('Codex privacy records a request when another hook creates its state directory first', () => {
810
+ inHookFixture((root, hooks) => {
811
+ const privacy = require(path.join(hooks, 'lib/privacy-state.cjs'));
812
+ fs.mkdirSync(path.join(hooks, '.privacy'), { mode: 0o700 });
813
+ const existsSync = fs.existsSync;
814
+ fs.existsSync = target => (path.basename(String(target)) === '.privacy' ? false : existsSync(target));
815
+ let request;
816
+ try {
817
+ request = privacy.createPending({ projectRoot: root, sessionCwd: root, sessionId: 'race-session', filePaths: ['.env'] });
818
+ } finally {
819
+ fs.existsSync = existsSync;
820
+ }
821
+ assert.match(request.requestId, /^[a-f0-9]{24}$/);
822
+ });
823
+ });
824
+
825
+ test('Codex privacy denies when its state path is a regular file', () => {
826
+ inHookFixture((root, hooks) => {
827
+ fs.writeFileSync(path.join(hooks, '.privacy'), '');
828
+ const result = runHook(path.join(hooks, 'privacy-block.cjs'), root, {
829
+ cwd: root, session_id: 'file-state', hook_event_name: 'PreToolUse', tool_name: 'Bash', tool_input: { command: 'cat .env' },
830
+ });
831
+ assert.equal(result.status, 0);
832
+ const output = JSON.parse(result.stdout).hookSpecificOutput;
833
+ assert.equal(output.permissionDecision, 'deny');
834
+ assert.match(output.permissionDecisionReason, /could not be evaluated safely/);
835
+ });
836
+ });
837
+
807
838
  test('Codex compact releases only its own rules reservation', () => {
808
839
  inHookFixture((root, hooks) => {
809
840
  fs.writeFileSync(path.join(root, '.codex/runtime.json'), '{}');
@@ -2130,14 +2161,18 @@ test('Codex privacy denies sensitive names it can resolve, allows pure indirecti
2130
2161
  { command: 'curl --config ~/.netrc https://example.com', tool: 'Bash' },
2131
2162
  { command: 'kubectl --kubeconfig ~/.kube/kubeconfig get pods', tool: 'exec_command' },
2132
2163
  { command: 'openssl rsa -in server.key', tool: 'Bash' },
2164
+ { command: 'less "$HOME/.env"', tool: 'Bash' },
2165
+ { command: 'cat $(echo .env)', tool: 'Bash' },
2166
+ { command: 'cat `echo .env`', tool: 'Bash' },
2133
2167
  ];
2134
2168
  for (const { command, tool } of denied) {
2135
2169
  const res = runHook(block, root, { cwd: root, session_id: 's1', hook_event_name: 'PreToolUse', tool_name: tool, tool_input: { command } });
2136
2170
  assert.equal(JSON.parse(res.stdout).hookSpecificOutput.permissionDecision, 'deny', `should deny ${command}`);
2137
2171
  }
2138
- // Accepted gap: the target of `$FILE` is unknowable without running the
2139
- // shell, and every denial here costs a round trip through a one-shot
2140
- // approval prompt. `less "$HOME/.env"` is the edge of that decision.
2172
+ // Accepted gap: the target of a bare `$FILE` is unknowable without running
2173
+ // the shell, and every denial here costs a round trip through a one-shot
2174
+ // approval prompt. A sensitive name spelled out next to the expansion
2175
+ // (`"$HOME/.env"`, `$(echo .env)`) is not indirection and is denied above.
2141
2176
  const allowed = [
2142
2177
  { command: "echo $VAR", tool: 'exec_command' },
2143
2178
  { command: 'echo "$HOME"', tool: 'Bash' },
@@ -2166,11 +2201,8 @@ test('Codex privacy denies sensitive names it can resolve, allows pure indirecti
2166
2201
  { command: 'cat "$FILE"', tool: 'exec_command' },
2167
2202
  { command: 'cat ${FILE}', tool: 'Bash' },
2168
2203
  { command: 'cat $1', tool: 'exec_command' },
2169
- { command: 'cat $(echo .env)', tool: 'Bash' },
2170
- { command: 'cat `echo .env`', tool: 'Bash' },
2171
2204
  { command: 'head $SECRET', tool: 'exec_command' },
2172
2205
  { command: 'tail ${MY_FILE}', tool: 'Bash' },
2173
- { command: 'less "$HOME/.env"', tool: 'Bash' },
2174
2206
  { command: 'cat README.md $EXTRA', tool: 'Bash' },
2175
2207
  { command: 'echo ok; cat README.md $FILE', tool: 'Bash' },
2176
2208
  { command: 'cat README.md | head $FILE', tool: 'exec_command' },
@@ -0,0 +1,160 @@
1
+ // Codex ignores the `disable-model-invocation` key that keeps a skill explicit-only on Claude Code. Its own
2
+ // mechanism is `agents/openai.yaml` beside SKILL.md with `policy.allow_implicit_invocation: false`. These cases
3
+ // pin that the Codex install writes that file for every explicit-only skill through the ownership-aware writer
4
+ // (so upgrades and removals stay clean), writes it for no other skill, and that the catalog never lists such a skill.
5
+ const assert = require('node:assert/strict');
6
+ const fs = require('node:fs');
7
+ const os = require('node:os');
8
+ const path = require('node:path');
9
+ const test = require('node:test');
10
+ const { spawnSync } = require('node:child_process');
11
+
12
+ const { PLATFORMS } = require('../lib/context');
13
+ const manifestLib = require('../lib/manifest');
14
+ const { removeSkill } = require('../phases/skill-inventory');
15
+
16
+ const PACKAGE_ROOT = path.join(__dirname, '..', '..');
17
+ const INSTALLER = path.join(PACKAGE_ROOT, 'bin/install.js');
18
+ const SOURCE_SKILLS = path.join(PACKAGE_ROOT, 'src/claude/skills');
19
+ const POLICY = 'policy:\n allow_implicit_invocation: false\n';
20
+
21
+ const explicitOnly = fs.readdirSync(SOURCE_SKILLS)
22
+ .filter((name) => fs.existsSync(path.join(SOURCE_SKILLS, name, 'SKILL.md')))
23
+ .filter((name) => /^disable-model-invocation:\s*(["']?)true\1\s*$/m.test(
24
+ fs.readFileSync(path.join(SOURCE_SKILLS, name, 'SKILL.md'), 'utf8').split(/^---\s*$/m)[1] || ''));
25
+
26
+ function install(root, platform) {
27
+ const result = spawnSync(process.execPath, [INSTALLER, '--platform', platform, '--yes'],
28
+ { cwd: root, encoding: 'utf8', env: { ...process.env, PATH: '/usr/bin:/bin' } });
29
+ assert.equal(result.status, 0, result.stderr || result.stdout);
30
+ return result;
31
+ }
32
+
33
+ function withTempProject(run) {
34
+ const root = fs.mkdtempSync(path.join(os.tmpdir(), 'cafekit-codex-policy-'));
35
+ const previous = process.cwd();
36
+ try {
37
+ assert.equal(spawnSync('git', ['-C', root, 'init', '-q']).status, 0);
38
+ process.chdir(root);
39
+ return run(root);
40
+ } finally {
41
+ process.chdir(previous);
42
+ fs.rmSync(root, { recursive: true, force: true });
43
+ }
44
+ }
45
+
46
+ const policyFile = (root, skill) => path.join(root, '.agents/skills', skill, 'agents/openai.yaml');
47
+ const ownershipKey = (root, file) => path.relative(path.join(root, PLATFORMS.codex.ownership?.recordRoot || PLATFORMS.codex.folder), file).replace(/\\/g, '/');
48
+ const readOwnership = (root) => JSON.parse(fs.readFileSync(path.join(root, PLATFORMS.codex.folder, manifestLib.MANIFEST_FILE), 'utf8'));
49
+ const writeOwnership = (root, data) => fs.writeFileSync(path.join(root, PLATFORMS.codex.folder, manifestLib.MANIFEST_FILE), JSON.stringify(data, null, 2));
50
+
51
+ function catalog(root, script, skillsRoot) {
52
+ const result = spawnSync(process.execPath, [path.join(root, script), '--json', '--root', path.join(root, skillsRoot)],
53
+ { cwd: root, encoding: 'utf8' });
54
+ assert.equal(result.status, 0, result.stderr);
55
+ return JSON.parse(result.stdout);
56
+ }
57
+
58
+ test('the explicit-only skills are vibe and adv', () => {
59
+ assert.deepEqual(explicitOnly.sort(), ['adv', 'vibe']);
60
+ });
61
+
62
+ test('a Codex install writes the policy file for every explicit-only skill, owned, and for no other skill', () => {
63
+ withTempProject((root) => {
64
+ install(root, 'codex');
65
+ const ownership = readOwnership(root);
66
+ for (const skill of explicitOnly) {
67
+ const file = policyFile(root, skill);
68
+ assert.equal(fs.readFileSync(file, 'utf8'), POLICY, skill);
69
+ assert.equal(ownership.files[ownershipKey(root, file)]?.sha256, manifestLib.hashFile(file), `${skill} policy is not owned`);
70
+ }
71
+ const others = fs.readdirSync(path.join(root, '.agents/skills')).filter((name) => !explicitOnly.includes(name));
72
+ assert.ok(others.length > 10);
73
+ for (const skill of others) assert.equal(fs.existsSync(policyFile(root, skill)), false, skill);
74
+ });
75
+ });
76
+
77
+ test('reinstalling keeps the policy bytes and its ownership record unchanged', () => {
78
+ withTempProject((root) => {
79
+ install(root, 'codex');
80
+ const before = explicitOnly.map((skill) => readOwnership(root).files[ownershipKey(root, policyFile(root, skill))]?.sha256);
81
+ install(root, 'codex');
82
+ const after = explicitOnly.map((skill) => readOwnership(root).files[ownershipKey(root, policyFile(root, skill))]?.sha256);
83
+ assert.deepEqual(after, before);
84
+ for (const skill of explicitOnly) assert.equal(fs.readFileSync(policyFile(root, skill), 'utf8'), POLICY);
85
+ });
86
+ });
87
+
88
+ test('an owned policy file under a skill that dropped the key is removed on upgrade; a user-created one stays', () => {
89
+ withTempProject((root) => {
90
+ install(root, 'codex');
91
+ // An earlier release that marked ask explicit-only would have written and owned this file.
92
+ const owned = policyFile(root, 'ask');
93
+ fs.mkdirSync(path.dirname(owned), { recursive: true });
94
+ fs.writeFileSync(owned, POLICY);
95
+ const data = readOwnership(root);
96
+ data.files[ownershipKey(root, owned)] = { sha256: manifestLib.hashFile(owned), version: 'old' };
97
+ writeOwnership(root, data);
98
+ // A file the user wrote is never ours to delete.
99
+ const userFile = policyFile(root, 'debug');
100
+ fs.mkdirSync(path.dirname(userFile), { recursive: true });
101
+ fs.writeFileSync(userFile, POLICY);
102
+
103
+ install(root, 'codex');
104
+ assert.equal(fs.existsSync(owned), false, 'owned stale policy survived the upgrade');
105
+ assert.equal(readOwnership(root).files[ownershipKey(root, owned)], undefined, 'stale policy stayed in the manifest');
106
+ assert.equal(fs.readFileSync(userFile, 'utf8'), POLICY, 'user-created policy was removed');
107
+ });
108
+ });
109
+
110
+ test('a user edit to the policy file is preserved on upgrade', () => {
111
+ withTempProject((root) => {
112
+ install(root, 'codex');
113
+ const file = policyFile(root, 'vibe');
114
+ const edited = 'policy:\n allow_implicit_invocation: true\n';
115
+ fs.writeFileSync(file, edited);
116
+ install(root, 'codex');
117
+ assert.equal(fs.readFileSync(file, 'utf8'), edited);
118
+ });
119
+ });
120
+
121
+ test('removing an explicit-only skill removes its whole tree, the policy file included', () => {
122
+ withTempProject((root) => {
123
+ install(root, 'codex');
124
+ const ctx = {
125
+ ownership: { [PLATFORMS.codex.folder]: readOwnership(root) },
126
+ trackers: { codex: manifestLib.createTracker(PLATFORMS.codex.folder, 'next', PLATFORMS.codex.ownership) },
127
+ dryRun: false,
128
+ ui: { warn() {}, detail() {} },
129
+ results: { updated: 0, preserved: 0, preservedFiles: [] }
130
+ };
131
+ removeSkill(ctx, 'codex', 'vibe');
132
+ assert.deepEqual(ctx.results.preservedFiles, []);
133
+ assert.equal(fs.existsSync(path.join(root, '.agents/skills/vibe')), false);
134
+ });
135
+ });
136
+
137
+ test('a Claude install writes no policy file', () => {
138
+ withTempProject((root) => {
139
+ install(root, 'claude');
140
+ for (const skill of fs.readdirSync(path.join(root, '.claude/skills'))) {
141
+ assert.equal(fs.existsSync(path.join(root, '.claude/skills', skill, 'agents/openai.yaml')), false, skill);
142
+ }
143
+ });
144
+ });
145
+
146
+ test('neither runtime catalog lists an explicit-only skill, and both still list the others', () => {
147
+ withTempProject((root) => {
148
+ install(root, 'claude,codex');
149
+ for (const [script, skillsRoot] of [['.codex/scripts/generate-skill-catalog.cjs', '.agents/skills'],
150
+ ['.claude/scripts/generate-skill-catalog.cjs', '.claude/skills']]) {
151
+ const parsed = catalog(root, script, skillsRoot);
152
+ const ids = parsed.skills.map((skill) => skill.public_id);
153
+ for (const skill of explicitOnly) {
154
+ assert.equal(ids.includes(`cf:${skill}`), false, `${script} lists cf:${skill}`);
155
+ assert.ok(parsed.diagnostics.some((item) => item.directory === skill && item.code === 'explicit_only'), `${script}: ${skill}`);
156
+ }
157
+ assert.ok(ids.includes('cf:ask') && ids.includes('cf:specs'), script);
158
+ }
159
+ });
160
+ });
@@ -2326,7 +2326,10 @@ test('Codex installed Specs and spec-maker reject adaptive coverage mutations',
2326
2326
  );
2327
2327
  assert.equal(catalog.status, 0, catalog.stderr);
2328
2328
  const catalogData = JSON.parse(catalog.stdout);
2329
- assert.equal(catalogData.total, skillNames.length);
2329
+ // Explicit-only skills run only when named, so the catalog leaves them out of automatic routing.
2330
+ const explicitOnly = skillFiles.filter((file) => /^disable-model-invocation:\s*true\s*$/m.test(fs.readFileSync(file, 'utf8')));
2331
+ assert.ok(explicitOnly.length >= 1);
2332
+ assert.equal(catalogData.total, skillNames.length - explicitOnly.length);
2330
2333
  assert.equal(
2331
2334
  catalogData.root,
2332
2335
  fs.realpathSync(path.join(root, '.agents', 'skills'))
@@ -3213,15 +3216,15 @@ test('Codex installed code_auditor carries no Strict attestation marker', () =>
3213
3216
  });
3214
3217
  });
3215
3218
 
3216
- test('reinstall preserves untracked Codex runtime files while omp keeps its cleanup policy', () => {
3219
+ test('reinstall preserves untracked Codex and omp runtime files', () => {
3217
3220
  for (const platform of ['codex', 'omp']) inTempProject(root => {
3218
3221
  const first = installPlatforms(root, ['codex', 'omp']);
3219
3222
  assert.equal(first.status, 0, `${first.stdout}\n${first.stderr}`);
3220
3223
  const runtime = `.${platform}`;
3221
3224
  const retired = ['change-firewall.cjs', 'spec-final-state.cjs'];
3222
3225
  for (const name of retired) fs.writeFileSync(path.join(root, runtime, 'scripts', name), 'legacy\n');
3223
- const retiredSkillFiles = platform === 'codex'
3224
- ? ['code-review/references/verification-gate.md', 'specs/references/archive-workflow.md'] : [];
3226
+ // omp installs no skills, so a reinstall of omp alone must leave .agents/skills to Codex.
3227
+ const retiredSkillFiles = ['code-review/references/verification-gate.md', 'specs/references/archive-workflow.md'];
3225
3228
  for (const rel of retiredSkillFiles) {
3226
3229
  const target = path.join(root, '.agents/skills', rel);
3227
3230
  fs.mkdirSync(path.dirname(target), { recursive: true });
@@ -3229,10 +3232,14 @@ test('reinstall preserves untracked Codex runtime files while omp keeps its clea
3229
3232
  }
3230
3233
  const again = installPlatforms(root, [platform], ['--force-overwrite']);
3231
3234
  assert.equal(again.status, 0, `${again.stdout}\n${again.stderr}`);
3232
- const preserved = platform === 'codex';
3233
- for (const rel of retiredSkillFiles) assert.equal(fs.existsSync(path.join(root, '.agents/skills', rel)), preserved, rel);
3234
- for (const name of retired) assert.equal(fs.existsSync(path.join(root, runtime, 'scripts', name)), preserved, name);
3235
- if (preserved) assert.match(again.stdout + again.stderr, /Preserved 4 obsolete runtime/);
3235
+ const output = again.stdout + again.stderr;
3236
+ for (const rel of retiredSkillFiles) assert.equal(fs.existsSync(path.join(root, '.agents/skills', rel)), true, rel);
3237
+ for (const name of retired) assert.equal(fs.existsSync(path.join(root, runtime, 'scripts', name)), true, name);
3238
+ if (platform === 'codex') assert.match(output, /Preserved 4 obsolete runtime/);
3239
+ else {
3240
+ assert.match(output, /Preserved 2 obsolete runtime/);
3241
+ assert.doesNotMatch(output, /\.agents\/skills/);
3242
+ }
3236
3243
  assert.equal(fs.existsSync(path.join(root, '.agents/skills/code-review/SKILL.md')), true);
3237
3244
  assert.equal(fs.existsSync(path.join(root, runtime, 'scripts/spec-resolver.cjs')), true);
3238
3245
  });
@@ -0,0 +1,230 @@
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 { spawnSync } = require('node:child_process');
9
+
10
+ const REPO = path.resolve(__dirname, '../../../..');
11
+ const SRC = path.resolve(__dirname, '../../src');
12
+ const SCRIPT = path.resolve(__dirname, '../../scripts/build-codex-plugin.mjs');
13
+ let lib;
14
+ let claudeLib;
15
+ let work;
16
+ let tree;
17
+
18
+ function walk(root, base = root) {
19
+ return fs.readdirSync(root, { withFileTypes: true }).flatMap((entry) => {
20
+ const full = path.join(root, entry.name);
21
+ return entry.isDirectory() ? walk(full, base) : [path.relative(base, full)];
22
+ });
23
+ }
24
+
25
+ before(async () => {
26
+ lib = await import(SCRIPT);
27
+ claudeLib = await import(path.resolve(__dirname, '../../scripts/build-claude-plugin.mjs'));
28
+ work = fs.mkdtempSync(path.join(os.tmpdir(), 'ck-codex-plugin-build-'));
29
+ tree = lib.build(path.join(work, 'cf-codex'));
30
+ });
31
+
32
+ after(() => fs.rmSync(work, { recursive: true, force: true }));
33
+
34
+ test('inventory-and-policy: the Claude plugin skill list, Codex names, and explicit-only policies', () => {
35
+ const skills = fs.readdirSync(path.join(tree, 'skills')).sort();
36
+ // The Claude plugin's skills plus the Codex-only ones.
37
+ assert.deepEqual(skills, [...claudeLib.inScopeSkills(), ...lib.CODEX_ONLY_SKILLS].sort());
38
+ const { isExplicitOnlySkill, CODEX_POLICY_FILE, CODEX_EXPLICIT_ONLY_POLICY } = require('../phases/copy-payload');
39
+ for (const skill of skills) {
40
+ const text = fs.readFileSync(path.join(tree, 'skills', skill, 'SKILL.md'), 'utf8');
41
+ assert.match(text, new RegExp(`^---\\nname: cf-${skill}\\n`), `${skill} keeps the installer's Codex name`);
42
+ const policy = path.join(tree, 'skills', skill, CODEX_POLICY_FILE);
43
+ const skillSource = lib.CODEX_ONLY_SKILLS.includes(skill) ? path.join(SRC, 'codex/plugin/skills', skill) : path.join(SRC, 'claude/skills', skill);
44
+ if (isExplicitOnlySkill(skillSource)) {
45
+ assert.equal(fs.readFileSync(policy, 'utf8'), CODEX_EXPLICIT_ONLY_POLICY, `${skill} policy`);
46
+ } else {
47
+ assert.ok(!fs.existsSync(policy), `${skill} has an unexpected policy file`);
48
+ }
49
+ }
50
+ });
51
+
52
+ test('path-allowlist: Markdown reaches plugin files through {CAFEKIT_ROOT}, never an install path', () => {
53
+ const offenders = [];
54
+ for (const file of walk(tree).filter((f) => f.endsWith('.md'))) {
55
+ const text = fs.readFileSync(path.join(tree, file), 'utf8');
56
+ if (/\.codex[/\\](skills|references|scripts|rules|hooks|output-styles)\b/.test(text)) offenders.push(`${file}: .codex runtime path`);
57
+ if (/\.agents[/\\]skills\b/.test(text)) offenders.push(`${file}: .agents/skills path`);
58
+ if (/(?<!~\/)\.codex\/agents\//.test(text)) offenders.push(`${file}: project .codex/agents path`);
59
+ if (/\.claude[/\\](skills|references|scripts|rules)\b/.test(text)) offenders.push(`${file}: .claude runtime path`);
60
+ if (/\{CAFEKIT_ROOT\}\/scripts\//.test(text)) offenders.push(`${file}: script reached by path instead of {cafekit:<name>}`);
61
+ for (const [, rel] of text.matchAll(/\{CAFEKIT_ROOT\}\/([^\s`'")]+)/g)) {
62
+ const target = rel.replace(/[.,:;]+$/, '');
63
+ if (/[<*]/.test(target)) continue;
64
+ if (!fs.existsSync(path.join(tree, target))) offenders.push(`${file}: {CAFEKIT_ROOT}/${target} does not exist`);
65
+ }
66
+ }
67
+ assert.deepEqual(offenders, []);
68
+ const commands = lib.scriptCommands(tree);
69
+ assert.ok(commands.includes('provenance'), 'provenance is not in the command table');
70
+ for (const name of commands) assert.ok(fs.existsSync(path.join(tree, 'scripts', `${name}.cjs`)), `${name} has no script`);
71
+ });
72
+
73
+ test('manifest-and-marketplace: plugin.json and the repo marketplace carry the keys Codex reads', () => {
74
+ const manifest = JSON.parse(fs.readFileSync(path.join(tree, '.codex-plugin/plugin.json'), 'utf8'));
75
+ assert.equal(manifest.name, 'cf');
76
+ assert.equal(manifest.version, require('../../package.json').version);
77
+ for (const key of ['description', 'author', 'license', 'interface']) assert.ok(manifest[key], `plugin.json lacks ${key}`);
78
+ const market = lib.marketplace();
79
+ assert.equal(typeof market.name, 'string');
80
+ const entry = market.plugins.find((p) => p.name === 'cf');
81
+ assert.deepEqual(entry.source, { source: 'local', path: './plugins/cf-codex' });
82
+ assert.ok(entry.policy?.installation && entry.policy?.authentication && entry.category, 'entry lacks policy or category');
83
+ });
84
+
85
+ test('marketplace-not-ignored: git can commit .agents/plugins/marketplace.json', () => {
86
+ // --no-index: a tracked file would otherwise always read as not ignored.
87
+ const run = spawnSync('git', ['check-ignore', '-q', '--no-index', '.agents/plugins/marketplace.json'], { cwd: REPO });
88
+ assert.equal(run.status, 1, 'the marketplace file is ignored by git');
89
+ const skills = spawnSync('git', ['check-ignore', '-q', '--no-index', '.agents/skills/specs/SKILL.md'], { cwd: REPO });
90
+ assert.equal(skills.status, 0, 'npx-installed .agents/skills must stay ignored');
91
+ // A reinstall must not append `.agents/` after `!.agents/plugins/` and hide the marketplace again.
92
+ const { hasPattern } = require('../phases/root-config');
93
+ assert.ok(hasPattern(['.agents/*', '!.agents/plugins/'], '.agents/'));
94
+ });
95
+
96
+ test('claude-marketplace-guard: the Codex marketplace never points at the Claude plugin tree', () => {
97
+ const entry = lib.marketplace().plugins.find((p) => p.name === 'cf');
98
+ assert.notEqual(path.normalize(entry.source.path), path.normalize('./plugins/cf'));
99
+ assert.ok(!fs.existsSync(path.join(REPO, 'plugins/cf/.codex-plugin')), 'the Claude tree must not carry a Codex manifest');
100
+ });
101
+
102
+ test('check-detects-drift: --check fails after one byte changes in a fresh copy', () => {
103
+ const copy = lib.build(path.join(work, 'copy'));
104
+ const clean = spawnSync('node', [SCRIPT, '--check', copy], { encoding: 'utf8' });
105
+ assert.equal(clean.status, 0, clean.stderr);
106
+ fs.appendFileSync(path.join(copy, 'skills/specs/SKILL.md'), 'x');
107
+ const drift = spawnSync('node', [SCRIPT, '--check', copy], { encoding: 'utf8' });
108
+ assert.equal(drift.status, 1);
109
+ assert.match(drift.stderr, /changed skills\/specs\/SKILL\.md/);
110
+ });
111
+
112
+ // ── Runtime and hooks.json ─────────────────────────────────────────────────────
113
+
114
+ const RUNTIME_DIRS = ['hooks', 'rules', 'output-styles'];
115
+
116
+ function npxInstall() {
117
+ const repo = fs.mkdtempSync(path.join(work, 'npx-'));
118
+ spawnSync('git', ['init', '-q'], { cwd: repo });
119
+ const run = spawnSync('node', [path.resolve(__dirname, '../install.js'), '--platform', 'codex', '-y'], {
120
+ cwd: repo, encoding: 'utf8', env: { ...process.env, HOME: fs.mkdtempSync(path.join(work, 'home-')) },
121
+ });
122
+ assert.equal(run.status, 0, run.stderr || run.stdout);
123
+ return repo;
124
+ }
125
+
126
+ function runtimeFiles(root) {
127
+ return RUNTIME_DIRS.flatMap((dir) => (fs.existsSync(path.join(root, dir)) ? walk(path.join(root, dir)).map((f) => `${dir}/${f}`) : []))
128
+ .concat(fs.existsSync(path.join(root, 'runtime.schema.json')) ? ['runtime.schema.json'] : [])
129
+ .filter((f) => f !== 'hooks/hooks.json' && !f.includes('/.logs/'))
130
+ .sort();
131
+ }
132
+
133
+ test('runtime-matches-npx: the plugin runtime holds the same files and bytes as an npx Codex install', () => {
134
+ const installed = path.join(npxInstall(), '.codex');
135
+ const expected = runtimeFiles(installed);
136
+ assert.deepEqual(runtimeFiles(tree), expected);
137
+ for (const file of expected) {
138
+ assert.ok(fs.readFileSync(path.join(tree, file)).equals(fs.readFileSync(path.join(installed, file))), `${file} differs from the npx install`);
139
+ }
140
+ });
141
+
142
+ test('requires-resolve: every relative require in the plugin hooks resolves inside the tree', () => {
143
+ const missing = [];
144
+ for (const file of walk(path.join(tree, 'hooks')).filter((f) => f.endsWith('.cjs'))) {
145
+ const full = path.join(tree, 'hooks', file);
146
+ for (const [, spec] of fs.readFileSync(full, 'utf8').matchAll(/require\(\s*['"](\.{1,2}\/[^'"]+)['"]\s*\)/g)) {
147
+ const target = path.resolve(path.dirname(full), spec);
148
+ if (![target, `${target}.cjs`, `${target}.js`].some((t) => fs.existsSync(t))) missing.push(`hooks/${file} → ${spec}`);
149
+ }
150
+ }
151
+ assert.deepEqual(missing, []);
152
+ });
153
+
154
+ function pluginHandlers() {
155
+ const { hooks } = JSON.parse(fs.readFileSync(path.join(tree, 'hooks/hooks.json'), 'utf8'));
156
+ return Object.entries(hooks).flatMap(([event, groups]) => groups.flatMap((group) => group.hooks.map((h) => ({ event, ...h }))));
157
+ }
158
+
159
+ test('hook-commands-fallback: every plugin hook has a POSIX command with a node PATH fallback and a Windows command', () => {
160
+ const template = JSON.parse(fs.readFileSync(path.join(SRC, 'codex/hooks.json'), 'utf8')).hooks;
161
+ const handlers = pluginHandlers();
162
+ // The template's runtime hooks plus the one plugin-only SessionStart hook.
163
+ const pluginOnly = handlers.filter((h) => h.command.includes('/plugin/hooks/'));
164
+ assert.deepEqual(pluginOnly.map((h) => h.event), ['SessionStart']);
165
+ assert.equal(handlers.length - pluginOnly.length, Object.values(template).flat().flatMap((g) => g.hooks).length);
166
+ for (const handler of handlers) {
167
+ const [, dir, script] = handler.command.match(/((?:plugin\/)?hooks)\/([a-z0-9-]+\.cjs)"$/) || [];
168
+ assert.ok(script && fs.existsSync(path.join(tree, dir, script)), `bad command ${handler.command}`);
169
+ assert.match(handler.command, /^PATH="\$PATH":'[^']+'(:'[^']+')* node "\$\{PLUGIN_ROOT\}\/(plugin\/)?hooks\/[a-z0-9-]+\.cjs"$/);
170
+ assert.equal(typeof handler.commandWindows, 'string');
171
+ assert.ok(handler.commandWindows.includes(script) && handler.commandWindows.includes('PLUGIN_ROOT'), handler.commandWindows);
172
+ }
173
+ });
174
+
175
+ test('no-build-machine-paths: hooks.json names no absolute path of the machine that built it', () => {
176
+ const text = fs.readFileSync(path.join(tree, 'hooks/hooks.json'), 'utf8');
177
+ for (const local of [REPO, os.homedir(), path.dirname(process.execPath)]) assert.ok(!text.includes(local), `hooks.json contains ${local}`);
178
+ const windows = pluginHandlers()[0].commandWindows;
179
+ const decoded = spawnSync('node', ['-e', `${windows.replace(/^node -e "|"$/g, '').replace(/require\('module'\)\.runMain\(\)/, 'console.log(process.argv[1])')}`], {
180
+ encoding: 'utf8', env: { ...process.env, PLUGIN_ROOT: path.join(work, 'win-root') },
181
+ });
182
+ assert.equal(decoded.stdout.trim(), path.join(work, 'win-root', 'hooks', pluginHandlers()[0].command.match(/hooks\/([a-z0-9-]+\.cjs)"$/)[1]));
183
+ });
184
+
185
+ test('npx-hook-commands-unchanged: the npx install still gets absolute project launchers', () => {
186
+ const repo = npxInstall();
187
+ const { hooks } = JSON.parse(fs.readFileSync(path.join(repo, '.codex/hooks.json'), 'utf8'));
188
+ for (const handler of Object.values(hooks).flat().flatMap((g) => g.hooks)) {
189
+ assert.match(handler.command, /node '[^']+\/\.codex\/hooks\/[a-z0-9-]+\.cjs'$/);
190
+ assert.ok(handler.command.includes(fs.realpathSync(repo)));
191
+ assert.ok(!handler.command.includes('PLUGIN_ROOT'));
192
+ const encoded = handler.commandWindows.split(' ').pop();
193
+ assert.ok(Buffer.from(encoded, 'base64url').toString('utf8').startsWith(path.join(fs.realpathSync(repo), '.codex', 'hooks')), 'npx Windows launcher lost its project path');
194
+ }
195
+ });
196
+
197
+ const SAMPLE_PAYLOADS = {
198
+ SessionStart: { source: 'startup' },
199
+ UserPromptSubmit: { prompt: 'hello' },
200
+ PreToolUse: { tool_name: 'Bash', tool_input: { command: 'ls' } },
201
+ PermissionRequest: { tool_name: 'Bash', tool_input: { command: 'ls' } },
202
+ PostToolUse: { tool_name: 'apply_patch', tool_input: { command: '' }, tool_response: {} },
203
+ Stop: { stop_hook_active: false },
204
+ SubagentStart: { agent_type: 'debugger' },
205
+ SubagentStop: { agent_type: 'debugger', stop_hook_active: false },
206
+ PreCompact: { trigger: 'manual' },
207
+ };
208
+
209
+ test('hook-smoke: every generated plugin launcher runs its hook to exit 0 without a crash log', () => {
210
+ // A private root holding the tree, so every crash log a hook writes lands under it.
211
+ const sandbox = fs.mkdtempSync(path.join(work, 'smoke-'));
212
+ const smokeTree = lib.build(path.join(sandbox, 'plugin', 'cf-codex'));
213
+ const repo = path.join(sandbox, 'repo');
214
+ fs.mkdirSync(repo);
215
+ spawnSync('git', ['init', '-q'], { cwd: repo });
216
+ const data = path.join(sandbox, 'data');
217
+ const failures = [];
218
+ for (const handler of pluginHandlers()) {
219
+ const payload = { session_id: 'codex-smoke', hook_event_name: handler.event, cwd: repo, ...(SAMPLE_PAYLOADS[handler.event] || {}) };
220
+ const run = spawnSync('sh', ['-c', handler.command], {
221
+ cwd: repo, input: JSON.stringify(payload), encoding: 'utf8', timeout: 20000,
222
+ env: { ...process.env, HOME: path.join(sandbox, 'home'), PLUGIN_ROOT: smokeTree, PLUGIN_DATA: data, CLAUDE_PLUGIN_ROOT: smokeTree, CLAUDE_PLUGIN_DATA: data },
223
+ });
224
+ if (run.status !== 0) failures.push(`${handler.event} ${handler.command}: exit ${run.status} ${run.stderr.slice(0, 200)}`);
225
+ }
226
+ const crashLogs = walk(sandbox).filter((f) => f.endsWith('hook-log.jsonl'))
227
+ .filter((f) => /"status":"crash"/.test(fs.readFileSync(path.join(sandbox, f), 'utf8')));
228
+ for (const log of crashLogs) failures.push(`crash logged in ${log}: ${fs.readFileSync(path.join(sandbox, log), 'utf8').slice(0, 200)}`);
229
+ assert.deepEqual(failures, []);
230
+ });
@@ -0,0 +1,135 @@
1
+ 'use strict';
2
+
3
+ // The Codex plugin cannot ship AGENTS.md and Codex substitutes nothing in skill text, so
4
+ // a plugin-only SessionStart hook injects the CafeKit core and defines {CAFEKIT_ROOT}
5
+ // and the {cafekit:<name>} commands. The npx install must never receive that hook.
6
+
7
+ const { test, before, after } = require('node:test');
8
+ const assert = require('node:assert/strict');
9
+ const fs = require('node:fs');
10
+ const os = require('node:os');
11
+ const path = require('node:path');
12
+ const { spawnSync } = require('node:child_process');
13
+
14
+ const SCRIPTS = path.resolve(__dirname, '../../scripts');
15
+ const INSTALL = path.resolve(__dirname, '../install.js');
16
+ const ROOT_LENGTH = 120;
17
+ let work;
18
+ let lib;
19
+ let tree;
20
+
21
+ before(async () => {
22
+ work = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'ck-codex-context-')));
23
+ lib = await import(path.join(SCRIPTS, 'build-codex-plugin.mjs'));
24
+ // A plugin root of exactly ROOT_LENGTH characters, the size the core budget assumes.
25
+ const pad = ROOT_LENGTH - work.length - '/cf-codex'.length - 1;
26
+ assert.ok(pad > 0, `temp directory too long: ${work}`);
27
+ tree = lib.build(path.join(work, 'p'.repeat(pad), 'cf-codex'));
28
+ assert.equal(tree.length, ROOT_LENGTH);
29
+ });
30
+
31
+ after(() => fs.rmSync(work, { recursive: true, force: true }));
32
+
33
+ function hook(root, { cwd = work, env = {} } = {}) {
34
+ const base = { ...process.env, HOME: path.join(work, 'home'), PLUGIN_DATA: path.join(work, 'data'), CLAUDE_PLUGIN_DATA: path.join(work, 'data') };
35
+ delete base.PLUGIN_ROOT; delete base.CLAUDE_PLUGIN_ROOT;
36
+ const fullEnv = { ...base, ...(root ? { PLUGIN_ROOT: root, CLAUDE_PLUGIN_ROOT: root } : {}), ...env };
37
+ const script = path.join(root || tree, 'plugin/hooks/plugin-context.cjs');
38
+ return spawnSync('node', [script], { cwd, encoding: 'utf8', env: fullEnv, input: JSON.stringify({ session_id: 'ctx', hook_event_name: 'SessionStart', source: 'startup', cwd }) });
39
+ }
40
+
41
+ function context(root = tree, options) {
42
+ const result = hook(root, options);
43
+ assert.equal(result.status, 0, result.stderr);
44
+ return JSON.parse(result.stdout).hookSpecificOutput.additionalContext;
45
+ }
46
+
47
+ test('emits-session-start-json: the hook prints the SessionStart JSON Codex reads, registered for every source', () => {
48
+ const result = hook(tree);
49
+ assert.equal(result.status, 0, result.stderr);
50
+ const output = JSON.parse(result.stdout);
51
+ assert.equal(output.hookSpecificOutput.hookEventName, 'SessionStart');
52
+ assert.equal(typeof output.hookSpecificOutput.additionalContext, 'string');
53
+ const { hooks } = JSON.parse(fs.readFileSync(path.join(tree, 'hooks/hooks.json'), 'utf8'));
54
+ const group = hooks.SessionStart.find((g) => g.hooks.some((h) => h.command.includes('/plugin/hooks/plugin-context.cjs')));
55
+ assert.ok(group, 'plugin-context is not registered on SessionStart');
56
+ assert.deepEqual(group.matcher.split('|').sort(), ['clear', 'compact', 'resume', 'startup']);
57
+ assert.match(group.hooks[0].commandWindows, /'plugin','hooks','plugin-context\.cjs'/);
58
+ });
59
+
60
+ test('core-under-10000-chars: with a 120-character plugin root the injected text stays within the host cap', () => {
61
+ const text = context();
62
+ assert.ok(text.length <= 10000, `${text.length} characters`);
63
+ });
64
+
65
+ test('marker-in-first-2000: the core marker sits in the first 2000 characters', () => {
66
+ const index = context().indexOf('CAFEKIT-CODEX-CORE-LOADED');
67
+ assert.ok(index >= 0 && index < 2000, `marker at ${index}`);
68
+ });
69
+
70
+ test('command-table-complete: every recorded script has one absolute command that runs', () => {
71
+ const text = context();
72
+ const names = lib.scriptCommands(tree);
73
+ assert.ok(names.length > 0);
74
+ const table = [...text.matchAll(/^ {2}- `\{cafekit:([A-Za-z0-9-]+)\}`: `(.+)`$/gm)];
75
+ assert.deepEqual(table.map(([, name]) => name), names);
76
+ assert.ok(!/\{cafekit:[A-Za-z0-9-]+\}/.test(text.replace(/^ {2}- `\{cafekit:[^\n]*$/gm, '')), 'a command is named outside the table');
77
+ const scratch = fs.mkdtempSync(path.join(work, 'run-'));
78
+ for (const [, name, command] of table) {
79
+ assert.match(command, new RegExp(`^node '${tree.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}/scripts/${name}\\.cjs'$`));
80
+ const run = spawnSync('sh', ['-c', `${command} --help`], { cwd: scratch, encoding: 'utf8', input: '' });
81
+ assert.ok([0, 1, 2].includes(run.status), `${name}: exit ${run.status}`);
82
+ assert.doesNotMatch(run.stderr, /Cannot find module|MODULE_NOT_FOUND/, name);
83
+ }
84
+ });
85
+
86
+ test('rule-paths-exist: the root, the rules directory and every rules file named in the core exist', () => {
87
+ const text = context();
88
+ assert.match(text, new RegExp(`means \`${tree.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\``));
89
+ const section = text.slice(text.indexOf('## Detailed rules'));
90
+ const dir = section.match(/`([^`]+\/rules)\/`/)[1];
91
+ assert.equal(dir, path.join(tree, 'rules'));
92
+ const files = [...section.matchAll(/`([a-z-]+\.md)`/g)].map(([, f]) => f);
93
+ assert.ok(files.length >= 9, files.join(', '));
94
+ for (const file of files) assert.ok(fs.existsSync(path.join(dir, file)), file);
95
+ assert.doesNotMatch(text, /\{CAFEKIT_ROOT\}\//, 'an unresolved root token remains');
96
+ });
97
+
98
+ test('dollar-in-root: a plugin root containing `$&` is injected literally', () => {
99
+ const root = path.join(work, 'dollar-$&-root', 'cf-codex');
100
+ fs.cpSync(tree, root, { recursive: true });
101
+ const text = context(root);
102
+ assert.ok(text.includes(`means \`${root}\``));
103
+ assert.ok(text.includes(`node '${root}/scripts/provenance.cjs'`));
104
+ });
105
+
106
+ test('silent-without-plugin-root: without a plugin root, or with one inherited from elsewhere, the hook prints nothing', () => {
107
+ for (const env of [{}, { PLUGIN_ROOT: work, CLAUDE_PLUGIN_ROOT: work }]) {
108
+ const result = hook(null, { env });
109
+ assert.equal(result.status, 0, result.stderr);
110
+ assert.equal(result.stdout, '');
111
+ }
112
+ });
113
+
114
+ function walk(root) {
115
+ if (!fs.existsSync(root)) return [];
116
+ return fs.readdirSync(root, { withFileTypes: true }).flatMap((entry) => {
117
+ const full = path.join(root, entry.name);
118
+ return entry.isDirectory() ? walk(full) : [full];
119
+ });
120
+ }
121
+
122
+ test('npx-install-lacks-plugin-hook: an npx Codex install neither ships nor calls the plugin hook, and the plugin stands down beside it', () => {
123
+ const repo = path.join(work, 'npx-repo');
124
+ fs.mkdirSync(repo);
125
+ spawnSync('git', ['init', '-q'], { cwd: repo });
126
+ const install = spawnSync('node', [INSTALL, '--platform', 'codex', '-y'], { cwd: repo, encoding: 'utf8', env: { ...process.env, HOME: path.join(work, 'npx-home') } });
127
+ assert.equal(install.status, 0, install.stderr || install.stdout);
128
+ const installed = walk(repo).filter((f) => !f.includes(`${path.sep}.git${path.sep}`));
129
+ assert.deepEqual(installed.filter((f) => /plugin-context\.cjs$|script-commands\.json$/.test(f)), []);
130
+ assert.ok(!installed.some((f) => fs.readFileSync(f).includes('CAFEKIT-CODEX-CORE-LOADED')), 'the plugin core reached the npx install');
131
+ assert.doesNotMatch(fs.readFileSync(path.join(repo, '.codex/hooks.json'), 'utf8'), /plugin-context/);
132
+ const beside = hook(tree, { cwd: repo });
133
+ assert.equal(beside.status, 0, beside.stderr);
134
+ assert.equal(beside.stdout, '');
135
+ });