@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.
Files changed (49) hide show
  1. package/README.md +80 -9
  2. package/bin/__tests__/codex-hooks.test.js +331 -0
  3. package/bin/__tests__/codex-native.test.js +458 -0
  4. package/bin/__tests__/installer-safety.test.js +115 -1
  5. package/bin/install.js +21 -7
  6. package/bin/lib/backup.js +23 -3
  7. package/bin/lib/codex-frontmatter.js +56 -0
  8. package/bin/lib/codex-install.js +195 -0
  9. package/bin/lib/context.js +65 -8
  10. package/bin/lib/copy-utils.js +1 -1
  11. package/bin/lib/i18n.js +12 -3
  12. package/bin/lib/managed-writer.js +17 -2
  13. package/bin/lib/manifest.js +21 -4
  14. package/bin/lib/opencode-install.js +21 -19
  15. package/bin/lib/path-safety.js +40 -0
  16. package/bin/lib/version-check.js +25 -12
  17. package/bin/phases/claude-runtime.js +3 -5
  18. package/bin/phases/codex-runtime.js +110 -0
  19. package/bin/phases/copy-payload.js +36 -16
  20. package/bin/phases/post-install.js +84 -47
  21. package/bin/phases/root-config.js +4 -2
  22. package/bin/phases/select-platform.js +92 -58
  23. package/bin/phases/summary.js +6 -1
  24. package/package.json +3 -2
  25. package/src/claude/scripts/generate-skill-catalog.cjs +8 -3
  26. package/src/codex/AGENTS.md +30 -0
  27. package/src/codex/agents-gitignore +11 -0
  28. package/src/codex/gitignore +12 -0
  29. package/src/codex/hooks/agent.cjs +66 -0
  30. package/src/codex/hooks/docs-sync.cjs +66 -0
  31. package/src/codex/hooks/inspect-block.cjs +40 -0
  32. package/src/codex/hooks/lib/hook-context.cjs +93 -0
  33. package/src/codex/hooks/lib/inspect-paths.cjs +199 -0
  34. package/src/codex/hooks/lib/privacy-state.cjs +199 -0
  35. package/src/codex/hooks/lib/spec-receipt.cjs +78 -0
  36. package/src/codex/hooks/lib/spec-utils.cjs +50 -0
  37. package/src/codex/hooks/lib/state-store.cjs +166 -0
  38. package/src/codex/hooks/privacy-approval.cjs +59 -0
  39. package/src/codex/hooks/privacy-block.cjs +182 -0
  40. package/src/codex/hooks/rules.cjs +79 -0
  41. package/src/codex/hooks/session.cjs +114 -0
  42. package/src/codex/hooks/spec-gate.cjs +80 -0
  43. package/src/codex/hooks/spec-state.cjs +73 -0
  44. package/src/codex/hooks/state.cjs +157 -0
  45. package/src/codex/hooks/task-scaffold-guard.cjs +63 -0
  46. package/src/codex/hooks.json +122 -0
  47. package/src/codex/rules/hook-protocols.md +32 -0
  48. package/src/codex/rules/state-sync.md +41 -0
  49. package/src/codex/runtime.json +28 -0
@@ -419,6 +419,20 @@ function upsertCafeKitAgentsBlock(existingContent, blockContent) {
419
419
  return existingContent.replace(blockPattern, managedBlock);
420
420
  }
421
421
 
422
+ // Pre-managed-block releases wrote the shipped template as the whole file.
423
+ // Wrap only an exact shipped prefix and preserve any user/Codex suffix.
424
+ if (existingContent.startsWith(blockContent)) {
425
+ return `${managedBlock}${existingContent.slice(blockContent.length)}`;
426
+ }
427
+ const trimmedLegacy = blockContent.trimEnd();
428
+ if (
429
+ existingContent.startsWith(trimmedLegacy)
430
+ && ['', '\n', '\r'].includes(existingContent.charAt(trimmedLegacy.length))
431
+ ) {
432
+ return `${managedBlock}${existingContent.slice(trimmedLegacy.length)}`;
433
+ }
434
+ if (!existingContent) return `${managedBlock}\n`;
435
+
422
436
  const separator = existingContent.endsWith('\n') ? '\n' : '\n\n';
423
437
  return `${existingContent}${separator}${managedBlock}\n`;
424
438
  }
