@haposoft/cafekit 0.9.6 → 0.10.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 +93 -1252
- package/bin/lib/backup.js +111 -0
- package/bin/lib/context.js +273 -0
- package/bin/lib/lock.js +86 -0
- package/bin/lib/managed-writer.js +167 -0
- package/bin/lib/manifest.js +182 -0
- package/bin/lib/ui.js +108 -0
- package/bin/phases/claude-runtime.js +132 -0
- package/bin/phases/claude-settings.js +130 -0
- package/bin/phases/copy-payload.js +220 -0
- package/bin/phases/opencode-runtime.js +38 -0
- package/bin/phases/post-install.js +141 -0
- package/bin/phases/report.js +66 -0
- package/bin/phases/root-config.js +49 -0
- package/bin/phases/select-platform.js +94 -0
- package/bin/phases/summary.js +54 -0
- package/bin/phases/write-metadata.js +60 -0
- package/package.json +5 -1
- package/src/claude/CLAUDE.md +2 -2
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Phase: copy payload (skills, agents, references, scripts, commands).
|
|
3
|
+
*
|
|
4
|
+
* Ported from the original copyPlatformFiles(), but every write goes through
|
|
5
|
+
* the ownership-aware writer so re-runs update pristine files and preserve
|
|
6
|
+
* user-modified ones instead of blindly skipping/overwriting.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
const fs = require('fs');
|
|
10
|
+
const path = require('path');
|
|
11
|
+
const {
|
|
12
|
+
PLATFORMS,
|
|
13
|
+
DEPENDENCY_TEMPLATES,
|
|
14
|
+
isClaudeCompatibleRuntime,
|
|
15
|
+
getRuntimeSupportTargetDir,
|
|
16
|
+
getCopyOptions
|
|
17
|
+
} = require('../lib/context');
|
|
18
|
+
const { writeManagedFile, copyManagedTree } = require('../lib/managed-writer');
|
|
19
|
+
const { report, treeAction } = require('./report');
|
|
20
|
+
const {
|
|
21
|
+
convertOpenCodeAgentContent,
|
|
22
|
+
convertOpenCodeCommandContent
|
|
23
|
+
} = require('../lib/opencode-install');
|
|
24
|
+
|
|
25
|
+
const SRC = path.join(__dirname, '../../src');
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Spec command files installed for a platform. Claude ships spec-* commands;
|
|
29
|
+
* OpenCode generates its own command wrappers elsewhere.
|
|
30
|
+
*/
|
|
31
|
+
function getPlatformSpecFiles(platformKey, ctx) {
|
|
32
|
+
if (platformKey === 'claude') {
|
|
33
|
+
const manifestCommands = ctx.manifest?.commands?.core;
|
|
34
|
+
if (Array.isArray(manifestCommands)) {
|
|
35
|
+
return manifestCommands;
|
|
36
|
+
}
|
|
37
|
+
return [
|
|
38
|
+
'spec-init.md',
|
|
39
|
+
'spec-requirements.md',
|
|
40
|
+
'spec-design.md',
|
|
41
|
+
'spec-validate.md',
|
|
42
|
+
'spec-tasks.md',
|
|
43
|
+
'spec-status.md',
|
|
44
|
+
'code.md',
|
|
45
|
+
'test.md',
|
|
46
|
+
'docs.md'
|
|
47
|
+
];
|
|
48
|
+
}
|
|
49
|
+
return [];
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** DEPENDENCY_TEMPLATES is empty by default; kept for parity/extensibility. */
|
|
53
|
+
function ensureWorkflowDependencies(ctx, platformKey) {
|
|
54
|
+
const platform = PLATFORMS[platformKey];
|
|
55
|
+
const tracker = ctx.trackers[platformKey];
|
|
56
|
+
const commandTemplates = DEPENDENCY_TEMPLATES.commands[platformKey] || {};
|
|
57
|
+
Object.entries(commandTemplates).forEach(([fileName, content]) => {
|
|
58
|
+
if (!content) return;
|
|
59
|
+
const dest = path.join(platform.commandsDir, fileName);
|
|
60
|
+
if (!ctx.dryRun) {
|
|
61
|
+
fs.mkdirSync(path.dirname(dest), { recursive: true });
|
|
62
|
+
fs.writeFileSync(dest, content, 'utf8');
|
|
63
|
+
tracker.record(dest);
|
|
64
|
+
}
|
|
65
|
+
report(ctx, 'created', `dependency: ${fileName}`);
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function copyPlatformFiles(ctx, platformKey) {
|
|
70
|
+
const platform = PLATFORMS[platformKey];
|
|
71
|
+
const tracker = ctx.trackers[platformKey];
|
|
72
|
+
const platformFolder = platform.folder;
|
|
73
|
+
|
|
74
|
+
// For OpenCode, text bodies are path-normalized (.claude → .opencode, etc.).
|
|
75
|
+
const bodyTransform = platformKey === 'opencode'
|
|
76
|
+
? getCopyOptions('opencode', {}).transform
|
|
77
|
+
: undefined;
|
|
78
|
+
|
|
79
|
+
const skillsSourceDir = path.join(SRC, 'claude/skills');
|
|
80
|
+
const agentsSourceDir = path.join(SRC, platform.sourceDir, 'agents');
|
|
81
|
+
|
|
82
|
+
if (!ctx.dryRun) {
|
|
83
|
+
fs.mkdirSync(platform.skillsDir, { recursive: true });
|
|
84
|
+
fs.mkdirSync(platform.agentsDir, { recursive: true });
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// ── Skills ──────────────────────────────────────────────
|
|
88
|
+
if (fs.existsSync(skillsSourceDir)) {
|
|
89
|
+
const specSkillSource = path.join(skillsSourceDir, 'specs');
|
|
90
|
+
const specSkillDest = path.join(platform.skillsDir, 'specs');
|
|
91
|
+
|
|
92
|
+
if (fs.existsSync(specSkillSource)) {
|
|
93
|
+
const agg = copyManagedTree({
|
|
94
|
+
src: specSkillSource, dest: specSkillDest, platformFolder, ctx, tracker, transform: bodyTransform
|
|
95
|
+
});
|
|
96
|
+
ctx.results.installedSkills++;
|
|
97
|
+
report(ctx, treeAction(agg), 'Skill: specs');
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// Keep spec templates in sync (Claude command runtime reads these).
|
|
101
|
+
if (platformKey === 'claude') {
|
|
102
|
+
const legacyInitTemplate = path.join(platform.skillsDir, 'specs', 'templates', 'init.json');
|
|
103
|
+
if (fs.existsSync(legacyInitTemplate)) {
|
|
104
|
+
if (!ctx.dryRun) fs.rmSync(legacyInitTemplate, { force: true });
|
|
105
|
+
ctx.ui.detail(` ↻ ${ctx.dryRun ? '[dry-run] ' : ''}Removed legacy template: ${legacyInitTemplate}`);
|
|
106
|
+
ctx.results.updated++;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const specTemplates = [
|
|
110
|
+
'spec-state.json',
|
|
111
|
+
'requirements-init.md',
|
|
112
|
+
'requirements.md',
|
|
113
|
+
'design.md',
|
|
114
|
+
'research.md',
|
|
115
|
+
'task.md'
|
|
116
|
+
];
|
|
117
|
+
|
|
118
|
+
specTemplates.forEach((fileName) => {
|
|
119
|
+
const src = path.join(specSkillSource, 'templates', fileName);
|
|
120
|
+
const dest = path.join(platform.skillsDir, 'specs', 'templates', fileName);
|
|
121
|
+
const { action } = writeManagedFile({ src, dest, platformFolder, ctx, tracker });
|
|
122
|
+
report(ctx, action, `template: ${fileName}`);
|
|
123
|
+
});
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// Additional required skills.
|
|
127
|
+
let requiredSkills = [];
|
|
128
|
+
if (isClaudeCompatibleRuntime(platformKey)) {
|
|
129
|
+
requiredSkills = ctx.manifest?.skills?.required || [];
|
|
130
|
+
}
|
|
131
|
+
requiredSkills
|
|
132
|
+
.filter((skillName) => skillName !== 'specs')
|
|
133
|
+
.forEach((skillName) => {
|
|
134
|
+
const skillSource = path.join(skillsSourceDir, skillName);
|
|
135
|
+
const skillDest = path.join(platform.skillsDir, skillName);
|
|
136
|
+
if (fs.existsSync(skillSource)) {
|
|
137
|
+
const agg = copyManagedTree({
|
|
138
|
+
src: skillSource, dest: skillDest, platformFolder, ctx, tracker, transform: bodyTransform
|
|
139
|
+
});
|
|
140
|
+
ctx.results.installedSkills++;
|
|
141
|
+
report(ctx, treeAction(agg), `Skill: ${skillName}`);
|
|
142
|
+
} else {
|
|
143
|
+
report(ctx, 'missing', `skill: ${skillName}`);
|
|
144
|
+
}
|
|
145
|
+
});
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// ── Agents + references + scripts ───────────────────────
|
|
149
|
+
if (isClaudeCompatibleRuntime(platformKey)) {
|
|
150
|
+
if (fs.existsSync(agentsSourceDir)) {
|
|
151
|
+
const requiredAgents = ctx.manifest?.agents?.required || [
|
|
152
|
+
'tester.md', 'code-reviewer.md', 'fullstack-developer.md', 'debugger.md'
|
|
153
|
+
];
|
|
154
|
+
const agentTransform = platformKey === 'opencode'
|
|
155
|
+
? (content, src) => convertOpenCodeAgentContent(content, path.basename(src))
|
|
156
|
+
: undefined;
|
|
157
|
+
|
|
158
|
+
requiredAgents.forEach((fileName) => {
|
|
159
|
+
const src = path.join(agentsSourceDir, fileName);
|
|
160
|
+
const dest = path.join(platform.agentsDir, fileName);
|
|
161
|
+
const { action } = writeManagedFile({
|
|
162
|
+
src, dest, platformFolder, ctx, tracker, transform: agentTransform
|
|
163
|
+
});
|
|
164
|
+
report(ctx, action, `agent: ${fileName}`);
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
const refsSource = path.join(SRC, platform.sourceDir, 'references');
|
|
168
|
+
if (fs.existsSync(refsSource)) {
|
|
169
|
+
const refsDest = getRuntimeSupportTargetDir(platformKey, 'references');
|
|
170
|
+
const agg = copyManagedTree({
|
|
171
|
+
src: refsSource, dest: refsDest, platformFolder, ctx, tracker, transform: bodyTransform
|
|
172
|
+
});
|
|
173
|
+
report(ctx, treeAction(agg), 'Agent reference manuals');
|
|
174
|
+
}
|
|
175
|
+
} else {
|
|
176
|
+
report(ctx, 'missing', `agents: ${platform.agentsDir}`);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
const scriptsSourceDir = path.join(SRC, 'claude/scripts');
|
|
180
|
+
if (fs.existsSync(scriptsSourceDir)) {
|
|
181
|
+
const scriptsDest = getRuntimeSupportTargetDir(platformKey, 'scripts');
|
|
182
|
+
const agg = copyManagedTree({
|
|
183
|
+
src: scriptsSourceDir, dest: scriptsDest, platformFolder, ctx, tracker, transform: bodyTransform
|
|
184
|
+
});
|
|
185
|
+
report(ctx, treeAction(agg), 'Native scripts');
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
ensureWorkflowDependencies(ctx, platformKey);
|
|
190
|
+
|
|
191
|
+
// ── Commands ────────────────────────────────────────────
|
|
192
|
+
const sourceSubdir = platform.sourceSubdir || 'commands';
|
|
193
|
+
const commandsSourceDir = path.join(SRC, platform.sourceDir, sourceSubdir);
|
|
194
|
+
const specFiles = getPlatformSpecFiles(platformKey, ctx);
|
|
195
|
+
|
|
196
|
+
specFiles.forEach((file) => {
|
|
197
|
+
const src = path.join(commandsSourceDir, file);
|
|
198
|
+
const dest = path.join(platform.commandsDir, file);
|
|
199
|
+
if (!fs.existsSync(src)) {
|
|
200
|
+
ctx.ui.error(`Source file not found: ${file}`);
|
|
201
|
+
ctx.results.errors++;
|
|
202
|
+
return;
|
|
203
|
+
}
|
|
204
|
+
const cmdTransform = (content, s) => {
|
|
205
|
+
let c = content.replace(/\{\{SKILLS_DIR\}\}/g, platform.skillsRef);
|
|
206
|
+
if (platformKey === 'opencode') {
|
|
207
|
+
c = convertOpenCodeCommandContent(c, path.basename(s));
|
|
208
|
+
}
|
|
209
|
+
return c;
|
|
210
|
+
};
|
|
211
|
+
const { action } = writeManagedFile({
|
|
212
|
+
src, dest, platformFolder, ctx, tracker, transform: cmdTransform
|
|
213
|
+
});
|
|
214
|
+
report(ctx, action, file);
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
return ctx;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
module.exports = { copyPlatformFiles, getPlatformSpecFiles };
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Phase: OpenCode runtime installation.
|
|
3
|
+
*
|
|
4
|
+
* Delegates to the existing opencode-install.js module (kept as-is) in the
|
|
5
|
+
* exact original order. These writers manage their own files (plugins, command
|
|
6
|
+
* templates, AGENTS.md, opencode.json) and honor the legacy `upgrade` option.
|
|
7
|
+
* In dry-run we skip them and report intent, since they are not dry-run aware.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
const {
|
|
11
|
+
copyOpenCodeSharedRuntimeFiles,
|
|
12
|
+
copyOpenCodePlugins,
|
|
13
|
+
copyOpenCodeCommandTemplates,
|
|
14
|
+
copyOpenCodeAgentsMdFile,
|
|
15
|
+
mergeOpenCodeConfig
|
|
16
|
+
} = require('../lib/opencode-install');
|
|
17
|
+
const { copyRulesDirectory } = require('./claude-runtime');
|
|
18
|
+
|
|
19
|
+
function installOpenCodeRuntime(ctx, platformKey) {
|
|
20
|
+
if (platformKey !== 'opencode') return;
|
|
21
|
+
|
|
22
|
+
if (ctx.dryRun) {
|
|
23
|
+
ctx.ui.detail(' → [dry-run] OpenCode runtime (plugins, command templates, AGENTS.md, opencode.json) would be installed');
|
|
24
|
+
// rules/ is dry-run aware; still preview it.
|
|
25
|
+
copyRulesDirectory(ctx, platformKey);
|
|
26
|
+
return;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const { results, options } = ctx;
|
|
30
|
+
copyOpenCodeSharedRuntimeFiles(platformKey, results, options);
|
|
31
|
+
copyOpenCodePlugins(platformKey, results, options);
|
|
32
|
+
copyOpenCodeCommandTemplates(platformKey, results, options);
|
|
33
|
+
copyOpenCodeAgentsMdFile(platformKey, results, options);
|
|
34
|
+
copyRulesDirectory(ctx, platformKey);
|
|
35
|
+
mergeOpenCodeConfig(platformKey, results);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
module.exports = { installOpenCodeRuntime };
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Phase: post-install runtime configuration.
|
|
3
|
+
*
|
|
4
|
+
* OpenCode model, optional Gemini CLI install + API key, and addressing config.
|
|
5
|
+
* Interactive prompts use ctx.ui (clack); in non-interactive/dry-run they are
|
|
6
|
+
* skipped via fallbacks so spawned/CI runs never hang. Writes (.env, CLAUDE.md,
|
|
7
|
+
* opencode.json) happen only when a value is actually provided.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
const fs = require('fs');
|
|
11
|
+
const path = require('path');
|
|
12
|
+
const { execSync } = require('child_process');
|
|
13
|
+
const { PLATFORMS } = require('../lib/context');
|
|
14
|
+
const { setupOpenCodeModel } = require('../lib/opencode-install');
|
|
15
|
+
|
|
16
|
+
function checkGeminiCLI() {
|
|
17
|
+
try {
|
|
18
|
+
execSync('which gemini', { stdio: 'ignore' });
|
|
19
|
+
return true;
|
|
20
|
+
} catch {
|
|
21
|
+
return false;
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function installGeminiCLI(ctx) {
|
|
26
|
+
ctx.ui.startSpinner('Installing gemini-cli...');
|
|
27
|
+
try {
|
|
28
|
+
execSync('npm install -g @google/gemini-cli', { stdio: 'ignore' });
|
|
29
|
+
ctx.ui.stopSpinner('gemini-cli installed');
|
|
30
|
+
return true;
|
|
31
|
+
} catch {
|
|
32
|
+
ctx.ui.stopSpinner('Could not install gemini-cli automatically');
|
|
33
|
+
ctx.ui.warn('Run manually: npm install -g @google/gemini-cli');
|
|
34
|
+
return false;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function configureGeminiKey(ctx, apiKey, platforms) {
|
|
39
|
+
const envBody = `GEMINI_API_KEY=${apiKey}\nVISUAL_MODEL=gemma-4-31b-it\nSEARCH_MODEL=gemini-2.5-pro\n`;
|
|
40
|
+
const targets = platforms.filter((key) => PLATFORMS[key]).map((key) => PLATFORMS[key].folder);
|
|
41
|
+
if (targets.length === 0) targets.push('.claude');
|
|
42
|
+
|
|
43
|
+
for (const folder of targets) {
|
|
44
|
+
try {
|
|
45
|
+
const targetDir = path.join(process.cwd(), folder);
|
|
46
|
+
if (!fs.existsSync(targetDir)) fs.mkdirSync(targetDir, { recursive: true });
|
|
47
|
+
fs.writeFileSync(path.join(targetDir, '.env'), envBody, { mode: 0o600 });
|
|
48
|
+
ctx.ui.success(`Gemini API key stored in ${folder}/.env (0600)`);
|
|
49
|
+
} catch (error) {
|
|
50
|
+
ctx.ui.error(`Failed to configure Gemini API key: ${error.message}`);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
async function setupGemini(ctx) {
|
|
56
|
+
if (checkGeminiCLI()) {
|
|
57
|
+
ctx.ui.info('gemini-cli already installed');
|
|
58
|
+
} else if (ctx.interactive) {
|
|
59
|
+
const yes = await ctx.ui.confirm(
|
|
60
|
+
{ message: 'Install gemini-cli? (enables hapo:inspect ext mode)', initialValue: false },
|
|
61
|
+
false
|
|
62
|
+
);
|
|
63
|
+
if (yes === true) installGeminiCLI(ctx);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const apiKey = await ctx.ui.text(
|
|
67
|
+
{ message: 'Gemini API key (Enter to skip)', placeholder: 'aistudio.google.com/apikey' },
|
|
68
|
+
''
|
|
69
|
+
);
|
|
70
|
+
if (apiKey && typeof apiKey === 'string' && apiKey.trim()) {
|
|
71
|
+
configureGeminiKey(ctx, apiKey.trim(), ctx.platforms);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function configureAddressing(ctx, userAddress) {
|
|
76
|
+
const claudeMdFile = path.join(process.cwd(), 'CLAUDE.md');
|
|
77
|
+
if (!fs.existsSync(claudeMdFile)) {
|
|
78
|
+
ctx.ui.warn('CLAUDE.md not found at project root; skipped addressing');
|
|
79
|
+
return;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
let content = fs.readFileSync(claudeMdFile, 'utf8');
|
|
83
|
+
const addressingSection = `## Addressing (Context Overflow Indicator)
|
|
84
|
+
|
|
85
|
+
The AI always addresses the user as "${userAddress}" throughout the conversation. If the AI stops doing so, it is a sign the context has been compacted/truncated — tell the user to consider \`/clear\`.`;
|
|
86
|
+
|
|
87
|
+
// Idempotent: replace existing section (shared marker) or append.
|
|
88
|
+
const regex = /##[^\n]*Context Overflow Indicator[^\n]*[\s\S]*?(?=\n##|\n*$)/;
|
|
89
|
+
content = regex.test(content)
|
|
90
|
+
? content.replace(regex, addressingSection)
|
|
91
|
+
: `${content.endsWith('\n') ? content : `${content}\n`}\n${addressingSection}\n`;
|
|
92
|
+
|
|
93
|
+
fs.writeFileSync(claudeMdFile, content, 'utf8');
|
|
94
|
+
ctx.ui.success(`Addressing set: AI will call you "${userAddress}"`);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
async function setupAddressing(ctx) {
|
|
98
|
+
if (!ctx.platforms.includes('claude')) return;
|
|
99
|
+
|
|
100
|
+
const answer = await ctx.ui.text(
|
|
101
|
+
{ message: 'How should the AI address you? (e.g. boss, sir — Enter to skip)', placeholder: 'Enter to skip' },
|
|
102
|
+
''
|
|
103
|
+
);
|
|
104
|
+
if (ctx.ui.isCancel(answer)) return;
|
|
105
|
+
const userAddress = (answer || '').trim();
|
|
106
|
+
if (!userAddress) return;
|
|
107
|
+
|
|
108
|
+
if (/[^a-zA-ZÀ-ỹ\s]/.test(userAddress)) {
|
|
109
|
+
ctx.ui.warn('Invalid input (letters only); skipped addressing');
|
|
110
|
+
return;
|
|
111
|
+
}
|
|
112
|
+
configureAddressing(ctx, userAddress);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** Run the post-install configuration sequence. */
|
|
116
|
+
async function runPostInstall(ctx) {
|
|
117
|
+
if (ctx.dryRun) {
|
|
118
|
+
ctx.ui.info('[dry-run] Skipping OpenCode model / Gemini / addressing setup');
|
|
119
|
+
return ctx;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// OpenCode model uses its own readline; only run it when it won't block:
|
|
123
|
+
// interactive, or an env override is present (which it reads without prompting).
|
|
124
|
+
const hasModelEnv = Boolean(process.env.OPENCODE_MODEL || process.env.OPENCODE_DEFAULT_MODEL);
|
|
125
|
+
if (ctx.platforms.includes('opencode') && (ctx.interactive || hasModelEnv)) {
|
|
126
|
+
await setupOpenCodeModel(ctx.platforms, ctx.results);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
await setupGemini(ctx);
|
|
130
|
+
await setupAddressing(ctx);
|
|
131
|
+
|
|
132
|
+
// Re-record CLAUDE.md baseline so the installer-managed file (template +
|
|
133
|
+
// addressing) stays "pristine" and keeps receiving upstream updates.
|
|
134
|
+
if (ctx.platforms.includes('claude') && ctx.trackers.claude && fs.existsSync('CLAUDE.md')) {
|
|
135
|
+
ctx.trackers.claude.record('CLAUDE.md');
|
|
136
|
+
ctx.trackers.claude.write();
|
|
137
|
+
}
|
|
138
|
+
return ctx;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
module.exports = { checkGeminiCLI, configureGeminiKey, configureAddressing, runPostInstall };
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared reporting helper for install phases.
|
|
3
|
+
*
|
|
4
|
+
* Maps an ownership-writer action to a UI update + results counter so every
|
|
5
|
+
* phase reports outcomes consistently. Routine create/update lines go to
|
|
6
|
+
* ctx.ui.detail (ephemeral spinner progress when interactive; plain log when
|
|
7
|
+
* not). Preserved/missing are surfaced more prominently.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
const SYMBOL = {
|
|
11
|
+
created: '✓',
|
|
12
|
+
updated: '↻',
|
|
13
|
+
unchanged: '→',
|
|
14
|
+
preserved: '⊘',
|
|
15
|
+
missing: '⚠'
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Report a single file/group outcome.
|
|
20
|
+
*
|
|
21
|
+
* @param {object} ctx run context (ui + results + dryRun)
|
|
22
|
+
* @param {string} action 'created'|'updated'|'unchanged'|'preserved'|'missing'
|
|
23
|
+
* @param {string} label human label, e.g. "Skill: specs"
|
|
24
|
+
*/
|
|
25
|
+
function report(ctx, action, label) {
|
|
26
|
+
const prefix = ctx.dryRun ? '[dry-run] ' : '';
|
|
27
|
+
const sym = SYMBOL[action] || ' ';
|
|
28
|
+
const r = ctx.results;
|
|
29
|
+
|
|
30
|
+
switch (action) {
|
|
31
|
+
case 'created':
|
|
32
|
+
ctx.ui.detail(` ${sym} ${prefix}${label}`);
|
|
33
|
+
r.copied++;
|
|
34
|
+
break;
|
|
35
|
+
case 'updated':
|
|
36
|
+
ctx.ui.detail(` ${sym} ${prefix}${label}`);
|
|
37
|
+
r.updated++;
|
|
38
|
+
break;
|
|
39
|
+
case 'unchanged':
|
|
40
|
+
r.unchanged++;
|
|
41
|
+
break;
|
|
42
|
+
case 'preserved':
|
|
43
|
+
ctx.ui.detail(` ${sym} ${prefix}preserved (user-modified): ${label}`);
|
|
44
|
+
r.preserved++;
|
|
45
|
+
break;
|
|
46
|
+
case 'missing':
|
|
47
|
+
ctx.ui.warn(`${prefix}Missing: ${label}`);
|
|
48
|
+
r.missingDependencies++;
|
|
49
|
+
break;
|
|
50
|
+
default:
|
|
51
|
+
break;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Pick the dominant action for a tree-copy aggregate, for a single summary line.
|
|
57
|
+
* created wins over updated wins over preserved wins over unchanged.
|
|
58
|
+
*/
|
|
59
|
+
function treeAction(agg) {
|
|
60
|
+
if (agg.created > 0) return 'created';
|
|
61
|
+
if (agg.updated > 0) return 'updated';
|
|
62
|
+
if (agg.preserved > 0) return 'preserved';
|
|
63
|
+
return 'unchanged';
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
module.exports = { SYMBOL, report, treeAction };
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Phase: root project configuration (.gitignore).
|
|
3
|
+
*
|
|
4
|
+
* Ensures the root .gitignore carries CafeKit-managed patterns, including the
|
|
5
|
+
* new install backup dir and lock file so they never get committed. Honors dry-run.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
const fs = require('fs');
|
|
9
|
+
const path = require('path');
|
|
10
|
+
|
|
11
|
+
function ensureGitignore(ctx) {
|
|
12
|
+
const gitignorePath = path.join(process.cwd(), '.gitignore');
|
|
13
|
+
const header = '# CafeKit / Ecosystem';
|
|
14
|
+
const patterns = [
|
|
15
|
+
'specs/_shared/',
|
|
16
|
+
'plans/',
|
|
17
|
+
'!plans/templates/',
|
|
18
|
+
'.cafekit-backup/',
|
|
19
|
+
'.cafekit.lock'
|
|
20
|
+
];
|
|
21
|
+
const prefix = ctx.dryRun ? '[dry-run] ' : '';
|
|
22
|
+
|
|
23
|
+
if (!fs.existsSync(gitignorePath)) {
|
|
24
|
+
const content = ['# Git Ignore', '', header, ...patterns, ''].join('\n');
|
|
25
|
+
if (!ctx.dryRun) fs.writeFileSync(gitignorePath, content, 'utf8');
|
|
26
|
+
ctx.ui.detail(` ✓ ${prefix}.gitignore created at root`);
|
|
27
|
+
ctx.results.copied++;
|
|
28
|
+
return;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const content = fs.readFileSync(gitignorePath, 'utf8');
|
|
32
|
+
const lines = content.split('\n').map((l) => l.trim());
|
|
33
|
+
const missing = patterns.filter((p) => !lines.includes(p));
|
|
34
|
+
|
|
35
|
+
if (missing.length > 0) {
|
|
36
|
+
let newContent = content;
|
|
37
|
+
if (!newContent.endsWith('\n')) newContent += '\n';
|
|
38
|
+
if (!content.includes(header)) newContent += `\n${header}\n`;
|
|
39
|
+
newContent += missing.join('\n') + '\n';
|
|
40
|
+
if (!ctx.dryRun) fs.writeFileSync(gitignorePath, newContent, 'utf8');
|
|
41
|
+
ctx.ui.detail(` ↻ ${prefix}.gitignore updated: added ${missing.join(', ')}`);
|
|
42
|
+
ctx.results.updated++;
|
|
43
|
+
} else {
|
|
44
|
+
ctx.ui.detail(` → ${prefix}.gitignore already up to date`);
|
|
45
|
+
ctx.results.skipped++;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
module.exports = { ensureGitignore };
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Phase: platform detection + selection.
|
|
3
|
+
*
|
|
4
|
+
* Resolves which platforms to install for: auto-detect existing folders, or
|
|
5
|
+
* prompt (clack) when interactive. In non-interactive mode it uses detection
|
|
6
|
+
* only and defaults to Claude when nothing is found — never blocks on input.
|
|
7
|
+
* Sets ctx.platforms, or ctx.cancelled if the user backs out.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
const {
|
|
11
|
+
PLATFORMS,
|
|
12
|
+
detectPlatforms,
|
|
13
|
+
getPlatformKeys,
|
|
14
|
+
warnLegacyClaudeFolder
|
|
15
|
+
} = require('../lib/context');
|
|
16
|
+
|
|
17
|
+
/** clack select: which platform(s) to install for. */
|
|
18
|
+
async function promptPlatformSelection(ctx) {
|
|
19
|
+
const options = [
|
|
20
|
+
...getPlatformKeys().map((key) => ({
|
|
21
|
+
value: [key],
|
|
22
|
+
label: PLATFORMS[key].name,
|
|
23
|
+
hint: `${PLATFORMS[key].folder}/`
|
|
24
|
+
})),
|
|
25
|
+
{ value: getPlatformKeys(), label: 'All platforms' }
|
|
26
|
+
];
|
|
27
|
+
const r = await ctx.ui.select(
|
|
28
|
+
{ message: 'Select which platform(s) to install for', options },
|
|
29
|
+
[]
|
|
30
|
+
);
|
|
31
|
+
if (ctx.ui.isCancel(r)) return [];
|
|
32
|
+
return r;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** clack confirm: install for all detected platforms? */
|
|
36
|
+
async function confirmAllDetected(ctx, detected) {
|
|
37
|
+
const names = detected.map((key) => PLATFORMS[key].name).join(', ');
|
|
38
|
+
const r = await ctx.ui.confirm(
|
|
39
|
+
{ message: `Install for all detected platforms? (${names})`, initialValue: true },
|
|
40
|
+
true
|
|
41
|
+
);
|
|
42
|
+
if (ctx.ui.isCancel(r)) return false;
|
|
43
|
+
return r;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Resolve ctx.platforms via detection + prompts. Sets ctx.cancelled on backout.
|
|
48
|
+
*/
|
|
49
|
+
async function resolvePlatforms(ctx) {
|
|
50
|
+
let platforms = detectPlatforms();
|
|
51
|
+
|
|
52
|
+
if (platforms.length === 0) {
|
|
53
|
+
if (ctx.interactive) {
|
|
54
|
+
platforms = await promptPlatformSelection(ctx);
|
|
55
|
+
if (platforms.length === 0) {
|
|
56
|
+
ctx.ui.outro('Installation cancelled.');
|
|
57
|
+
ctx.cancelled = true;
|
|
58
|
+
return ctx;
|
|
59
|
+
}
|
|
60
|
+
} else {
|
|
61
|
+
// Non-interactive with nothing detected → sensible default, never block.
|
|
62
|
+
platforms = ['claude'];
|
|
63
|
+
ctx.ui.info('No platform detected; defaulting to Claude Code (.claude/).');
|
|
64
|
+
}
|
|
65
|
+
} else if (platforms.length > 1 && ctx.interactive) {
|
|
66
|
+
const proceed = await confirmAllDetected(ctx, platforms);
|
|
67
|
+
if (!proceed) {
|
|
68
|
+
platforms = await promptPlatformSelection(ctx);
|
|
69
|
+
if (platforms.length === 0) {
|
|
70
|
+
ctx.ui.outro('Installation cancelled.');
|
|
71
|
+
ctx.cancelled = true;
|
|
72
|
+
return ctx;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
ctx.platforms = platforms;
|
|
78
|
+
|
|
79
|
+
const platformNames = platforms.map((key) => PLATFORMS[key].name).join(', ');
|
|
80
|
+
ctx.ui.info(`Installing for: ${platformNames}`);
|
|
81
|
+
warnLegacyClaudeFolder(platforms);
|
|
82
|
+
|
|
83
|
+
if (ctx.options.forceOverwrite) {
|
|
84
|
+
ctx.ui.info('Mode: force-overwrite (replace user-modified files; backup kept)');
|
|
85
|
+
} else if (ctx.dryRun) {
|
|
86
|
+
ctx.ui.info('Mode: dry-run (preview only, no changes written)');
|
|
87
|
+
} else {
|
|
88
|
+
ctx.ui.info('Mode: install/update (selective; preserves user-modified files)');
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
return ctx;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
module.exports = { promptPlatformSelection, resolvePlatforms };
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Phase: final summary report.
|
|
3
|
+
*
|
|
4
|
+
* Renders install stats (incl. selective-merge counters) + next steps through
|
|
5
|
+
* ctx.ui, so it appears as a framed clack note/outro when interactive and as
|
|
6
|
+
* plain text otherwise.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
const { PLATFORMS, packageJson } = require('../lib/context');
|
|
10
|
+
|
|
11
|
+
function printSummary(ctx) {
|
|
12
|
+
const { results, platforms, options, ui } = ctx;
|
|
13
|
+
|
|
14
|
+
const stats = [
|
|
15
|
+
`Copied: ${results.copied}`,
|
|
16
|
+
`Updated: ${results.updated}`,
|
|
17
|
+
`Unchanged: ${results.unchanged}`,
|
|
18
|
+
`Preserved: ${results.preserved}`,
|
|
19
|
+
`Skills: ${results.installedSkills > 0 ? 'yes' : 'no'}`,
|
|
20
|
+
`CafeKit Version: ${packageJson.version}`,
|
|
21
|
+
results.missingDependencies > 0 ? `Missing: ${results.missingDependencies}` : null,
|
|
22
|
+
results.errors > 0 ? `Errors: ${results.errors}` : null
|
|
23
|
+
].filter(Boolean).join('\n');
|
|
24
|
+
|
|
25
|
+
ui.note(stats, ctx.dryRun ? 'Dry-run preview' : 'Installation complete');
|
|
26
|
+
|
|
27
|
+
if (results.preserved > 0) {
|
|
28
|
+
const list = results.preservedFiles.slice(0, 8).map((f) => ` ⊘ ${f}`).join('\n');
|
|
29
|
+
const more = results.preservedFiles.length > 8 ? `\n ... and ${results.preservedFiles.length - 8} more` : '';
|
|
30
|
+
ui.warn(`${results.preserved} user-modified file(s) preserved:\n${list}${more}\n`
|
|
31
|
+
+ 'Re-run with --force-overwrite to replace them (a backup is kept under .cafekit-backup/).');
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
if (ctx.dryRun) {
|
|
35
|
+
ui.outro('Dry-run only — re-run without --dry-run to apply.');
|
|
36
|
+
return;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const lines = [];
|
|
40
|
+
for (const key of platforms) {
|
|
41
|
+
if (key === 'claude') lines.push('Claude: use /hapo:specs <feature-description>');
|
|
42
|
+
else lines.push('OpenCode: ask the agent to start a feature or brainstorm');
|
|
43
|
+
}
|
|
44
|
+
const envHosts = platforms.map((key) => `${PLATFORMS[key].folder}/.env`).join(' and ');
|
|
45
|
+
lines.push(`API keys isolated in ${envHosts}`);
|
|
46
|
+
if (!options.forceOverwrite) {
|
|
47
|
+
lines.push('Use --force-overwrite to refresh user-modified files');
|
|
48
|
+
}
|
|
49
|
+
ui.note(lines.join('\n'), 'Next steps');
|
|
50
|
+
|
|
51
|
+
ui.outro(`Done — docs: https://github.com/haposoft/cafekit`);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
module.exports = { printSummary };
|