@haposoft/cafekit 0.14.1 → 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 (100) hide show
  1. package/README.md +86 -11
  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 +259 -0
  5. package/bin/install.js +30 -8
  6. package/bin/lib/backup.js +82 -18
  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 +60 -9
  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 +98 -48
  21. package/bin/phases/root-config.js +4 -2
  22. package/bin/phases/select-platform.js +105 -66
  23. package/bin/phases/summary.js +6 -1
  24. package/package.json +3 -2
  25. package/src/claude/CLAUDE.md +1 -1
  26. package/src/claude/agents/code-auditor.md +1 -1
  27. package/src/claude/agents/debugger.md +1 -0
  28. package/src/claude/agents/god-developer.md +10 -1
  29. package/src/claude/agents/inspector.md +1 -0
  30. package/src/claude/agents/spec-maker.md +1 -1
  31. package/src/claude/agents/test-runner.md +2 -2
  32. package/src/claude/hooks/__tests__/privacy-block.test.js +50 -10
  33. package/src/claude/hooks/__tests__/spec-gate.test.js +75 -1
  34. package/src/claude/hooks/__tests__/state.test.js +65 -0
  35. package/src/claude/hooks/docs-sync.cjs +15 -21
  36. package/src/claude/hooks/inspect-block.cjs +1 -1
  37. package/src/claude/hooks/lib/config.cjs +0 -5
  38. package/src/claude/hooks/privacy-block.cjs +39 -62
  39. package/src/claude/hooks/spec-gate.cjs +18 -7
  40. package/src/claude/hooks/spec-state.cjs +4 -0
  41. package/src/claude/hooks/state.cjs +13 -3
  42. package/src/claude/hooks/usage.cjs +6 -0
  43. package/src/claude/rules/orchestrator.md +2 -2
  44. package/src/claude/rules/skill-domain-routing.md +27 -86
  45. package/src/claude/rules/skill-workflow-routing.md +11 -82
  46. package/src/claude/rules/state-sync.md +1 -1
  47. package/src/claude/runtime.json +7 -7
  48. package/src/claude/scripts/generate-skill-catalog.cjs +8 -3
  49. package/src/claude/scripts/validate-spec-output.cjs +6 -0
  50. package/src/claude/skills/code-review/references/spec-compliance-review.md +1 -1
  51. package/src/claude/skills/develop/SKILL.md +26 -5
  52. package/src/claude/skills/develop/references/parallel-waves.md +112 -0
  53. package/src/claude/skills/develop/references/quality-gate.md +10 -2
  54. package/src/claude/skills/hotfix/references/parallel-patterns.md +1 -1
  55. package/src/claude/skills/inspect/SKILL.md +42 -113
  56. package/src/claude/skills/inspect/references/internal-inspection.md +16 -135
  57. package/src/claude/skills/specs/SKILL.md +134 -386
  58. package/src/claude/skills/specs/rules/tasks-generation.md +1 -1
  59. package/src/claude/skills/specs/templates/task.md +3 -1
  60. package/src/claude/skills/sync/SKILL.md +2 -2
  61. package/src/claude/skills/sync/references/sync-protocols.md +4 -4
  62. package/src/claude/skills/test/SKILL.md +1 -1
  63. package/src/claude/status.cjs +9 -6
  64. package/src/codex/AGENTS.md +30 -0
  65. package/src/codex/agents-gitignore +11 -0
  66. package/src/codex/gitignore +12 -0
  67. package/src/codex/hooks/agent.cjs +66 -0
  68. package/src/codex/hooks/docs-sync.cjs +66 -0
  69. package/src/codex/hooks/inspect-block.cjs +40 -0
  70. package/src/codex/hooks/lib/hook-context.cjs +93 -0
  71. package/src/codex/hooks/lib/inspect-paths.cjs +199 -0
  72. package/src/codex/hooks/lib/privacy-state.cjs +199 -0
  73. package/src/codex/hooks/lib/spec-receipt.cjs +78 -0
  74. package/src/codex/hooks/lib/spec-utils.cjs +50 -0
  75. package/src/codex/hooks/lib/state-store.cjs +166 -0
  76. package/src/codex/hooks/privacy-approval.cjs +59 -0
  77. package/src/codex/hooks/privacy-block.cjs +182 -0
  78. package/src/codex/hooks/rules.cjs +79 -0
  79. package/src/codex/hooks/session.cjs +114 -0
  80. package/src/codex/hooks/spec-gate.cjs +80 -0
  81. package/src/codex/hooks/spec-state.cjs +73 -0
  82. package/src/codex/hooks/state.cjs +157 -0
  83. package/src/codex/hooks/task-scaffold-guard.cjs +63 -0
  84. package/src/codex/hooks.json +122 -0
  85. package/src/codex/rules/hook-protocols.md +32 -0
  86. package/src/codex/rules/state-sync.md +41 -0
  87. package/src/codex/runtime.json +28 -0
  88. package/src/opencode/plugins/docs-sync.ts +10 -16
  89. package/src/claude/archive-command/code/SKILL.md +0 -55
  90. package/src/claude/archive-command/code/references/execution-loop.md +0 -21
  91. package/src/claude/archive-command/code.md +0 -17
  92. package/src/claude/archive-command/docs.md +0 -609
  93. package/src/claude/archive-command/review/codebase/parallel.md +0 -76
  94. package/src/claude/archive-command/spec-design.md +0 -247
  95. package/src/claude/archive-command/spec-init.md +0 -120
  96. package/src/claude/archive-command/spec-requirements.md +0 -138
  97. package/src/claude/archive-command/spec-status.md +0 -98
  98. package/src/claude/archive-command/spec-tasks.md +0 -173
  99. package/src/claude/archive-command/spec-validate.md +0 -118
  100. package/src/claude/archive-command/test.md +0 -8