@@ -442,35 +456,23 @@ function getOpenCodeAgentsTemplateContent() {
442
456
  function copyOpenCodeAgentsMdFile(platformKey, results, options = {}) {
443
457
  if (platformKey !== 'opencode') return;
444
458
 
445
- const shouldOverwriteManagedFiles = Boolean(options.upgrade);
446
459
  const dest = 'AGENTS.md';
447
460
  const content = getOpenCodeAgentsTemplateContent();
448
461
 
449
462
  if (content) {
450
463
  const destinationExists = fs.existsSync(dest);
464
+ const existingContent = destinationExists ? fs.readFileSync(dest, 'utf8') : '';
465
+ const nextContent = upsertCafeKitAgentsBlock(existingContent, content);
451
466
 
452
- if (destinationExists && !shouldOverwriteManagedFiles) {
453
- const existingContent = fs.readFileSync(dest, 'utf8');
454
- const nextContent = upsertCafeKitAgentsBlock(existingContent, content);
455
-
456
- if (nextContent === existingContent) {
457
- console.log(` → Skipped: AGENTS.md (already up to date)`);
458
- results.skipped++;
459
- return;
460
- }
461
-
467
+ if (nextContent === existingContent) {
468
+ console.log(` → Skipped: AGENTS.md (already up to date)`);
469
+ results.skipped++;
470
+ } else if (destinationExists) {
462
471
  fs.writeFileSync(dest, nextContent, 'utf8');
463
472
  console.log(` ↻ AGENTS.md merged`);
464
473
  results.updated++;
465
- return;
466
- }
467
-
468
- fs.writeFileSync(dest, content, 'utf8');
469
-
470
- if (destinationExists && shouldOverwriteManagedFiles) {
471
- console.log(` ↻ AGENTS.md overwritten`);
472
- results.updated++;
473
474
  } else {
475
+ fs.writeFileSync(dest, nextContent, 'utf8');
474
476
  console.log(` ✓ AGENTS.md installed`);
475
477
  results.copied++;
476
478
  }
@@ -0,0 +1,40 @@
1
+ 'use strict';
2
+
3
+ const fs = require('fs');
4
+ const path = require('path');
5
+
6
+ function isInside(base, target) {
7
+ const relative = path.relative(base, target);
8
+ return relative === ''
9
+ || (!relative.startsWith(`..${path.sep}`) && relative !== '..' && !path.isAbsolute(relative));
10
+ }
11
+
12
+ /**
13
+ * Reject installer targets that traverse symlinks below the project root.
14
+ * Managed payloads and rollback snapshots must never follow a project-owned
15
+ * link into another directory.
16
+ */
17
+ function assertNoSymlinkPath(filePath, basePath = process.cwd()) {
18
+ const base = path.resolve(basePath);
19
+ const target = path.resolve(filePath);
20
+ if (!isInside(base, target)) {
21
+ throw new Error(`Managed path is outside the project root: ${filePath}`);
22
+ }
23
+
24
+ const relative = path.relative(base, target);
25
+ let cursor = base;
26
+ for (const segment of relative.split(path.sep).filter(Boolean)) {
27
+ cursor = path.join(cursor, segment);
28
+ try {
29
+ if (fs.lstatSync(cursor).isSymbolicLink()) {
30
+ throw new Error(`Refusing to follow symlinked managed path: ${filePath}`);
31
+ }
32
+ } catch (error) {
33
+ if (error.code === 'ENOENT') break;
34
+ throw error;
35
+ }
36
+ }
37
+ return target;
38
+ }
39
+
40
+ module.exports = { assertNoSymlinkPath, isInside };
@@ -4,7 +4,7 @@
4
4
  * Reads each platform's cafekit.json to compare the installed version with the
5
5
  * incoming one and decides whether to proceed:
6
6
  *
7
- * same → info + exit 0 (unless --force-overwrite)
7
+ * same → selective refresh (or full reset with --force-overwrite)
8
8
  * newer → normal update
9
9
  * older → downgrade warning + confirm (interactive) or abort (non-interactive)
10
10
  * missing → fresh install, no check needed
@@ -43,7 +43,7 @@ function getInstalledVersion(platformKey) {
43
43
 
44
44
  /**
45
45
  * Check versions for all selected platforms and decide whether to proceed.
46
- * Sets ctx.cancelled on same-version (without --force) or unconfirmed downgrade.
46
+ * Sets ctx.cancelled on a skipped same-version refresh or unconfirmed downgrade.
47
47
  * Sets ctx.isUpdate = true when upgrading from an older version.
48
48
  * Shows interactive prompt when version differs, asking user what to do.
49
49
  */
@@ -65,33 +65,48 @@ async function checkVersions(ctx) {
65
65
  ctx.isUpdate = false;
66
66
  return ctx;
67
67
  }
68
+ const hasFreshPlatform = platformsWithInstall.length < ctx.platforms.length;
68
69
 
69
70
  // Get unique versions across all platforms
70
71
  const uniqueVersions = [...new Set(platformsWithInstall.map(p => p.installed))];
71
72
  const installedVersion = uniqueVersions.length === 1 ? uniqueVersions[0] : `${uniqueVersions.join(', ')}`;
72
73
 
73
- // Determine overall comparison (assuming same version across platforms)
74
- const firstPlatform = platformsWithInstall[0];
75
- const cmp = cmpVersion(firstPlatform.installed, incoming);
74
+ const compared = platformsWithInstall.map((platform) => ({
75
+ ...platform,
76
+ cmp: cmpVersion(platform.installed, incoming)
77
+ }));
78
+ // Any downgrade risk takes precedence. Otherwise update every stale runtime
79
+ // even when the first selected runtime already matches the incoming version.
80
+ const firstPlatform = compared.find((platform) => platform.cmp > 0)
81
+ || compared.find((platform) => platform.cmp < 0)
82
+ || compared[0];
83
+ const cmp = compared.some((platform) => platform.cmp > 0)
84
+ ? 1
85
+ : compared.some((platform) => platform.cmp < 0) ? -1 : 0;
76
86
 
77
87
  // ── Same version ───────────────────────────────────────────────────────────
78
88
  if (cmp === 0) {
79
89
  ctx.isUpdate = false;
80
90
 
91
+ // A same-version runtime must not prevent adding another selected platform
92
+ // (for example, adding Codex to an existing Claude-only installation).
93
+ if (hasFreshPlatform) return ctx;
94
+
81
95
  if (ctx.options.forceOverwrite) {
82
96
  ctx.ui.info(ctx.t ? ctx.t('versionForceReinstall', { v: installedVersion }) : `Reinstalling CafeKit ${installedVersion} (--force-overwrite)...`);
83
97
  return ctx;
84
98
  }
85
99
 
86
100
  if (!ctx.interactive) {
87
- ctx.ui.info(ctx.t ? ctx.t('versionUpToDate', { v: installedVersion }) : `CafeKit ${installedVersion} is already up to date. Use --force-overwrite to reinstall.`);
88
- ctx.cancelled = true;
101
+ ctx.ui.info(ctx.t
102
+ ? ctx.t('versionRefreshing', { v: installedVersion })
103
+ : `Refreshing CafeKit ${installedVersion}; user-modified files will be preserved.`);
89
104
  return ctx;
90
105
  }
91
106
 
92
107
  // Show current vs new and ask what to do
93
108
  const options = [
94
- { value: 'reinstall', label: ctx.t ? ctx.t('reinstallOption') : 'Reinstall (overwrite managed files)' },
109
+ { value: 'refresh', label: ctx.t ? ctx.t('refreshOption') : 'Refresh managed files (preserve your edits)' },
95
110
  { value: 'skip', label: ctx.t ? ctx.t('skipOption') : 'Skip (exit)' }
96
111
  ];
97
112
 
@@ -107,10 +122,6 @@ async function checkVersions(ctx) {
107
122
  return ctx;
108
123
  }
109
124
 
110
- if (answer === 'reinstall') {
111
- ctx.options.forceOverwrite = true;
112
- }
113
-
114
125
  return ctx;
115
126
  }
116
127
 
@@ -194,6 +205,8 @@ async function checkVersions(ctx) {
194
205
  ? ctx.t('versionDowngrade', { from: firstPlatform.installed, to: incoming })
195
206
  : `Downgrading ${firstPlatform.installed} → ${incoming}. This may remove features.`;
196
207
 
208
+ if (ctx.options.forceOverwrite) return ctx;
209
+
197
210
  if (!ctx.interactive) {
198
211
  ctx.ui.warn ? ctx.ui.warn(msg + ' Downgrade aborted in non-interactive mode. Use --force-overwrite to proceed.') : console.warn(msg);
199
212
  ctx.cancelled = true;
@@ -11,7 +11,7 @@ const fs = require('fs');
11
11
  const path = require('path');
12
12
  const {
13
13
  PLATFORMS,
14
- isClaudeCompatibleRuntime,
14
+ hasPlatformCapability,
15
15
  getRuntimeSupportTargetDir,
16
16
  getCopyOptions
17
17
  } = require('../lib/context');
@@ -165,7 +165,7 @@ function copyClaudeMdFile(ctx, platformKey) {
165
165
 
166
166
  /** Copy the rules/ tree (ownership-aware; no longer force-overwrites). */
167
167
  function copyRulesDirectory(ctx, platformKey) {
168
- if (!isClaudeCompatibleRuntime(platformKey)) return;
168
+ if (!hasPlatformCapability(platformKey, 'rules')) return;
169
169
 
170
170
  const src = path.join(SRC, 'claude/rules');
171
171
  const dest = getRuntimeSupportTargetDir(platformKey, 'rules');
@@ -175,9 +175,7 @@ function copyRulesDirectory(ctx, platformKey) {
175
175
  return;
176
176
  }
177
177
 
178
- const transform = platformKey === 'opencode'
179
- ? getCopyOptions('opencode', {}).transform
180
- : undefined;
178
+ const transform = getCopyOptions(platformKey, {}).transform;
181
179
  const agg = copyManagedTree({
182
180
  src, dest, platformFolder: PLATFORMS[platformKey].folder, ctx, tracker: ctx.trackers[platformKey], transform
183
181
  });
@@ -0,0 +1,110 @@
1
+ 'use strict';
2
+
3
+ const fs = require('fs');
4
+ const path = require('path');
5
+ const { PLATFORMS } = require('../lib/context');
6
+ const { writeManagedFile, copyManagedTree } = require('../lib/managed-writer');
7
+ const { report, treeAction } = require('./report');
8
+ const { copyRulesDirectory } = require('./claude-runtime');
9
+ const {
10
+ normalizeCodexBody,
11
+ upsertManagedCodexBlock
12
+ } = require('../lib/codex-install');
13
+
14
+ const SRC = path.join(__dirname, '../../src');
15
+ const CODEX_SRC = path.join(SRC, 'codex');
16
+
17
+ const CODEX_OWN_RUNTIME = [
18
+ ['gitignore', '.gitignore'],
19
+ ['runtime.json', 'runtime.json'],
20
+ ['hooks.json', 'hooks.json']
21
+ ];
22
+
23
+ function writeSourceFile(ctx, platformKey, src, dest, label, transform) {
24
+ const platform = PLATFORMS[platformKey];
25
+ const { action } = writeManagedFile({
26
+ src,
27
+ dest,
28
+ platformFolder: platform.folder,
29
+ ctx,
30
+ tracker: ctx.trackers[platformKey],
31
+ transform
32
+ });
33
+ report(ctx, action, label);
34
+ }
35
+
36
+ function installRuntimeFiles(ctx, platformKey) {
37
+ const platform = PLATFORMS[platformKey];
38
+ for (const [sourceRel, targetRel] of CODEX_OWN_RUNTIME) {
39
+ writeSourceFile(
40
+ ctx,
41
+ platformKey,
42
+ path.join(CODEX_SRC, sourceRel),
43
+ path.join(platform.folder, targetRel),
44
+ `Codex runtime: ${targetRel}`
45
+ );
46
+ }
47
+ writeSourceFile(
48
+ ctx,
49
+ platformKey,
50
+ path.join(CODEX_SRC, 'agents-gitignore'),
51
+ path.join('.agents', '.gitignore'),
52
+ 'Codex skills: .agents/.gitignore'
53
+ );
54
+
55
+ const agg = copyManagedTree({
56
+ src: path.join(CODEX_SRC, 'hooks'),
57
+ dest: path.join(platform.folder, 'hooks'),
58
+ platformFolder: platform.folder,
59
+ ctx,
60
+ tracker: ctx.trackers[platformKey]
61
+ });
62
+ report(ctx, treeAction(agg), 'Codex native hooks');
63
+ }
64
+
65
+ function installNativeRuleOverrides(ctx, platformKey) {
66
+ const platform = PLATFORMS[platformKey];
67
+ const source = path.join(CODEX_SRC, 'rules');
68
+ if (!fs.existsSync(source)) return;
69
+ const agg = copyManagedTree({
70
+ src: source,
71
+ dest: path.join(platform.folder, 'rules'),
72
+ platformFolder: platform.folder,
73
+ ctx,
74
+ tracker: ctx.trackers[platformKey]
75
+ });
76
+ report(ctx, treeAction(agg), 'Codex native rule overrides');
77
+ }
78
+
79
+ function installManagedAgentsMd(ctx) {
80
+ const source = path.join(CODEX_SRC, 'AGENTS.md');
81
+ if (!fs.existsSync(source)) {
82
+ report(ctx, 'missing', 'Codex AGENTS.md template');
83
+ return;
84
+ }
85
+
86
+ const destination = 'AGENTS.md';
87
+ const exists = fs.existsSync(destination);
88
+ const existing = exists ? fs.readFileSync(destination, 'utf8') : '';
89
+ const block = normalizeCodexBody(fs.readFileSync(source, 'utf8'))
90
+ .replace(/^# CLAUDE\.md\s*$/m, '# CafeKit for Codex CLI')
91
+ .trim();
92
+ const next = upsertManagedCodexBlock(existing, block);
93
+ const action = !exists ? 'created' : next === existing ? 'unchanged' : 'updated';
94
+
95
+ if (!ctx.dryRun && action !== 'unchanged') {
96
+ fs.writeFileSync(destination, next, 'utf8');
97
+ }
98
+ report(ctx, action, 'AGENTS.md (CafeKit Codex block)');
99
+ }
100
+
101
+ function installCodexRuntime(ctx, platformKey) {
102
+ if (platformKey !== 'codex') return ctx;
103
+ installRuntimeFiles(ctx, platformKey);
104
+ copyRulesDirectory(ctx, platformKey);
105
+ installNativeRuleOverrides(ctx, platformKey);
106
+ installManagedAgentsMd(ctx);
107
+ return ctx;
108
+ }
109
+
110
+ module.exports = { installCodexRuntime };
@@ -11,7 +11,7 @@ const path = require('path');
11
11
  const {
12
12
  PLATFORMS,
13
13
  DEPENDENCY_TEMPLATES,
14
- isClaudeCompatibleRuntime,
14
+ hasPlatformCapability,
15
15
  getRuntimeSupportTargetDir,
16
16
  getCopyOptions
17
17
  } = require('../lib/context');
@@ -21,6 +21,10 @@ const {
21
21
  convertOpenCodeAgentContent,
22
22
  convertOpenCodeCommandContent
23
23
  } = require('../lib/opencode-install');
24
+ const {
25
+ codexAgentName,
26
+ convertCodexAgentContent
27
+ } = require('../lib/codex-install');
24
28
 
25
29
  const SRC = path.join(__dirname, '../../src');
26
30
 
@@ -53,6 +57,7 @@ function getPlatformSpecFiles(platformKey, ctx) {
53
57
  function ensureWorkflowDependencies(ctx, platformKey) {
54
58
  const platform = PLATFORMS[platformKey];
55
59
  const tracker = ctx.trackers[platformKey];
60
+ if (!platform.commandsDir) return;
56
61
  const commandTemplates = DEPENDENCY_TEMPLATES.commands[platformKey] || {};
57
62
  Object.entries(commandTemplates).forEach(([fileName, content]) => {
58
63
  if (!content) return;
@@ -72,20 +77,18 @@ function copyPlatformFiles(ctx, platformKey) {
72
77
  const platformFolder = platform.folder;
73
78
 
74
79
  // For OpenCode, text bodies are path-normalized (.claude → .opencode, etc.).
75
- const bodyTransform = platformKey === 'opencode'
76
- ? getCopyOptions('opencode', {}).transform
77
- : undefined;
80
+ const bodyTransform = getCopyOptions(platformKey, {}).transform;
78
81
 
79
82
  const skillsSourceDir = path.join(SRC, 'claude/skills');
80
83
  const agentsSourceDir = path.join(SRC, platform.sourceDir, 'agents');
81
84
 
82
85
  if (!ctx.dryRun) {
83
- fs.mkdirSync(platform.skillsDir, { recursive: true });
84
- fs.mkdirSync(platform.agentsDir, { recursive: true });
86
+ if (platform.skillsDir) fs.mkdirSync(platform.skillsDir, { recursive: true });
87
+ if (platform.agentsDir) fs.mkdirSync(platform.agentsDir, { recursive: true });
85
88
  }
86
89
 
87
90
  // ── Skills ──────────────────────────────────────────────
88
- if (fs.existsSync(skillsSourceDir)) {
91
+ if (hasPlatformCapability(platformKey, 'skills') && fs.existsSync(skillsSourceDir)) {
89
92
  const specSkillSource = path.join(skillsSourceDir, 'specs');
90
93
  const specSkillDest = path.join(platform.skillsDir, 'specs');
91
94
 
@@ -98,9 +101,9 @@ function copyPlatformFiles(ctx, platformKey) {
98
101
  }
99
102
 
100
103
  // Keep spec templates in sync (Claude command runtime reads these).
101
- if (platformKey === 'claude') {
104
+ if (platformKey === 'claude' || platformKey === 'codex') {
102
105
  const legacyInitTemplate = path.join(platform.skillsDir, 'specs', 'templates', 'init.json');
103
- if (fs.existsSync(legacyInitTemplate)) {
106
+ if (platformKey === 'claude' && fs.existsSync(legacyInitTemplate)) {
104
107
  if (!ctx.dryRun) fs.rmSync(legacyInitTemplate, { force: true });
105
108
  ctx.ui.detail(` ↻ ${ctx.dryRun ? '[dry-run] ' : ''}Removed legacy template: ${legacyInitTemplate}`);
106
109
  ctx.results.updated++;
@@ -118,14 +121,21 @@ function copyPlatformFiles(ctx, platformKey) {
118
121
  specTemplates.forEach((fileName) => {
119
122
  const src = path.join(specSkillSource, 'templates', fileName);
120
123
  const dest = path.join(platform.skillsDir, 'specs', 'templates', fileName);
121
- const { action } = writeManagedFile({ src, dest, platformFolder, ctx, tracker });
124
+ const { action } = writeManagedFile({
125
+ src,
126
+ dest,
127
+ platformFolder,
128
+ ctx,
129
+ tracker,
130
+ transform: bodyTransform
131
+ });
122
132
  report(ctx, action, `template: ${fileName}`);
123
133
  });
124
134
  }
125
135
 
126
136
  // Additional required skills.
127
137
  let requiredSkills = [];
128
- if (isClaudeCompatibleRuntime(platformKey)) {
138
+ if (hasPlatformCapability(platformKey, 'skills')) {
129
139
  requiredSkills = ctx.manifest?.skills?.required || [];
130
140
  }
131
141
  requiredSkills
@@ -146,18 +156,24 @@ function copyPlatformFiles(ctx, platformKey) {
146
156
  }
147
157
 
148
158
  // ── Agents + references + scripts ───────────────────────
149
- if (isClaudeCompatibleRuntime(platformKey)) {
159
+ if (hasPlatformCapability(platformKey, 'agents')) {
150
160
  if (fs.existsSync(agentsSourceDir)) {
151
161
  const requiredAgents = ctx.manifest?.agents?.required || [
152
162
  'tester.md', 'code-reviewer.md', 'fullstack-developer.md', 'debugger.md'
153
163
  ];
154
- const agentTransform = platformKey === 'opencode'
155
- ? (content, src) => convertOpenCodeAgentContent(content, path.basename(src))
156
- : undefined;
164
+ let agentTransform;
165
+ if (platformKey === 'opencode') {
166
+ agentTransform = (content, src) => convertOpenCodeAgentContent(content, path.basename(src));
167
+ } else if (platformKey === 'codex') {
168
+ agentTransform = (content, src) => convertCodexAgentContent(content, path.basename(src));
169
+ }
157
170
 
158
171
  requiredAgents.forEach((fileName) => {
159
172
  const src = path.join(agentsSourceDir, fileName);
160
- const dest = path.join(platform.agentsDir, fileName);
173
+ const destName = platformKey === 'codex'
174
+ ? `${codexAgentName(fileName)}.toml`
175
+ : fileName;
176
+ const dest = path.join(platform.agentsDir, destName);
161
177
  const { action } = writeManagedFile({
162
178
  src, dest, platformFolder, ctx, tracker, transform: agentTransform
163
179
  });
@@ -189,6 +205,10 @@ function copyPlatformFiles(ctx, platformKey) {
189
205
  ensureWorkflowDependencies(ctx, platformKey);
190
206
 
191
207
  // ── Commands ────────────────────────────────────────────
208
+ if (!hasPlatformCapability(platformKey, 'commands') || !platform.commandsDir) {
209
+ return ctx;
210
+ }
211
+
192
212
  const sourceSubdir = platform.sourceSubdir || 'commands';
193
213
  const commandsSourceDir = path.join(SRC, platform.sourceDir, sourceSubdir);
194
214
  const specFiles = getPlatformSpecFiles(platformKey, ctx);
@@ -16,26 +16,60 @@ const path = require('path');
16
16
  const { PLATFORMS } = require('../lib/context');
17
17
  const { LANGUAGE_LABELS } = require('../lib/i18n');
18
18
  const { setupOpenCodeModel } = require('../lib/opencode-install');
19
+ const { transformManagedCodexContent } = require('../lib/codex-install');
19
20
  const { transformManagedClaudeContent } = require('./claude-runtime');
20
-
21
- function updateManagedClaudeFile(filePath, transform) {
22
- const content = fs.readFileSync(filePath, 'utf8');
23
- const next = transformManagedClaudeContent(content, transform);
24
- if (next === content) return false;
25
- fs.writeFileSync(filePath, next, 'utf8');
26
- return true;
27
- }
28
-
29
- function readManagedClaudeBody(filePath) {
21
+ const ASSISTANT_NAMES = {
22
+ claude: 'Claude Code',
23
+ codex: 'Codex CLI',
24
+ opencode: 'OpenCode'
25
+ };
26
+ const LANGUAGE_LABEL_BY_CODE = {
27
+ vi: 'Vietnamese',
28
+ ja: 'Japanese',
29
+ en: 'English'
30
+ };
31
+
32
+ function readManagedBody(filePath, transformManagedContent) {
30
33
  const content = fs.readFileSync(filePath, 'utf8');
31
34
  let body = '';
32
- transformManagedClaudeContent(content, (managed) => {
35
+ transformManagedContent(content, (managed) => {
33
36
  body = managed;
34
37
  return managed;
35
38
  });
36
39
  return body;
37
40
  }
38
41
 
42
+ function instructionTargets(ctx) {
43
+ const targets = {
44
+ claude: {
45
+ file: path.join(process.cwd(), 'CLAUDE.md'),
46
+ transform: transformManagedClaudeContent
47
+ },
48
+ codex: {
49
+ file: path.join(process.cwd(), 'AGENTS.md'),
50
+ transform: transformManagedCodexContent
51
+ }
52
+ };
53
+ return Object.keys(targets)
54
+ .filter((key) => ctx.platforms.includes(key))
55
+ .map((key) => ({ key, ...targets[key] }));
56
+ }
57
+
58
+ function updateInstructionTarget(target, updateManaged) {
59
+ if (!fs.existsSync(target.file)) return false;
60
+ const content = fs.readFileSync(target.file, 'utf8');
61
+ const next = target.transform(content, updateManaged);
62
+ if (next === content) return false;
63
+ fs.writeFileSync(target.file, next, 'utf8');
64
+ return true;
65
+ }
66
+
67
+ function trackerOwnsPath(tracker, filePath) {
68
+ if (!tracker?.keyFor) return false;
69
+ const trackedKey = tracker.keyFor(filePath);
70
+ return tracker.recorded(trackedKey) !== null;
71
+ }
72
+
39
73
  /** Write `"language"` field into .claude/settings.json so it's visible at a glance. */
40
74
  function patchSettingsLanguage(ctx) {
41
75
  if (!ctx.platforms.includes('claude')) return;
@@ -65,17 +99,12 @@ function patchSettingsLanguage(ctx) {
65
99
  * can update it idempotently.
66
100
  */
67
101
  function patchLanguageSection(ctx) {
68
- if (!ctx.platforms.includes('claude')) return;
69
102
  // Skip when locale is English (default — no override needed in CLAUDE.md).
70
103
  const locale = ctx.locale || ctx.lang;
71
104
  if (!locale || locale === 'en' || locale === 'English') return;
72
105
 
73
- const claudeMdFile = path.join(process.cwd(), 'CLAUDE.md');
74
- if (!fs.existsSync(claudeMdFile)) return;
75
-
76
- const LANG_LABEL = { vi: 'Vietnamese', ja: 'Japanese', en: 'English' };
77
106
  // Use locale directly when set (e.g. "Korean"), or map from lang code.
78
- const label = locale in LANG_LABEL ? LANG_LABEL[locale] : locale;
107
+ const label = locale in LANGUAGE_LABEL_BY_CODE ? LANGUAGE_LABEL_BY_CODE[locale] : locale;
79
108
 
80
109
  const newSection = `## Language Consistency <!-- cafekit:lang -->
81
110
 
@@ -86,12 +115,15 @@ Always respond in **${label}**. Technical terms, code identifiers, and file path
86
115
  // Replace only inside the installer-owned block.
87
116
  const markerRe = /## Language Consistency <!-- cafekit:lang -->[\s\S]*?(?=\n##|\n*$)/;
88
117
  const genericRe = /## Language Consistency\n[\s\S]*?(?=\n##|\n*$)/;
89
- updateManagedClaudeFile(claudeMdFile, (managed) => {
90
- if (markerRe.test(managed)) return managed.replace(markerRe, newSection);
91
- if (genericRe.test(managed)) return managed.replace(genericRe, newSection);
92
- return `${managed.trimEnd()}\n\n${newSection}\n`;
93
- });
118
+ for (const target of instructionTargets(ctx)) {
119
+ updateInstructionTarget(target, (managed) => {
120
+ if (markerRe.test(managed)) return managed.replace(markerRe, newSection);
121
+ if (genericRe.test(managed)) return managed.replace(genericRe, newSection);
122
+ return `${managed.trimEnd()}\n\n${newSection}\n`;
123
+ });
124
+ }
94
125
  }
126
+
95
127
  function patchRuntimeLocale(ctx) {
96
128
  for (const key of ctx.platforms) {
97
129
  const rtPath = path.join(PLATFORMS[key].folder, 'runtime.json');
@@ -112,48 +144,53 @@ function patchRuntimeLocale(ctx) {
112
144
  if (!ctx.locale && data.locale.responseLanguage) continue;
113
145
  data.locale.responseLanguage = locale;
114
146
  fs.writeFileSync(rtPath, `${JSON.stringify(data, null, 2)}\n`, 'utf8');
115
- if (ctx.trackers[key]) ctx.trackers[key].record(rtPath);
147
+ // A preserved user-created/user-modified runtime was deliberately not
148
+ // recorded by managed-writer. Updating its explicit locale must not turn
149
+ // the whole user file into a pristine CafeKit ownership baseline.
150
+ const tracker = ctx.trackers[key];
151
+ if (trackerOwnsPath(tracker, rtPath)) tracker.record(rtPath);
116
152
  }
117
153
  }
118
154
 
119
155
  /** Human assistant name for the active platform(s). */
120
- function assistantName(ctx) {
121
- return ctx.platforms.includes('claude') ? 'Claude Code' : 'OpenCode';
156
+ function assistantName(platformKey) {
157
+ return ASSISTANT_NAMES[platformKey] || ASSISTANT_NAMES.opencode;
122
158
  }
123
159
 
124
160
  function configureAddressing(ctx, userAddress) {
125
- const claudeMdFile = path.join(process.cwd(), 'CLAUDE.md');
126
- if (!fs.existsSync(claudeMdFile)) {
127
- ctx.ui.warn('CLAUDE.md not found at project root; skipped addressing');
128
- return;
129
- }
130
-
131
- const name = assistantName(ctx);
132
- const addressingSection = `## Addressing (Context Overflow Indicator)
161
+ let found = false;
162
+ for (const target of instructionTargets(ctx)) {
163
+ if (!fs.existsSync(target.file)) continue;
164
+ found = true;
165
+ const name = assistantName(target.key);
166
+ const addressingSection = `## Addressing (Context Overflow Indicator)
133
167
 
134
168
  ${name} always addresses the user as "${userAddress}" throughout the conversation. If it stops doing so, it is a sign the context has been compacted/truncated — tell the user to consider \`/clear\`.`;
135
169
 
136
- // Idempotent and scoped to the installer-owned block.
137
- const regex = /##[^\n]*Context Overflow Indicator[^\n]*[\s\S]*?(?=\n##|\n*$)/;
138
- const changed = updateManagedClaudeFile(claudeMdFile, (managed) => (
139
- regex.test(managed)
140
- ? managed.replace(regex, addressingSection)
141
- : `${managed.endsWith('\n') ? managed : `${managed}\n`}\n${addressingSection}\n`
142
- ));
143
- if (changed) ctx.ui.success(ctx.t('addressingSet', { name, addr: userAddress }));
170
+ const regex = /##[^\n]*Context Overflow Indicator[^\n]*[\s\S]*?(?=\n##|\n*$)/;
171
+ const changed = updateInstructionTarget(target, (managed) => (
172
+ regex.test(managed)
173
+ ? managed.replace(regex, addressingSection)
174
+ : `${managed.endsWith('\n') ? managed : `${managed}\n`}\n${addressingSection}\n`
175
+ ));
176
+ if (changed) {
177
+ ctx.ui.success(ctx.t('addressingSet', { name, addr: userAddress }));
178
+ }
179
+ }
180
+ if (!found) ctx.ui.warn('Project instruction file not found; skipped addressing');
144
181
  }
145
182
 
146
183
  async function setupAddressing(ctx) {
147
- if (!ctx.platforms.includes('claude')) return;
184
+ if (!ctx.platforms.some((key) => key === 'claude' || key === 'codex')) return;
148
185
 
149
- // Check if there's already an addressing section in CLAUDE.md
150
- const claudeMdFile = path.join(process.cwd(), 'CLAUDE.md');
151
186
  let existingName = null;
152
- if (fs.existsSync(claudeMdFile)) {
153
- const content = readManagedClaudeBody(claudeMdFile);
187
+ for (const target of instructionTargets(ctx)) {
188
+ if (!fs.existsSync(target.file)) continue;
189
+ const content = readManagedBody(target.file, target.transform);
154
190
  const match = content.match(/##[^\n]*Context Overflow Indicator[^\n]*[\s\S]*?always addresses the user as "([^"]+)"/);
155
191
  if (match) {
156
192
  existingName = match[1];
193
+ break;
157
194
  }
158
195
  }
159
196
 
@@ -223,4 +260,4 @@ async function runPostInstall(ctx) {
223
260
  return ctx;
224
261
  }
225
262
 
226
- module.exports = { configureAddressing, runPostInstall };
263
+ module.exports = { configureAddressing, patchRuntimeLocale, runPostInstall };