@haposoft/cafekit 0.8.17 → 0.9.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/bin/install.js CHANGED
@@ -4,9 +4,12 @@
4
4
  * CafeKit Installer
5
5
  * Multi-platform installer for AI coding assistants
6
6
  *
7
- * Supported platforms:
8
- * - claude: Claude Code (.claude/)
9
- * - opencode: OpenCode (.opencode/)
7
+ * Supported platforms (each install is fully self-contained):
8
+ * - claude: Claude Code → .claude/ (skills, rules, scripts, references, runtime.json)
9
+ * - opencode: OpenCode → .opencode/ (same layout, content rewritten on copy)
10
+ *
11
+ * Selecting both creates both folders independently. There is no shared
12
+ * runtime directory between platforms.
10
13
  *
11
14
  * To add a new platform:
12
15
  * 1. Add to PLATFORMS registry below
@@ -20,6 +23,19 @@ const os = require('os');
20
23
  const readline = require('readline');
21
24
  const { execSync } = require('child_process');
22
25
  const packageJson = require('../package.json');
26
+ const { copyRecursive, isTextAsset, readJsonFile } = require('./lib/copy-utils');
27
+ const {
28
+ normalizeOpenCodeBody,
29
+ getOpenCodeCopyOptions,
30
+ convertOpenCodeAgentContent,
31
+ convertOpenCodeCommandContent,
32
+ copyOpenCodeAgentsMdFile,
33
+ copyOpenCodeSharedRuntimeFiles,
34
+ copyOpenCodePlugins,
35
+ copyOpenCodeCommandTemplates,
36
+ mergeOpenCodeConfig,
37
+ setupOpenCodeModel
38
+ } = require('./lib/opencode-install');
23
39
  const INSTALL_COMMAND = `npx ${packageJson.name}@${packageJson.version}`;
24
40
 
25
41
  function validateManifestV2(manifest) {
@@ -65,92 +81,6 @@ const DEPENDENCY_TEMPLATES = {
65
81
  }
66
82
  };
67
83
 
68
- const OPENCODE_TOOL_MAP = {
69
- Read: 'read',
70
- Glob: 'glob',
71
- Grep: 'grep',
72
- Edit: 'edit',
73
- Write: 'edit',
74
- MultiEdit: 'edit',
75
- Bash: 'bash',
76
- WebFetch: 'webfetch',
77
- WebSearch: 'websearch',
78
- NotebookEdit: 'edit'
79
- };
80
-
81
- const OPENCODE_COMMAND_TEMPLATES = [
82
- {
83
- fileName: 'brainstorm.md',
84
- skillName: 'brainstorm',
85
- agent: 'brainstormer',
86
- subtask: true,
87
- description: 'Explore and narrow an idea before writing a CafeKit spec.'
88
- },
89
- {
90
- fileName: 'specs.md',
91
- skillName: 'specs',
92
- agent: 'spec-maker',
93
- subtask: true,
94
- description: 'Create, update, validate, or approve a CafeKit specification.'
95
- },
96
- {
97
- fileName: 'develop.md',
98
- skillName: 'develop',
99
- agent: 'god-developer',
100
- subtask: true,
101
- description: 'Implement approved CafeKit spec tasks with scope fidelity.'
102
- },
103
- {
104
- fileName: 'test.md',
105
- skillName: 'test',
106
- agent: 'test-runner',
107
- subtask: true,
108
- description: 'Run CafeKit verification, QA, and evidence collection.'
109
- },
110
- {
111
- fileName: 'code-review.md',
112
- skillName: 'code-review',
113
- agent: 'code-auditor',
114
- subtask: true,
115
- description: 'Review implementation against code quality and spec compliance.'
116
- },
117
- {
118
- fileName: 'debug.md',
119
- skillName: 'debug',
120
- agent: 'debugger',
121
- subtask: true,
122
- description: 'Diagnose a bug or failure with evidence before fixing.'
123
- },
124
- {
125
- fileName: 'hotfix.md',
126
- skillName: 'hotfix',
127
- agent: 'god-developer',
128
- subtask: true,
129
- description: 'Apply a scout-first, narrow production hotfix.'
130
- },
131
- {
132
- fileName: 'docs.md',
133
- skillName: 'docs',
134
- agent: 'docs-keeper',
135
- subtask: true,
136
- description: 'Create, update, or reconstruct source-backed project documentation.'
137
- },
138
- {
139
- fileName: 'inspect.md',
140
- skillName: 'inspect',
141
- agent: 'inspector',
142
- subtask: true,
143
- description: 'Inspect a codebase, artifact, or external context before planning.'
144
- },
145
- {
146
- fileName: 'generate-graph.md',
147
- skillName: 'generate-graph',
148
- agent: 'god-developer',
149
- subtask: true,
150
- description: 'Generate technical diagrams from project context.'
151
- }
152
- ];
153
-
154
84
  // ═══════════════════════════════════════════════════════════
155
85
  // PLATFORM REGISTRY - Add new platforms here
156
86
  // ═══════════════════════════════════════════════════════════