@@ -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,15 +11,54 @@ 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');
18
+ const { sha256 } = require('../lib/manifest');
18
19
  const { writeManagedFile, copyManagedTree } = require('../lib/managed-writer');
19
20
  const { report, treeAction } = require('./report');
20
21
  const { normalizeOpenCodeBody } = require('../lib/opencode-install');
21
22
 
22
23
  const SRC = path.join(__dirname, '../../src');
24
+ const CLAUDE_START = '<!-- CAFEKIT CLAUDE START -->';
25
+ const CLAUDE_END = '<!-- CAFEKIT CLAUDE END -->';
26
+
27
+ function managedClaudeBlock(template) {
28
+ return `${CLAUDE_START}\n${template.trimEnd()}\n${CLAUDE_END}`;
29
+ }
30
+
31
+ function managedClaudeRange(content) {
32
+ const start = content.indexOf(CLAUDE_START);
33
+ if (start === -1) return null;
34
+ const endStart = content.indexOf(CLAUDE_END, start + CLAUDE_START.length);
35
+ if (endStart === -1) return null;
36
+ return { start, end: endStart + CLAUDE_END.length, bodyStart: start + CLAUDE_START.length, bodyEnd: endStart };
37
+ }
38
+
39
+ /** Transform only the CafeKit-owned body, preserving all surrounding bytes. */
40
+ function transformManagedClaudeContent(content, transform) {
41
+ const range = managedClaudeRange(content);
42
+ if (!range) return content;
43
+ const body = content.slice(range.bodyStart, range.bodyEnd);
44
+ return `${content.slice(0, range.bodyStart)}${transform(body)}${content.slice(range.bodyEnd)}`;
45
+ }
46
+
47
+ function upsertManagedClaudeBlock(existing, template, legacyOwned) {
48
+ const block = managedClaudeBlock(template);
49
+ const range = managedClaudeRange(existing);
50
+ if (range) {
51
+ return `${existing.slice(0, range.start)}${block}${existing.slice(range.end)}`;
52
+ }
53
+
54
+ // Whole-file migration is deliberately limited to exact shipped content or
55
+ // a file whose bytes still match the legacy ownership manifest.
56
+ if (existing === template || legacyOwned) return `${block}\n`;
57
+ if (!existing) return `${block}\n`;
58
+
59
+ const separator = existing.endsWith('\n') ? '\n' : '\n\n';
60
+ return `${existing}${separator}${block}\n`;
61
+ }
23
62
 
