worclaude 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (89) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +278 -0
  3. package/package.json +62 -0
  4. package/src/commands/backup.js +55 -0
  5. package/src/commands/diff.js +76 -0
  6. package/src/commands/init.js +628 -0
  7. package/src/commands/restore.js +95 -0
  8. package/src/commands/status.js +141 -0
  9. package/src/commands/upgrade.js +208 -0
  10. package/src/core/backup.js +94 -0
  11. package/src/core/config.js +54 -0
  12. package/src/core/detector.js +43 -0
  13. package/src/core/file-categorizer.js +177 -0
  14. package/src/core/merger.js +413 -0
  15. package/src/core/scaffolder.js +60 -0
  16. package/src/data/agents.js +164 -0
  17. package/src/index.js +51 -0
  18. package/src/prompts/agent-selection.js +99 -0
  19. package/src/prompts/claude-md-merge.js +153 -0
  20. package/src/prompts/conflict-resolution.js +24 -0
  21. package/src/prompts/project-type.js +75 -0
  22. package/src/prompts/tech-stack.js +35 -0
  23. package/src/utils/display.js +41 -0
  24. package/src/utils/file.js +70 -0
  25. package/src/utils/hash.js +13 -0
  26. package/src/utils/time.js +22 -0
  27. package/templates/agents/optional/backend/api-designer.md +61 -0
  28. package/templates/agents/optional/backend/auth-auditor.md +63 -0
  29. package/templates/agents/optional/backend/database-analyst.md +61 -0
  30. package/templates/agents/optional/data/data-pipeline-reviewer.md +68 -0
  31. package/templates/agents/optional/data/ml-experiment-tracker.md +67 -0
  32. package/templates/agents/optional/data/prompt-engineer.md +75 -0
  33. package/templates/agents/optional/devops/ci-fixer.md +64 -0
  34. package/templates/agents/optional/devops/dependency-manager.md +55 -0
  35. package/templates/agents/optional/devops/deploy-validator.md +68 -0
  36. package/templates/agents/optional/devops/docker-helper.md +63 -0
  37. package/templates/agents/optional/docs/changelog-generator.md +69 -0
  38. package/templates/agents/optional/docs/doc-writer.md +60 -0
  39. package/templates/agents/optional/frontend/style-enforcer.md +47 -0
  40. package/templates/agents/optional/frontend/ui-reviewer.md +51 -0
  41. package/templates/agents/optional/quality/bug-fixer.md +54 -0
  42. package/templates/agents/optional/quality/performance-auditor.md +65 -0
  43. package/templates/agents/optional/quality/refactorer.md +61 -0
  44. package/templates/agents/optional/quality/security-reviewer.md +74 -0
  45. package/templates/agents/universal/build-validator.md +15 -0
  46. package/templates/agents/universal/code-simplifier.md +17 -0
  47. package/templates/agents/universal/plan-reviewer.md +20 -0
  48. package/templates/agents/universal/test-writer.md +17 -0
  49. package/templates/agents/universal/verify-app.md +16 -0
  50. package/templates/claude-md.md +40 -0
  51. package/templates/commands/commit-push-pr.md +9 -0
  52. package/templates/commands/compact-safe.md +8 -0
  53. package/templates/commands/end.md +9 -0
  54. package/templates/commands/review-plan.md +10 -0
  55. package/templates/commands/setup.md +112 -0
  56. package/templates/commands/start.md +3 -0
  57. package/templates/commands/status.md +6 -0
  58. package/templates/commands/techdebt.md +9 -0
  59. package/templates/commands/update-claude-md.md +9 -0
  60. package/templates/commands/verify.md +8 -0
  61. package/templates/mcp-json.json +3 -0
  62. package/templates/progress-md.md +21 -0
  63. package/templates/settings/base.json +64 -0
  64. package/templates/settings/docker.json +9 -0
  65. package/templates/settings/go.json +10 -0
  66. package/templates/settings/node.json +17 -0
  67. package/templates/settings/python.json +16 -0
  68. package/templates/settings/rust.json +11 -0
  69. package/templates/skills/templates/backend-conventions.md +57 -0
  70. package/templates/skills/templates/frontend-design-system.md +48 -0
  71. package/templates/skills/templates/project-patterns.md +48 -0
  72. package/templates/skills/universal/claude-md-maintenance.md +110 -0
  73. package/templates/skills/universal/context-management.md +71 -0
  74. package/templates/skills/universal/git-conventions.md +95 -0
  75. package/templates/skills/universal/planning-with-files.md +114 -0
  76. package/templates/skills/universal/prompt-engineering.md +97 -0
  77. package/templates/skills/universal/review-and-handoff.md +106 -0
  78. package/templates/skills/universal/subagent-usage.md +108 -0
  79. package/templates/skills/universal/testing.md +116 -0
  80. package/templates/skills/universal/verification.md +120 -0
  81. package/templates/spec-md-backend.md +85 -0
  82. package/templates/spec-md-cli.md +79 -0
  83. package/templates/spec-md-data.md +74 -0
  84. package/templates/spec-md-devops.md +87 -0
  85. package/templates/spec-md-frontend.md +81 -0
  86. package/templates/spec-md-fullstack.md +81 -0
  87. package/templates/spec-md-library.md +87 -0
  88. package/templates/spec-md.md +22 -0
  89. package/templates/workflow-meta.json +10 -0
