@haposoft/cafekit 0.9.7 → 0.10.2

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.
@@ -0,0 +1,130 @@
1
+ /**
2
+ * Phase helper: Claude settings.json merge + obsolete-hook pruning.
3
+ *
4
+ * settings.json is merged (not a verbatim payload copy), so it stays outside
5
+ * the ownership model: managed statusLine/hooks are merged into the user's
6
+ * existing settings, and obsolete hook registrations are pruned. Honors dry-run.
7
+ */
8
+
9
+ const fs = require('fs');
10
+ const path = require('path');
11
+ const { PLATFORMS } = require('../lib/context');
12
+
13
+ const SRC_CLAUDE = path.join(__dirname, '../../src/claude');
14
+
15
+ /**
16
+ * Remove obsolete hook entries from a settings object whose command matches any
17
+ * manifest-listed substring (e.g. a retired router hook). Returns a possibly
18
+ * new settings object.
19
+ */
20
+ function pruneObsoleteSettingsHooks(settings, ctx) {
21
+ const obsoleteSubstrings = ctx.manifest?.obsolete?.settingsHookCommandSubstrings || [];
22
+
23
+ if (!settings.hooks || obsoleteSubstrings.length === 0) {
24
+ return settings;
25
+ }
26
+
27
+ let removedCount = 0;
28
+ const prunedHooks = {};
29
+
30
+ Object.entries(settings.hooks).forEach(([eventName, matcherEntries]) => {
31
+ if (!Array.isArray(matcherEntries)) {
32
+ prunedHooks[eventName] = matcherEntries;
33
+ return;
34
+ }
35
+
36
+ const prunedEntries = [];
37
+ matcherEntries.forEach((entry) => {
38
+ if (!Array.isArray(entry?.hooks)) {
39
+ prunedEntries.push(entry);
40
+ return;
41
+ }
42
+ const remainingHooks = entry.hooks.filter((hook) => {
43
+ const command = hook?.command || '';
44
+ const isObsolete = obsoleteSubstrings.some((substring) => command.includes(substring));
45
+ if (isObsolete) removedCount++;
46
+ return !isObsolete;
47
+ });
48
+ if (remainingHooks.length > 0) {
49
+ prunedEntries.push({ ...entry, hooks: remainingHooks });
50
+ }
51
+ });
52
+
53
+ if (prunedEntries.length > 0) {
54
+ prunedHooks[eventName] = prunedEntries;
55
+ }
56
+ });
57
+
58
+ if (removedCount > 0) {
59
+ ctx.ui.detail(` ↻ ${ctx.dryRun ? '[dry-run] ' : ''}Settings: removed ${removedCount} obsolete hook(s)`);
60
+ ctx.results.updated++;
61
+ return { ...settings, hooks: prunedHooks };
62
+ }
63
+
64
+ return settings;
65
+ }
66
+
67
+ /**
68
+ * Merge CafeKit-managed statusLine + hooks into .claude/settings.json,
69
+ * preserving user-owned entries. Claude only.
70
+ */
71
+ function mergeClaudeSettings(ctx, platformKey) {
72
+ if (platformKey !== 'claude') return;
73
+
74
+ const manifest = ctx.manifest;
75
+ if (!manifest?.settings?.template) return;
76
+
77
+ const templatePath = path.join(SRC_CLAUDE, manifest.settings.template);
78
+ const targetPath = path.join(PLATFORMS.claude.folder, 'settings.json');
79
+
80
+ if (!fs.existsSync(templatePath)) {
81
+ ctx.ui.warn(`Settings template not found: ${manifest.settings.template}`);
82
+ return;
83
+ }
84
+
85
+ const managedSettings = JSON.parse(fs.readFileSync(templatePath, 'utf8'));
86
+ let existingSettings = {};
87
+ if (fs.existsSync(targetPath)) {
88
+ existingSettings = JSON.parse(fs.readFileSync(targetPath, 'utf8'));
89
+ }
90
+
91
+ const mergedSettings = pruneObsoleteSettingsHooks({ ...existingSettings }, ctx);
92
+
93
+ // statusLine: adopt managed value if upgrading, missing, or CafeKit-owned.
94
+ if (managedSettings.statusLine) {
95
+ const existingCommand = existingSettings.statusLine?.command || '';
96
+ const isCafeKitOwned = existingCommand.includes('status.cjs') || existingCommand.includes('statusline.cjs');
97
+ if (ctx.options.forceOverwrite || !existingSettings.statusLine || isCafeKitOwned) {
98
+ mergedSettings.statusLine = managedSettings.statusLine;
99
+ ctx.ui.detail(` ✓ ${ctx.dryRun ? '[dry-run] ' : ''}Settings: statusLine merged`);
100
+ }
101
+ }
102
+
103
+ // hooks: append managed hooks that aren't already present (dedupe by command).
104
+ if (managedSettings.hooks) {
105
+ mergedSettings.hooks = mergedSettings.hooks || {};
106
+ Object.keys(managedSettings.hooks).forEach((eventName) => {
107
+ const managedHooks = managedSettings.hooks[eventName];
108
+ const existingHooks = mergedSettings.hooks[eventName] || [];
109
+ const mergedHooks = [...existingHooks];
110
+
111
+ managedHooks.forEach((managedHook) => {
112
+ const managedCommand = managedHook.hooks?.[0]?.command || '';
113
+ const isDuplicate = mergedHooks.some((existingHook) =>
114
+ existingHook.hooks?.some((h) => h.command === managedCommand));
115
+ if (!isDuplicate) {
116
+ mergedHooks.push(managedHook);
117
+ ctx.ui.detail(` ✓ ${ctx.dryRun ? '[dry-run] ' : ''}Settings: hook ${eventName} merged`);
118
+ }
119
+ });
120
+
121
+ mergedSettings.hooks[eventName] = mergedHooks;
122
+ });
123
+ }
124
+
125
+ if (!ctx.dryRun) {
126
+ fs.writeFileSync(targetPath, JSON.stringify(mergedSettings, null, 2), 'utf8');
127
+ }
128
+ }
129
+
130
+ module.exports = { pruneObsoleteSettingsHooks, mergeClaudeSettings };
@@ -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,118 @@
1
+ /**
2
+ * Phase: post-install runtime configuration.
3
+ *
4
+ * OpenCode model, optional Gemini API key, and addressing config. Interactive
5
+ * prompts use ctx.ui (clack); in non-interactive/dry-run they are skipped via
6
+ * fallbacks so spawned/CI runs never hang. Writes (.env, CLAUDE.md,
7
+ * opencode.json) happen only when a value is actually provided.
8
+ *
9
+ * Note: CafeKit no longer installs the `gemini-cli` (the upstream package was
10
+ * removed). We only configure GEMINI_API_KEY, which the SDK-based skills
11
+ * (e.g. hapo:ai-multimodal) read directly.
12
+ */
13
+
14
+ const fs = require('fs');
15
+ const path = require('path');
16
+ const { PLATFORMS } = require('../lib/context');
17
+ const { setupOpenCodeModel } = require('../lib/opencode-install');
18
+
19
+ /** Human assistant name for the active platform(s). */
20
+ function assistantName(ctx) {
21
+ return ctx.platforms.includes('claude') ? 'Claude Code' : 'OpenCode';
22
+ }
23
+
24
+ function configureGeminiKey(ctx, apiKey, platforms) {
25
+ const envBody = `GEMINI_API_KEY=${apiKey}\nVISUAL_MODEL=gemma-4-31b-it\nSEARCH_MODEL=gemini-2.5-pro\n`;
26
+ const targets = platforms.filter((key) => PLATFORMS[key]).map((key) => PLATFORMS[key].folder);
27
+ if (targets.length === 0) targets.push('.claude');
28
+
29
+ for (const folder of targets) {
30
+ try {
31
+ const targetDir = path.join(process.cwd(), folder);
32
+ if (!fs.existsSync(targetDir)) fs.mkdirSync(targetDir, { recursive: true });
33
+ fs.writeFileSync(path.join(targetDir, '.env'), envBody, { mode: 0o600 });
34
+ ctx.ui.success(`Gemini API key stored in ${folder}/.env (0600)`);
35
+ } catch (error) {
36
+ ctx.ui.error(`Failed to configure Gemini API key: ${error.message}`);
37
+ }
38
+ }
39
+ }
40
+
41
+ async function setupGeminiKey(ctx) {
42
+ const apiKey = await ctx.ui.text(
43
+ { message: 'Gemini API key for AI skills (Enter to skip)', placeholder: 'aistudio.google.com/apikey' },
44
+ ''
45
+ );
46
+ if (apiKey && typeof apiKey === 'string' && apiKey.trim()) {
47
+ configureGeminiKey(ctx, apiKey.trim(), ctx.platforms);
48
+ }
49
+ }
50
+
51
+ function configureAddressing(ctx, userAddress) {
52
+ const claudeMdFile = path.join(process.cwd(), 'CLAUDE.md');
53
+ if (!fs.existsSync(claudeMdFile)) {
54
+ ctx.ui.warn('CLAUDE.md not found at project root; skipped addressing');
55
+ return;
56
+ }
57
+
58
+ const name = assistantName(ctx);
59
+ let content = fs.readFileSync(claudeMdFile, 'utf8');
60
+ const addressingSection = `## Addressing (Context Overflow Indicator)
61
+
62
+ ${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\`.`;
63
+
64
+ // Idempotent: replace existing section (shared marker) or append.
65
+ const regex = /##[^\n]*Context Overflow Indicator[^\n]*[\s\S]*?(?=\n##|\n*$)/;
66
+ content = regex.test(content)
67
+ ? content.replace(regex, addressingSection)
68
+ : `${content.endsWith('\n') ? content : `${content}\n`}\n${addressingSection}\n`;
69
+
70
+ fs.writeFileSync(claudeMdFile, content, 'utf8');
71
+ ctx.ui.success(`${name} will call you "${userAddress}"`);
72
+ }
73
+
74
+ async function setupAddressing(ctx) {
75
+ if (!ctx.platforms.includes('claude')) return;
76
+
77
+ const answer = await ctx.ui.text(
78
+ { message: 'How should the AI address you?', placeholder: 'e.g. boss, sir — Enter to skip' },
79
+ ''
80
+ );
81
+ if (ctx.ui.isCancel(answer)) return;
82
+ const userAddress = (answer || '').trim();
83
+ if (!userAddress) return;
84
+
85
+ if (/[^a-zA-ZÀ-ỹ\s]/.test(userAddress)) {
86
+ ctx.ui.warn('Invalid input (letters only); skipped addressing');
87
+ return;
88
+ }
89
+ configureAddressing(ctx, userAddress);
90
+ }
91
+
92
+ /** Run the post-install configuration sequence. */
93
+ async function runPostInstall(ctx) {
94
+ if (ctx.dryRun) {
95
+ ctx.ui.info('[dry-run] Skipping OpenCode model / Gemini / addressing setup');
96
+ return ctx;
97
+ }
98
+
99
+ // OpenCode model uses its own readline; only run it when it won't block:
100
+ // interactive, or an env override is present (which it reads without prompting).
101
+ const hasModelEnv = Boolean(process.env.OPENCODE_MODEL || process.env.OPENCODE_DEFAULT_MODEL);
102
+ if (ctx.platforms.includes('opencode') && (ctx.interactive || hasModelEnv)) {
103
+ await setupOpenCodeModel(ctx.platforms, ctx.results);
104
+ }
105
+
106
+ await setupGeminiKey(ctx);
107
+ await setupAddressing(ctx);
108
+
109
+ // Re-record CLAUDE.md baseline so the installer-managed file (template +
110
+ // addressing) stays "pristine" and keeps receiving upstream updates.
111
+ if (ctx.platforms.includes('claude') && ctx.trackers.claude && fs.existsSync('CLAUDE.md')) {
112
+ ctx.trackers.claude.record('CLAUDE.md');
113
+ ctx.trackers.claude.write();
114
+ }
115
+ return ctx;
116
+ }
117
+
118
+ module.exports = { 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 };