claudex-setup 1.7.0 → 1.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +35 -0
- package/LICENSE +21 -0
- package/README.md +34 -12
- package/bin/cli.js +42 -5
- package/package.json +3 -2
- package/src/analyze.js +156 -4
- package/src/audit.js +2 -2
- package/src/benchmark.js +81 -7
- package/src/claudex-sync.json +6 -2
- package/src/context.js +3 -2
- package/src/domain-packs.js +223 -0
- package/src/governance.js +207 -2
- package/src/index.js +8 -0
- package/src/mcp-packs.js +139 -0
- package/src/plans.js +329 -59
- package/src/setup.js +60 -36
- package/src/techniques.js +30 -10
package/src/mcp-packs.js
ADDED
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
const MCP_PACKS = [
|
|
2
|
+
{
|
|
3
|
+
key: 'context7-docs',
|
|
4
|
+
label: 'Context7 Docs',
|
|
5
|
+
useWhen: 'Repos that benefit from live, current framework and library documentation during Claude sessions.',
|
|
6
|
+
adoption: 'Safe default docs pack for most application repos.',
|
|
7
|
+
servers: {
|
|
8
|
+
context7: {
|
|
9
|
+
command: 'npx',
|
|
10
|
+
args: ['-y', '@upstash/context7-mcp@latest'],
|
|
11
|
+
},
|
|
12
|
+
},
|
|
13
|
+
},
|
|
14
|
+
{
|
|
15
|
+
key: 'next-devtools',
|
|
16
|
+
label: 'Next.js Devtools',
|
|
17
|
+
useWhen: 'Next.js repos that need runtime-aware debugging and framework-specific tooling.',
|
|
18
|
+
adoption: 'Useful companion pack for frontend-ui repos running Next.js.',
|
|
19
|
+
servers: {
|
|
20
|
+
'next-devtools': {
|
|
21
|
+
command: 'npx',
|
|
22
|
+
args: ['-y', 'next-devtools-mcp@latest'],
|
|
23
|
+
},
|
|
24
|
+
},
|
|
25
|
+
},
|
|
26
|
+
{
|
|
27
|
+
key: 'github-mcp',
|
|
28
|
+
label: 'GitHub',
|
|
29
|
+
useWhen: 'Repos hosted on GitHub that benefit from issue, PR, and repository context during Claude sessions.',
|
|
30
|
+
adoption: 'Recommended for any GitHub-hosted project. Requires GITHUB_PERSONAL_ACCESS_TOKEN env var.',
|
|
31
|
+
servers: {
|
|
32
|
+
github: {
|
|
33
|
+
command: 'npx',
|
|
34
|
+
args: ['-y', '@modelcontextprotocol/server-github'],
|
|
35
|
+
env: { GITHUB_PERSONAL_ACCESS_TOKEN: '${GITHUB_PERSONAL_ACCESS_TOKEN}' },
|
|
36
|
+
},
|
|
37
|
+
},
|
|
38
|
+
},
|
|
39
|
+
{
|
|
40
|
+
key: 'postgres-mcp',
|
|
41
|
+
label: 'PostgreSQL',
|
|
42
|
+
useWhen: 'Repos with PostgreSQL databases that benefit from schema inspection and query assistance.',
|
|
43
|
+
adoption: 'Useful for backend-api and data-pipeline repos. Requires DATABASE_URL env var.',
|
|
44
|
+
servers: {
|
|
45
|
+
postgres: {
|
|
46
|
+
command: 'npx',
|
|
47
|
+
args: ['-y', '@modelcontextprotocol/server-postgres'],
|
|
48
|
+
env: { DATABASE_URL: '${DATABASE_URL}' },
|
|
49
|
+
},
|
|
50
|
+
},
|
|
51
|
+
},
|
|
52
|
+
{
|
|
53
|
+
key: 'memory-mcp',
|
|
54
|
+
label: 'Memory / Knowledge Graph',
|
|
55
|
+
useWhen: 'Long-running projects that benefit from persistent entity and relationship tracking across sessions.',
|
|
56
|
+
adoption: 'Useful for complex projects with many interconnected concepts. Stores data locally.',
|
|
57
|
+
servers: {
|
|
58
|
+
memory: {
|
|
59
|
+
command: 'npx',
|
|
60
|
+
args: ['-y', '@modelcontextprotocol/server-memory'],
|
|
61
|
+
},
|
|
62
|
+
},
|
|
63
|
+
},
|
|
64
|
+
];
|
|
65
|
+
|
|
66
|
+
function clone(value) {
|
|
67
|
+
return JSON.parse(JSON.stringify(value));
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function getMcpPack(key) {
|
|
71
|
+
return MCP_PACKS.find(pack => pack.key === key) || null;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function normalizeMcpPackKeys(keys = []) {
|
|
75
|
+
return [...new Set((Array.isArray(keys) ? keys : [])
|
|
76
|
+
.map(key => `${key}`.trim())
|
|
77
|
+
.filter(Boolean))]
|
|
78
|
+
.filter(key => !!getMcpPack(key));
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function mergeMcpServers(existing = {}, packKeys = []) {
|
|
82
|
+
const merged = clone(existing || {});
|
|
83
|
+
for (const key of normalizeMcpPackKeys(packKeys)) {
|
|
84
|
+
const pack = getMcpPack(key);
|
|
85
|
+
if (!pack) continue;
|
|
86
|
+
for (const [serverName, serverConfig] of Object.entries(pack.servers || {})) {
|
|
87
|
+
if (!merged[serverName]) {
|
|
88
|
+
merged[serverName] = clone(serverConfig);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
return merged;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function recommendMcpPacks(stacks = [], domainPacks = []) {
|
|
96
|
+
const recommended = new Set();
|
|
97
|
+
const stackKeys = new Set(stacks.map(stack => stack.key));
|
|
98
|
+
|
|
99
|
+
for (const pack of domainPacks) {
|
|
100
|
+
for (const key of pack.recommendedMcpPacks || []) {
|
|
101
|
+
recommended.add(key);
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
if (stackKeys.has('nextjs')) {
|
|
106
|
+
recommended.add('next-devtools');
|
|
107
|
+
}
|
|
108
|
+
if (stackKeys.size > 0) {
|
|
109
|
+
recommended.add('context7-docs');
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// GitHub MCP for repos with .github directory
|
|
113
|
+
const domainKeys = new Set(domainPacks.map(p => p.key));
|
|
114
|
+
if (domainKeys.has('oss-library') || domainKeys.has('enterprise-governed')) {
|
|
115
|
+
recommended.add('github-mcp');
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// Postgres MCP for data-heavy repos
|
|
119
|
+
if (domainKeys.has('data-pipeline') || domainKeys.has('backend-api')) {
|
|
120
|
+
recommended.add('postgres-mcp');
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// Memory MCP for complex/monorepo projects
|
|
124
|
+
if (domainKeys.has('monorepo') || domainKeys.has('enterprise-governed')) {
|
|
125
|
+
recommended.add('memory-mcp');
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
return MCP_PACKS
|
|
129
|
+
.filter(pack => recommended.has(pack.key))
|
|
130
|
+
.map(pack => clone(pack));
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
module.exports = {
|
|
134
|
+
MCP_PACKS,
|
|
135
|
+
getMcpPack,
|
|
136
|
+
normalizeMcpPackKeys,
|
|
137
|
+
mergeMcpServers,
|
|
138
|
+
recommendMcpPacks,
|
|
139
|
+
};
|
package/src/plans.js
CHANGED
|
@@ -6,6 +6,7 @@ const { analyzeProject } = require('./analyze');
|
|
|
6
6
|
const { ProjectContext } = require('./context');
|
|
7
7
|
const { TECHNIQUES, STACKS } = require('./techniques');
|
|
8
8
|
const { TEMPLATES } = require('./setup');
|
|
9
|
+
const { buildSettingsForProfile } = require('./governance');
|
|
9
10
|
const { writeActivityArtifact, writeRollbackArtifact } = require('./activity');
|
|
10
11
|
|
|
11
12
|
const TEMPLATE_DIR_MAP = {
|
|
@@ -35,6 +36,26 @@ const TEMPLATE_MODULES = {
|
|
|
35
36
|
};
|
|
36
37
|
|
|
37
38
|
const IMPACT_ORDER = { critical: 3, high: 2, medium: 1, low: 0 };
|
|
39
|
+
const FALLBACK_TEMPLATE_BY_KEY = {
|
|
40
|
+
importSyntax: 'claude-md',
|
|
41
|
+
verificationLoop: 'claude-md',
|
|
42
|
+
testCommand: 'claude-md',
|
|
43
|
+
lintCommand: 'claude-md',
|
|
44
|
+
buildCommand: 'claude-md',
|
|
45
|
+
securityReview: 'claude-md',
|
|
46
|
+
compactionAwareness: 'claude-md',
|
|
47
|
+
contextManagement: 'claude-md',
|
|
48
|
+
xmlTags: 'claude-md',
|
|
49
|
+
roleDefinition: 'claude-md',
|
|
50
|
+
constraintBlocks: 'claude-md',
|
|
51
|
+
claudeMdFreshness: 'claude-md',
|
|
52
|
+
permissionDeny: 'hooks',
|
|
53
|
+
secretsProtection: 'hooks',
|
|
54
|
+
preToolUseHook: 'hooks',
|
|
55
|
+
postToolUseHook: 'hooks',
|
|
56
|
+
sessionStartHook: 'hooks',
|
|
57
|
+
agentsHaveMaxTurns: 'agents',
|
|
58
|
+
};
|
|
38
59
|
|
|
39
60
|
function previewContent(content) {
|
|
40
61
|
return content.split('\n').slice(0, 12).join('\n');
|
|
@@ -46,73 +67,242 @@ function riskFromImpact(impact) {
|
|
|
46
67
|
return 'low';
|
|
47
68
|
}
|
|
48
69
|
|
|
70
|
+
function normalizeNewlines(content) {
|
|
71
|
+
return (content || '').replace(/\r\n/g, '\n');
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function ensureTrailingNewline(content) {
|
|
75
|
+
const normalized = normalizeNewlines(content);
|
|
76
|
+
return normalized.endsWith('\n') ? normalized : `${normalized}\n`;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function upsertManagedBlock(content, id, block) {
|
|
80
|
+
const start = `<!-- claudex-setup:${id}:start -->`;
|
|
81
|
+
const end = `<!-- claudex-setup:${id}:end -->`;
|
|
82
|
+
const wrapped = `${start}\n${block.trim()}\n${end}`;
|
|
83
|
+
const pattern = new RegExp(`${start}[\\s\\S]*?${end}`);
|
|
84
|
+
|
|
85
|
+
if (pattern.test(content)) {
|
|
86
|
+
return {
|
|
87
|
+
changed: true,
|
|
88
|
+
content: content.replace(pattern, wrapped),
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
return {
|
|
93
|
+
changed: true,
|
|
94
|
+
content: `${content.trimEnd()}\n\n${wrapped}\n`,
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function extractGeneratedBuildSection(content) {
|
|
99
|
+
const match = content.match(/## Build & Test\n([\s\S]*?)\n\n## Working Notes/);
|
|
100
|
+
return match ? `## Build & Test\n${match[1].trim()}` : null;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function extractGeneratedVerificationBlock(content) {
|
|
104
|
+
const match = content.match(/<verification>\n([\s\S]*?)\n<\/verification>/);
|
|
105
|
+
return match ? `<verification>\n${match[1].trim()}\n</verification>` : null;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function extractGeneratedWorkingNotes(content) {
|
|
109
|
+
const match = content.match(/## Working Notes\n([\s\S]*?)\n\n<constraints>/);
|
|
110
|
+
return match ? `## Working Notes\n${match[1].trim()}` : null;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function extractGeneratedConstraintsBlock(content) {
|
|
114
|
+
const match = content.match(/<constraints>\n([\s\S]*?)\n<\/constraints>/);
|
|
115
|
+
return match ? `<constraints>\n${match[1].trim()}\n</constraints>` : null;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function extractGeneratedContextSection(content) {
|
|
119
|
+
const match = content.match(/## Context Management\n([\s\S]*?)\n\n---/);
|
|
120
|
+
return match ? `## Context Management\n${match[1].trim()}` : null;
|
|
121
|
+
}
|
|
122
|
+
|
|
49
123
|
function getFailedTemplateGroups(ctx, only = []) {
|
|
50
124
|
const groups = new Map();
|
|
51
125
|
for (const [key, technique] of Object.entries(TECHNIQUES)) {
|
|
52
126
|
const passed = technique.check(ctx);
|
|
53
|
-
|
|
54
|
-
if (
|
|
55
|
-
if (
|
|
56
|
-
if (!
|
|
57
|
-
|
|
127
|
+
const templateKey = technique.template || FALLBACK_TEMPLATE_BY_KEY[key];
|
|
128
|
+
if (passed !== false || !templateKey) continue;
|
|
129
|
+
if (templateKey === 'mermaid') continue;
|
|
130
|
+
if (only.length > 0 && !only.includes(key) && !only.includes(templateKey)) continue;
|
|
131
|
+
if (!groups.has(templateKey)) {
|
|
132
|
+
groups.set(templateKey, []);
|
|
58
133
|
}
|
|
59
|
-
groups.get(
|
|
134
|
+
groups.get(templateKey).push({ key, ...technique });
|
|
60
135
|
}
|
|
61
136
|
return groups;
|
|
62
137
|
}
|
|
63
138
|
|
|
64
|
-
function
|
|
139
|
+
function buildClaudeMdPatchFile(ctx, stacks) {
|
|
140
|
+
const claudePath = ctx.fileContent('CLAUDE.md') !== null
|
|
141
|
+
? 'CLAUDE.md'
|
|
142
|
+
: (ctx.fileContent('.claude/CLAUDE.md') !== null ? '.claude/CLAUDE.md' : null);
|
|
143
|
+
if (!claudePath) return null;
|
|
144
|
+
|
|
145
|
+
const existing = normalizeNewlines(ctx.fileContent(claudePath));
|
|
146
|
+
const generated = TEMPLATES['claude-md'](stacks, ctx);
|
|
147
|
+
const buildSection = extractGeneratedBuildSection(generated);
|
|
148
|
+
const verificationBlock = extractGeneratedVerificationBlock(generated);
|
|
149
|
+
const workingNotes = extractGeneratedWorkingNotes(generated);
|
|
150
|
+
const constraintsBlock = extractGeneratedConstraintsBlock(generated);
|
|
151
|
+
const contextSection = extractGeneratedContextSection(generated);
|
|
152
|
+
let merged = existing;
|
|
153
|
+
let changed = false;
|
|
154
|
+
|
|
155
|
+
const hasTest = /npm test|pytest|jest|vitest|cargo test|go test|mix test|rspec/.test(merged);
|
|
156
|
+
const hasLint = /eslint|prettier|ruff|black|clippy|golangci-lint|rubocop/.test(merged);
|
|
157
|
+
const hasBuild = /npm run build|cargo build|go build|make|tsc|gradle build|mvn compile/.test(merged);
|
|
158
|
+
const hasVerification = merged.includes('<verification>');
|
|
159
|
+
const hasSecurityWorkflow = merged.toLowerCase().includes('security') || merged.includes('/security-review');
|
|
160
|
+
const hasImportGuidance = merged.includes('@import');
|
|
161
|
+
const hasRoleDefinition = /you are|your role|act as|persona|behave as/i.test(merged);
|
|
162
|
+
const hasConstraintBlock = /<constraints|<rules|<requirements|<boundaries/i.test(merged);
|
|
163
|
+
const hasCompaction = /\/compact|compaction/i.test(merged);
|
|
164
|
+
const hasContextManagement = /context.*(manage|window|limit|budget|token)/i.test(merged);
|
|
165
|
+
const modernFeatures = ['hook', 'skill', 'agent', 'subagent', 'mcp', 'worktree'];
|
|
166
|
+
const hasFreshness = modernFeatures.filter(feature => merged.toLowerCase().includes(feature)).length >= 2;
|
|
167
|
+
|
|
168
|
+
if ((!hasTest || !hasLint || !hasBuild) && buildSection) {
|
|
169
|
+
const result = upsertManagedBlock(merged, 'build-test', buildSection);
|
|
170
|
+
merged = result.content;
|
|
171
|
+
changed = true;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
if (!hasRoleDefinition && workingNotes) {
|
|
175
|
+
const result = upsertManagedBlock(merged, 'working-style', workingNotes);
|
|
176
|
+
merged = result.content;
|
|
177
|
+
changed = true;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
if (!hasConstraintBlock && constraintsBlock) {
|
|
181
|
+
const result = upsertManagedBlock(merged, 'constraints', constraintsBlock);
|
|
182
|
+
merged = result.content;
|
|
183
|
+
changed = true;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
if (!hasVerification && verificationBlock) {
|
|
187
|
+
const result = upsertManagedBlock(merged, 'verification', verificationBlock);
|
|
188
|
+
merged = result.content;
|
|
189
|
+
changed = true;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
if (!hasSecurityWorkflow) {
|
|
193
|
+
const result = upsertManagedBlock(merged, 'security-workflow', [
|
|
194
|
+
'## Security Workflow',
|
|
195
|
+
'- Run `/security-review` when touching authentication, permissions, secrets, or customer data.',
|
|
196
|
+
'- Treat secret access, shell commands, and risky file operations as review-worthy changes.',
|
|
197
|
+
].join('\n'));
|
|
198
|
+
merged = result.content;
|
|
199
|
+
changed = true;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
if (!hasImportGuidance) {
|
|
203
|
+
const result = upsertManagedBlock(merged, 'modularity', [
|
|
204
|
+
'## Modularity',
|
|
205
|
+
'- If this file grows, split it with `@import ./docs/...` so the base instructions stay concise.',
|
|
206
|
+
].join('\n'));
|
|
207
|
+
merged = result.content;
|
|
208
|
+
changed = true;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
if ((!hasCompaction || !hasContextManagement || !hasFreshness) && contextSection) {
|
|
212
|
+
const result = upsertManagedBlock(merged, 'context-management', contextSection);
|
|
213
|
+
merged = result.content;
|
|
214
|
+
changed = true;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
if (!changed) {
|
|
218
|
+
return null;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
return {
|
|
222
|
+
path: claudePath,
|
|
223
|
+
action: 'patch',
|
|
224
|
+
currentState: 'existing CLAUDE.md is missing recommended verification or security sections',
|
|
225
|
+
proposedState: 'append managed sections for verification, security workflow, and modularity',
|
|
226
|
+
content: ensureTrailingNewline(merged),
|
|
227
|
+
};
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
function buildAgentPatchFiles(ctx) {
|
|
231
|
+
if (!ctx.hasDir('.claude/agents')) {
|
|
232
|
+
return [];
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
return ctx.dirFiles('.claude/agents')
|
|
236
|
+
.filter(file => file.endsWith('.md'))
|
|
237
|
+
.map((file) => {
|
|
238
|
+
const relativePath = `.claude/agents/${file}`;
|
|
239
|
+
const content = normalizeNewlines(ctx.fileContent(relativePath) || '');
|
|
240
|
+
if (!content.startsWith('---\n') || content.includes('\nmaxTurns:')) {
|
|
241
|
+
return null;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
const updated = content.replace(/^---\n([\s\S]*?)\n---/, (match, frontmatter) => `---\n${frontmatter}\nmaxTurns: 50\n---`);
|
|
245
|
+
if (updated === content) {
|
|
246
|
+
return null;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
return {
|
|
250
|
+
path: relativePath,
|
|
251
|
+
action: 'patch',
|
|
252
|
+
currentState: 'existing agent is missing a maxTurns safety limit',
|
|
253
|
+
proposedState: 'add maxTurns: 50 to the agent frontmatter',
|
|
254
|
+
content: ensureTrailingNewline(updated),
|
|
255
|
+
};
|
|
256
|
+
})
|
|
257
|
+
.filter(Boolean);
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
function buildHookSettings(ctx, plannedHookFiles, options = {}) {
|
|
65
261
|
const existing = ctx.hasDir('.claude/hooks')
|
|
66
262
|
? ctx.dirFiles('.claude/hooks').filter(file => file.endsWith('.sh'))
|
|
67
263
|
: [];
|
|
68
264
|
const hookFiles = [...new Set([...existing, ...plannedHookFiles])].sort();
|
|
69
|
-
if (hookFiles.length === 0
|
|
265
|
+
if (hookFiles.length === 0) {
|
|
70
266
|
return null;
|
|
71
267
|
}
|
|
268
|
+
const settingsPath = '.claude/settings.json';
|
|
269
|
+
const existingSettings = ctx.jsonFile(settingsPath);
|
|
270
|
+
const settings = buildSettingsForProfile({
|
|
271
|
+
profileKey: options.profile || 'safe-write',
|
|
272
|
+
hookFiles,
|
|
273
|
+
existingSettings,
|
|
274
|
+
mcpPackKeys: options.mcpPacks || [],
|
|
275
|
+
});
|
|
276
|
+
const content = `${JSON.stringify(settings, null, 2)}\n`;
|
|
277
|
+
const existingContent = existingSettings ? `${JSON.stringify(existingSettings, null, 2)}\n` : null;
|
|
72
278
|
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
defaultMode: 'acceptEdits',
|
|
76
|
-
deny: [
|
|
77
|
-
'Read(./.env*)',
|
|
78
|
-
'Read(./secrets/**)',
|
|
79
|
-
'Bash(rm -rf *)',
|
|
80
|
-
'Bash(git reset --hard *)',
|
|
81
|
-
'Bash(git checkout -- *)',
|
|
82
|
-
'Bash(git clean *)',
|
|
83
|
-
'Bash(git push --force *)',
|
|
84
|
-
],
|
|
85
|
-
},
|
|
86
|
-
hooks: {
|
|
87
|
-
PostToolUse: [{
|
|
88
|
-
matcher: 'Write|Edit',
|
|
89
|
-
hooks: hookFiles.filter(file => file !== 'protect-secrets.sh').map(file => ({
|
|
90
|
-
type: 'command',
|
|
91
|
-
command: `bash .claude/hooks/${file}`,
|
|
92
|
-
timeout: 10,
|
|
93
|
-
})),
|
|
94
|
-
}],
|
|
95
|
-
},
|
|
96
|
-
};
|
|
97
|
-
|
|
98
|
-
if (hookFiles.includes('protect-secrets.sh')) {
|
|
99
|
-
settings.hooks.PreToolUse = [{
|
|
100
|
-
matcher: 'Read|Write|Edit',
|
|
101
|
-
hooks: [{
|
|
102
|
-
type: 'command',
|
|
103
|
-
command: 'bash .claude/hooks/protect-secrets.sh',
|
|
104
|
-
timeout: 5,
|
|
105
|
-
}],
|
|
106
|
-
}];
|
|
279
|
+
if (existingContent === content) {
|
|
280
|
+
return null;
|
|
107
281
|
}
|
|
108
282
|
|
|
109
283
|
return {
|
|
110
|
-
path:
|
|
111
|
-
|
|
284
|
+
path: settingsPath,
|
|
285
|
+
action: existingSettings ? 'patch' : 'create',
|
|
286
|
+
currentState: existingSettings
|
|
287
|
+
? 'existing settings are missing selected profile protections or hook registrations'
|
|
288
|
+
: 'settings file is missing',
|
|
289
|
+
proposedState: existingSettings
|
|
290
|
+
? `merge ${options.profile || 'safe-write'} profile protections into existing settings`
|
|
291
|
+
: `create settings for ${options.profile || 'safe-write'} profile and register hooks`,
|
|
292
|
+
content,
|
|
112
293
|
};
|
|
113
294
|
}
|
|
114
295
|
|
|
115
|
-
function buildTemplateFiles(templateKey, stacks, ctx) {
|
|
296
|
+
function buildTemplateFiles(templateKey, stacks, ctx, triggers, options = {}) {
|
|
297
|
+
const patchFiles = templateKey === 'agents' ? buildAgentPatchFiles(ctx) : [];
|
|
298
|
+
|
|
299
|
+
if (templateKey === 'claude-md') {
|
|
300
|
+
const patchFile = buildClaudeMdPatchFile(ctx, stacks);
|
|
301
|
+
if (patchFile) {
|
|
302
|
+
return [patchFile];
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
|
|
116
306
|
const template = TEMPLATES[templateKey];
|
|
117
307
|
if (!template) return [];
|
|
118
308
|
|
|
@@ -124,10 +314,13 @@ function buildTemplateFiles(templateKey, stacks, ctx) {
|
|
|
124
314
|
const targetDir = TEMPLATE_DIR_MAP[templateKey];
|
|
125
315
|
if (!targetDir) return [];
|
|
126
316
|
|
|
127
|
-
|
|
317
|
+
const generatedFiles = Object.entries(result).map(([fileName, content]) => ({
|
|
128
318
|
path: path.posix.join(targetDir.replace(/\\/g, '/'), fileName),
|
|
129
319
|
content,
|
|
130
320
|
}));
|
|
321
|
+
|
|
322
|
+
const patchedPaths = new Set(patchFiles.map(file => file.path));
|
|
323
|
+
return [...patchFiles, ...generatedFiles.filter(file => !patchedPaths.has(file.path))];
|
|
131
324
|
}
|
|
132
325
|
|
|
133
326
|
function toProposal(templateKey, triggers, templateFiles, ctx) {
|
|
@@ -139,9 +332,9 @@ function toProposal(templateKey, triggers, templateFiles, ctx) {
|
|
|
139
332
|
const highestImpact = sortedTriggers[0]?.impact || 'medium';
|
|
140
333
|
const files = templateFiles.map(file => {
|
|
141
334
|
const exists = ctx.fileContent(file.path) !== null || ctx.hasDir(file.path);
|
|
142
|
-
const action = exists ? 'manual-review' : 'create';
|
|
143
|
-
const currentState = exists ? 'file already exists and will be preserved' : 'missing';
|
|
144
|
-
const proposedState = exists ? 'generated baseline available for manual merge' : 'create new file';
|
|
335
|
+
const action = file.action || (exists ? 'manual-review' : 'create');
|
|
336
|
+
const currentState = file.currentState || (exists ? 'file already exists and will be preserved' : 'missing');
|
|
337
|
+
const proposedState = file.proposedState || (exists ? 'generated baseline available for manual merge' : 'create new file');
|
|
145
338
|
const diffPreview = [
|
|
146
339
|
`--- ${exists ? file.path : 'missing'}`,
|
|
147
340
|
`+++ ${file.path}`,
|
|
@@ -173,7 +366,7 @@ function toProposal(templateKey, triggers, templateFiles, ctx) {
|
|
|
173
366
|
})),
|
|
174
367
|
rationale: sortedTriggers.map(trigger => trigger.fix),
|
|
175
368
|
files,
|
|
176
|
-
readyToApply: files.some(file => file.action
|
|
369
|
+
readyToApply: files.some(file => ['create', 'patch'].includes(file.action)),
|
|
177
370
|
};
|
|
178
371
|
}
|
|
179
372
|
|
|
@@ -185,12 +378,12 @@ async function buildProposalBundle(options) {
|
|
|
185
378
|
const proposals = [];
|
|
186
379
|
|
|
187
380
|
for (const [templateKey, triggers] of groups.entries()) {
|
|
188
|
-
const templateFiles = buildTemplateFiles(templateKey, stacks, ctx);
|
|
381
|
+
const templateFiles = buildTemplateFiles(templateKey, stacks, ctx, triggers, options);
|
|
189
382
|
if (templateKey === 'hooks') {
|
|
190
383
|
const plannedHookFiles = templateFiles
|
|
191
384
|
.map(file => path.basename(file.path))
|
|
192
385
|
.filter(file => file.endsWith('.sh'));
|
|
193
|
-
const settingsFile = buildHookSettings(ctx, plannedHookFiles);
|
|
386
|
+
const settingsFile = buildHookSettings(ctx, plannedHookFiles, options);
|
|
194
387
|
if (settingsFile) {
|
|
195
388
|
templateFiles.push(settingsFile);
|
|
196
389
|
}
|
|
@@ -256,11 +449,75 @@ function writePlanFile(bundle, outFile) {
|
|
|
256
449
|
});
|
|
257
450
|
}
|
|
258
451
|
|
|
452
|
+
function tryParseJson(content) {
|
|
453
|
+
try {
|
|
454
|
+
return JSON.parse(content);
|
|
455
|
+
} catch {
|
|
456
|
+
return null;
|
|
457
|
+
}
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
function applyRuntimeSettingsOverlays(bundle, options) {
|
|
461
|
+
if (!bundle || !Array.isArray(bundle.proposals)) {
|
|
462
|
+
return bundle;
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
const ctx = new ProjectContext(options.dir);
|
|
466
|
+
const existingHooks = ctx.hasDir('.claude/hooks')
|
|
467
|
+
? ctx.dirFiles('.claude/hooks').filter(file => file.endsWith('.sh'))
|
|
468
|
+
: [];
|
|
469
|
+
|
|
470
|
+
const proposals = bundle.proposals.map((proposal) => {
|
|
471
|
+
const settingsIndex = proposal.files.findIndex(file => file.path === '.claude/settings.json');
|
|
472
|
+
if (settingsIndex === -1) {
|
|
473
|
+
return proposal;
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
const plannedHookFiles = proposal.files
|
|
477
|
+
.filter(file => file.path.startsWith('.claude/hooks/') && file.path.endsWith('.sh'))
|
|
478
|
+
.map(file => path.basename(file.path));
|
|
479
|
+
const hookFiles = [...new Set([...existingHooks, ...plannedHookFiles])].sort();
|
|
480
|
+
const currentSettings = tryParseJson(proposal.files[settingsIndex].content) || ctx.jsonFile('.claude/settings.json') || null;
|
|
481
|
+
const mergedSettings = buildSettingsForProfile({
|
|
482
|
+
profileKey: options.profile || 'safe-write',
|
|
483
|
+
hookFiles,
|
|
484
|
+
existingSettings: currentSettings,
|
|
485
|
+
mcpPackKeys: options.mcpPacks || [],
|
|
486
|
+
});
|
|
487
|
+
const updatedContent = `${JSON.stringify(mergedSettings, null, 2)}\n`;
|
|
488
|
+
const currentFile = proposal.files[settingsIndex];
|
|
489
|
+
|
|
490
|
+
const files = [...proposal.files];
|
|
491
|
+
files[settingsIndex] = {
|
|
492
|
+
...currentFile,
|
|
493
|
+
content: updatedContent,
|
|
494
|
+
preview: previewContent(updatedContent),
|
|
495
|
+
diffPreview: [
|
|
496
|
+
`--- ${ctx.fileContent(currentFile.path) !== null ? currentFile.path : 'missing'}`,
|
|
497
|
+
`+++ ${currentFile.path}`,
|
|
498
|
+
...previewContent(updatedContent).split('\n').map(line => `+${line}`),
|
|
499
|
+
].join('\n'),
|
|
500
|
+
currentState: currentFile.currentState || 'existing settings are missing runtime-selected protections or MCP packs',
|
|
501
|
+
proposedState: `merge ${options.profile || 'safe-write'} profile protections and requested MCP packs into settings`,
|
|
502
|
+
};
|
|
503
|
+
|
|
504
|
+
return {
|
|
505
|
+
...proposal,
|
|
506
|
+
files,
|
|
507
|
+
};
|
|
508
|
+
});
|
|
509
|
+
|
|
510
|
+
return {
|
|
511
|
+
...bundle,
|
|
512
|
+
proposals,
|
|
513
|
+
};
|
|
514
|
+
}
|
|
515
|
+
|
|
259
516
|
function resolvePlan(bundle, options) {
|
|
260
517
|
if (options.planFile) {
|
|
261
|
-
return JSON.parse(fs.readFileSync(options.planFile, 'utf8'));
|
|
518
|
+
return applyRuntimeSettingsOverlays(JSON.parse(fs.readFileSync(options.planFile, 'utf8')), options);
|
|
262
519
|
}
|
|
263
|
-
return bundle;
|
|
520
|
+
return applyRuntimeSettingsOverlays(bundle, options);
|
|
264
521
|
}
|
|
265
522
|
|
|
266
523
|
async function applyProposalBundle(options) {
|
|
@@ -275,38 +532,49 @@ async function applyProposalBundle(options) {
|
|
|
275
532
|
});
|
|
276
533
|
|
|
277
534
|
const createdFiles = [];
|
|
535
|
+
const patchedFiles = [];
|
|
278
536
|
const skippedFiles = [];
|
|
279
537
|
for (const proposal of selected) {
|
|
280
538
|
for (const file of proposal.files) {
|
|
281
|
-
if (file.action
|
|
539
|
+
if (!['create', 'patch'].includes(file.action)) {
|
|
282
540
|
skippedFiles.push(file.path);
|
|
283
541
|
continue;
|
|
284
542
|
}
|
|
285
543
|
const fullPath = path.join(options.dir, file.path);
|
|
286
|
-
if (fs.existsSync(fullPath)) {
|
|
544
|
+
if (file.action === 'create' && fs.existsSync(fullPath)) {
|
|
287
545
|
skippedFiles.push(file.path);
|
|
288
546
|
continue;
|
|
289
547
|
}
|
|
548
|
+
const previousContent = fs.existsSync(fullPath) ? fs.readFileSync(fullPath, 'utf8') : null;
|
|
290
549
|
if (!options.dryRun) {
|
|
291
550
|
fs.mkdirSync(path.dirname(fullPath), { recursive: true });
|
|
292
551
|
fs.writeFileSync(fullPath, file.content, 'utf8');
|
|
293
552
|
}
|
|
294
|
-
|
|
553
|
+
if (file.action === 'create') {
|
|
554
|
+
createdFiles.push(file.path);
|
|
555
|
+
} else {
|
|
556
|
+
patchedFiles.push({ path: file.path, previousContent });
|
|
557
|
+
}
|
|
295
558
|
}
|
|
296
559
|
}
|
|
297
560
|
|
|
298
561
|
let rollback = null;
|
|
299
562
|
let activity = null;
|
|
300
|
-
if (!options.dryRun && createdFiles.length > 0) {
|
|
563
|
+
if (!options.dryRun && (createdFiles.length > 0 || patchedFiles.length > 0)) {
|
|
301
564
|
rollback = writeRollbackArtifact(options.dir, {
|
|
302
565
|
sourcePlan: options.planFile ? path.basename(options.planFile) : 'live-plan',
|
|
303
566
|
createdFiles,
|
|
304
|
-
|
|
567
|
+
patchedFiles,
|
|
568
|
+
rollbackInstructions: [
|
|
569
|
+
...createdFiles.map(file => `Delete ${file}`),
|
|
570
|
+
...patchedFiles.map(file => `Restore previous content for ${file.path} from this manifest`),
|
|
571
|
+
],
|
|
305
572
|
});
|
|
306
573
|
activity = writeActivityArtifact(options.dir, 'apply', {
|
|
307
574
|
sourcePlan: options.planFile ? path.basename(options.planFile) : 'live-plan',
|
|
308
575
|
appliedProposalIds: selected.map(item => item.id),
|
|
309
576
|
createdFiles,
|
|
577
|
+
patchedFiles: patchedFiles.map(file => file.path),
|
|
310
578
|
skippedFiles,
|
|
311
579
|
rollbackArtifact: rollback.relativePath,
|
|
312
580
|
});
|
|
@@ -316,6 +584,7 @@ async function applyProposalBundle(options) {
|
|
|
316
584
|
proposalCount: bundle.proposals.length,
|
|
317
585
|
appliedProposalIds: selected.map(item => item.id),
|
|
318
586
|
createdFiles,
|
|
587
|
+
patchedFiles: patchedFiles.map(file => file.path),
|
|
319
588
|
skippedFiles,
|
|
320
589
|
dryRun: options.dryRun === true,
|
|
321
590
|
rollbackArtifact: rollback ? rollback.relativePath : null,
|
|
@@ -337,6 +606,7 @@ function printApplyResult(result, options = {}) {
|
|
|
337
606
|
}
|
|
338
607
|
console.log(` Applied proposal bundles: ${result.appliedProposalIds.join(', ') || 'none'}`);
|
|
339
608
|
console.log(` Created files: ${result.createdFiles.join(', ') || 'none'}`);
|
|
609
|
+
console.log(` Patched files: ${result.patchedFiles.join(', ') || 'none'}`);
|
|
340
610
|
if (result.rollbackArtifact) {
|
|
341
611
|
console.log(` Rollback: ${result.rollbackArtifact}`);
|
|
342
612
|
}
|