@@ -175,9 +105,9 @@ const PLATFORMS = {
175
105
  folder: '.opencode',
176
106
  detectFiles: ['.opencode', 'opencode.json', 'opencode.jsonc'],
177
107
  commandsDir: '.opencode/commands',
178
- skillsDir: '.claude/skills',
108
+ skillsDir: '.opencode/skills',
179
109
  agentsDir: '.opencode/agents',
180
- skillsRef: '.claude/skills',
110
+ skillsRef: '.opencode/skills',
181
111
  commandPrefix: '/',
182
112
  sourceDir: 'claude',
183
113
  sourceSubdir: 'archive-command'
@@ -223,15 +153,27 @@ function getPlatformKeys() {
223
153
  return Object.keys(PLATFORMS);
224
154
  }
225
155
 
156
+ // Warn opencode-only installs when a legacy .claude/ folder remains from
157
+ // pre-0.9.0 installs (when OpenCode shared the .claude/ runtime). We do not
158
+ // delete anything; user must remove it explicitly to opt in.
159
+ function warnLegacyClaudeFolder(platforms) {
160
+ if (!platforms.includes('opencode')) return;
161
+ if (platforms.includes('claude')) return;
162
+ if (!fs.existsSync('.claude')) return;
163
+
164
+ console.log('');
165
+ console.log('⚠ Legacy .claude/ folder detected.');
166
+ console.log(' As of CafeKit 0.9.0, OpenCode installs are self-contained under .opencode/.');
167
+ console.log(' The .claude/ folder from older installs is no longer used by OpenCode.');
168
+ console.log(' After this install finishes you can remove it: rm -rf .claude');
169
+ console.log('');
170
+ }
171
+
226
172
  function isClaudeCompatibleRuntime(platformKey) {
227
173
  return platformKey === 'claude' || platformKey === 'opencode';
228
174
  }
229
175
 
230
- function getClaudeSupportTargetDir(platformKey, subdir) {
231
- if (platformKey === 'opencode') {
232
- return path.join('.claude', subdir);
233
- }
234
-
176
+ function getRuntimeSupportTargetDir(platformKey, subdir) {
235
177
  return path.join(PLATFORMS[platformKey].folder, subdir);
236
178
  }
237
179
 
@@ -251,147 +193,11 @@ function parseInstallerArgs(argv) {
251
193
  return args;
252
194
  }
253
195
 
254
- function splitFrontmatter(content) {
255
- const match = content.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n?/);
256
-
257
- if (!match) {
258
- return {
259
- frontmatter: {},
260
- body: content
261
- };
262
- }
263
-
264
- const frontmatter = {};
265
- match[1].split(/\r?\n/).forEach(line => {
266
- const separatorIndex = line.indexOf(':');
267
- if (separatorIndex === -1) return;
268
-
269
- const key = line.slice(0, separatorIndex).trim();
270
- let value = line.slice(separatorIndex + 1).trim();
271
-
272
- if (
273
- (value.startsWith('"') && value.endsWith('"')) ||
274
- (value.startsWith("'") && value.endsWith("'"))
275
- ) {
276
- value = value.slice(1, -1);
277
- }
278
-
279
- if (key) {
280
- frontmatter[key] = value;
281
- }
282
- });
283
-
284
- return {
285
- frontmatter,
286
- body: content.slice(match[0].length)
287
- };
288
- }
289
-
290
- function formatOpenCodeValue(value) {
291
- return JSON.stringify(String(value || ''));
292
- }
293
-
294
- function normalizeOpenCodeBody(content) {
295
- return content
296
- .replace(/\.claude\/agents\//g, '.opencode/agents/')
297
- .replace(/CLAUDE\.md/g, 'AGENTS.md');
298
- }
299
-
300
- function getOpenCodeToolEntries(toolsValue) {
301
- const toolNames = String(toolsValue || '')
302
- .split(',')
303
- .map(tool => tool.trim())
304
- .filter(Boolean);
305
-
306
- const mappedTools = new Set();
307
-
308
- toolNames.forEach(toolName => {
309
- const mapped = OPENCODE_TOOL_MAP[toolName];
310
- if (mapped) {
311
- mappedTools.add(mapped);
312
- }
313
- });
314
-
315
- return Array.from(mappedTools).sort();
316
- }
317
-
318
- function convertOpenCodeAgentContent(content, fileName) {
319
- const { frontmatter, body } = splitFrontmatter(content);
320
- const agentName = frontmatter.name || path.basename(fileName, '.md');
321
- const description = frontmatter.description || `CafeKit agent: ${agentName}`;
322
- const mode = agentName === 'brainstormer' ? 'primary' : 'subagent';
323
- const permissionEntries = new Set(getOpenCodeToolEntries(frontmatter.tools));
324
- permissionEntries.add('skill');
325
- permissionEntries.add('task');
326
- const lines = [
327
- '---',
328
- `description: ${formatOpenCodeValue(description)}`,
329
- `mode: ${mode}`
330
- ];
331
-
332
- if (permissionEntries.size > 0) {
333
- lines.push('permission:');
334
- Array.from(permissionEntries).sort().forEach(permissionName => {
335
- lines.push(` ${permissionName}: allow`);
336
- });
337
- }
338
-
339
- lines.push('---', '');
340
-
341
- return `${lines.join('\n')}${normalizeOpenCodeBody(body).trimStart()}`;
342
- }
343
-
344
- function convertOpenCodeCommandContent(content, fileName) {
345
- const { frontmatter, body } = splitFrontmatter(content);
346
- const commandName = path.basename(fileName, '.md');
347
- const description = frontmatter.description || `CafeKit command: ${commandName}`;
348
- const lines = [
349
- '---',
350
- `description: ${formatOpenCodeValue(description)}`
351
- ];
352
-
353
- if (frontmatter.agent) {
354
- lines.push(`agent: ${formatOpenCodeValue(frontmatter.agent)}`);
355
- }
356
-
357
- lines.push('---', '');
358
-
359
- return `${lines.join('\n')}${normalizeOpenCodeBody(body).trimStart()}`;
360
- }
361
-
362
- function createOpenCodeSkillCommandContent(command) {
363
- const lines = [
364
- '---',
365
- `description: ${formatOpenCodeValue(command.description)}`
366
- ];
367
-
368
- if (command.agent) {
369
- lines.push(`agent: ${formatOpenCodeValue(command.agent)}`);
370
- }
371
-
372
- if (typeof command.subtask === 'boolean') {
373
- lines.push(`subtask: ${command.subtask ? 'true' : 'false'}`);
374
- }
375
-
376
- if (command.model) {
377
- lines.push(`model: ${formatOpenCodeValue(command.model)}`);
196
+ function getCopyOptions(platformKey, baseOptions = {}) {
197
+ if (platformKey === 'opencode') {
198
+ return getOpenCodeCopyOptions(baseOptions);
378
199
  }
379
-
380
- lines.push(
381
- '---',
382
- '',
383
- `Use the CafeKit skill at \`.claude/skills/${command.skillName}/SKILL.md\`.`,
384
- '',
385
- 'Read that skill first, then execute its workflow with these arguments:',
386
- '',
387
- '$ARGUMENTS',
388
- '',
389
- 'If the skill references supporting files, resolve them relative to the skill directory.',
390
- 'Keep the response aligned with the skill instructions and the current project context.',
391
- ''
392
- );
393
-
394
- return lines.join('\n');
200
+ return baseOptions;
395
201
  }
396
202
 
397
203
  // ═══════════════════════════════════════════════════════════
@@ -460,30 +266,6 @@ async function promptMultiPlatformConfirm(detected) {
460
266
  // FILE OPERATIONS
461
267
  // ═══════════════════════════════════════════════════════════
462
268
 
463
- function copyRecursive(src, dest, options = {}) {
464
- const exists = fs.existsSync(src);
465
- const stats = exists && fs.statSync(src);
466
- const isDirectory = exists && stats.isDirectory();
467
- const shouldOverwriteManagedFiles = Boolean(options.upgrade);
468
-
469
- if (isDirectory) {
470
- if (!fs.existsSync(dest)) {
471
- fs.mkdirSync(dest, { recursive: true });
472
- }
473
- fs.readdirSync(src).forEach(childItemName => {
474
- const destItemName = childItemName === 'gitignore' ? '.gitignore' : childItemName;
475
- copyRecursive(path.join(src, childItemName), path.join(dest, destItemName), options);
476
- });
477
- } else {
478
- if (fs.existsSync(dest) && !shouldOverwriteManagedFiles) {
479
- return;
480
- }
481
-
482
- fs.mkdirSync(path.dirname(dest), { recursive: true });
483
- fs.copyFileSync(src, dest);
484
- }
485
- }
486
-
487
269
  function ensureDependencyFile(targetPath, content, results, label, options = {}) {
488
270
  const destinationExists = fs.existsSync(targetPath);
489
271
  const shouldOverwriteManagedFiles = Boolean(options.upgrade);
@@ -529,18 +311,6 @@ function ensureWorkflowDependencies(platformKey, platform, results, options = {}
529
311
  });
530
312
  }
531
313
 
532
- function readJsonFile(filePath) {
533
- if (!fs.existsSync(filePath)) {
534
- return {};
535
- }
536
-
537
- try {
538
- return JSON.parse(fs.readFileSync(filePath, 'utf8'));
539
- } catch {
540
- return {};
541
- }
542
- }
543
-
544
314
  function writePlatformVersionMetadata(platformKey, results) {
545
315
  const platform = PLATFORMS[platformKey];
546
316
  const targetPath = path.join(platform.folder, 'cafekit.json');
@@ -623,9 +393,10 @@ function copyPlatformFiles(platformKey, results, options = {}) {
623
393
  const agentsSourceDir = path.join(__dirname, `../src/${platform.sourceDir}/agents`);
624
394
 
625
395
  // Create directories
626
- if (!fs.existsSync(platform.commandsDir)) {
627
- fs.mkdirSync(platform.commandsDir, { recursive: true });
628
- }
396
+ // commandsDir is created on-demand per platform: Claude only mkdirs when
397
+ // getPlatformSpecFiles returns files; OpenCode mkdirs inside
398
+ // copyOpenCodeCommandTemplates. Skip the unconditional mkdir to avoid
399
+ // leaving empty commands/ folders behind.
629
400
  if (!fs.existsSync(platform.skillsDir)) {
630
401
  fs.mkdirSync(platform.skillsDir, { recursive: true });
631
402
  }
@@ -640,7 +411,7 @@ function copyPlatformFiles(platformKey, results, options = {}) {
640
411
 
641
412
  if (fs.existsSync(specSkillSource)) {
642
413
  const skillExisted = fs.existsSync(specSkillDest);
643
- copyRecursive(specSkillSource, specSkillDest, options);
414
+ copyRecursive(specSkillSource, specSkillDest, getCopyOptions(platformKey, options));
644
415
  results.installedSkills++;
645
416
 
646
417
  if (shouldOverwriteManagedFiles && skillExisted) {
@@ -716,7 +487,7 @@ function copyPlatformFiles(platformKey, results, options = {}) {
716
487
 
717
488
  if (fs.existsSync(skillSource)) {
718
489
  const skillExisted = fs.existsSync(skillDest);
719
- copyRecursive(skillSource, skillDest, options);
490
+ copyRecursive(skillSource, skillDest, getCopyOptions(platformKey, options));
720
491
  results.installedSkills++;
721
492
 
722
493
  if (shouldOverwriteManagedFiles && skillExisted) {
@@ -782,9 +553,9 @@ function copyPlatformFiles(platformKey, results, options = {}) {
782
553
  // Copy agent reference manuals (debugger manuals, etc.) recursively
783
554
  const refsSource = path.join(__dirname, '../src', platform.sourceDir, 'references');
784
555
  if (fs.existsSync(refsSource)) {
785
- const refsDest = getClaudeSupportTargetDir(platformKey, 'references');
556
+ const refsDest = getRuntimeSupportTargetDir(platformKey, 'references');
786
557
  const refsExisted = fs.existsSync(refsDest);
787
- copyRecursive(refsSource, refsDest, options);
558
+ copyRecursive(refsSource, refsDest, getCopyOptions(platformKey, options));
788
559
 
789
560
  if (shouldOverwriteManagedFiles && refsExisted) {
790
561
  console.log(` ↻ Agent reference manuals updated`);
@@ -803,9 +574,9 @@ function copyPlatformFiles(platformKey, results, options = {}) {
803
574
  // Copy native scripts (browser-tool, docs-fetch, validate-docs)
804
575
  const scriptsSourceDir = path.join(__dirname, '../src/claude/scripts');
805
576
  if (fs.existsSync(scriptsSourceDir)) {
806
- const scriptsDest = getClaudeSupportTargetDir(platformKey, 'scripts');
577
+ const scriptsDest = getRuntimeSupportTargetDir(platformKey, 'scripts');
807
578
  const scriptsExisted = fs.existsSync(scriptsDest);
808
- copyRecursive(scriptsSourceDir, scriptsDest, options);
579
+ copyRecursive(scriptsSourceDir, scriptsDest, getCopyOptions(platformKey, options));
809
580
 
810
581
  if (shouldOverwriteManagedFiles && scriptsExisted) {
811
582
  console.log(` ↻ Native scripts updated`);
@@ -876,6 +647,9 @@ function copyRoutingFile(platformKey, results, options = {}) {
876
647
 
877
648
  let content = fs.readFileSync(source, 'utf8');
878
649
  content = content.replace(/\{\{SKILLS_DIR\}\}/g, platform.skillsRef);
650
+ if (platformKey === 'opencode') {
651
+ content = normalizeOpenCodeBody(content);
652
+ }
879
653
  fs.writeFileSync(dest, content);
880
654
 
881
655
  if (destinationExists && shouldOverwriteManagedFiles) {
@@ -888,102 +662,6 @@ function copyRoutingFile(platformKey, results, options = {}) {
888
662
  }
889
663
  }
890
664
 
891
- function createOpenCodeAgentsContent(sourceContent) {
892
- const adaptedContent = sourceContent
893
- .replace(/^# CLAUDE\.md/m, '# AGENTS.md')
894
- .replace(
895
- 'Primary operating instructions for Claude Code or any AI agent using this CafeKit runtime.',
896
- 'Primary operating instructions for OpenCode or any AI agent using this CafeKit runtime.'
897
- )
898
- .replace(/Claude Code/g, 'OpenCode')
899
- .replace(/CLAUDE\.md/g, 'AGENTS.md');
900
-
901
- const openCodeRuntimeBlock = [
902
- '## OpenCode Runtime Mapping',
903
- '',
904
- '- OpenCode project instructions live in `AGENTS.md`.',
905
- '- CafeKit commands live in `.opencode/commands/` and can be run as OpenCode slash commands.',
906
- '- CafeKit subagents live in `.opencode/agents/` using OpenCode frontmatter.',
907
- '- CafeKit skills intentionally live in `.claude/skills/` because OpenCode reads Claude-compatible skills natively.',
908
- '- Shared CafeKit support files live in `.claude/rules/`, `.claude/scripts/`, `.claude/references/`, and `.claude/runtime.json`.',
909
- '- OpenCode config is merged into `opencode.json`; keep project-specific model/provider choices there.',
910
- ''
911
- ].join('\n');
912
-
913
- return adaptedContent.replace('## Core Objective', `${openCodeRuntimeBlock}\n## Core Objective`);
914
- }
915
-
916
- function upsertCafeKitAgentsBlock(existingContent, blockContent) {
917
- const start = '<!-- CAFEKIT OPENCODE START -->';
918
- const end = '<!-- CAFEKIT OPENCODE END -->';
919
- const managedBlock = `${start}\n${blockContent.trim()}\n${end}`;
920
- const blockPattern = new RegExp(`${start}[\\s\\S]*?${end}`);
921
-
922
- if (blockPattern.test(existingContent)) {
923
- return existingContent.replace(blockPattern, managedBlock);
924
- }
925
-
926
- const separator = existingContent.endsWith('\n') ? '\n' : '\n\n';
927
- return `${existingContent}${separator}${managedBlock}\n`;
928
- }
929
-
930
- function getOpenCodeAgentsTemplateContent() {
931
- const openCodeTemplate = path.join(__dirname, '../src/opencode/AGENTS.md');
932
-
933
- if (fs.existsSync(openCodeTemplate)) {
934
- return fs.readFileSync(openCodeTemplate, 'utf8');
935
- }
936
-
937
- const claudeTemplate = path.join(__dirname, '../src/claude/CLAUDE.md');
938
- if (!fs.existsSync(claudeTemplate)) {
939
- return null;
940
- }
941
-
942
- return createOpenCodeAgentsContent(fs.readFileSync(claudeTemplate, 'utf8'));
943
- }
944
-
945
- // Copy OpenCode project instructions to AGENTS.md at the project root.
946
- function copyOpenCodeAgentsMdFile(platformKey, results, options = {}) {
947
- if (platformKey !== 'opencode') return;
948
-
949
- const shouldOverwriteManagedFiles = Boolean(options.upgrade);
950
- const dest = 'AGENTS.md';
951
- const content = getOpenCodeAgentsTemplateContent();
952
-
953
- if (content) {
954
- const destinationExists = fs.existsSync(dest);
955
-
956
- if (destinationExists && !shouldOverwriteManagedFiles) {
957
- const existingContent = fs.readFileSync(dest, 'utf8');
958
- const nextContent = upsertCafeKitAgentsBlock(existingContent, content);
959
-
960
- if (nextContent === existingContent) {
961
- console.log(` → Skipped: AGENTS.md (already up to date)`);
962
- results.skipped++;
963
- return;
964
- }
965
-
966
- fs.writeFileSync(dest, nextContent, 'utf8');
967
- console.log(` ↻ AGENTS.md merged`);
968
- results.updated++;
969
- return;
970
- }
971
-
972
- fs.writeFileSync(dest, content, 'utf8');
973
-
974
- if (destinationExists && shouldOverwriteManagedFiles) {
975
- console.log(` ↻ AGENTS.md overwritten`);
976
- results.updated++;
977
- } else {
978
- console.log(` ✓ AGENTS.md installed`);
979
- results.copied++;
980
- }
981
- } else {
982
- console.log(` ⚠ AGENTS.md template source not found`);
983
- results.missingDependencies++;
984
- }
985
- }
986
-
987
665
  // Copy Claude runtime files (statusline bundle)
988
666
  function copyClaudeRuntimeFiles(platformKey, results, options = {}) {
989
667
  if (platformKey !== 'claude') return;
@@ -1045,262 +723,6 @@ function removeObsoleteClaudeRuntimeFiles(platformKey, results) {
1045
723
  });
1046
724
  }
1047
725
 
1048
- function copyOpenCodeSharedRuntimeFiles(platformKey, results, options = {}) {
1049
- if (platformKey !== 'opencode') return;
1050
-
1051
- const shouldOverwriteManagedFiles = Boolean(options.upgrade);
1052
- const srcBase = path.join(__dirname, '../src/claude');
1053
- const runtimeFiles = ['gitignore', 'runtime.json'];
1054
-
1055
- runtimeFiles.forEach(relPath => {
1056
- const srcPath = path.join(srcBase, relPath);
1057
- const targetRelPath = relPath === 'gitignore' ? '.gitignore' : relPath;
1058
- const targetPath = path.join('.claude', targetRelPath);
1059
-
1060
- if (!fs.existsSync(srcPath)) {
1061
- console.log(` ⚠ Runtime file not found: ${relPath}`);
1062
- results.missingDependencies++;
1063
- return;
1064
- }
1065
-
1066
- const targetExists = fs.existsSync(targetPath);
1067
- const shouldCopy = shouldOverwriteManagedFiles || !targetExists;
1068
-
1069
- if (shouldCopy) {
1070
- fs.mkdirSync(path.dirname(targetPath), { recursive: true });
1071
- fs.copyFileSync(srcPath, targetPath);
1072
-
1073
- if (targetExists) {
1074
- console.log(` ↻ Shared runtime updated: ${targetRelPath}`);
1075
- results.updated++;
1076
- } else {
1077
- console.log(` ✓ Shared runtime installed: ${targetRelPath}`);
1078
- results.copied++;
1079
- }
1080
- } else {
1081
- results.skipped++;
1082
- }
1083
- });
1084
- }
1085
-
1086
- function copyOpenCodeCommandTemplates(platformKey, results, options = {}) {
1087
- if (platformKey !== 'opencode') return;
1088
-
1089
- const platform = PLATFORMS[platformKey];
1090
- const shouldOverwriteManagedFiles = Boolean(options.upgrade);
1091
-
1092
- OPENCODE_COMMAND_TEMPLATES.forEach(command => {
1093
- const targetPath = path.join(platform.commandsDir, command.fileName);
1094
- const targetExists = fs.existsSync(targetPath);
1095
-
1096
- if (targetExists && !shouldOverwriteManagedFiles) {
1097
- console.log(` → OpenCode command exists: ${command.fileName}`);
1098
- results.skipped++;
1099
- return;
1100
- }
1101
-
1102
- fs.mkdirSync(path.dirname(targetPath), { recursive: true });
1103
- fs.writeFileSync(targetPath, createOpenCodeSkillCommandContent(command), 'utf8');
1104
-
1105
- if (targetExists) {
1106
- console.log(` ↻ OpenCode command updated: ${command.fileName}`);
1107
- results.updated++;
1108
- } else {
1109
- console.log(` ✓ OpenCode command installed: ${command.fileName}`);
1110
- results.copied++;
1111
- }
1112
- });
1113
- }
1114
-
1115
- function readJsonWithComments(filePath) {
1116
- if (!fs.existsSync(filePath)) {
1117
- return {};
1118
- }
1119
-
1120
- const rawContent = fs.readFileSync(filePath, 'utf8');
1121
- const withoutBlockComments = rawContent.replace(/\/\*[\s\S]*?\*\//g, '');
1122
- const withoutLineComments = withoutBlockComments.replace(/(^|[^:])\/\/.*$/gm, '$1');
1123
-
1124
- return JSON.parse(withoutLineComments);
1125
- }
1126
-
1127
- function getOpenCodeConfigPath() {
1128
- const jsonPath = 'opencode.json';
1129
- const jsoncPath = 'opencode.jsonc';
1130
-
1131
- if (fs.existsSync(jsonPath)) {
1132
- return jsonPath;
1133
- }
1134
-
1135
- if (fs.existsSync(jsoncPath)) {
1136
- return jsoncPath;
1137
- }
1138
-
1139
- return jsonPath;
1140
- }
1141
-
1142
- function mergeOpenCodeConfig(platformKey, results) {
1143
- if (platformKey !== 'opencode') return;
1144
-
1145
- const targetPath = getOpenCodeConfigPath();
1146
- const targetExists = fs.existsSync(targetPath);
1147
- let config = {};
1148
-
1149
- try {
1150
- config = readJsonWithComments(targetPath);
1151
- } catch (error) {
1152
- console.log(` ⚠ OpenCode config not merged: ${error.message}`);
1153
- results.errors++;
1154
- return;
1155
- }
1156
-
1157
- const before = JSON.stringify(config);
1158
-
1159
- if (!config.$schema) {
1160
- config.$schema = 'https://opencode.ai/config.json';
1161
- }
1162
-
1163
- if (!Array.isArray(config.instructions)) {
1164
- config.instructions = [];
1165
- }
1166
-
1167
- if (!config.instructions.includes('AGENTS.md')) {
1168
- config.instructions.push('AGENTS.md');
1169
- }
1170
-
1171
- if (typeof config.permission === 'string') {
1172
- if (config.permission === 'allow') {
1173
- const afterStringPermission = JSON.stringify(config);
1174
- if (before === afterStringPermission && targetExists) {
1175
- console.log(` → OpenCode config already up to date: ${targetPath}`);
1176
- results.skipped++;
1177
- return;
1178
- }
1179
-
1180
- fs.writeFileSync(targetPath, `${JSON.stringify(config, null, 2)}\n`, 'utf8');
1181
- console.log(` ↻ OpenCode config merged: ${targetPath}`);
1182
- results.updated++;
1183
- return;
1184
- }
1185
-
1186
- console.log(` ⚠ OpenCode config permission is "${config.permission}"; preserving user setting`);
1187
- } else if (!config.permission || typeof config.permission !== 'object' || Array.isArray(config.permission)) {
1188
- config.permission = {};
1189
- }
1190
-
1191
- if (typeof config.permission === 'object' && !Array.isArray(config.permission)) {
1192
- if (!config.permission.skill || typeof config.permission.skill !== 'object' || Array.isArray(config.permission.skill)) {
1193
- config.permission.skill = {};
1194
- }
1195
-
1196
- if (!config.permission.skill['*']) {
1197
- config.permission.skill['*'] = 'allow';
1198
- }
1199
-
1200
- if (!config.permission.task || typeof config.permission.task !== 'object' || Array.isArray(config.permission.task)) {
1201
- config.permission.task = {};
1202
- }
1203
-
1204
- if (!config.permission.task['*']) {
1205
- config.permission.task['*'] = 'allow';
1206
- }
1207
- }
1208
-
1209
- const after = JSON.stringify(config);
1210
- if (before === after && targetExists) {
1211
- console.log(` → OpenCode config already up to date: ${targetPath}`);
1212
- results.skipped++;
1213
- return;
1214
- }
1215
-
1216
- fs.writeFileSync(targetPath, `${JSON.stringify(config, null, 2)}\n`, 'utf8');
1217
-
1218
- if (targetExists) {
1219
- console.log(` ↻ OpenCode config merged: ${targetPath}`);
1220
- results.updated++;
1221
- } else {
1222
- console.log(` ✓ OpenCode config created: ${targetPath}`);
1223
- results.copied++;
1224
- }
1225
- }
1226
-
1227
- function isValidOpenCodeModel(model) {
1228
- return typeof model === 'string' && /^[^/\s]+\/[^/\s]+$/.test(model.trim());
1229
- }
1230
-
1231
- function writeOpenCodeModel(model, results, sourceLabel = 'user input') {
1232
- const targetPath = getOpenCodeConfigPath();
1233
- let config = {};
1234
-
1235
- try {
1236
- config = readJsonWithComments(targetPath);
1237
- } catch (error) {
1238
- console.log(` ⚠ OpenCode model not configured: ${error.message}`);
1239
- results.errors++;
1240
- return false;
1241
- }
1242
-
1243
- if (typeof config.model === 'string' && config.model.trim()) {
1244
- console.log(` → OpenCode model already configured: ${config.model}`);
1245
- results.skipped++;
1246
- return false;
1247
- }
1248
-
1249
- config.model = model.trim();
1250
- fs.writeFileSync(targetPath, `${JSON.stringify(config, null, 2)}\n`, 'utf8');
1251
- console.log(` ✓ OpenCode model configured from ${sourceLabel}: ${config.model}`);
1252
- results.updated++;
1253
- return true;
1254
- }
1255
-
1256
- async function promptOpenCodeModel() {
1257
- const rl = readline.createInterface({
1258
- input: process.stdin,
1259
- output: process.stdout
1260
- });
1261
-
1262
- console.log('\n📝 OpenCode Model Configuration');
1263
- console.log(' Optional but recommended for subagents.');
1264
- console.log(' Format: provider/model-id (example: anthropic/claude-sonnet-4-20250514)');
1265
- console.log();
1266
-
1267
- return new Promise((resolve) => {
1268
- rl.question('Enter OpenCode model (or press Enter to skip): ', (answer) => {
1269
- rl.close();
1270
- resolve(answer.trim());
1271
- });
1272
- });
1273
- }
1274
-
1275
- async function setupOpenCodeModel(platforms, results) {
1276
- if (!platforms.includes('opencode')) return;
1277
-
1278
- const envModel = process.env.OPENCODE_MODEL || process.env.OPENCODE_DEFAULT_MODEL;
1279
- if (envModel) {
1280
- if (isValidOpenCodeModel(envModel)) {
1281
- writeOpenCodeModel(envModel, results, 'environment');
1282
- return;
1283
- }
1284
-
1285
- console.log(` ⚠ Ignoring invalid OpenCode model from environment: ${envModel}`);
1286
- }
1287
-
1288
- const model = await promptOpenCodeModel();
1289
-
1290
- if (!model) {
1291
- console.log(' ℹ Skipped OpenCode model configuration');
1292
- console.log(' Set later in opencode.json as: "model": "provider/model-id"');
1293
- return;
1294
- }
1295
-
1296
- if (!isValidOpenCodeModel(model)) {
1297
- console.log(' ⚠ Invalid OpenCode model format. Expected provider/model-id. Skipped.');
1298
- return;
1299
- }
1300
-
1301
- writeOpenCodeModel(model, results);
1302
- }
1303
-
1304
726
  function pruneObsoleteSettingsHooks(settings, results) {
1305
727
  const obsoleteSubstrings = CLAUDE_MIGRATION_MANIFEST?.obsolete?.settingsHookCommandSubstrings || [];
1306
728
 
@@ -1449,10 +871,10 @@ function copyRulesDirectory(platformKey, results, options = {}) {
1449
871
  if (!isClaudeCompatibleRuntime(platformKey)) return;
1450
872
 
1451
873
  const source = path.join(__dirname, '../src/claude/rules');
1452
- const dest = getClaudeSupportTargetDir(platformKey, 'rules');
874
+ const dest = getRuntimeSupportTargetDir(platformKey, 'rules');
1453
875
 
1454
876
  if (fs.existsSync(source)) {
1455
- copyRecursive(source, dest, { upgrade: true });
877
+ copyRecursive(source, dest, getCopyOptions(platformKey, { upgrade: true }));
1456
878
  console.log(` ↻ rules/ directory overwritten`);
1457
879
  results.updated++;
1458
880
  } else {
@@ -1563,26 +985,37 @@ async function promptGeminiAPIKey() {
1563
985
  });
1564
986
  }
1565
987
 
1566
- function configureGeminiKey(apiKey) {
1567
- try {
1568
- const targetDir = path.join(process.cwd(), '.claude');
1569
- const localEnvFile = path.join(targetDir, '.env');
988
+ function configureGeminiKey(apiKey, platforms = ['claude']) {
989
+ const envBody = `GEMINI_API_KEY=${apiKey}\nVISUAL_MODEL=gemma-4-31b-it\nSEARCH_MODEL=gemini-2.5-pro\n`;
990
+ const targets = platforms
991
+ .filter(key => PLATFORMS[key])
992
+ .map(key => PLATFORMS[key].folder);
1570
993
 
1571
- if (!fs.existsSync(targetDir)) {
1572
- fs.mkdirSync(targetDir, { recursive: true });
1573
- }
994
+ if (targets.length === 0) {
995
+ targets.push('.claude');
996
+ }
1574
997
 
1575
- // Luôn ghi trực tiếp key vào rốn của não bộ AI
1576
- fs.writeFileSync(localEnvFile, `GEMINI_API_KEY=${apiKey}\nVISUAL_MODEL=gemma-4-31b-it\nSEARCH_MODEL=gemini-2.5-pro\n`, { mode: 0o600 });
1577
- console.log(' ✓ Gemini API key configured securely in project (.claude/.env)');
998
+ let success = true;
999
+ for (const folder of targets) {
1000
+ try {
1001
+ const targetDir = path.join(process.cwd(), folder);
1002
+ const localEnvFile = path.join(targetDir, '.env');
1578
1003
 
1579
- return true;
1580
- } catch (error) {
1581
- console.log(' ✗ Failed to configure Gemini API key');
1582
- console.log(` Error: ${error.message}`);
1583
- console.log(' You can manually set: export GEMINI_API_KEY="your-key"');
1584
- return false;
1004
+ if (!fs.existsSync(targetDir)) {
1005
+ fs.mkdirSync(targetDir, { recursive: true });
1006
+ }
1007
+
1008
+ fs.writeFileSync(localEnvFile, envBody, { mode: 0o600 });
1009
+ console.log(` ✓ Gemini API key configured securely in project (${folder}/.env)`);
1010
+ } catch (error) {
1011
+ success = false;
1012
+ console.log(' ✗ Failed to configure Gemini API key');
1013
+ console.log(` Error: ${error.message}`);
1014
+ console.log(' You can manually set: export GEMINI_API_KEY="your-key"');
1015
+ }
1585
1016
  }
1017
+
1018
+ return success;
1586
1019
  }
1587
1020
 
1588
1021
  async function setupGeminiCLI(platforms) {
@@ -1604,7 +1037,7 @@ async function setupGeminiCLI(platforms) {
1604
1037
  // Always prompt for API config since we need it for hapo:test Multimodal
1605
1038
  const apiKey = await promptGeminiAPIKey();
1606
1039
  if (apiKey) {
1607
- configureGeminiKey(apiKey);
1040
+ configureGeminiKey(apiKey, platforms);
1608
1041
  } else {
1609
1042
  console.log('\n ℹ Skipped API key configuration');
1610
1043
  console.log(' Set later: export GEMINI_API_KEY="your-key"');
@@ -1651,6 +1084,8 @@ async function main() {
1651
1084
  const platformNames = platforms.map(key => PLATFORMS[key].name).join(', ');
1652
1085
  console.log(`\nInstalling for: ${platformNames}`);
1653
1086
 
1087
+ warnLegacyClaudeFolder(platforms);
1088
+
1654
1089
  if (installerOptions.upgrade) {
1655
1090
  console.log('Mode: upgrade (overwrite managed files)\n');
1656
1091
  } else {
@@ -1689,6 +1124,7 @@ async function main() {
1689
1124
 
1690
1125
  if (platformKey === 'opencode') {
1691
1126
  copyOpenCodeSharedRuntimeFiles(platformKey, results, installerOptions);
1127
+ copyOpenCodePlugins(platformKey, results, installerOptions);
1692
1128
  copyOpenCodeCommandTemplates(platformKey, results, installerOptions);
1693
1129
  copyOpenCodeAgentsMdFile(platformKey, results, installerOptions);
1694
1130
  copyRulesDirectory(platformKey, results, installerOptions);
@@ -1749,7 +1185,8 @@ async function main() {
1749
1185
  } else {
1750
1186
  console.log('\n 2. CafeKit skills and AGENTS.md are installed for the selected runtime');
1751
1187
  }
1752
- console.log(' 3. Project API Keys are now securely isolated in .claude/.env');
1188
+ const envHosts = platforms.map(key => `${PLATFORMS[key].folder}/.env`).join(' and ');
1189
+ console.log(` 3. Project API Keys are now securely isolated in ${envHosts}`);
1753
1190
  if (!installerOptions.upgrade) {
1754
1191
  console.log(' 4. To refresh managed templates later, run installer with --upgrade');
1755
1192
  }