@@ -0,0 +1,177 @@
1
+ import path from 'node:path';
2
+ import { hashContent, hashFile } from '../utils/hash.js';
3
+ import { readTemplate } from './scaffolder.js';
4
+ import { fileExists, listFilesRecursive } from '../utils/file.js';
5
+ import {
6
+ UNIVERSAL_AGENTS,
7
+ AGENT_CATALOG,
8
+ COMMAND_FILES,
9
+ UNIVERSAL_SKILLS,
10
+ TEMPLATE_SKILLS,
11
+ } from '../data/agents.js';
12
+
13
+ /**
14
+ * Build a map of all workflow template files to their hash keys, template paths, and hashes.
15
+ * Hash keys match the format stored in workflow-meta.json (relative to .claude/).
16
+ */
17
+ export async function buildTemplateHashMap() {
18
+ const map = {};
19
+
20
+ // Universal agents: key = agents/{name}.md, templatePath = agents/universal/{name}.md
21
+ for (const name of UNIVERSAL_AGENTS) {
22
+ const key = `agents/${name}.md`;
23
+ const templatePath = `agents/universal/${name}.md`;
24
+ const content = await readTemplate(templatePath);
25
+ map[key] = { templatePath, hash: hashContent(content), type: 'universal-agent' };
26
+ }
27
+
28
+ // Optional agents: key = agents/{name}.md, templatePath = agents/optional/{category}/{name}.md
29
+ for (const [name, info] of Object.entries(AGENT_CATALOG)) {
30
+ const key = `agents/${name}.md`;
31
+ const templatePath = `agents/optional/${info.category}/${name}.md`;
32
+ const content = await readTemplate(templatePath);
33
+ map[key] = { templatePath, hash: hashContent(content), type: 'optional-agent' };
34
+ }
35
+
36
+ // Commands: key = commands/{name}.md, templatePath = commands/{name}.md
37
+ for (const name of COMMAND_FILES) {
38
+ const key = `commands/${name}.md`;
39
+ const templatePath = `commands/${name}.md`;
40
+ const content = await readTemplate(templatePath);
41
+ map[key] = { templatePath, hash: hashContent(content), type: 'command' };
42
+ }
43
+
44
+ // Universal skills: key = skills/{name}.md, templatePath = skills/universal/{name}.md
45
+ for (const name of UNIVERSAL_SKILLS) {
46
+ const key = `skills/${name}.md`;
47
+ const templatePath = `skills/universal/${name}.md`;
48
+ const content = await readTemplate(templatePath);
49
+ map[key] = { templatePath, hash: hashContent(content), type: 'universal-skill' };
50
+ }
51
+
52
+ // Template skills: key = skills/{name}.md, templatePath = skills/templates/{name}.md
53
+ // These contain {variable} placeholders — raw hash won't match stored (substituted) hash
54
+ for (const name of TEMPLATE_SKILLS) {
55
+ const key = `skills/${name}.md`;
56
+ const templatePath = `skills/templates/${name}.md`;
57
+ const content = await readTemplate(templatePath);
58
+ map[key] = { templatePath, hash: hashContent(content), type: 'template-skill' };
59
+ }
60
+
61
+ return map;
62
+ }
63
+
64
+ /**
65
+ * Categorize all workflow files by comparing stored hashes, on-disk hashes, and template hashes.
66
+ */
67
+ export async function categorizeFiles(projectRoot, meta) {
68
+ const templateMap = await buildTemplateHashMap();
69
+ const storedHashes = meta.fileHashes || {};
70
+ const claudeDir = path.join(projectRoot, '.claude');
71
+
72
+ const result = {
73
+ autoUpdate: [],
74
+ conflict: [],
75
+ newFiles: [],
76
+ unchanged: [],
77
+ deleted: [],
78
+ userAdded: [],
79
+ modified: [],
80
+ outdated: [],
81
+ };
82
+
83
+ // Track which keys we've processed
84
+ const processedKeys = new Set();
85
+
86
+ // 1. Process each file in stored hashes
87
+ for (const [key, storedHash] of Object.entries(storedHashes)) {
88
+ processedKeys.add(key);
89
+ const filePath = path.join(claudeDir, ...key.split('/'));
90
+
91
+ // Check if file still exists on disk
92
+ if (!(await fileExists(filePath))) {
93
+ result.deleted.push({ key });
94
+ continue;
95
+ }
96
+
97
+ // Compute current on-disk hash
98
+ const currentHash = await hashFile(filePath);
99
+ const userModified = currentHash !== storedHash;
100
+
101
+ // Look up in template map
102
+ const templateEntry = templateMap[key];
103
+
104
+ if (!templateEntry) {
105
+ // File is in stored hashes but not in template map — treat as tracked file
106
+ if (userModified) {
107
+ result.modified.push({ key });
108
+ } else {
109
+ result.unchanged.push({ key });
110
+ }
111
+ continue;
112
+ }
113
+
114
+ // Skip template skills from outdated detection (raw vs substituted hash mismatch)
115
+ if (templateEntry.type === 'template-skill') {
116
+ if (userModified) {
117
+ result.modified.push({ key });
118
+ } else {
119
+ result.unchanged.push({ key });
120
+ }
121
+ continue;
122
+ }
123
+
124
+ const templateChanged = templateEntry.hash !== storedHash;
125
+
126
+ if (!templateChanged) {
127
+ // Template unchanged — only report user modifications
128
+ if (userModified) {
129
+ result.modified.push({ key });
130
+ } else {
131
+ result.unchanged.push({ key });
132
+ }
133
+ } else {
134
+ // Template was updated in new version
135
+ result.outdated.push({ key, templatePath: templateEntry.templatePath });
136
+ if (userModified) {
137
+ result.conflict.push({ key, templatePath: templateEntry.templatePath });
138
+ } else {
139
+ result.autoUpdate.push({ key, templatePath: templateEntry.templatePath });
140
+ }
141
+ }
142
+ }
143
+
144
+ // 2. Find new files (in template map but not in stored hashes)
145
+ for (const [key, entry] of Object.entries(templateMap)) {
146
+ if (processedKeys.has(key)) continue;
147
+
148
+ // Skip optional agents the user didn't select
149
+ if (entry.type === 'optional-agent') {
150
+ const agentName = key.replace('agents/', '').replace('.md', '');
151
+ if (!meta.optionalAgents?.includes(agentName)) {
152
+ continue;
153
+ }
154
+ }
155
+
156
+ // Skip template skills (would need variable substitution)
157
+ if (entry.type === 'template-skill') continue;
158
+
159
+ result.newFiles.push({ key, templatePath: entry.templatePath });
160
+ }
161
+
162
+ // 3. Find user-added files (on disk but not in stored hashes or template map)
163
+ try {
164
+ const allFiles = await listFilesRecursive(claudeDir);
165
+ for (const filePath of allFiles) {
166
+ const relKey = path.relative(claudeDir, filePath).split(path.sep).join('/');
167
+ if (relKey === 'workflow-meta.json' || relKey === 'settings.json') continue;
168
+ if (processedKeys.has(relKey)) continue;
169
+ if (templateMap[relKey]) continue; // It's a known template file (maybe new)
170
+ result.userAdded.push({ key: relKey, fullPath: filePath });
171
+ }
172
+ } catch {
173
+ // .claude/ directory might not exist or be empty
174
+ }
175
+
176
+ return result;
177
+ }
@@ -0,0 +1,413 @@
1
+ import path from 'node:path';
2
+ import os from 'node:os';
3
+ import { readFile, writeFile } from '../utils/file.js';
4
+ import {
5
+ readTemplate,
6
+ substituteVariables,
7
+ scaffoldFile,
8
+ mergeSettings,
9
+ } from './scaffolder.js';
10
+ import { promptHookConflict } from '../prompts/conflict-resolution.js';
11
+ import {
12
+ detectMissingSections,
13
+ generateWorkflowSuggestions,
14
+ promptClaudeMdMerge,
15
+ interactiveSectionMerge,
16
+ } from '../prompts/claude-md-merge.js';
17
+ import {
18
+ UNIVERSAL_AGENTS,
19
+ AGENT_CATALOG,
20
+ COMMAND_FILES,
21
+ UNIVERSAL_SKILLS,
22
+ TEMPLATE_SKILLS,
23
+ NOTIFICATION_COMMANDS,
24
+ SPEC_MD_TEMPLATE_MAP,
25
+ } from '../data/agents.js';
26
+ import * as display from '../utils/display.js';
27
+
28
+ // --- Settings builder (shared with Scenario A) ---
29
+
30
+ export async function buildSettingsJson(languages, useDocker) {
31
+ const baseSettings = JSON.parse(await readTemplate('settings/base.json'));
32
+ const formatters = [];
33
+ const settingsToMerge = [];
34
+
35
+ for (const lang of languages) {
36
+ if (lang !== 'other') {
37
+ const stackRaw = await readTemplate(`settings/${lang}.json`);
38
+ const stackSettings = JSON.parse(stackRaw);
39
+ if (stackSettings.formatter) {
40
+ formatters.push(stackSettings.formatter);
41
+ }
42
+ delete stackSettings.formatter;
43
+ settingsToMerge.push(stackSettings);
44
+ }
45
+ }
46
+
47
+ if (useDocker) {
48
+ const dockerRaw = await readTemplate('settings/docker.json');
49
+ const dockerSettings = JSON.parse(dockerRaw);
50
+ delete dockerSettings.formatter;
51
+ settingsToMerge.push(dockerSettings);
52
+ }
53
+
54
+ const formatter =
55
+ formatters.length > 0 ? formatters.join(' && ') : "echo 'No formatter configured'";
56
+
57
+ const mergedSettings = mergeSettings(baseSettings, ...settingsToMerge);
58
+
59
+ const platform = os.platform();
60
+ const notification = NOTIFICATION_COMMANDS[platform] || NOTIFICATION_COMMANDS.linux;
61
+
62
+ replaceHookCommands(mergedSettings, {
63
+ formatter_command: formatter,
64
+ notification_command: notification,
65
+ });
66
+
67
+ const settingsStr = JSON.stringify(mergedSettings, null, 2);
68
+ return { settingsStr, settingsObject: mergedSettings };
69
+ }
70
+
71
+ function replaceHookCommands(settings, variables) {
72
+ if (!settings.hooks) return;
73
+ for (const entries of Object.values(settings.hooks)) {
74
+ for (const entry of entries) {
75
+ if (!entry.hooks) continue;
76
+ for (const hook of entry.hooks) {
77
+ if (typeof hook.command === 'string') {
78
+ hook.command = hook.command.replace(/\{(\w+)\}/g, (match, key) =>
79
+ variables[key] !== undefined ? variables[key] : match
80
+ );
81
+ }
82
+ }
83
+ }
84
+ }
85
+ }
86
+
87
+ function parseUserJson(raw, filename) {
88
+ try {
89
+ return JSON.parse(raw);
90
+ } catch {
91
+ // Try stripping shell-escaped braces (common zsh heredoc artifact)
92
+ const cleaned = raw.replace(/\\([{}])/g, '$1');
93
+ try {
94
+ return JSON.parse(cleaned);
95
+ } catch (err) {
96
+ throw new Error(`existing ${filename} contains invalid JSON: ${err.message}`);
97
+ }
98
+ }
99
+ }
100
+
101
+ // --- Sub-merge operations ---
102
+
103
+ async function mergeSkills(projectRoot, existingScan, variables, report) {
104
+ const allSkills = [
105
+ ...UNIVERSAL_SKILLS.map((s) => ({ name: s, templatePath: `skills/universal/${s}.md`, vars: {} })),
106
+ ...TEMPLATE_SKILLS.map((s) => ({ name: s, templatePath: `skills/templates/${s}.md`, vars: variables })),
107
+ ];
108
+
109
+ for (const skill of allSkills) {
110
+ const filename = `${skill.name}.md`;
111
+ const destDir = path.join('.claude', 'skills');
112
+
113
+ if (existingScan.existingSkills.includes(filename)) {
114
+ // Tier 2: conflict — save as .workflow-ref.md
115
+ await scaffoldFile(
116
+ skill.templatePath,
117
+ path.join(destDir, `${skill.name}.workflow-ref.md`),
118
+ skill.vars,
119
+ projectRoot
120
+ );
121
+ report.conflicts.skills.push(filename);
122
+ } else {
123
+ // Tier 1: add
124
+ await scaffoldFile(skill.templatePath, path.join(destDir, filename), skill.vars, projectRoot);
125
+ report.added.skills.push(filename);
126
+ }
127
+ }
128
+ }
129
+
130
+ async function mergeAgents(projectRoot, existingScan, selectedAgents, report) {
131
+ // Universal agents
132
+ for (const agent of UNIVERSAL_AGENTS) {
133
+ const filename = `${agent}.md`;
134
+ const destDir = path.join('.claude', 'agents');
135
+
136
+ if (existingScan.existingAgents.includes(filename)) {
137
+ await scaffoldFile(
138
+ `agents/universal/${agent}.md`,
139
+ path.join(destDir, `${agent}.workflow-ref.md`),
140
+ {},
141
+ projectRoot
142
+ );
143
+ report.conflicts.agents.push(filename);
144
+ } else {
145
+ await scaffoldFile(
146
+ `agents/universal/${agent}.md`,
147
+ path.join(destDir, filename),
148
+ {},
149
+ projectRoot
150
+ );
151
+ report.added.agents.push(filename);
152
+ }
153
+ }
154
+
155
+ // Selected optional agents
156
+ for (const agent of selectedAgents) {
157
+ const category = AGENT_CATALOG[agent].category;
158
+ const filename = `${agent}.md`;
159
+ const destDir = path.join('.claude', 'agents');
160
+
161
+ if (existingScan.existingAgents.includes(filename)) {
162
+ await scaffoldFile(
163
+ `agents/optional/${category}/${agent}.md`,
164
+ path.join(destDir, `${agent}.workflow-ref.md`),
165
+ {},
166
+ projectRoot
167
+ );
168
+ report.conflicts.agents.push(filename);
169
+ } else {
170
+ await scaffoldFile(
171
+ `agents/optional/${category}/${agent}.md`,
172
+ path.join(destDir, filename),
173
+ {},
174
+ projectRoot
175
+ );
176
+ report.added.agents.push(filename);
177
+ }
178
+ }
179
+ }
180
+
181
+ async function mergeCommands(projectRoot, existingScan, report) {
182
+ for (const cmd of COMMAND_FILES) {
183
+ const filename = `${cmd}.md`;
184
+ const destDir = path.join('.claude', 'commands');
185
+
186
+ if (existingScan.existingCommands.includes(filename)) {
187
+ await scaffoldFile(
188
+ `commands/${cmd}.md`,
189
+ path.join(destDir, `${cmd}.workflow-ref.md`),
190
+ {},
191
+ projectRoot
192
+ );
193
+ report.conflicts.commands.push(filename);
194
+ } else {
195
+ await scaffoldFile(`commands/${cmd}.md`, path.join(destDir, filename), {}, projectRoot);
196
+ report.added.commands.push(filename);
197
+ }
198
+ }
199
+ }
200
+
201
+ export async function mergeSettingsPermissionsAndHooks(projectRoot, workflowSettings, report) {
202
+ const existingRaw = await readFile(path.join(projectRoot, '.claude', 'settings.json'));
203
+ const existing = parseUserJson(existingRaw, '.claude/settings.json');
204
+
205
+ // Merge permissions (Tier 1)
206
+ const existingAllow = existing.permissions?.allow || [];
207
+ const workflowAllow = workflowSettings.permissions?.allow || [];
208
+ const newPerms = workflowAllow.filter((p) => !existingAllow.includes(p));
209
+ if (!existing.permissions) existing.permissions = {};
210
+ existing.permissions.allow = [...existingAllow, ...newPerms];
211
+ report.added.permissions = newPerms.length;
212
+
213
+ // Merge hooks (Tier 1 + Tier 3)
214
+ if (!existing.hooks) existing.hooks = {};
215
+ const workflowHooks = workflowSettings.hooks || {};
216
+
217
+ for (const category of Object.keys(workflowHooks)) {
218
+ if (!existing.hooks[category]) existing.hooks[category] = [];
219
+
220
+ const existingEntries = existing.hooks[category];
221
+ const existingMatchers = new Map(existingEntries.map((h) => [h.matcher, h]));
222
+
223
+ for (const workflowEntry of workflowHooks[category]) {
224
+ if (existingMatchers.has(workflowEntry.matcher)) {
225
+ const existingEntry = existingMatchers.get(workflowEntry.matcher);
226
+
227
+ // If hooks are identical, skip — no conflict to resolve
228
+ const existingCmd = existingEntry.hooks?.[0]?.command || '';
229
+ const workflowCmd = workflowEntry.hooks?.[0]?.command || '';
230
+ if (existingCmd === workflowCmd) {
231
+ continue;
232
+ }
233
+
234
+ // Tier 3: conflict — ask user
235
+ const resolution = await promptHookConflict(category, existingEntry, workflowEntry);
236
+
237
+ if (resolution === 'replace') {
238
+ const idx = existingEntries.indexOf(existingEntry);
239
+ existingEntries[idx] = workflowEntry;
240
+ report.hookConflicts.push(
241
+ `${category} "${workflowEntry.matcher}": replaced with workflow hook`
242
+ );
243
+ } else if (resolution === 'chain') {
244
+ const idx = existingEntries.indexOf(existingEntry);
245
+ existingEntries[idx] = {
246
+ matcher: existingEntry.matcher,
247
+ hooks: [
248
+ {
249
+ type: 'command',
250
+ command: `${existingEntry.hooks[0].command} && ${workflowEntry.hooks[0].command}`,
251
+ },
252
+ ],
253
+ };
254
+ report.hookConflicts.push(
255
+ `${category} "${workflowEntry.matcher}": chained both hooks`
256
+ );
257
+ } else {
258
+ report.hookConflicts.push(
259
+ `${category} "${workflowEntry.matcher}": kept existing hook`
260
+ );
261
+ }
262
+ } else {
263
+ // Tier 1: no conflict — append
264
+ existingEntries.push(workflowEntry);
265
+ report.added.hooks++;
266
+ }
267
+ }
268
+ }
269
+
270
+ await writeFile(
271
+ path.join(projectRoot, '.claude', 'settings.json'),
272
+ JSON.stringify(existing, null, 2)
273
+ );
274
+ }
275
+
276
+ async function mergeSettingsJson(projectRoot, existingScan, selections, report) {
277
+ const { languages, useDocker } = selections;
278
+ const { settingsStr, settingsObject: workflowSettings } = await buildSettingsJson(
279
+ languages,
280
+ useDocker
281
+ );
282
+
283
+ if (!existingScan.hasSettingsJson) {
284
+ // No existing settings — create fresh
285
+ await writeFile(path.join(projectRoot, '.claude', 'settings.json'), settingsStr);
286
+ report.added.permissions = workflowSettings.permissions?.allow?.length || 0;
287
+ report.added.hooks = countHooks(workflowSettings.hooks);
288
+ return;
289
+ }
290
+
291
+ try {
292
+ await mergeSettingsPermissionsAndHooks(projectRoot, workflowSettings, report);
293
+ } catch {
294
+ display.warn(
295
+ 'Existing settings.json contains invalid JSON — creating fresh settings instead.'
296
+ );
297
+ await writeFile(path.join(projectRoot, '.claude', 'settings.json'), settingsStr);
298
+ report.added.permissions = workflowSettings.permissions?.allow?.length || 0;
299
+ report.added.hooks = countHooks(workflowSettings.hooks);
300
+ }
301
+ }
302
+
303
+ function countHooks(hooks) {
304
+ if (!hooks) return 0;
305
+ return Object.values(hooks).reduce((sum, entries) => sum + entries.length, 0);
306
+ }
307
+
308
+ async function mergeMcpJson(projectRoot, existingScan) {
309
+ if (!existingScan.hasMcpJson) {
310
+ await scaffoldFile('mcp-json.json', '.mcp.json', {}, projectRoot);
311
+ return;
312
+ }
313
+
314
+ // Merge mcpServers — user's servers take priority
315
+ const existingRaw = await readFile(path.join(projectRoot, '.mcp.json'));
316
+ const existing = parseUserJson(existingRaw, '.mcp.json');
317
+ const workflowRaw = await readTemplate('mcp-json.json');
318
+ const workflow = JSON.parse(workflowRaw);
319
+
320
+ const merged = {
321
+ ...existing,
322
+ mcpServers: { ...workflow.mcpServers, ...existing.mcpServers },
323
+ };
324
+
325
+ await writeFile(path.join(projectRoot, '.mcp.json'), JSON.stringify(merged, null, 2));
326
+ }
327
+
328
+ async function mergeDocSpecs(projectRoot, existingScan, variables, selections, report) {
329
+ if (!existingScan.hasProgressMd) {
330
+ await scaffoldFile(
331
+ 'progress-md.md',
332
+ path.join('docs', 'spec', 'PROGRESS.md'),
333
+ variables,
334
+ projectRoot
335
+ );
336
+ }
337
+ report.skipped.progressMd = existingScan.hasProgressMd;
338
+
339
+ if (!existingScan.hasSpecMd) {
340
+ const primaryType = selections.projectTypes[0];
341
+ const specTemplate = SPEC_MD_TEMPLATE_MAP[primaryType] || 'spec-md.md';
342
+ await scaffoldFile(specTemplate, path.join('docs', 'spec', 'SPEC.md'), variables, projectRoot);
343
+ }
344
+ report.skipped.specMd = existingScan.hasSpecMd;
345
+ }
346
+
347
+ async function handleClaudeMd(projectRoot, existingScan, variables, report) {
348
+ if (!existingScan.hasClaudeMd) {
349
+ // No CLAUDE.md — scaffold fresh
350
+ await scaffoldFile('claude-md.md', 'CLAUDE.md', variables, projectRoot);
351
+ report.claudeMdHandling = 'created';
352
+ return;
353
+ }
354
+
355
+ const existingContent = await readFile(path.join(projectRoot, 'CLAUDE.md'));
356
+ const renderedTemplate = substituteVariables(await readTemplate('claude-md.md'), variables);
357
+ const missingSections = detectMissingSections(existingContent);
358
+
359
+ if (missingSections.length === 0) {
360
+ display.newline();
361
+ display.info(`Your CLAUDE.md (${existingContent.split(/\r?\n/).length} lines) was detected.`);
362
+ display.success('Your CLAUDE.md already has all recommended sections!');
363
+ report.claudeMdHandling = 'kept';
364
+ return;
365
+ }
366
+
367
+ const choice = await promptClaudeMdMerge(existingContent, missingSections);
368
+
369
+ if (choice === 'keep') {
370
+ const suggestions = generateWorkflowSuggestions(existingContent, renderedTemplate);
371
+ await writeFile(path.join(projectRoot, 'CLAUDE.md.workflow-suggestions'), suggestions);
372
+ report.claudeMdHandling = 'suggestions-generated';
373
+ } else if (choice === 'merge-sections') {
374
+ const updatedContent = await interactiveSectionMerge(
375
+ existingContent,
376
+ renderedTemplate,
377
+ missingSections
378
+ );
379
+ await writeFile(path.join(projectRoot, 'CLAUDE.md'), updatedContent);
380
+ report.claudeMdHandling = 'merged-sections';
381
+ }
382
+ }
383
+
384
+ // --- Main merge function ---
385
+
386
+ export async function performMerge(projectRoot, existingScan, selections, variables, { spinner } = {}) {
387
+ const report = {
388
+ added: { skills: [], agents: [], commands: [], permissions: 0, hooks: 0 },
389
+ conflicts: { skills: [], agents: [], commands: [] },
390
+ skipped: { progressMd: false, specMd: false },
391
+ claudeMdHandling: 'kept',
392
+ hookConflicts: [],
393
+ };
394
+
395
+ await mergeSkills(projectRoot, existingScan, variables, report);
396
+ await mergeAgents(projectRoot, existingScan, selections.selectedAgents, report);
397
+ await mergeCommands(projectRoot, existingScan, report);
398
+
399
+ // Stop spinner before settings merge — hook conflicts may prompt for input
400
+ if (spinner) spinner.stop();
401
+ await mergeSettingsJson(projectRoot, existingScan, selections, report);
402
+ if (spinner) spinner.start();
403
+
404
+ await mergeMcpJson(projectRoot, existingScan);
405
+ await mergeDocSpecs(projectRoot, existingScan, variables, selections, report);
406
+
407
+ // Stop spinner before CLAUDE.md merge — interactive prompts for section selection
408
+ if (spinner) spinner.stop();
409
+ await handleClaudeMd(projectRoot, existingScan, variables, report);
410
+ if (spinner) spinner.start();
411
+
412
+ return report;
413
+ }
@@ -0,0 +1,60 @@
1
+ import path from 'node:path';
2
+ import { fileURLToPath } from 'node:url';
3
+ import { readFile, writeFile } from '../utils/file.js';
4
+
5
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
6
+
7
+ export function getTemplatesDir() {
8
+ return path.resolve(__dirname, '..', '..', 'templates');
9
+ }
10
+
11
+ export async function readTemplate(relativePath) {
12
+ const fullPath = path.join(getTemplatesDir(), relativePath);
13
+ return readFile(fullPath);
14
+ }
15
+
16
+ export function substituteVariables(content, variables) {
17
+ return content.replace(/\{(\w+)\}/g, (match, key) => {
18
+ return variables[key] !== undefined ? variables[key] : match;
19
+ });
20
+ }
21
+
22
+ export async function scaffoldFile(templateRelativePath, destRelativePath, variables, projectRoot) {
23
+ const root = projectRoot || process.cwd();
24
+ const template = await readTemplate(templateRelativePath);
25
+ const content = substituteVariables(template, variables);
26
+ const destPath = path.join(root, destRelativePath);
27
+ await writeFile(destPath, content);
28
+ }
29
+
30
+ export async function scaffoldDirectory(templateDir, destDir, variables, projectRoot) {
31
+ const root = projectRoot || process.cwd();
32
+ const fs = await import('fs-extra');
33
+ const templatesBase = getTemplatesDir();
34
+ const srcDir = path.join(templatesBase, templateDir);
35
+
36
+ const entries = await fs.readdir(srcDir, { withFileTypes: true });
37
+ for (const entry of entries) {
38
+ if (entry.isFile()) {
39
+ const templatePath = path.join(templateDir, entry.name);
40
+ const destPath = path.join(destDir, entry.name);
41
+ await scaffoldFile(templatePath, destPath, variables, root);
42
+ }
43
+ }
44
+ }
45
+
46
+ export function mergeSettings(base, ...stacks) {
47
+ const merged = JSON.parse(JSON.stringify(base));
48
+ const baseAllow = merged.permissions?.allow || [];
49
+
50
+ for (const stack of stacks) {
51
+ if (!stack) continue;
52
+ const stackAllow = stack.permissions?.allow || [];
53
+ if (stackAllow.length > 0) {
54
+ baseAllow.push(...stackAllow);
55
+ }
56
+ }
57
+
58
+ merged.permissions.allow = [...new Set(baseAllow)];
59
+ return merged;
60
+ }