@haposoft/cafekit 0.14.2 → 0.15.0
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 +80 -9
- package/bin/__tests__/codex-hooks.test.js +331 -0
- package/bin/__tests__/codex-native.test.js +458 -0
- package/bin/__tests__/installer-safety.test.js +115 -1
- package/bin/install.js +21 -7
- package/bin/lib/backup.js +23 -3
- package/bin/lib/codex-frontmatter.js +56 -0
- package/bin/lib/codex-install.js +195 -0
- package/bin/lib/context.js +65 -8
- package/bin/lib/copy-utils.js +1 -1
- package/bin/lib/i18n.js +12 -3
- package/bin/lib/managed-writer.js +17 -2
- package/bin/lib/manifest.js +21 -4
- package/bin/lib/opencode-install.js +21 -19
- package/bin/lib/path-safety.js +40 -0
- package/bin/lib/version-check.js +25 -12
- package/bin/phases/claude-runtime.js +3 -5
- package/bin/phases/codex-runtime.js +110 -0
- package/bin/phases/copy-payload.js +36 -16
- package/bin/phases/post-install.js +84 -47
- package/bin/phases/root-config.js +4 -2
- package/bin/phases/select-platform.js +92 -58
- package/bin/phases/summary.js +6 -1
- package/package.json +3 -2
- package/src/claude/scripts/generate-skill-catalog.cjs +8 -3
- package/src/codex/AGENTS.md +30 -0
- package/src/codex/agents-gitignore +11 -0
- package/src/codex/gitignore +12 -0
- package/src/codex/hooks/agent.cjs +66 -0
- package/src/codex/hooks/docs-sync.cjs +66 -0
- package/src/codex/hooks/inspect-block.cjs +40 -0
- package/src/codex/hooks/lib/hook-context.cjs +93 -0
- package/src/codex/hooks/lib/inspect-paths.cjs +199 -0
- package/src/codex/hooks/lib/privacy-state.cjs +199 -0
- package/src/codex/hooks/lib/spec-receipt.cjs +78 -0
- package/src/codex/hooks/lib/spec-utils.cjs +50 -0
- package/src/codex/hooks/lib/state-store.cjs +166 -0
- package/src/codex/hooks/privacy-approval.cjs +59 -0
- package/src/codex/hooks/privacy-block.cjs +182 -0
- package/src/codex/hooks/rules.cjs +79 -0
- package/src/codex/hooks/session.cjs +114 -0
- package/src/codex/hooks/spec-gate.cjs +80 -0
- package/src/codex/hooks/spec-state.cjs +73 -0
- package/src/codex/hooks/state.cjs +157 -0
- package/src/codex/hooks/task-scaffold-guard.cjs +63 -0
- package/src/codex/hooks.json +122 -0
- package/src/codex/rules/hook-protocols.md +32 -0
- package/src/codex/rules/state-sync.md +41 -0
- package/src/codex/runtime.json +28 -0
|
@@ -0,0 +1,458 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const assert = require('node:assert/strict');
|
|
4
|
+
const fs = require('node:fs');
|
|
5
|
+
const os = require('node:os');
|
|
6
|
+
const path = require('node:path');
|
|
7
|
+
const { spawnSync } = require('node:child_process');
|
|
8
|
+
const { test } = require('node:test');
|
|
9
|
+
|
|
10
|
+
const {
|
|
11
|
+
normalizeCodexBody,
|
|
12
|
+
transformManagedCodexContent,
|
|
13
|
+
upsertManagedCodexBlock
|
|
14
|
+
} = require('../lib/codex-install');
|
|
15
|
+
const { createTracker } = require('../lib/manifest');
|
|
16
|
+
const { checkVersions } = require('../lib/version-check');
|
|
17
|
+
const {
|
|
18
|
+
resolvePlatforms,
|
|
19
|
+
selectLanguage
|
|
20
|
+
} = require('../phases/select-platform');
|
|
21
|
+
|
|
22
|
+
const PACKAGE_ROOT = path.join(__dirname, '../..');
|
|
23
|
+
const PACKAGE_VERSION = JSON.parse(
|
|
24
|
+
fs.readFileSync(path.join(PACKAGE_ROOT, 'package.json'), 'utf8')
|
|
25
|
+
).version;
|
|
26
|
+
const INSTALLER = path.join(PACKAGE_ROOT, 'bin/install.js');
|
|
27
|
+
const MANIFEST = JSON.parse(
|
|
28
|
+
fs.readFileSync(path.join(PACKAGE_ROOT, 'src/claude/migration-manifest.json'), 'utf8')
|
|
29
|
+
);
|
|
30
|
+
|
|
31
|
+
function inTempProject(run) {
|
|
32
|
+
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'cafekit-codex-native-'));
|
|
33
|
+
try {
|
|
34
|
+
return run(root);
|
|
35
|
+
} finally {
|
|
36
|
+
fs.rmSync(root, { recursive: true, force: true });
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function install(root, extraArgs = []) {
|
|
41
|
+
return spawnSync(
|
|
42
|
+
process.execPath,
|
|
43
|
+
[INSTALLER, '--platform', 'codex', '--yes', ...extraArgs],
|
|
44
|
+
{
|
|
45
|
+
cwd: root,
|
|
46
|
+
encoding: 'utf8',
|
|
47
|
+
env: { ...process.env, PATH: '/usr/bin:/bin' }
|
|
48
|
+
}
|
|
49
|
+
);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function allFiles(root, predicate) {
|
|
53
|
+
const found = [];
|
|
54
|
+
for (const entry of fs.readdirSync(root, { withFileTypes: true })) {
|
|
55
|
+
const target = path.join(root, entry.name);
|
|
56
|
+
if (entry.isDirectory()) found.push(...allFiles(target, predicate));
|
|
57
|
+
else if (predicate(target)) found.push(target);
|
|
58
|
+
}
|
|
59
|
+
return found;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function parseGeneratedTomlString(content, key) {
|
|
63
|
+
const match = content.match(new RegExp(`^${key} = (.+)$`, 'm'));
|
|
64
|
+
assert.ok(match, `missing TOML key: ${key}`);
|
|
65
|
+
return JSON.parse(match[1]);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
test('Codex payload transform emits native skill and subagent syntax', () => {
|
|
69
|
+
const transformed = normalizeCodexBody(
|
|
70
|
+
'Agent(subagent_type="god-developer", prompt="Implement it", description="Code Feature")\n' +
|
|
71
|
+
'Use `/specs auth`, `SendMessage`, `Bash`, `Read`, and `Edit`.'
|
|
72
|
+
);
|
|
73
|
+
|
|
74
|
+
assert.match(
|
|
75
|
+
transformed,
|
|
76
|
+
/spawn_agent\(agent_type="god_developer", fork_turns="none", message="Implement it", task_name="code_feature"\)/
|
|
77
|
+
);
|
|
78
|
+
assert.match(transformed, /\$hapo-specs auth/);
|
|
79
|
+
assert.match(transformed, /`send_message`/);
|
|
80
|
+
assert.match(transformed, /`exec_command`/);
|
|
81
|
+
assert.match(transformed, /`apply_patch`/);
|
|
82
|
+
assert.doesNotMatch(transformed, /Agent\(|subagent_type|\/specs\b|Claude Code/);
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
test('Codex payload transform preserves executable keyword arguments', () => {
|
|
86
|
+
const transformed = normalizeCodexBody(
|
|
87
|
+
'parser = ArgumentParser(description="Analyze it")\n' +
|
|
88
|
+
'result = client.generate(prompt=prompt)\n' +
|
|
89
|
+
'skills = ".claude/skills"\n',
|
|
90
|
+
'/fixture/tool.py'
|
|
91
|
+
);
|
|
92
|
+
|
|
93
|
+
assert.match(transformed, /description="Analyze it"/);
|
|
94
|
+
assert.match(transformed, /prompt=prompt/);
|
|
95
|
+
assert.match(transformed, /skills = "\.agents\/skills"/);
|
|
96
|
+
assert.doesNotMatch(transformed, /task_name=|message=prompt/);
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
test('Codex managed AGENTS block preserves malformed marker topologies', () => {
|
|
100
|
+
const malformed = [
|
|
101
|
+
'user before\n<!-- CAFEKIT CODEX START -->\nuser tail\n',
|
|
102
|
+
'user before\n<!-- CAFEKIT CODEX END -->\nuser tail\n',
|
|
103
|
+
'<!-- CAFEKIT CODEX END -->\nuser\n<!-- CAFEKIT CODEX START -->\n',
|
|
104
|
+
'<!-- CAFEKIT CODEX START -->\na\n<!-- CAFEKIT CODEX START -->\nb\n<!-- CAFEKIT CODEX END -->\n',
|
|
105
|
+
'<!-- CAFEKIT CODEX START -->\na\n<!-- CAFEKIT CODEX END -->\nb\n<!-- CAFEKIT CODEX END -->\n'
|
|
106
|
+
];
|
|
107
|
+
for (const content of malformed) {
|
|
108
|
+
assert.equal(upsertManagedCodexBlock(content, 'replacement'), content);
|
|
109
|
+
assert.equal(transformManagedCodexContent(content, () => 'replacement'), content);
|
|
110
|
+
}
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
test('platform resolver keeps saved runtimes and newly detected Codex', async () => {
|
|
114
|
+
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'cafekit-codex-detect-'));
|
|
115
|
+
const originalCwd = process.cwd();
|
|
116
|
+
try {
|
|
117
|
+
for (const folder of ['.claude', '.opencode', '.codex']) {
|
|
118
|
+
fs.mkdirSync(path.join(root, folder), { recursive: true });
|
|
119
|
+
}
|
|
120
|
+
fs.writeFileSync(
|
|
121
|
+
path.join(root, '.claude', 'cafekit.json'),
|
|
122
|
+
`${JSON.stringify({ version: PACKAGE_VERSION, platform: 'claude' })}\n`
|
|
123
|
+
);
|
|
124
|
+
process.chdir(root);
|
|
125
|
+
const ctx = {
|
|
126
|
+
options: { platforms: [], forceOverwrite: false },
|
|
127
|
+
interactive: false,
|
|
128
|
+
dryRun: false,
|
|
129
|
+
ui: { info() {} },
|
|
130
|
+
t: (key) => key
|
|
131
|
+
};
|
|
132
|
+
await resolvePlatforms(ctx);
|
|
133
|
+
assert.deepEqual(ctx.platforms, ['claude', 'opencode', 'codex']);
|
|
134
|
+
} finally {
|
|
135
|
+
process.chdir(originalCwd);
|
|
136
|
+
fs.rmSync(root, { recursive: true, force: true });
|
|
137
|
+
}
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
test('explicit Codex install restores the Codex locale first', async () => {
|
|
141
|
+
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'cafekit-codex-locale-'));
|
|
142
|
+
const originalCwd = process.cwd();
|
|
143
|
+
try {
|
|
144
|
+
for (const [folder, responseLanguage] of [
|
|
145
|
+
['.claude', '日本語'],
|
|
146
|
+
['.codex', 'Tiếng Việt']
|
|
147
|
+
]) {
|
|
148
|
+
fs.mkdirSync(path.join(root, folder), { recursive: true });
|
|
149
|
+
fs.writeFileSync(
|
|
150
|
+
path.join(root, folder, 'runtime.json'),
|
|
151
|
+
`${JSON.stringify({ locale: { responseLanguage } })}\n`
|
|
152
|
+
);
|
|
153
|
+
}
|
|
154
|
+
process.chdir(root);
|
|
155
|
+
const ctx = {
|
|
156
|
+
options: { platforms: ['codex'] },
|
|
157
|
+
interactive: false,
|
|
158
|
+
lang: 'en',
|
|
159
|
+
setLang(code, locale) {
|
|
160
|
+
this.lang = code;
|
|
161
|
+
this.locale = locale;
|
|
162
|
+
},
|
|
163
|
+
ui: { info() {} },
|
|
164
|
+
t: (key) => key
|
|
165
|
+
};
|
|
166
|
+
await selectLanguage(ctx);
|
|
167
|
+
assert.equal(ctx.locale, 'Tiếng Việt');
|
|
168
|
+
} finally {
|
|
169
|
+
process.chdir(originalCwd);
|
|
170
|
+
fs.rmSync(root, { recursive: true, force: true });
|
|
171
|
+
}
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
test('same-version install can add Codex beside an existing runtime', async () => {
|
|
175
|
+
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'cafekit-codex-version-'));
|
|
176
|
+
const originalCwd = process.cwd();
|
|
177
|
+
try {
|
|
178
|
+
fs.mkdirSync(path.join(root, '.claude'), { recursive: true });
|
|
179
|
+
fs.writeFileSync(
|
|
180
|
+
path.join(root, '.claude', 'cafekit.json'),
|
|
181
|
+
`${JSON.stringify({ version: PACKAGE_VERSION, platform: 'claude' })}\n`
|
|
182
|
+
);
|
|
183
|
+
process.chdir(root);
|
|
184
|
+
const ctx = {
|
|
185
|
+
platforms: ['claude', 'codex'],
|
|
186
|
+
dryRun: false,
|
|
187
|
+
interactive: false,
|
|
188
|
+
options: { forceOverwrite: false },
|
|
189
|
+
ui: { info() {}, warn() {} }
|
|
190
|
+
};
|
|
191
|
+
await checkVersions(ctx);
|
|
192
|
+
assert.notEqual(ctx.cancelled, true);
|
|
193
|
+
} finally {
|
|
194
|
+
process.chdir(originalCwd);
|
|
195
|
+
fs.rmSync(root, { recursive: true, force: true });
|
|
196
|
+
}
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
test('same-version non-interactive install performs a selective refresh', async () => {
|
|
200
|
+
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'cafekit-codex-version-'));
|
|
201
|
+
const originalCwd = process.cwd();
|
|
202
|
+
try {
|
|
203
|
+
fs.mkdirSync(path.join(root, '.codex'), { recursive: true });
|
|
204
|
+
fs.writeFileSync(
|
|
205
|
+
path.join(root, '.codex', 'cafekit.json'),
|
|
206
|
+
`${JSON.stringify({ version: PACKAGE_VERSION, platform: 'codex' })}\n`
|
|
207
|
+
);
|
|
208
|
+
process.chdir(root);
|
|
209
|
+
const messages = [];
|
|
210
|
+
const ctx = {
|
|
211
|
+
platforms: ['codex'],
|
|
212
|
+
dryRun: false,
|
|
213
|
+
interactive: false,
|
|
214
|
+
options: { forceOverwrite: false },
|
|
215
|
+
ui: { info(message) { messages.push(message); }, warn() {} },
|
|
216
|
+
t: (key) => key
|
|
217
|
+
};
|
|
218
|
+
await checkVersions(ctx);
|
|
219
|
+
assert.notEqual(ctx.cancelled, true);
|
|
220
|
+
assert.equal(ctx.options.forceOverwrite, false);
|
|
221
|
+
assert.deepEqual(messages, ['versionRefreshing']);
|
|
222
|
+
} finally {
|
|
223
|
+
process.chdir(originalCwd);
|
|
224
|
+
fs.rmSync(root, { recursive: true, force: true });
|
|
225
|
+
}
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
test('same-version interactive refresh does not enable force overwrite', async () => {
|
|
229
|
+
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'cafekit-codex-version-'));
|
|
230
|
+
const originalCwd = process.cwd();
|
|
231
|
+
try {
|
|
232
|
+
fs.mkdirSync(path.join(root, '.codex'), { recursive: true });
|
|
233
|
+
fs.writeFileSync(
|
|
234
|
+
path.join(root, '.codex', 'cafekit.json'),
|
|
235
|
+
`${JSON.stringify({ version: PACKAGE_VERSION, platform: 'codex' })}\n`
|
|
236
|
+
);
|
|
237
|
+
process.chdir(root);
|
|
238
|
+
const ctx = {
|
|
239
|
+
platforms: ['codex'],
|
|
240
|
+
dryRun: false,
|
|
241
|
+
interactive: true,
|
|
242
|
+
options: { forceOverwrite: false },
|
|
243
|
+
ui: {
|
|
244
|
+
info() {},
|
|
245
|
+
warn() {},
|
|
246
|
+
select: async () => 'refresh',
|
|
247
|
+
isCancel: () => false
|
|
248
|
+
},
|
|
249
|
+
t: (key) => key
|
|
250
|
+
};
|
|
251
|
+
await checkVersions(ctx);
|
|
252
|
+
assert.notEqual(ctx.cancelled, true);
|
|
253
|
+
assert.equal(ctx.options.forceOverwrite, false);
|
|
254
|
+
} finally {
|
|
255
|
+
process.chdir(originalCwd);
|
|
256
|
+
fs.rmSync(root, { recursive: true, force: true });
|
|
257
|
+
}
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
test('mixed runtime versions update the stale Codex install', async () => {
|
|
261
|
+
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'cafekit-codex-version-'));
|
|
262
|
+
const originalCwd = process.cwd();
|
|
263
|
+
try {
|
|
264
|
+
for (const [folder, platform, version] of [
|
|
265
|
+
['.claude', 'claude', PACKAGE_VERSION],
|
|
266
|
+
['.codex', 'codex', '0.14.1']
|
|
267
|
+
]) {
|
|
268
|
+
fs.mkdirSync(path.join(root, folder), { recursive: true });
|
|
269
|
+
fs.writeFileSync(
|
|
270
|
+
path.join(root, folder, 'cafekit.json'),
|
|
271
|
+
`${JSON.stringify({ version, platform })}\n`
|
|
272
|
+
);
|
|
273
|
+
}
|
|
274
|
+
process.chdir(root);
|
|
275
|
+
const ctx = {
|
|
276
|
+
platforms: ['claude', 'codex'],
|
|
277
|
+
dryRun: false,
|
|
278
|
+
interactive: false,
|
|
279
|
+
options: { forceOverwrite: false },
|
|
280
|
+
ui: { info() {}, warn() {} }
|
|
281
|
+
};
|
|
282
|
+
await checkVersions(ctx);
|
|
283
|
+
assert.notEqual(ctx.cancelled, true);
|
|
284
|
+
assert.equal(ctx.isUpdate, true);
|
|
285
|
+
} finally {
|
|
286
|
+
process.chdir(originalCwd);
|
|
287
|
+
fs.rmSync(root, { recursive: true, force: true });
|
|
288
|
+
}
|
|
289
|
+
});
|
|
290
|
+
|
|
291
|
+
test('Codex ownership rejects files outside its split managed roots', () => {
|
|
292
|
+
inTempProject((root) => {
|
|
293
|
+
const originalCwd = process.cwd();
|
|
294
|
+
process.chdir(root);
|
|
295
|
+
try {
|
|
296
|
+
const tracker = createTracker('.codex', PACKAGE_VERSION, {
|
|
297
|
+
recordRoot: '.',
|
|
298
|
+
allowedRoots: ['.codex', '.agents']
|
|
299
|
+
});
|
|
300
|
+
assert.throws(() => tracker.keyFor('AGENTS.md'), /outside allowed roots/);
|
|
301
|
+
assert.equal(tracker.keyFor('.agents/skills/specs/SKILL.md'), '.agents/skills/specs/SKILL.md');
|
|
302
|
+
} finally {
|
|
303
|
+
process.chdir(originalCwd);
|
|
304
|
+
}
|
|
305
|
+
});
|
|
306
|
+
});
|
|
307
|
+
|
|
308
|
+
test('Codex dry-run leaves both managed roots untouched', () => {
|
|
309
|
+
inTempProject((root) => {
|
|
310
|
+
const result = install(root, ['--dry-run']);
|
|
311
|
+
assert.equal(result.status, 0, `${result.stdout}\n${result.stderr}`);
|
|
312
|
+
assert.equal(fs.existsSync(path.join(root, '.codex')), false);
|
|
313
|
+
assert.equal(fs.existsSync(path.join(root, '.agents')), false);
|
|
314
|
+
assert.equal(fs.existsSync(path.join(root, 'AGENTS.md')), false);
|
|
315
|
+
assert.equal(fs.existsSync(path.join(root, '.gitignore')), false);
|
|
316
|
+
});
|
|
317
|
+
});
|
|
318
|
+
|
|
319
|
+
test('Codex install is project-local, native, and upgrade-safe', () => {
|
|
320
|
+
inTempProject((root) => {
|
|
321
|
+
const userInstructions = '# User rules\n\nKeep this exact.\n';
|
|
322
|
+
fs.writeFileSync(path.join(root, 'AGENTS.md'), userInstructions);
|
|
323
|
+
|
|
324
|
+
const first = install(root);
|
|
325
|
+
assert.equal(first.status, 0, `${first.stdout}\n${first.stderr}`);
|
|
326
|
+
assert.equal(fs.existsSync(path.join(root, '.claude')), false);
|
|
327
|
+
assert.equal(fs.existsSync(path.join(root, '.opencode')), false);
|
|
328
|
+
assert.equal(fs.existsSync(path.join(root, '.codex', 'config.toml')), false);
|
|
329
|
+
|
|
330
|
+
for (const relative of [
|
|
331
|
+
'.codex/hooks.json',
|
|
332
|
+
'.codex/runtime.json',
|
|
333
|
+
'.codex/hooks/privacy-block.cjs',
|
|
334
|
+
'.codex/rules/workflow.md',
|
|
335
|
+
'.codex/rules/hook-protocols.md',
|
|
336
|
+
'.codex/scripts/validate-spec-output.cjs',
|
|
337
|
+
'.agents/.gitignore',
|
|
338
|
+
'.agents/skills/specs/SKILL.md'
|
|
339
|
+
]) {
|
|
340
|
+
assert.equal(fs.existsSync(path.join(root, relative)), true, `missing ${relative}`);
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
const agentsGitignore = fs.readFileSync(
|
|
344
|
+
path.join(root, '.agents', '.gitignore'),
|
|
345
|
+
'utf8'
|
|
346
|
+
);
|
|
347
|
+
assert.match(agentsGitignore, /skills\/\*\*\/\.venv\//);
|
|
348
|
+
assert.match(agentsGitignore, /skills\/\*\*\/node_modules\//);
|
|
349
|
+
assert.match(agentsGitignore, /!skills\/\*\*\/\.env\.example/);
|
|
350
|
+
|
|
351
|
+
if (process.platform !== 'win32') {
|
|
352
|
+
for (const [sourceRelative, installedRelative] of [
|
|
353
|
+
['src/claude/scripts/validate-spec-output.cjs', '.codex/scripts/validate-spec-output.cjs'],
|
|
354
|
+
['src/claude/skills/chrome-devtools/scripts/install.sh', '.agents/skills/chrome-devtools/scripts/install.sh'],
|
|
355
|
+
['src/claude/skills/ai-multimodal/scripts/check_setup.py', '.agents/skills/ai-multimodal/scripts/check_setup.py']
|
|
356
|
+
]) {
|
|
357
|
+
const sourceMode = fs.statSync(path.join(PACKAGE_ROOT, sourceRelative)).mode & 0o111;
|
|
358
|
+
const installedMode = fs.statSync(path.join(root, installedRelative)).mode & 0o111;
|
|
359
|
+
assert.notEqual(sourceMode, 0, `fixture should be executable: ${sourceRelative}`);
|
|
360
|
+
assert.equal(installedMode, sourceMode, `execute bits differ: ${installedRelative}`);
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
for (const fileName of MANIFEST.agents.required) {
|
|
365
|
+
const name = path.basename(fileName, '.md').replace(/-/g, '_');
|
|
366
|
+
const agentPath = path.join(root, '.codex', 'agents', `${name}.toml`);
|
|
367
|
+
const content = fs.readFileSync(agentPath, 'utf8');
|
|
368
|
+
assert.equal(parseGeneratedTomlString(content, 'name'), name);
|
|
369
|
+
assert.ok(parseGeneratedTomlString(content, 'description').length > 8);
|
|
370
|
+
assert.ok(parseGeneratedTomlString(content, 'developer_instructions').length > 20);
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
const skillFiles = allFiles(
|
|
374
|
+
path.join(root, '.agents', 'skills'),
|
|
375
|
+
(file) => path.basename(file) === 'SKILL.md'
|
|
376
|
+
);
|
|
377
|
+
const skillNames = skillFiles.map((file) => {
|
|
378
|
+
const match = fs.readFileSync(file, 'utf8').match(/^name:\s*(.+)$/m);
|
|
379
|
+
assert.ok(match, `missing skill name in ${file}`);
|
|
380
|
+
return match[1].trim();
|
|
381
|
+
});
|
|
382
|
+
assert.ok(skillNames.length >= 20);
|
|
383
|
+
assert.equal(new Set(skillNames).size, skillNames.length);
|
|
384
|
+
assert.ok(skillNames.every((name) => /^[a-z0-9-]+$/.test(name)));
|
|
385
|
+
|
|
386
|
+
const catalog = spawnSync(
|
|
387
|
+
process.execPath,
|
|
388
|
+
[path.join(root, '.codex', 'scripts', 'generate-skill-catalog.cjs'), '--json'],
|
|
389
|
+
{ cwd: root, encoding: 'utf8' }
|
|
390
|
+
);
|
|
391
|
+
assert.equal(catalog.status, 0, catalog.stderr);
|
|
392
|
+
const catalogData = JSON.parse(catalog.stdout);
|
|
393
|
+
assert.equal(catalogData.total, skillNames.length);
|
|
394
|
+
assert.equal(
|
|
395
|
+
catalogData.root,
|
|
396
|
+
fs.realpathSync(path.join(root, '.agents', 'skills'))
|
|
397
|
+
);
|
|
398
|
+
|
|
399
|
+
const dockerScript = fs.readFileSync(
|
|
400
|
+
path.join(root, '.agents', 'skills', 'devops', 'scripts', 'docker_optimize.py'),
|
|
401
|
+
'utf8'
|
|
402
|
+
);
|
|
403
|
+
assert.match(dockerScript, /description="Analyze Dockerfile for optimization opportunities"/);
|
|
404
|
+
assert.doesNotMatch(dockerScript, /task_name="analyze_dockerfile/);
|
|
405
|
+
|
|
406
|
+
const multimodalScript = fs.readFileSync(
|
|
407
|
+
path.join(root, '.agents', 'skills', 'ai-multimodal', 'scripts', 'gemini_batch_process.py'),
|
|
408
|
+
'utf8'
|
|
409
|
+
);
|
|
410
|
+
assert.match(multimodalScript, /prompt=prompt/);
|
|
411
|
+
assert.doesNotMatch(multimodalScript, /message=prompt/);
|
|
412
|
+
|
|
413
|
+
const modelVisibleFiles = [
|
|
414
|
+
path.join(root, 'AGENTS.md'),
|
|
415
|
+
...allFiles(path.join(root, '.codex', 'agents'), (file) => file.endsWith('.toml')),
|
|
416
|
+
...allFiles(path.join(root, '.codex', 'rules'), (file) => file.endsWith('.md')),
|
|
417
|
+
...allFiles(path.join(root, '.agents', 'skills'), (file) => file.endsWith('.md'))
|
|
418
|
+
];
|
|
419
|
+
const visible = modelVisibleFiles.map((file) => fs.readFileSync(file, 'utf8')).join('\n');
|
|
420
|
+
assert.doesNotMatch(visible, /\bAgent\(|subagent_type|`Agent`|\bSendMessage\b|\/hapo:|\bhapo:|Claude Code/);
|
|
421
|
+
assert.doesNotMatch(visible, /@@PRIVACY_PROMPT_START@@|Claude Tasks/);
|
|
422
|
+
assert.match(visible, /\$hapo-specs/);
|
|
423
|
+
|
|
424
|
+
const agentsMd = fs.readFileSync(path.join(root, 'AGENTS.md'), 'utf8');
|
|
425
|
+
assert.ok(agentsMd.startsWith(userInstructions));
|
|
426
|
+
assert.equal((agentsMd.match(/<!-- CAFEKIT CODEX START -->/g) || []).length, 1);
|
|
427
|
+
assert.match(agentsMd, /fork_turns: "none"/);
|
|
428
|
+
assert.match(agentsMd, /repository is trusted/);
|
|
429
|
+
|
|
430
|
+
const ownership = JSON.parse(
|
|
431
|
+
fs.readFileSync(path.join(root, '.codex', 'cafekit-manifest.json'), 'utf8')
|
|
432
|
+
);
|
|
433
|
+
assert.ok(Object.keys(ownership.files).every((key) => (
|
|
434
|
+
(key.startsWith('.codex/') || key.startsWith('.agents/')) && !key.includes('../')
|
|
435
|
+
)));
|
|
436
|
+
|
|
437
|
+
const questionSkill = path.join(root, '.agents', 'skills', 'question', 'SKILL.md');
|
|
438
|
+
fs.appendFileSync(questionSkill, '\nUSER-CODEX-SENTINEL\n');
|
|
439
|
+
|
|
440
|
+
const sameVersion = install(root);
|
|
441
|
+
assert.equal(sameVersion.status, 0, `${sameVersion.stdout}\n${sameVersion.stderr}`);
|
|
442
|
+
assert.match(fs.readFileSync(questionSkill, 'utf8'), /USER-CODEX-SENTINEL/);
|
|
443
|
+
|
|
444
|
+
const metadataPath = path.join(root, '.codex', 'cafekit.json');
|
|
445
|
+
const metadata = JSON.parse(fs.readFileSync(metadataPath, 'utf8'));
|
|
446
|
+
metadata.version = '0.14.1';
|
|
447
|
+
fs.writeFileSync(metadataPath, `${JSON.stringify(metadata, null, 2)}\n`);
|
|
448
|
+
|
|
449
|
+
const upgrade = install(root);
|
|
450
|
+
assert.equal(upgrade.status, 0, `${upgrade.stdout}\n${upgrade.stderr}`);
|
|
451
|
+
assert.match(fs.readFileSync(questionSkill, 'utf8'), /USER-CODEX-SENTINEL/);
|
|
452
|
+
assert.ok(fs.readFileSync(path.join(root, 'AGENTS.md'), 'utf8').startsWith(userInstructions));
|
|
453
|
+
|
|
454
|
+
const forced = install(root, ['--force-overwrite']);
|
|
455
|
+
assert.equal(forced.status, 0, `${forced.stdout}\n${forced.stderr}`);
|
|
456
|
+
assert.doesNotMatch(fs.readFileSync(questionSkill, 'utf8'), /USER-CODEX-SENTINEL/);
|
|
457
|
+
});
|
|
458
|
+
});
|
|
@@ -7,8 +7,12 @@ const path = require('node:path');
|
|
|
7
7
|
const { test } = require('node:test');
|
|
8
8
|
|
|
9
9
|
const backup = require('../lib/backup');
|
|
10
|
+
const {
|
|
11
|
+
copyOpenCodeAgentsMdFile,
|
|
12
|
+
normalizeOpenCodeBody
|
|
13
|
+
} = require('../lib/opencode-install');
|
|
10
14
|
const { copyClaudeMdFile } = require('../phases/claude-runtime');
|
|
11
|
-
const { configureAddressing } = require('../phases/post-install');
|
|
15
|
+
const { configureAddressing, patchRuntimeLocale } = require('../phases/post-install');
|
|
12
16
|
|
|
13
17
|
const START = '<!-- CAFEKIT CLAUDE START -->';
|
|
14
18
|
const END = '<!-- CAFEKIT CLAUDE END -->';
|
|
@@ -104,6 +108,68 @@ test('addressing changes remain inside the managed CLAUDE block', () => {
|
|
|
104
108
|
});
|
|
105
109
|
});
|
|
106
110
|
|
|
111
|
+
test('OpenCode force refresh preserves user and Codex AGENTS blocks', () => {
|
|
112
|
+
inTempProject(() => {
|
|
113
|
+
const userContent = '# User instructions\n\nKeep this exact.\n';
|
|
114
|
+
const codexBlock = '<!-- CAFEKIT CODEX START -->\nCodex managed\n<!-- CAFEKIT CODEX END -->\n';
|
|
115
|
+
fs.writeFileSync('AGENTS.md', `${userContent}\n${codexBlock}`);
|
|
116
|
+
const results = { copied: 0, updated: 0, skipped: 0, missingDependencies: 0 };
|
|
117
|
+
|
|
118
|
+
copyOpenCodeAgentsMdFile('opencode', results, { upgrade: true });
|
|
119
|
+
|
|
120
|
+
const content = fs.readFileSync('AGENTS.md', 'utf8');
|
|
121
|
+
assert.ok(content.startsWith(userContent));
|
|
122
|
+
assert.ok(content.includes(codexBlock.trim()));
|
|
123
|
+
assert.equal((content.match(/<!-- CAFEKIT OPENCODE START -->/g) || []).length, 1);
|
|
124
|
+
assert.equal(results.updated, 1);
|
|
125
|
+
});
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
test('legacy unmarked OpenCode AGENTS content is wrapped without duplication', () => {
|
|
129
|
+
inTempProject(() => {
|
|
130
|
+
const source = path.join(__dirname, '../../src/opencode/AGENTS.md');
|
|
131
|
+
const legacy = normalizeOpenCodeBody(fs.readFileSync(source, 'utf8'));
|
|
132
|
+
const codexBlock = '<!-- CAFEKIT CODEX START -->\nCodex managed\n<!-- CAFEKIT CODEX END -->\n';
|
|
133
|
+
fs.writeFileSync('AGENTS.md', `${legacy}\n${codexBlock}`);
|
|
134
|
+
const results = { copied: 0, updated: 0, skipped: 0, missingDependencies: 0 };
|
|
135
|
+
|
|
136
|
+
copyOpenCodeAgentsMdFile('opencode', results, { upgrade: true });
|
|
137
|
+
|
|
138
|
+
const content = fs.readFileSync('AGENTS.md', 'utf8');
|
|
139
|
+
assert.equal((content.match(/<!-- CAFEKIT OPENCODE START -->/g) || []).length, 1);
|
|
140
|
+
assert.equal((content.match(/^# AGENTS\.md$/gm) || []).length, 1);
|
|
141
|
+
assert.ok(content.includes(codexBlock.trim()));
|
|
142
|
+
});
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
test('locale patch does not claim ownership of a preserved user runtime', () => {
|
|
146
|
+
inTempProject(() => {
|
|
147
|
+
fs.mkdirSync('.codex', { recursive: true });
|
|
148
|
+
fs.writeFileSync(
|
|
149
|
+
'.codex/runtime.json',
|
|
150
|
+
`${JSON.stringify({ custom: true, locale: { responseLanguage: 'English' } })}\n`
|
|
151
|
+
);
|
|
152
|
+
const ctx = {
|
|
153
|
+
platforms: ['codex'],
|
|
154
|
+
locale: 'Tiếng Việt',
|
|
155
|
+
lang: 'vi',
|
|
156
|
+
trackers: {
|
|
157
|
+
codex: {
|
|
158
|
+
keyFor: () => '.codex/runtime.json',
|
|
159
|
+
recorded: () => null,
|
|
160
|
+
record: () => assert.fail('preserved runtime must not become installer-owned')
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
};
|
|
164
|
+
|
|
165
|
+
patchRuntimeLocale(ctx);
|
|
166
|
+
|
|
167
|
+
const runtime = JSON.parse(fs.readFileSync('.codex/runtime.json', 'utf8'));
|
|
168
|
+
assert.equal(runtime.custom, true);
|
|
169
|
+
assert.equal(runtime.locale.responseLanguage, 'Tiếng Việt');
|
|
170
|
+
});
|
|
171
|
+
});
|
|
172
|
+
|
|
107
173
|
test('backup restores existing targets and removes targets absent before the run', () => {
|
|
108
174
|
inTempProject(() => {
|
|
109
175
|
fs.mkdirSync('.claude/nested', { recursive: true });
|
|
@@ -135,6 +201,29 @@ test('backup restores existing targets and removes targets absent before the run
|
|
|
135
201
|
});
|
|
136
202
|
});
|
|
137
203
|
|
|
204
|
+
test('backup preserves nested dependency symlinks without following them', () => {
|
|
205
|
+
inTempProject(() => {
|
|
206
|
+
fs.mkdirSync('.agents/skills/demo/.venv/bin', { recursive: true });
|
|
207
|
+
fs.mkdirSync('toolchain', { recursive: true });
|
|
208
|
+
fs.writeFileSync('toolchain/python3', 'project-owned target\n');
|
|
209
|
+
const linkPath = '.agents/skills/demo/.venv/bin/python3';
|
|
210
|
+
const linkTarget = '../../../../../toolchain/python3';
|
|
211
|
+
fs.symlinkSync(linkTarget, linkPath);
|
|
212
|
+
|
|
213
|
+
const backupDir = backup.snapshot(['.agents'], '20260729-nested-symlink');
|
|
214
|
+
const backupLink = path.join(backupDir, 'data', linkPath);
|
|
215
|
+
assert.equal(fs.lstatSync(backupLink).isSymbolicLink(), true);
|
|
216
|
+
assert.equal(fs.readlinkSync(backupLink), linkTarget);
|
|
217
|
+
|
|
218
|
+
fs.rmSync('.agents', { recursive: true, force: true });
|
|
219
|
+
backup.restore(backupDir);
|
|
220
|
+
|
|
221
|
+
assert.equal(fs.lstatSync(linkPath).isSymbolicLink(), true);
|
|
222
|
+
assert.equal(fs.readlinkSync(linkPath), linkTarget);
|
|
223
|
+
assert.equal(fs.readFileSync(linkPath, 'utf8'), 'project-owned target\n');
|
|
224
|
+
});
|
|
225
|
+
});
|
|
226
|
+
|
|
138
227
|
test('backup rejects traversal targets before snapshot or restore deletion', () => {
|
|
139
228
|
inTempProject(() => {
|
|
140
229
|
assert.throws(
|
|
@@ -143,3 +232,28 @@ test('backup rejects traversal targets before snapshot or restore deletion', ()
|
|
|
143
232
|
);
|
|
144
233
|
});
|
|
145
234
|
});
|
|
235
|
+
|
|
236
|
+
test('managed targets and snapshots reject project symlink traversal', () => {
|
|
237
|
+
inTempProject((root) => {
|
|
238
|
+
fs.mkdirSync('external', { recursive: true });
|
|
239
|
+
fs.symlinkSync(
|
|
240
|
+
path.join(root, 'external'),
|
|
241
|
+
'.agents',
|
|
242
|
+
process.platform === 'win32' ? 'junction' : 'dir'
|
|
243
|
+
);
|
|
244
|
+
|
|
245
|
+
const { createTracker } = require('../lib/manifest');
|
|
246
|
+
const tracker = createTracker('.codex', 'test', {
|
|
247
|
+
recordRoot: '.',
|
|
248
|
+
allowedRoots: ['.codex', '.agents']
|
|
249
|
+
});
|
|
250
|
+
assert.throws(
|
|
251
|
+
() => tracker.keyFor('.agents/skills/specs/SKILL.md'),
|
|
252
|
+
/Refusing to follow symlinked managed path/
|
|
253
|
+
);
|
|
254
|
+
assert.throws(
|
|
255
|
+
() => backup.snapshot(['.agents'], '20260729-symlink-test'),
|
|
256
|
+
/Refusing to follow symlinked managed path/
|
|
257
|
+
);
|
|
258
|
+
});
|
|
259
|
+
});
|
package/bin/install.js
CHANGED
|
@@ -32,6 +32,7 @@ const {
|
|
|
32
32
|
} = require('./phases/claude-runtime');
|
|
33
33
|
const { mergeClaudeSettings } = require('./phases/claude-settings');
|
|
34
34
|
const { installOpenCodeRuntime } = require('./phases/opencode-runtime');
|
|
35
|
+
const { installCodexRuntime } = require('./phases/codex-runtime');
|
|
35
36
|
const { writePlatformVersionMetadata } = require('./phases/write-metadata');
|
|
36
37
|
const { checkVersions } = require('./lib/version-check');
|
|
37
38
|
const { ensureGitignore } = require('./phases/root-config');
|
|
@@ -46,7 +47,11 @@ function installPlatform(ctx, platformKey) {
|
|
|
46
47
|
|
|
47
48
|
// Read ownership baseline + start a fresh tracker for this platform.
|
|
48
49
|
ctx.ownership[platform.folder] = manifestLib.read(platform.folder);
|
|
49
|
-
ctx.trackers[platformKey] = manifestLib.createTracker(
|
|
50
|
+
ctx.trackers[platformKey] = manifestLib.createTracker(
|
|
51
|
+
platform.folder,
|
|
52
|
+
packageJson.version,
|
|
53
|
+
platform.ownership
|
|
54
|
+
);
|
|
50
55
|
|
|
51
56
|
const before = { copied: ctx.results.copied, updated: ctx.results.updated, skills: ctx.results.installedSkills };
|
|
52
57
|
ctx.ui.startSpinner(ctx.t('installingPlatform', { name: platform.name }));
|
|
@@ -66,6 +71,10 @@ function installPlatform(ctx, platformKey) {
|
|
|
66
71
|
installOpenCodeRuntime(ctx, platformKey);
|
|
67
72
|
}
|
|
68
73
|
|
|
74
|
+
if (platformKey === 'codex') {
|
|
75
|
+
installCodexRuntime(ctx, platformKey);
|
|
76
|
+
}
|
|
77
|
+
|
|
69
78
|
writePlatformVersionMetadata(ctx, platformKey);
|
|
70
79
|
|
|
71
80
|
const wrote = (ctx.results.copied - before.copied) + (ctx.results.updated - before.updated);
|
|
@@ -74,7 +83,7 @@ function installPlatform(ctx, platformKey) {
|
|
|
74
83
|
name: platform.name, files: wrote, skills
|
|
75
84
|
}));
|
|
76
85
|
|
|
77
|
-
ctx.results.targets.push(platform.commandsDir);
|
|
86
|
+
ctx.results.targets.push(platform.commandsDir || platform.skillsDir || platform.folder);
|
|
78
87
|
}
|
|
79
88
|
|
|
80
89
|
function printHelp() {
|
|
@@ -83,10 +92,11 @@ function printHelp() {
|
|
|
83
92
|
Usage: npx @haposoft/cafekit [options]
|
|
84
93
|
|
|
85
94
|
Installs CafeKit skills, agents, rules and runtime into the current project
|
|
86
|
-
|
|
95
|
+
for Claude Code, OpenCode, and/or Codex CLI. Re-runs are selective: managed files are updated,
|
|
87
96
|
your edits are preserved.
|
|
88
97
|
|
|
89
98
|
Options:
|
|
99
|
+
--platform <id[,id]> Select claude, opencode, or codex explicitly
|
|
90
100
|
--dry-run Preview changes; write nothing
|
|
91
101
|
--force-overwrite Overwrite user-modified managed files (backup kept)
|
|
92
102
|
-u, --upgrade, -f, --force Alias of --force-overwrite
|
|
@@ -133,7 +143,7 @@ async function main() {
|
|
|
133
143
|
await resolvePlatforms(ctx);
|
|
134
144
|
if (ctx.cancelled) { lock.release(); process.exit(0); }
|
|
135
145
|
|
|
136
|
-
// Version check: same →
|
|
146
|
+
// Version check: same → selective refresh, downgrade → confirm, upgrade → picker
|
|
137
147
|
await checkVersions(ctx);
|
|
138
148
|
if (ctx.cancelled) { lock.release(); process.exit(0); }
|
|
139
149
|
|
|
@@ -158,8 +168,10 @@ async function main() {
|
|
|
158
168
|
// Capture platform folders AND the root files the pipeline mutates
|
|
159
169
|
// (CLAUDE.md, .gitignore) so a mid-run failure rolls back cleanly.
|
|
160
170
|
if (!ctx.dryRun) {
|
|
161
|
-
const
|
|
162
|
-
|
|
171
|
+
const targets = ctx.platforms.flatMap((key) => (
|
|
172
|
+
PLATFORMS[key].backupTargets || [PLATFORMS[key].folder]
|
|
173
|
+
));
|
|
174
|
+
ctx.backupDir = backup.snapshot([...targets, '.gitignore'], ctx.runId);
|
|
163
175
|
}
|
|
164
176
|
|
|
165
177
|
for (const platformKey of ctx.platforms) {
|
|
@@ -169,7 +181,9 @@ async function main() {
|
|
|
169
181
|
ensureGitignore(ctx);
|
|
170
182
|
await runPostInstall(ctx);
|
|
171
183
|
await setupSkillDeps(ctx);
|
|
172
|
-
|
|
184
|
+
if (ctx.platforms.includes('claude')) {
|
|
185
|
+
await setupRtk(ctx);
|
|
186
|
+
}
|
|
173
187
|
|
|
174
188
|
// Phase handlers may report recoverable-looking errors through counters
|
|
175
189
|
// instead of throwing. Treat them as transactional failure so the snapshot
|