@haposoft/cafekit 0.18.2 → 0.18.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -1
- package/bin/__tests__/adv-skill.test.js +370 -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-hook-runtime-fixture.cjs +26 -0
- package/bin/__tests__/codex-hooks-ownership.test.js +8 -11
- package/bin/__tests__/codex-hooks.test.js +87 -85
- package/bin/__tests__/codex-implicit-policy.test.js +160 -0
- package/bin/__tests__/codex-native-hook-boundaries.test.js +2 -4
- package/bin/__tests__/codex-native.test.js +136 -2298
- 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-quiet-hooks.test.js +2 -8
- package/bin/__tests__/codex-runtime-layout.test.js +81 -0
- package/bin/__tests__/codex-setup-agents.test.js +216 -0
- package/bin/__tests__/codex-skill-projections.test.js +101 -0
- package/bin/__tests__/develop-contract.test.js +100 -874
- 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__/orca-session.test.js +2 -16
- package/bin/__tests__/package-inventory.test.js +228 -2044
- package/bin/__tests__/plugin-safety-keys.test.js +121 -0
- package/bin/__tests__/privacy-paths.test.js +184 -0
- package/bin/__tests__/secret-scanner.test.js +73 -0
- package/bin/__tests__/skill-routing-source.test.js +6 -0
- package/bin/__tests__/toaa-skill.test.js +195 -0
- package/bin/__tests__/usage-hook-retired.test.js +24 -6
- package/bin/__tests__/validator-grounding.test.js +0 -198
- package/bin/__tests__/vibe-run.test.js +215 -0
- package/bin/__tests__/vibe-skill.test.js +548 -0
- package/bin/__tests__/website-docs.test.js +58 -0
- package/bin/install.js +99 -1
- package/bin/lib/backup.js +43 -24
- package/bin/lib/codex-install.js +12 -3
- 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 -35
- package/src/claude/hooks/__tests__/setup-statusline.test.js +179 -0
- package/src/claude/hooks/__tests__/spec-gate.test.js +86 -48
- package/src/claude/hooks/__tests__/state.test.js +21 -0
- 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 +27 -1
- package/src/claude/migration-manifest.json +7 -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/scan-staged-secrets.cjs +49 -3
- 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 +11 -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/sync/SKILL.md +4 -0
- package/src/claude/skills/toaa/SKILL.md +82 -0
- package/src/claude/skills/toaa/references/autonomous-orchestration.md +54 -0
- package/src/claude/skills/toaa/references/prerequisites-checklist.md +37 -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 +119 -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 +31 -6
- 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
- package/bin/__tests__/codex-research-projection.test.js +0 -70
- package/bin/__tests__/codex-specs-projection.test.js +0 -76
- package/bin/__tests__/specs-v2-execution-closeout.test.js +0 -58
- package/src/claude/skills/chrome-devtools/scripts/__tests__/error-handling.test.js +0 -102
|
@@ -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
|
+
});
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const assert = require('node:assert/strict');
|
|
4
|
+
const fs = require('node:fs');
|
|
5
|
+
const path = require('node:path');
|
|
6
|
+
const { test } = require('node:test');
|
|
7
|
+
const {
|
|
8
|
+
normalizeCodexBody,
|
|
9
|
+
CODEX_SPECS_SCOPE_MARKER, CODEX_SPECS_SCOPE_NOTE,
|
|
10
|
+
CODEX_RESEARCH_DEPTH_MARKER, CODEX_RESEARCH_DEPTH_NOTE,
|
|
11
|
+
} = require('../lib/codex-install');
|
|
12
|
+
const { splitFrontmatter } = require('../lib/codex-frontmatter');
|
|
13
|
+
|
|
14
|
+
const SKILLS = path.resolve(__dirname, '../../src/claude/skills');
|
|
15
|
+
|
|
16
|
+
// Skills that Codex projects with an extra note inserted after the frontmatter.
|
|
17
|
+
const PROJECTED = [
|
|
18
|
+
{ skill: 'specs', codexName: 'cf-specs', marker: CODEX_SPECS_SCOPE_MARKER, note: CODEX_SPECS_SCOPE_NOTE },
|
|
19
|
+
{ skill: 'research', codexName: 'cf-research', marker: CODEX_RESEARCH_DEPTH_MARKER, note: CODEX_RESEARCH_DEPTH_NOTE },
|
|
20
|
+
].map((entry) => {
|
|
21
|
+
const file = path.join(SKILLS, entry.skill, 'SKILL.md');
|
|
22
|
+
return { ...entry, file, source: fs.readFileSync(file, 'utf8') };
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
// Ask carries its own boundary note; every other skill body is projected unchanged.
|
|
26
|
+
const OWN_PROJECTION = new Set([...PROJECTED.map(({ file }) => file), path.join(SKILLS, 'ask/SKILL.md')]);
|
|
27
|
+
|
|
28
|
+
function skillFiles(directory) {
|
|
29
|
+
return fs.readdirSync(directory, { withFileTypes: true }).flatMap((entry) => {
|
|
30
|
+
const file = path.join(directory, entry.name);
|
|
31
|
+
return entry.isDirectory() ? skillFiles(file) : entry.name === 'SKILL.md' ? [file] : [];
|
|
32
|
+
});
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const occurrences = (text, marker) => text.split(marker).length - 1;
|
|
36
|
+
|
|
37
|
+
for (const { skill, codexName, marker, note, file, source } of PROJECTED) {
|
|
38
|
+
test(`codex ${skill} projection carries its note immediately after unchanged frontmatter`, () => {
|
|
39
|
+
assert.ok(typeof marker === 'string' && marker.length > 0);
|
|
40
|
+
assert.equal(typeof note, 'string');
|
|
41
|
+
const plain = normalizeCodexBody(source);
|
|
42
|
+
const projected = normalizeCodexBody(source, file);
|
|
43
|
+
assert.match(projected, /^---\r?\n/);
|
|
44
|
+
const before = splitFrontmatter(plain), after = splitFrontmatter(projected);
|
|
45
|
+
assert.equal(after.frontmatter.name, codexName);
|
|
46
|
+
assert.deepEqual(after.frontmatter, before.frontmatter);
|
|
47
|
+
assert.equal(projected.slice(0, projected.length - after.body.length), plain.slice(0, plain.length - before.body.length));
|
|
48
|
+
assert.equal(after.body, `\n${note}\n\n${before.body}`);
|
|
49
|
+
assert.equal(occurrences(projected, marker), 1);
|
|
50
|
+
assert.ok(note.includes(marker));
|
|
51
|
+
assert.doesNotMatch(note, /\b(?:Write|Edit|Read|Bash|WebSearch|WebFetch|AskUserQuestion)\b/);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
test(`codex ${skill} note is scoped to the exact skill source path and supports Windows separators`, () => {
|
|
55
|
+
for (const sourcePath of ['', `skills/${skill}/references/templates.md`, `other-skills/${skill}/SKILL.md`]) {
|
|
56
|
+
const projected = normalizeCodexBody(source, sourcePath);
|
|
57
|
+
assert.ok(!projected.includes(marker), sourcePath);
|
|
58
|
+
assert.equal(projected, normalizeCodexBody(source), sourcePath);
|
|
59
|
+
}
|
|
60
|
+
assert.ok(normalizeCodexBody(source, `C:\\project\\skills\\${skill}\\SKILL.md`).includes(marker));
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
test('each projection marker appears only in its own skill and other skill bodies stay unchanged', () => {
|
|
65
|
+
const all = skillFiles(SKILLS);
|
|
66
|
+
for (const name of ['ask', 'research', 'specs']) assert.ok(all.includes(path.join(SKILLS, name, 'SKILL.md')));
|
|
67
|
+
for (const file of all) {
|
|
68
|
+
const source = fs.readFileSync(file, 'utf8');
|
|
69
|
+
const projected = normalizeCodexBody(source, file);
|
|
70
|
+
for (const { marker, file: ownFile } of PROJECTED) {
|
|
71
|
+
assert.equal(occurrences(projected, marker), file === ownFile ? 1 : 0, `${marker} in ${file}`);
|
|
72
|
+
}
|
|
73
|
+
if (!OWN_PROJECTION.has(file)) assert.equal(projected, normalizeCodexBody(source), file);
|
|
74
|
+
}
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
// Projecting an already projected skill must not add its note or description boundary again.
|
|
78
|
+
test('every Codex skill projection is idempotent', () => {
|
|
79
|
+
for (const file of skillFiles(SKILLS)) {
|
|
80
|
+
const projected = normalizeCodexBody(fs.readFileSync(file, 'utf8'), file);
|
|
81
|
+
assert.equal(normalizeCodexBody(projected, file), projected, file);
|
|
82
|
+
}
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
test('codex specs scope note keeps the GATE-SCOPE answer boundary', () => {
|
|
86
|
+
assert.match(CODEX_SPECS_SCOPE_MARKER, /GATE-SCOPE.*final answer/);
|
|
87
|
+
assert.match(CODEX_SPECS_SCOPE_NOTE, /not in commentary or through `request_user_input`/);
|
|
88
|
+
assert.match(CODEX_SPECS_SCOPE_NOTE, /Do not send a summary after/);
|
|
89
|
+
assert.match(CODEX_SPECS_SCOPE_NOTE, /\[NEEDS CLARIFICATION: <question>\]/);
|
|
90
|
+
assert.match(CODEX_SPECS_SCOPE_NOTE, /EXPAND, KEEP, and CUT together/);
|
|
91
|
+
assert.match(CODEX_SPECS_SCOPE_NOTE, /Preserve.*NEEDS CLARIFICATION.*EXPAND.*KEEP.*CUT.*do not translate/);
|
|
92
|
+
assert.doesNotMatch(CODEX_SPECS_SCOPE_NOTE, /customer|sales|notif|khách|bán hàng|thông báo/i);
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
test('codex research depth note keeps the quick-answer boundary', () => {
|
|
96
|
+
assert.match(CODEX_RESEARCH_DEPTH_NOTE, /known option/);
|
|
97
|
+
assert.match(CODEX_RESEARCH_DEPTH_NOTE, /Quick/);
|
|
98
|
+
assert.match(CODEX_RESEARCH_DEPTH_NOTE, /changes the recommendation/);
|
|
99
|
+
assert.match(CODEX_RESEARCH_DEPTH_NOTE, /conflicting evidence/i);
|
|
100
|
+
assert.doesNotMatch(CODEX_RESEARCH_DEPTH_NOTE, /\b(?:Drizzle|Prisma|BullMQ|pg-boss|billing|ADR-004)\b/);
|
|
101
|
+
});
|