@haposoft/cafekit 0.15.1 → 0.16.0-rc.1
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 +6 -2
- package/bin/__tests__/codex-hooks.test.js +140 -0
- package/bin/__tests__/codex-native.test.js +230 -2
- package/bin/__tests__/develop-contract.test.js +1120 -0
- package/bin/__tests__/installer-safety.test.js +553 -2
- package/bin/__tests__/package-inventory.test.js +258 -0
- package/bin/__tests__/secret-scanner.test.js +157 -0
- package/bin/__tests__/validator-grounding.test.js +713 -0
- package/bin/install.js +5 -1
- package/bin/lib/codex-install.js +21 -6
- package/bin/lib/context.js +17 -6
- package/bin/lib/copy-utils.js +31 -0
- package/bin/lib/i18n.js +12 -3
- package/bin/lib/instruction-blocks.js +85 -0
- package/bin/lib/managed-writer.js +4 -1
- package/bin/lib/opencode-install.js +75 -30
- package/bin/lib/skill-deps.js +45 -7
- package/bin/phases/claude-runtime.js +103 -4
- package/bin/phases/codex-runtime.js +41 -7
- package/bin/phases/post-install.js +29 -22
- package/bin/phases/root-config.js +89 -10
- package/bin/phases/select-platform.js +23 -0
- package/bin/phases/skills-setup.js +7 -3
- package/package.json +15 -5
- package/src/claude/CLAUDE.md +6 -122
- package/src/claude/agents/code-auditor.md +21 -22
- package/src/claude/agents/debugger.md +3 -3
- package/src/claude/agents/deployer.md +1 -1
- package/src/claude/agents/{god-developer.md → implementer.md} +2 -2
- package/src/claude/agents/project-manager.md +1 -1
- package/src/claude/agents/spec-maker.md +2 -2
- package/src/claude/hooks/__tests__/spec-gate.test.js +356 -5
- package/src/claude/hooks/agent.cjs +7 -14
- package/src/claude/hooks/rules.cjs +33 -53
- package/src/claude/hooks/spec-gate.cjs +66 -21
- package/src/claude/hooks/spec-state.cjs +5 -0
- package/src/claude/migration-manifest.json +9 -2
- package/src/claude/runtime.json +0 -3
- package/src/claude/scripts/generate-skill-catalog.cjs +1 -1
- package/src/claude/scripts/scan-staged-secrets.cjs +170 -0
- package/src/claude/scripts/spec-ground.cjs +189 -83
- package/src/claude/scripts/validate-spec-output.cjs +294 -30
- package/src/claude/scripts/workflow-policy.cjs +609 -0
- package/src/claude/skills/ai-multimodal/SKILL.md +2 -2
- package/src/claude/skills/ai-multimodal/references/audio-processing.md +6 -6
- package/src/claude/skills/ai-multimodal/references/video-analysis.md +1 -1
- package/src/claude/skills/backend-development/SKILL.md +4 -4
- package/src/claude/skills/backend-development/references/backend-architecture.md +1 -1
- package/src/claude/skills/backend-development/references/backend-devops.md +2 -2
- package/src/claude/skills/backend-development/references/backend-mindset.md +3 -3
- package/src/claude/skills/backend-development/references/backend-performance.md +5 -5
- package/src/claude/skills/backend-development/references/backend-security.md +3 -3
- package/src/claude/skills/backend-development/references/backend-technologies.md +5 -5
- package/src/claude/skills/backend-development/references/backend-testing.md +2 -2
- package/src/claude/skills/brainstorm/SKILL.md +2 -3
- package/src/claude/skills/chrome-devtools/scripts/package-lock.json +261 -150
- package/src/claude/skills/chrome-devtools/scripts/package.json +1 -1
- package/src/claude/skills/code-review/SKILL.md +12 -8
- package/src/claude/skills/code-review/references/verification-gate.md +4 -4
- package/src/claude/skills/debug/SKILL.md +2 -0
- package/src/claude/skills/develop/SKILL.md +100 -52
- package/src/claude/skills/develop/references/parallel-waves.md +105 -26
- package/src/claude/skills/develop/references/quality-gate.md +58 -38
- package/src/claude/skills/develop/references/subagent-patterns.md +2 -2
- package/src/claude/skills/git/SKILL.md +44 -50
- package/src/claude/skills/hotfix/SKILL.md +10 -8
- package/src/claude/skills/hotfix/references/parallel-patterns.md +2 -2
- package/src/claude/skills/hotfix/references/review-cycle.md +26 -21
- package/src/claude/skills/inspect/SKILL.md +12 -30
- package/src/claude/skills/mobile-development/SKILL.md +12 -12
- package/src/claude/skills/mobile-development/references/mobile-android.md +7 -7
- package/src/claude/skills/mobile-development/references/mobile-best-practices.md +22 -23
- package/src/claude/skills/mobile-development/references/mobile-frameworks.md +13 -13
- package/src/claude/skills/mobile-development/references/mobile-ios.md +1 -1
- package/src/claude/skills/mobile-development/references/mobile-mindset.md +17 -19
- package/src/claude/skills/pptx/scripts/package-lock.json +868 -0
- package/src/claude/skills/pptx/scripts/package.json +1 -1
- package/src/claude/skills/question/SKILL.md +13 -13
- package/src/claude/skills/research/SKILL.md +2 -2
- package/src/claude/skills/specs/SKILL.md +32 -15
- package/src/claude/skills/specs/references/review.md +3 -3
- package/src/claude/skills/specs/rules/tasks-generation.md +1 -1
- package/src/claude/skills/specs/templates/spec-state.json +7 -3
- package/src/claude/skills/sync/SKILL.md +26 -1
- package/src/claude/skills/sync/references/sync-protocols.md +3 -3
- package/src/claude/skills/test/SKILL.md +12 -4
- package/src/claude/skills/test/references/failure-triage.md +17 -17
- package/src/claude/skills/test/references/test-memory.md +2 -2
- package/src/codex/AGENTS.md +16 -24
- package/src/codex/hooks/lib/spec-receipt.cjs +57 -4
- package/src/codex/hooks/rules.cjs +27 -26
- package/src/codex/hooks/spec-gate.cjs +19 -8
- package/src/codex/hooks/spec-state.cjs +10 -1
- package/src/codex/hooks.json +14 -14
- package/src/common/AGENTS.md +43 -0
- package/src/opencode/AGENTS.md +21 -121
- package/src/opencode/plugins/rules.ts +26 -34
- package/src/opencode/plugins/spec-gate.ts +283 -0
- package/src/claude/skills/inspect/references/external-gemini-inspection.md +0 -169
package/README.md
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
> Native spec-driven workflow and runtime bundle for Claude Code, Codex CLI, and OpenCode.
|
|
4
4
|
|
|
5
|
-
[](https://github.com/haposoft/cafekit)
|
|
6
6
|
[](LICENSE)
|
|
7
7
|
[](https://claude.ai/code)
|
|
8
8
|
[](https://developers.openai.com/codex)
|
|
@@ -68,8 +68,10 @@ On install, CafeKit updates the **project-root** `.gitignore` with:
|
|
|
68
68
|
```text
|
|
69
69
|
# CafeKit / Ecosystem
|
|
70
70
|
specs/_shared/
|
|
71
|
-
plans
|
|
71
|
+
plans/*
|
|
72
|
+
!plans/*.md
|
|
72
73
|
!plans/templates/
|
|
74
|
+
!plans/templates/**
|
|
73
75
|
.cafekit-backup/
|
|
74
76
|
.cafekit.lock
|
|
75
77
|
.claude/
|
|
@@ -134,6 +136,8 @@ AGENTS.md
|
|
|
134
136
|
CafeKit owns only its marked block in root `AGENTS.md`. Codex discovers skills
|
|
135
137
|
and custom agents natively after the repository is trusted; no project
|
|
136
138
|
`config.toml` is generated. Review and trust project hooks with `/hooks`.
|
|
139
|
+
On Windows, installed hook commands are bound to the canonical project path
|
|
140
|
+
without requiring Git and remain stable when a session starts in a subdirectory.
|
|
137
141
|
CafeKit uses Codex's native status and usage UI instead of installing the
|
|
138
142
|
Claude statusline.
|
|
139
143
|
|
|
@@ -27,6 +27,10 @@ function inHookFixture(run) {
|
|
|
27
27
|
try {
|
|
28
28
|
fs.cpSync(CODEX_HOOKS, hooks, { recursive: true });
|
|
29
29
|
fs.mkdirSync(path.join(root, '.codex', 'scripts'), { recursive: true });
|
|
30
|
+
fs.copyFileSync(
|
|
31
|
+
path.join(PACKAGE_ROOT, 'src/claude/scripts/workflow-policy.cjs'),
|
|
32
|
+
path.join(root, '.codex', 'scripts', 'workflow-policy.cjs'),
|
|
33
|
+
);
|
|
30
34
|
fs.writeFileSync(path.join(root, '.codex', 'scripts', 'spec-scaffold.cjs'), '');
|
|
31
35
|
return run(root, hooks);
|
|
32
36
|
} finally {
|
|
@@ -329,3 +333,139 @@ test('Codex state hook persists direct native event fields at project root', ()
|
|
|
329
333
|
assert.equal(codexStateDir(root, ''), null);
|
|
330
334
|
});
|
|
331
335
|
});
|
|
336
|
+
|
|
337
|
+
test('Codex canonical receipt provenance requires both Base and Head', () => {
|
|
338
|
+
const receipt = require(path.join(CODEX_HOOKS, 'lib', 'spec-receipt.cjs'));
|
|
339
|
+
const onlyBase = receipt.validateCanonicalReceipt('Verification: PASS\nCommand: pnpm test\nExit: 0\nBase: abc\n');
|
|
340
|
+
assert.ok(onlyBase.includes('provenance'), 'only Base should fail');
|
|
341
|
+
const onlyHead = receipt.validateCanonicalReceipt('Verification: PASS\nCommand: pnpm test\nExit: 0\nHead: def\n');
|
|
342
|
+
assert.ok(onlyHead.includes('provenance'), 'only Head should fail');
|
|
343
|
+
const both = receipt.validateCanonicalReceipt('Verification: PASS\nCommand: pnpm test\nExit: 0\nBase: abc\nHead: def\n');
|
|
344
|
+
assert.deepEqual(both, [], 'both Base and Head should pass');
|
|
345
|
+
const onlyBaseSha = receipt.validateCanonicalReceipt('Verification: PASS\nCommand: pnpm test\nExit: 0\nbase_sha: abc\n');
|
|
346
|
+
assert.ok(onlyBaseSha.includes('provenance'));
|
|
347
|
+
const bothSha = receipt.validateCanonicalReceipt('Verification: PASS\nCommand: pnpm test\nExit: 0\nbase_sha: abc\nhead_sha: def\n');
|
|
348
|
+
assert.deepEqual(bothSha, [], 'both base_sha and head_sha should pass');
|
|
349
|
+
// Empty / bare provenance must fail — non-empty same-line value required
|
|
350
|
+
const emptyBase = receipt.validateCanonicalReceipt('Verification: PASS\nCommand: pnpm test\nExit: 0\nBase:\nHead: def\n');
|
|
351
|
+
assert.ok(emptyBase.includes('provenance'), 'empty Base: should fail');
|
|
352
|
+
const spacesBase = receipt.validateCanonicalReceipt('Verification: PASS\nCommand: pnpm test\nExit: 0\nBase: \nHead: def\n');
|
|
353
|
+
assert.ok(spacesBase.includes('provenance'), 'Base: spaces only should fail');
|
|
354
|
+
const emptyHead = receipt.validateCanonicalReceipt('Verification: PASS\nCommand: pnpm test\nExit: 0\nBase: abc\nHead:\n');
|
|
355
|
+
assert.ok(emptyHead.includes('provenance'), 'empty Head: should fail');
|
|
356
|
+
const bareBaseSha = receipt.validateCanonicalReceipt('Verification: PASS\nCommand: pnpm test\nExit: 0\nbase_sha\nhead_sha: def\n');
|
|
357
|
+
assert.ok(bareBaseSha.includes('provenance'), 'bare base_sha without colon/value should fail');
|
|
358
|
+
const emptyBaseSha = receipt.validateCanonicalReceipt('Verification: PASS\nCommand: pnpm test\nExit: 0\nbase_sha:\nhead_sha: def\n');
|
|
359
|
+
assert.ok(emptyBaseSha.includes('provenance'), 'empty base_sha: should fail');
|
|
360
|
+
const spacesBaseSha = receipt.validateCanonicalReceipt('Verification: PASS\nCommand: pnpm test\nExit: 0\nbase_sha: \nhead_sha: def\n');
|
|
361
|
+
assert.ok(spacesBaseSha.includes('provenance'), 'base_sha spaces only should fail');
|
|
362
|
+
const bareBoth = receipt.validateCanonicalReceipt('Verification: PASS\nCommand: pnpm test\nExit: 0\nbase_sha head_sha\n');
|
|
363
|
+
assert.ok(bareBoth.includes('provenance'), 'bare base_sha head_sha without colon should fail');
|
|
364
|
+
const validBaseHead = receipt.validateCanonicalReceipt('Verification: PASS\nCommand: pnpm test\nExit: 0\nBase: abc123\nHead: def456\n');
|
|
365
|
+
assert.deepEqual(validBaseHead, [], 'valid Base+Head with values should pass');
|
|
366
|
+
const validSha = receipt.validateCanonicalReceipt('Verification: PASS\nCommand: pnpm test\nExit: 0\nbase_sha: abc123\nhead_sha: def456\n');
|
|
367
|
+
assert.deepEqual(validSha, [], 'valid base_sha+head_sha with values should pass');
|
|
368
|
+
|
|
369
|
+
// Integration via spec-gate hook: only Base blocks, both passes
|
|
370
|
+
inHookFixture((root, hooks) => {
|
|
371
|
+
const feature = path.join(root, 'specs', 'auth');
|
|
372
|
+
const taskPath = 'tasks/task-R0-01-auth.md';
|
|
373
|
+
fs.mkdirSync(path.join(feature, 'tasks'), { recursive: true });
|
|
374
|
+
const gate = path.join(hooks, 'spec-gate.cjs');
|
|
375
|
+
const payload = { cwd: path.join(root, 'packages', 'app'), session_id: 'session-a', hook_event_name: 'Stop', stop_hook_active: false };
|
|
376
|
+
|
|
377
|
+
// only Base -> block
|
|
378
|
+
fs.writeFileSync(path.join(feature, 'spec.json'), JSON.stringify({ status: 'in_progress', task_registry: { [taskPath]: { status: 'done', completed_at: '2026-07-29T10:00:00.000Z' } } }));
|
|
379
|
+
fs.writeFileSync(path.join(feature, taskPath), 'Status: done\n\n## Evidence\n\nVerification: PASS\nCommand: pnpm test\nExit: 0\nBase: abc\n');
|
|
380
|
+
fs.mkdirSync(payload.cwd, { recursive: true });
|
|
381
|
+
const blocked = runHook(gate, payload.cwd, payload);
|
|
382
|
+
assert.equal(JSON.parse(blocked.stdout).decision, 'block');
|
|
383
|
+
assert.match(JSON.parse(blocked.stdout).reason, /\bg\b|\bprovenance\b/);
|
|
384
|
+
|
|
385
|
+
// both -> no block
|
|
386
|
+
fs.writeFileSync(path.join(feature, taskPath), 'Status: done\n\n## Evidence\n\nVerification: PASS\nCommand: pnpm test\nExit: 0\nBase: abc\nHead: def\n');
|
|
387
|
+
const passed = runHook(gate, payload.cwd, payload);
|
|
388
|
+
assert.equal(passed.stdout, '');
|
|
389
|
+
|
|
390
|
+
// empty Base: should block provenance — reset gate cache so re-checked as newly-done
|
|
391
|
+
try { fs.rmSync(path.join(root, '.codex', 'hooks', '.logs', 'spec-gate-last.json'), { force: true }); } catch {}
|
|
392
|
+
fs.writeFileSync(path.join(feature, taskPath), 'Status: done\n\n## Evidence\n\nVerification: PASS\nCommand: pnpm test\nExit: 0\nBase:\nHead: def\n');
|
|
393
|
+
const blockedEmpty = runHook(gate, payload.cwd, payload);
|
|
394
|
+
assert.equal(JSON.parse(blockedEmpty.stdout).decision, 'block');
|
|
395
|
+
assert.match(JSON.parse(blockedEmpty.stdout).reason, /\bg\b/);
|
|
396
|
+
|
|
397
|
+
// bare base_sha without colon should block — reset cache again
|
|
398
|
+
try { fs.rmSync(path.join(root, '.codex', 'hooks', '.logs', 'spec-gate-last.json'), { force: true }); } catch {}
|
|
399
|
+
fs.writeFileSync(path.join(feature, taskPath), 'Status: done\n\n## Evidence\n\nVerification: PASS\nCommand: pnpm test\nExit: 0\nbase_sha head_sha\n');
|
|
400
|
+
const blockedBare = runHook(gate, payload.cwd, payload);
|
|
401
|
+
assert.equal(JSON.parse(blockedBare.stdout).decision, 'block');
|
|
402
|
+
assert.match(JSON.parse(blockedBare.stdout).reason, /\bg\b/);
|
|
403
|
+
});
|
|
404
|
+
});
|
|
405
|
+
|
|
406
|
+
test('Codex cache hardening: every done re-validated — mutation, deletion, malformed cache, unchanged valid', () => {
|
|
407
|
+
inHookFixture((root, hooks) => {
|
|
408
|
+
const feature = path.join(root, 'specs', 'auth');
|
|
409
|
+
const taskPath = 'tasks/task-R0-01-auth.md';
|
|
410
|
+
fs.mkdirSync(path.join(feature, 'tasks'), { recursive: true });
|
|
411
|
+
const gate = path.join(hooks, 'spec-gate.cjs');
|
|
412
|
+
const appCwd = path.join(root, 'packages', 'app');
|
|
413
|
+
fs.mkdirSync(appCwd, { recursive: true });
|
|
414
|
+
const payload = { cwd: appCwd, session_id: 'session-a', hook_event_name: 'Stop', stop_hook_active: false };
|
|
415
|
+
const cacheFile = path.join(root, '.codex', 'hooks', '.logs', 'spec-gate-last.json');
|
|
416
|
+
const validBody = 'Status: done\n\n## Evidence\n\nVerification: PASS\nCommand: pnpm test\nExit: 0\nBase: abc123\nHead: def456\n```\npass\n```\n';
|
|
417
|
+
|
|
418
|
+
// first-run (no cache) with valid receipt → no block, cache seeded
|
|
419
|
+
try { fs.rmSync(cacheFile, { force: true }); } catch {}
|
|
420
|
+
fs.writeFileSync(path.join(feature, 'spec.json'), JSON.stringify({ status: 'in_progress', task_registry: { [taskPath]: { status: 'done', completed_at: '2026-07-29T10:00:00.000Z' } } }));
|
|
421
|
+
fs.writeFileSync(path.join(feature, taskPath), validBody);
|
|
422
|
+
let res = runHook(gate, appCwd, payload);
|
|
423
|
+
assert.equal(res.stdout, '', 'first-run valid should not block');
|
|
424
|
+
assert.ok(fs.existsSync(cacheFile), 'cache file must be created on first valid run');
|
|
425
|
+
assert.equal(JSON.parse(fs.readFileSync(cacheFile, 'utf8')).auth[taskPath], 'done');
|
|
426
|
+
|
|
427
|
+
// cache-hit unchanged valid → still no block (revalidation passes)
|
|
428
|
+
res = runHook(gate, appCwd, payload);
|
|
429
|
+
assert.equal(res.stdout, '', 'cache-hit unchanged valid must not block');
|
|
430
|
+
|
|
431
|
+
// mutation: remove Command/Verification → must block even though cache says done
|
|
432
|
+
fs.writeFileSync(path.join(feature, taskPath), 'Status: done\n\n## Evidence\n\nExit: 0\nBase: abc123\nHead: def456\n```\npass\n```\n');
|
|
433
|
+
res = runHook(gate, appCwd, payload);
|
|
434
|
+
assert.equal(JSON.parse(res.stdout).decision, 'block', 'mutated receipt missing Verification/Command should block on cache-hit');
|
|
435
|
+
assert.match(JSON.parse(res.stdout).reason, /\bc\b|\be\b/);
|
|
436
|
+
// second hit still blocks
|
|
437
|
+
res = runHook(gate, appCwd, payload);
|
|
438
|
+
assert.equal(JSON.parse(res.stdout).decision, 'block', 'second hit after mutation still blocks');
|
|
439
|
+
|
|
440
|
+
// restore valid, then mutate provenance (remove Head) → block
|
|
441
|
+
fs.writeFileSync(path.join(feature, taskPath), validBody);
|
|
442
|
+
assert.equal(runHook(gate, appCwd, payload).stdout, '', 'restored valid should pass again');
|
|
443
|
+
fs.writeFileSync(path.join(feature, taskPath), 'Status: done\n\n## Evidence\n\nVerification: PASS\nCommand: pnpm test\nExit: 0\nBase: abc123\n```\npass\n```\n');
|
|
444
|
+
res = runHook(gate, appCwd, payload);
|
|
445
|
+
assert.equal(JSON.parse(res.stdout).decision, 'block');
|
|
446
|
+
assert.match(JSON.parse(res.stdout).reason, /\bg\b/);
|
|
447
|
+
|
|
448
|
+
// deletion → block with check a
|
|
449
|
+
fs.writeFileSync(path.join(feature, taskPath), validBody);
|
|
450
|
+
assert.equal(runHook(gate, appCwd, payload).stdout, '');
|
|
451
|
+
fs.unlinkSync(path.join(feature, taskPath));
|
|
452
|
+
res = runHook(gate, appCwd, payload);
|
|
453
|
+
assert.equal(JSON.parse(res.stdout).decision, 'block');
|
|
454
|
+
assert.match(JSON.parse(res.stdout).reason, /\ba\b/);
|
|
455
|
+
// restore for next sub-test
|
|
456
|
+
fs.writeFileSync(path.join(feature, taskPath), validBody);
|
|
457
|
+
try { fs.rmSync(cacheFile, { force: true }); } catch {}
|
|
458
|
+
assert.equal(runHook(gate, appCwd, payload).stdout, '');
|
|
459
|
+
|
|
460
|
+
// malformed cache with valid receipt → must still pass (cache parse fail-open)
|
|
461
|
+
fs.writeFileSync(cacheFile, '{ malformed');
|
|
462
|
+
fs.writeFileSync(path.join(feature, taskPath), validBody);
|
|
463
|
+
res = runHook(gate, appCwd, payload);
|
|
464
|
+
assert.equal(res.stdout, '', 'malformed cache with valid receipt must not block');
|
|
465
|
+
// malformed cache with invalid receipt → must still block
|
|
466
|
+
fs.writeFileSync(cacheFile, '{ malformed again');
|
|
467
|
+
fs.writeFileSync(path.join(feature, taskPath), 'Status: done\n');
|
|
468
|
+
res = runHook(gate, appCwd, payload);
|
|
469
|
+
assert.equal(JSON.parse(res.stdout).decision, 'block', 'malformed cache with invalid receipt must block');
|
|
470
|
+
});
|
|
471
|
+
});
|
|
@@ -18,6 +18,7 @@ const {
|
|
|
18
18
|
resolvePlatforms,
|
|
19
19
|
selectLanguage
|
|
20
20
|
} = require('../phases/select-platform');
|
|
21
|
+
const { MESSAGES } = require('../lib/i18n');
|
|
21
22
|
|
|
22
23
|
const PACKAGE_ROOT = path.join(__dirname, '../..');
|
|
23
24
|
const PACKAGE_VERSION = JSON.parse(
|
|
@@ -59,6 +60,12 @@ function allFiles(root, predicate) {
|
|
|
59
60
|
return found;
|
|
60
61
|
}
|
|
61
62
|
|
|
63
|
+
function allHookHandlers(config) {
|
|
64
|
+
return Object.values(config.hooks).flatMap((groups) => (
|
|
65
|
+
groups.flatMap((group) => group.hooks)
|
|
66
|
+
));
|
|
67
|
+
}
|
|
68
|
+
|
|
62
69
|
function parseGeneratedTomlString(content, key) {
|
|
63
70
|
const match = content.match(new RegExp(`^${key} = (.+)$`, 'm'));
|
|
64
71
|
assert.ok(match, `missing TOML key: ${key}`);
|
|
@@ -67,13 +74,13 @@ function parseGeneratedTomlString(content, key) {
|
|
|
67
74
|
|
|
68
75
|
test('Codex payload transform emits native skill and subagent syntax', () => {
|
|
69
76
|
const transformed = normalizeCodexBody(
|
|
70
|
-
'Agent(subagent_type="
|
|
77
|
+
'Agent(subagent_type="implementer", prompt="Implement it", description="Code Feature")\n' +
|
|
71
78
|
'Use `/specs auth`, `SendMessage`, `Bash`, `Read`, and `Edit`.'
|
|
72
79
|
);
|
|
73
80
|
|
|
74
81
|
assert.match(
|
|
75
82
|
transformed,
|
|
76
|
-
/spawn_agent\(agent_type="
|
|
83
|
+
/spawn_agent\(agent_type="implementer", fork_turns="none", message="Implement it", task_name="code_feature"\)/
|
|
77
84
|
);
|
|
78
85
|
assert.match(transformed, /\$hapo-specs auth/);
|
|
79
86
|
assert.match(transformed, /`send_message`/);
|
|
@@ -196,6 +203,114 @@ test('same-version install can add Codex beside an existing runtime', async () =
|
|
|
196
203
|
}
|
|
197
204
|
});
|
|
198
205
|
|
|
206
|
+
test('resolvePlatforms interactive prompts to add more platforms when prior install exists', async () => {
|
|
207
|
+
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'cafekit-add-platforms-'));
|
|
208
|
+
const originalCwd = process.cwd();
|
|
209
|
+
try {
|
|
210
|
+
fs.mkdirSync(path.join(root, '.claude'), { recursive: true });
|
|
211
|
+
fs.writeFileSync(
|
|
212
|
+
path.join(root, '.claude', 'cafekit.json'),
|
|
213
|
+
`${JSON.stringify({ version: PACKAGE_VERSION, platform: 'claude' })}\n`
|
|
214
|
+
);
|
|
215
|
+
process.chdir(root);
|
|
216
|
+
const ctx = {
|
|
217
|
+
options: { platforms: [], forceOverwrite: false },
|
|
218
|
+
interactive: true,
|
|
219
|
+
dryRun: false,
|
|
220
|
+
ui: {
|
|
221
|
+
info() {},
|
|
222
|
+
confirm: async ({ message }) => {
|
|
223
|
+
if (message.includes('Existing platforms') || message.includes('addPlatformsPrompt')) return true;
|
|
224
|
+
if (message.includes('confirmAllDetected') || message.includes('existing configs')) return true;
|
|
225
|
+
return true;
|
|
226
|
+
},
|
|
227
|
+
select: async ({ message, options }) => {
|
|
228
|
+
if (message.includes('selectPlatform') || message.includes('Select platform')) {
|
|
229
|
+
return ['codex'];
|
|
230
|
+
}
|
|
231
|
+
return options[0].value;
|
|
232
|
+
},
|
|
233
|
+
isCancel: () => false
|
|
234
|
+
},
|
|
235
|
+
t: (key, vars) => {
|
|
236
|
+
const tpl = (MESSAGES.en && MESSAGES.en[key]) || key;
|
|
237
|
+
if (vars) return tpl.replace(/\{(\w+)\}/g, (_, name) => (vars[name] !== undefined ? String(vars[name]) : `{${name}}`));
|
|
238
|
+
return tpl;
|
|
239
|
+
}
|
|
240
|
+
};
|
|
241
|
+
await resolvePlatforms(ctx);
|
|
242
|
+
assert.deepEqual(ctx.platforms.sort(), ['claude', 'codex'].sort());
|
|
243
|
+
} finally {
|
|
244
|
+
process.chdir(originalCwd);
|
|
245
|
+
fs.rmSync(root, { recursive: true, force: true });
|
|
246
|
+
}
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
test('resolvePlatforms interactive keeps existing platforms when user declines to add more', async () => {
|
|
250
|
+
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'cafekit-keep-platforms-'));
|
|
251
|
+
const originalCwd = process.cwd();
|
|
252
|
+
try {
|
|
253
|
+
fs.mkdirSync(path.join(root, '.claude'), { recursive: true });
|
|
254
|
+
fs.writeFileSync(
|
|
255
|
+
path.join(root, '.claude', 'cafekit.json'),
|
|
256
|
+
`${JSON.stringify({ version: PACKAGE_VERSION, platform: 'claude' })}\n`
|
|
257
|
+
);
|
|
258
|
+
process.chdir(root);
|
|
259
|
+
const ctx = {
|
|
260
|
+
options: { platforms: [], forceOverwrite: false },
|
|
261
|
+
interactive: true,
|
|
262
|
+
dryRun: false,
|
|
263
|
+
ui: {
|
|
264
|
+
info() {},
|
|
265
|
+
confirm: async ({ message }) => {
|
|
266
|
+
// Check that the message is properly rendered without {names} placeholder
|
|
267
|
+
// If i18n uses {existing} but ctx passes {names}, the rendered message will contain {existing}
|
|
268
|
+
if (message.includes('Existing platforms') || message.includes('既存プラットフォーム') || message.includes('Nền tảng hiện có')) {
|
|
269
|
+
assert.ok(!message.includes('{'), `Message should not contain unrendered placeholder: ${message}`);
|
|
270
|
+
assert.ok(!message.includes('}'), `Message should not contain unrendered placeholder: ${message}`);
|
|
271
|
+
return false; // decline to add more
|
|
272
|
+
}
|
|
273
|
+
return true; // confirmAllDetected
|
|
274
|
+
},
|
|
275
|
+
select: async () => ['claude'],
|
|
276
|
+
isCancel: () => false
|
|
277
|
+
},
|
|
278
|
+
t: (key, vars) => {
|
|
279
|
+
const tpl = (MESSAGES.en && MESSAGES.en[key]) || key;
|
|
280
|
+
if (vars) return tpl.replace(/\{(\w+)\}/g, (_, name) => (vars[name] !== undefined ? String(vars[name]) : `{${name}}`));
|
|
281
|
+
return tpl;
|
|
282
|
+
}
|
|
283
|
+
};
|
|
284
|
+
await resolvePlatforms(ctx);
|
|
285
|
+
assert.deepEqual(ctx.platforms, ['claude']);
|
|
286
|
+
} finally {
|
|
287
|
+
process.chdir(originalCwd);
|
|
288
|
+
fs.rmSync(root, { recursive: true, force: true });
|
|
289
|
+
}
|
|
290
|
+
});
|
|
291
|
+
|
|
292
|
+
test('addPlatformsPrompt placeholder consistency across all locales', () => {
|
|
293
|
+
const expectedPlaceholders = ['names'];
|
|
294
|
+
const placeholderRegex = /\{(\w+)\}/g;
|
|
295
|
+
|
|
296
|
+
for (const [locale, messages] of Object.entries(MESSAGES)) {
|
|
297
|
+
const template = messages.addPlatformsPrompt;
|
|
298
|
+
assert.ok(template, `Missing addPlatformsPrompt key in locale: ${locale}`);
|
|
299
|
+
|
|
300
|
+
const placeholders = [];
|
|
301
|
+
let match;
|
|
302
|
+
while ((match = placeholderRegex.exec(template)) !== null) {
|
|
303
|
+
placeholders.push(match[1]);
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
assert.deepEqual(
|
|
307
|
+
placeholders,
|
|
308
|
+
expectedPlaceholders,
|
|
309
|
+
`Locale ${locale} addPlatformsPrompt has incorrect placeholders. Template: "${template}". Expected: ${JSON.stringify(expectedPlaceholders)}, Got: ${JSON.stringify(placeholders)}`
|
|
310
|
+
);
|
|
311
|
+
}
|
|
312
|
+
});
|
|
313
|
+
|
|
199
314
|
test('same-version non-interactive install performs a selective refresh', async () => {
|
|
200
315
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'cafekit-codex-version-'));
|
|
201
316
|
const originalCwd = process.cwd();
|
|
@@ -316,6 +431,64 @@ test('Codex dry-run leaves both managed roots untouched', () => {
|
|
|
316
431
|
});
|
|
317
432
|
});
|
|
318
433
|
|
|
434
|
+
test('Codex Windows hook launchers stay project-bound without Git from nested cwd', () => {
|
|
435
|
+
inTempProject((root) => {
|
|
436
|
+
const projectRoot = path.join(root, 'project with spaces');
|
|
437
|
+
fs.mkdirSync(projectRoot, { recursive: true });
|
|
438
|
+
const installed = install(projectRoot);
|
|
439
|
+
assert.equal(installed.status, 0, `${installed.stdout}\n${installed.stderr}`);
|
|
440
|
+
|
|
441
|
+
const config = JSON.parse(
|
|
442
|
+
fs.readFileSync(path.join(projectRoot, '.codex', 'hooks.json'), 'utf8')
|
|
443
|
+
);
|
|
444
|
+
const handlers = allHookHandlers(config);
|
|
445
|
+
assert.equal(handlers.length, 14);
|
|
446
|
+
for (const handler of handlers) {
|
|
447
|
+
assert.doesNotMatch(handler.commandWindows, /\$\(/);
|
|
448
|
+
assert.doesNotMatch(handler.commandWindows, /\bgit\b/i);
|
|
449
|
+
assert.doesNotMatch(handler.commandWindows, /process\.cwd\(\)|existsSync/);
|
|
450
|
+
const encodedPath = handler.commandWindows.match(/\s([A-Za-z0-9_-]+)$/)?.[1];
|
|
451
|
+
assert.ok(encodedPath, `missing encoded hook path in: ${handler.commandWindows}`);
|
|
452
|
+
const target = Buffer.from(encodedPath, 'base64url').toString('utf8');
|
|
453
|
+
assert.equal(path.dirname(target), fs.realpathSync(path.join(projectRoot, '.codex', 'hooks')));
|
|
454
|
+
assert.equal(
|
|
455
|
+
fs.existsSync(target),
|
|
456
|
+
true,
|
|
457
|
+
`missing installed hook: ${path.basename(target)}`
|
|
458
|
+
);
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
const nested = path.join(projectRoot, 'nested', 'workspace');
|
|
462
|
+
fs.mkdirSync(nested, { recursive: true });
|
|
463
|
+
const shadowHooks = path.join(projectRoot, 'nested', '.codex', 'hooks');
|
|
464
|
+
const shadowMarker = path.join(root, 'shadow-hook-ran');
|
|
465
|
+
fs.mkdirSync(shadowHooks, { recursive: true });
|
|
466
|
+
fs.writeFileSync(
|
|
467
|
+
path.join(shadowHooks, 'session.cjs'),
|
|
468
|
+
`require('node:fs').writeFileSync(${JSON.stringify(shadowMarker)}, 'unsafe')\n`
|
|
469
|
+
);
|
|
470
|
+
const session = config.hooks.SessionStart[0].hooks[0];
|
|
471
|
+
const nodeCommand = session.commandWindows.replace(/^node /, `"${process.execPath}" `);
|
|
472
|
+
const noGitEnv = { ...process.env, PATH: '' };
|
|
473
|
+
const launched = spawnSync(nodeCommand, {
|
|
474
|
+
cwd: nested,
|
|
475
|
+
encoding: 'utf8',
|
|
476
|
+
input: JSON.stringify({
|
|
477
|
+
session_id: 'windows-launcher-test',
|
|
478
|
+
cwd: nested,
|
|
479
|
+
hook_event_name: 'SessionStart',
|
|
480
|
+
source: 'startup'
|
|
481
|
+
}),
|
|
482
|
+
env: noGitEnv,
|
|
483
|
+
shell: true
|
|
484
|
+
});
|
|
485
|
+
assert.equal(launched.status, 0, launched.stderr);
|
|
486
|
+
assert.match(launched.stdout, /Session startup\./);
|
|
487
|
+
assert.match(launched.stdout, /CafeKit project root:/);
|
|
488
|
+
assert.equal(fs.existsSync(shadowMarker), false);
|
|
489
|
+
});
|
|
490
|
+
});
|
|
491
|
+
|
|
319
492
|
test('Codex install is project-local, native, and upgrade-safe', () => {
|
|
320
493
|
inTempProject((root) => {
|
|
321
494
|
const userInstructions = '# User rules\n\nKeep this exact.\n';
|
|
@@ -456,3 +629,58 @@ test('Codex install is project-local, native, and upgrade-safe', () => {
|
|
|
456
629
|
assert.doesNotMatch(fs.readFileSync(questionSkill, 'utf8'), /USER-CODEX-SENTINEL/);
|
|
457
630
|
});
|
|
458
631
|
});
|
|
632
|
+
|
|
633
|
+
test('Codex install on top of existing Claude installation preserves content and adds Codex', () => {
|
|
634
|
+
inTempProject((root) => {
|
|
635
|
+
// Setup existing Claude installation with content
|
|
636
|
+
fs.mkdirSync(path.join(root, '.claude'), { recursive: true });
|
|
637
|
+
fs.writeFileSync(
|
|
638
|
+
path.join(root, '.claude', 'cafekit.json'),
|
|
639
|
+
`${JSON.stringify({ version: PACKAGE_VERSION, platform: 'claude' })}\n`
|
|
640
|
+
);
|
|
641
|
+
|
|
642
|
+
// Existing CLAUDE.md with content
|
|
643
|
+
const claudeMdContent = '# CLAUDE.md\n\n## User Instructions\n\nThis is my existing CLAUDE.md content.\n';
|
|
644
|
+
fs.writeFileSync(path.join(root, 'CLAUDE.md'), claudeMdContent);
|
|
645
|
+
|
|
646
|
+
// Existing AGENTS.md with content
|
|
647
|
+
const agentsMdContent = '# AGENTS.md\n\n## User Rules\n\nKeep this exact.\n';
|
|
648
|
+
fs.writeFileSync(path.join(root, 'AGENTS.md'), agentsMdContent);
|
|
649
|
+
|
|
650
|
+
// Run Codex install
|
|
651
|
+
const result = install(root, ['--platform', 'codex']);
|
|
652
|
+
assert.equal(result.status, 0, `${result.stdout}\n${result.stderr}`);
|
|
653
|
+
|
|
654
|
+
// Assert .claude/ and its content still exist
|
|
655
|
+
assert.equal(fs.existsSync(path.join(root, '.claude')), true, '.claude should exist');
|
|
656
|
+
assert.equal(fs.existsSync(path.join(root, '.claude', 'cafekit.json')), true, '.claude/cafekit.json should exist');
|
|
657
|
+
const claudeMetadata = JSON.parse(fs.readFileSync(path.join(root, '.claude', 'cafekit.json'), 'utf8'));
|
|
658
|
+
assert.equal(claudeMetadata.platform, 'claude', '.claude/cafekit.json should still be claude platform');
|
|
659
|
+
|
|
660
|
+
// Assert .codex/ is created with payload
|
|
661
|
+
assert.equal(fs.existsSync(path.join(root, '.codex')), true, '.codex should be created');
|
|
662
|
+
assert.equal(fs.existsSync(path.join(root, '.codex', 'hooks.json')), true, '.codex/hooks.json should exist');
|
|
663
|
+
assert.equal(fs.existsSync(path.join(root, '.codex', 'runtime.json')), true, '.codex/runtime.json should exist');
|
|
664
|
+
assert.equal(fs.existsSync(path.join(root, '.codex', 'hooks', 'privacy-block.cjs')), true, '.codex/hooks/privacy-block.cjs should exist');
|
|
665
|
+
assert.equal(fs.existsSync(path.join(root, '.codex', 'rules', 'workflow.md')), true, '.codex/rules/workflow.md should exist');
|
|
666
|
+
assert.equal(fs.existsSync(path.join(root, '.codex', 'scripts', 'validate-spec-output.cjs')), true, '.codex/scripts/validate-spec-output.cjs should exist');
|
|
667
|
+
assert.equal(fs.existsSync(path.join(root, '.agents', '.gitignore')), true, '.agents/.gitignore should exist');
|
|
668
|
+
assert.equal(fs.existsSync(path.join(root, '.agents', 'skills', 'specs', 'SKILL.md')), true, '.agents/skills/specs/SKILL.md should exist');
|
|
669
|
+
|
|
670
|
+
// Assert .codex/cafekit.json has platform codex
|
|
671
|
+
const codexMetadata = JSON.parse(fs.readFileSync(path.join(root, '.codex', 'cafekit.json'), 'utf8'));
|
|
672
|
+
assert.equal(codexMetadata.platform, 'codex', '.codex/cafekit.json should have platform codex');
|
|
673
|
+
assert.equal(codexMetadata.version, PACKAGE_VERSION, '.codex/cafekit.json should have current version');
|
|
674
|
+
|
|
675
|
+
// Assert AGENTS.md root contains CODEX markers AND preserves original content
|
|
676
|
+
const agentsMd = fs.readFileSync(path.join(root, 'AGENTS.md'), 'utf8');
|
|
677
|
+
assert.match(agentsMd, /<!-- CAFEKIT CODEX START -->/, 'AGENTS.md should have CODEX START marker');
|
|
678
|
+
assert.match(agentsMd, /<!-- CAFEKIT CODEX END -->/, 'AGENTS.md should have CODEX END marker');
|
|
679
|
+
assert.match(agentsMd, /Keep this exact\./, 'AGENTS.md should preserve original user content');
|
|
680
|
+
assert.ok(agentsMd.startsWith(agentsMdContent), 'AGENTS.md should start with original user content');
|
|
681
|
+
|
|
682
|
+
// Assert CLAUDE.md is unchanged
|
|
683
|
+
const claudeMd = fs.readFileSync(path.join(root, 'CLAUDE.md'), 'utf8');
|
|
684
|
+
assert.equal(claudeMd, claudeMdContent, 'CLAUDE.md should be unchanged');
|
|
685
|
+
});
|
|
686
|
+
});
|