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.
- package/LICENSE +21 -0
- package/README.md +278 -0
- package/package.json +62 -0
- package/src/commands/backup.js +55 -0
- package/src/commands/diff.js +76 -0
- package/src/commands/init.js +628 -0
- package/src/commands/restore.js +95 -0
- package/src/commands/status.js +141 -0
- package/src/commands/upgrade.js +208 -0
- package/src/core/backup.js +94 -0
- package/src/core/config.js +54 -0
- package/src/core/detector.js +43 -0
- package/src/core/file-categorizer.js +177 -0
- package/src/core/merger.js +413 -0
- package/src/core/scaffolder.js +60 -0
- package/src/data/agents.js +164 -0
- package/src/index.js +51 -0
- package/src/prompts/agent-selection.js +99 -0
- package/src/prompts/claude-md-merge.js +153 -0
- package/src/prompts/conflict-resolution.js +24 -0
- package/src/prompts/project-type.js +75 -0
- package/src/prompts/tech-stack.js +35 -0
- package/src/utils/display.js +41 -0
- package/src/utils/file.js +70 -0
- package/src/utils/hash.js +13 -0
- package/src/utils/time.js +22 -0
- package/templates/agents/optional/backend/api-designer.md +61 -0
- package/templates/agents/optional/backend/auth-auditor.md +63 -0
- package/templates/agents/optional/backend/database-analyst.md +61 -0
- package/templates/agents/optional/data/data-pipeline-reviewer.md +68 -0
- package/templates/agents/optional/data/ml-experiment-tracker.md +67 -0
- package/templates/agents/optional/data/prompt-engineer.md +75 -0
- package/templates/agents/optional/devops/ci-fixer.md +64 -0
- package/templates/agents/optional/devops/dependency-manager.md +55 -0
- package/templates/agents/optional/devops/deploy-validator.md +68 -0
- package/templates/agents/optional/devops/docker-helper.md +63 -0
- package/templates/agents/optional/docs/changelog-generator.md +69 -0
- package/templates/agents/optional/docs/doc-writer.md +60 -0
- package/templates/agents/optional/frontend/style-enforcer.md +47 -0
- package/templates/agents/optional/frontend/ui-reviewer.md +51 -0
- package/templates/agents/optional/quality/bug-fixer.md +54 -0
- package/templates/agents/optional/quality/performance-auditor.md +65 -0
- package/templates/agents/optional/quality/refactorer.md +61 -0
- package/templates/agents/optional/quality/security-reviewer.md +74 -0
- package/templates/agents/universal/build-validator.md +15 -0
- package/templates/agents/universal/code-simplifier.md +17 -0
- package/templates/agents/universal/plan-reviewer.md +20 -0
- package/templates/agents/universal/test-writer.md +17 -0
- package/templates/agents/universal/verify-app.md +16 -0
- package/templates/claude-md.md +40 -0
- package/templates/commands/commit-push-pr.md +9 -0
- package/templates/commands/compact-safe.md +8 -0
- package/templates/commands/end.md +9 -0
- package/templates/commands/review-plan.md +10 -0
- package/templates/commands/setup.md +112 -0
- package/templates/commands/start.md +3 -0
- package/templates/commands/status.md +6 -0
- package/templates/commands/techdebt.md +9 -0
- package/templates/commands/update-claude-md.md +9 -0
- package/templates/commands/verify.md +8 -0
- package/templates/mcp-json.json +3 -0
- package/templates/progress-md.md +21 -0
- package/templates/settings/base.json +64 -0
- package/templates/settings/docker.json +9 -0
- package/templates/settings/go.json +10 -0
- package/templates/settings/node.json +17 -0
- package/templates/settings/python.json +16 -0
- package/templates/settings/rust.json +11 -0
- package/templates/skills/templates/backend-conventions.md +57 -0
- package/templates/skills/templates/frontend-design-system.md +48 -0
- package/templates/skills/templates/project-patterns.md +48 -0
- package/templates/skills/universal/claude-md-maintenance.md +110 -0
- package/templates/skills/universal/context-management.md +71 -0
- package/templates/skills/universal/git-conventions.md +95 -0
- package/templates/skills/universal/planning-with-files.md +114 -0
- package/templates/skills/universal/prompt-engineering.md +97 -0
- package/templates/skills/universal/review-and-handoff.md +106 -0
- package/templates/skills/universal/subagent-usage.md +108 -0
- package/templates/skills/universal/testing.md +116 -0
- package/templates/skills/universal/verification.md +120 -0
- package/templates/spec-md-backend.md +85 -0
- package/templates/spec-md-cli.md +79 -0
- package/templates/spec-md-data.md +74 -0
- package/templates/spec-md-devops.md +87 -0
- package/templates/spec-md-frontend.md +81 -0
- package/templates/spec-md-fullstack.md +81 -0
- package/templates/spec-md-library.md +87 -0
- package/templates/spec-md.md +22 -0
- 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
|
+
}
|