24
63
  /** Copy ROUTING.md (SKILLS_DIR substituted; OpenCode body normalized). */
25
64
  function copyRoutingFile(ctx, platformKey) {
@@ -105,15 +144,28 @@ function copyClaudeMdFile(ctx, platformKey) {
105
144
  return;
106
145
  }
107
146
 
108
- const { action } = writeManagedFile({
109
- src, dest, platformFolder: PLATFORMS.claude.folder, ctx, tracker: ctx.trackers.claude
110
- });
147
+ const tracker = ctx.trackers.claude;
148
+ // Older releases recorded the root file relative to .claude/. Never retain
149
+ // that whole-file ownership claim, even when this run makes no content change.
150
+ if (tracker) tracker.prune('../CLAUDE.md');
151
+
152
+ const template = fs.readFileSync(src, 'utf8');
153
+ const exists = fs.existsSync(dest);
154
+ const existing = exists ? fs.readFileSync(dest, 'utf8') : '';
155
+ const legacyRecord = ctx.ownership?.[PLATFORMS.claude.folder]?.files?.['../CLAUDE.md'];
156
+ const legacyOwned = Boolean(legacyRecord && legacyRecord.sha256 === sha256(existing));
157
+ const next = upsertManagedClaudeBlock(existing, template, legacyOwned);
158
+ const action = !exists ? 'created' : next === existing ? 'unchanged' : 'updated';
159
+
160
+ if (!ctx.dryRun && action !== 'unchanged') {
161
+ fs.writeFileSync(dest, next, 'utf8');
162
+ }
111
163
  report(ctx, action, 'CLAUDE.md');
112
164
  }
113
165
 
114
166
  /** Copy the rules/ tree (ownership-aware; no longer force-overwrites). */
115
167
  function copyRulesDirectory(ctx, platformKey) {
116
- if (!isClaudeCompatibleRuntime(platformKey)) return;
168
+ if (!hasPlatformCapability(platformKey, 'rules')) return;
117
169
 
118
170
  const src = path.join(SRC, 'claude/rules');
119
171
  const dest = getRuntimeSupportTargetDir(platformKey, 'rules');
@@ -123,9 +175,7 @@ function copyRulesDirectory(ctx, platformKey) {
123
175
  return;
124
176
  }
125
177
 
126
- const transform = platformKey === 'opencode'
127
- ? getCopyOptions('opencode', {}).transform
128
- : undefined;
178
+ const transform = getCopyOptions(platformKey, {}).transform;
129
179
  const agg = copyManagedTree({
130
180
  src, dest, platformFolder: PLATFORMS[platformKey].folder, ctx, tracker: ctx.trackers[platformKey], transform
131
181
  });
@@ -137,5 +187,6 @@ module.exports = {
137
187
  copyClaudeRuntimeFiles,
138
188
  removeObsoleteClaudeRuntimeFiles,
139
189
  copyClaudeMdFile,
140
- copyRulesDirectory
190
+ copyRulesDirectory,
191
+ transformManagedClaudeContent
141
192
  };
@@ -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,6 +16,59 @@ 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');
20
+ const { transformManagedClaudeContent } = require('./claude-runtime');
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) {
33
+ const content = fs.readFileSync(filePath, 'utf8');
34
+ let body = '';
35
+ transformManagedContent(content, (managed) => {
36
+ body = managed;
37
+ return managed;
38
+ });
39
+ return body;
40
+ }
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
+ }
19
72
 
