@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
|
@@ -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 (
|
|
453
|
-
|
|
454
|
-
|
|
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 };
|
package/bin/lib/version-check.js
CHANGED
|
@@ -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 →
|
|
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
|
|
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
|
-
|
|
74
|
-
|
|
75
|
-
|
|
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
|
|
88
|
-
|
|
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: '
|
|
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
|
-
|
|
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 (!
|
|
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
|
|
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
|
-
|
|
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
|
|
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({
|
|
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 (
|
|
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 (
|
|
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
|
-
|
|
155
|
-
|
|
156
|
-
|
|
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
|
|
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
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
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
|
-
|
|
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(
|
|
121
|
-
return
|
|
156
|
+
function assistantName(platformKey) {
|
|
157
|
+
return ASSISTANT_NAMES[platformKey] || ASSISTANT_NAMES.opencode;
|
|
122
158
|
}
|
|
123
159
|
|
|
124
160
|
function configureAddressing(ctx, userAddress) {
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
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
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
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.
|
|
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
|
-
|
|
153
|
-
|
|
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 };
|