20
73
  /** Write `"language"` field into .claude/settings.json so it's visible at a glance. */
21
74
  function patchSettingsLanguage(ctx) {
@@ -46,17 +99,12 @@ function patchSettingsLanguage(ctx) {
46
99
  * can update it idempotently.
47
100
  */
48
101
  function patchLanguageSection(ctx) {
49
- if (!ctx.platforms.includes('claude')) return;
50
102
  // Skip when locale is English (default — no override needed in CLAUDE.md).
51
103
  const locale = ctx.locale || ctx.lang;
52
104
  if (!locale || locale === 'en' || locale === 'English') return;
53
105
 
54
- const claudeMdFile = path.join(process.cwd(), 'CLAUDE.md');
55
- if (!fs.existsSync(claudeMdFile)) return;
56
-
57
- const LANG_LABEL = { vi: 'Vietnamese', ja: 'Japanese', en: 'English' };
58
106
  // Use locale directly when set (e.g. "Korean"), or map from lang code.
59
- 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;
60
108
 
61
109
  const newSection = `## Language Consistency <!-- cafekit:lang -->
62
110
 
@@ -64,21 +112,18 @@ Always respond in **${label}**. Technical terms, code identifiers, and file path
64
112
 
65
113
  `;
66
114
 
67
- let content = fs.readFileSync(claudeMdFile, 'utf8');
68
- // Replace if marker present, else replace the generic section.
115
+ // Replace only inside the installer-owned block.
69
116
  const markerRe = /## Language Consistency <!-- cafekit:lang -->[\s\S]*?(?=\n##|\n*$)/;
70
117
  const genericRe = /## Language Consistency\n[\s\S]*?(?=\n##|\n*$)/;
71
-
72
- if (markerRe.test(content)) {
73
- content = content.replace(markerRe, newSection);
74
- } else if (genericRe.test(content)) {
75
- content = content.replace(genericRe, newSection);
76
- } else {
77
- content += `\n${newSection}\n`;
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
+ });
78
124
  }
79
-
80
- fs.writeFileSync(claudeMdFile, content, 'utf8');
81
125
  }
126
+
82
127
  function patchRuntimeLocale(ctx) {
83
128
  for (const key of ctx.platforms) {
84
129
  const rtPath = path.join(PLATFORMS[key].folder, 'runtime.json');
@@ -93,51 +138,59 @@ function patchRuntimeLocale(ctx) {
93
138
  // Use locale (freeform label) so custom languages propagate to the AI hook.
94
139
  const locale = ctx.locale || ctx.lang;
95
140
  if (data.locale.responseLanguage === locale) continue;
141
+ // Hardening: never downgrade an existing configured label to a bare default
142
+ // code when this run never made an explicit language choice (ctx.locale
143
+ // empty). Protects user config on any code path that skips selectLanguage.
144
+ if (!ctx.locale && data.locale.responseLanguage) continue;
96
145
  data.locale.responseLanguage = locale;
97
146
  fs.writeFileSync(rtPath, `${JSON.stringify(data, null, 2)}\n`, 'utf8');
98
- 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);
99
152
  }
100
153
  }
101
154
 
102
155
  /** Human assistant name for the active platform(s). */
103
- function assistantName(ctx) {
104
- return ctx.platforms.includes('claude') ? 'Claude Code' : 'OpenCode';
156
+ function assistantName(platformKey) {
157
+ return ASSISTANT_NAMES[platformKey] || ASSISTANT_NAMES.opencode;
105
158
  }
106
159
 
107
160
  function configureAddressing(ctx, userAddress) {
108
- const claudeMdFile = path.join(process.cwd(), 'CLAUDE.md');
109
- if (!fs.existsSync(claudeMdFile)) {
110
- ctx.ui.warn('CLAUDE.md not found at project root; skipped addressing');
111
- return;
112
- }
113
-
114
- const name = assistantName(ctx);
115
- let content = fs.readFileSync(claudeMdFile, 'utf8');
116
- 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)
117
167
 
118
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\`.`;
119
169
 
120
- // Idempotent: replace existing section (shared marker) or append.
121
- const regex = /##[^\n]*Context Overflow Indicator[^\n]*[\s\S]*?(?=\n##|\n*$)/;
122
- content = regex.test(content)
123
- ? content.replace(regex, addressingSection)
124
- : `${content.endsWith('\n') ? content : `${content}\n`}\n${addressingSection}\n`;
125
-
126
- fs.writeFileSync(claudeMdFile, content, 'utf8');
127
- 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');
128
181
  }
129
182
 
130
183
  async function setupAddressing(ctx) {
131
- if (!ctx.platforms.includes('claude')) return;
184
+ if (!ctx.platforms.some((key) => key === 'claude' || key === 'codex')) return;
132
185
 
133
- // Check if there's already an addressing section in CLAUDE.md
134
- const claudeMdFile = path.join(process.cwd(), 'CLAUDE.md');
135
186
  let existingName = null;
136
- if (fs.existsSync(claudeMdFile)) {
137
- const content = fs.readFileSync(claudeMdFile, 'utf8');
187
+ for (const target of instructionTargets(ctx)) {
188
+ if (!fs.existsSync(target.file)) continue;
189
+ const content = readManagedBody(target.file, target.transform);
138
190
  const match = content.match(/##[^\n]*Context Overflow Indicator[^\n]*[\s\S]*?always addresses the user as "([^"]+)"/);
139
191
  if (match) {
140
192
  existingName = match[1];
193
+ break;
141
194
  }
142
195
  }
143
196
 
@@ -199,15 +252,12 @@ async function runPostInstall(ctx) {
199
252
  // Write "language" field into .claude/settings.json for visibility.
200
253
  patchSettingsLanguage(ctx);
201
254
 
202
- // Re-record post-write baselines so installer-managed files stay "pristine",
203
- // then flush each touched platform tracker.
204
- if (ctx.platforms.includes('claude') && ctx.trackers.claude && fs.existsSync('CLAUDE.md')) {
205
- ctx.trackers.claude.record('CLAUDE.md');
206
- }
255
+ // Flush touched platform trackers. Root CLAUDE.md is block-managed and must
256
+ // never regain a whole-file ownership record.
207
257
  for (const key of ctx.platforms) {
208
258
  if (ctx.trackers[key]) ctx.trackers[key].write();
209
259
  }
210
260
  return ctx;
211
261
  }
212
262
 
213
- module.exports = { configureAddressing, runPostInstall };
263
+ module.exports = { configureAddressing, patchRuntimeLocale, runPostInstall };
@@ -4,7 +4,7 @@
4
4
  * Ensures the root .gitignore carries CafeKit-managed patterns:
5
5
  * - workflow dirs (plans, shared research)
6
6
  * - install safety artifacts (backup, lock)
7
- * - runtime folders (.claude/, .opencode/) — reinstall via
7
+ * - runtime folders (.claude/, .opencode/, .codex/, .agents/) — reinstall via
8
8
  * `npx @haposoft/cafekit`; do not commit the local payload
9
9
  *
10
10
  * Layered with the in-folder `.claude/.gitignore` /
@@ -35,7 +35,9 @@ function ensureGitignore(ctx) {
35
35
  '.cafekit.lock',
36
36
  // Local runtime payload — reinstall with npx; keep out of git
37
37
  '.claude/',
38
- '.opencode/'
38
+ '.opencode/',
39
+ '.codex/',
40
+ '.agents/'
39
41
  ];
40
42
  const prefix = ctx.dryRun ? '[dry-run] ' : '';
41
43