@ennamjsc/agents-scaffold 1.1.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/dist/index.js +674 -0
- package/dist/index.js.map +1 -0
- package/package.json +42 -0
- package/templates/_shared/.claude/agents/project-owner.md +19 -0
- package/templates/_shared/.claude/agents/reviewer.md +24 -0
- package/templates/_shared/.claude/agents/team-lead.md +20 -0
- package/templates/_shared/.claude/commands/boot.md +14 -0
- package/templates/_shared/.claude/commands/checkpoint.md +28 -0
- package/templates/_shared/.claude/commands/escalate.md +26 -0
- package/templates/_shared/.claude/commands/memory.md +12 -0
- package/templates/_shared/.claude/hooks/session-start.ps1 +3 -0
- package/templates/_shared/.claude/hooks/session-start.sh +4 -0
- package/templates/_shared/.claude/settings.json.hbs +17 -0
- package/templates/_shared/.gitignore.append +12 -0
- package/templates/_shared/.mcp.json.hbs +25 -0
- package/templates/_shared/.serena/checkpoint/.gitkeep +1 -0
- package/templates/_shared/.serena/memories/INDEX.md +13 -0
- package/templates/_shared/.serena/memories/backlog/.gitkeep +1 -0
- package/templates/_shared/.serena/memories/comms/active/.gitkeep +1 -0
- package/templates/_shared/.serena/memories/comms/resolved/.gitkeep +1 -0
- package/templates/_shared/.serena/memories/decisions/.gitkeep +1 -0
- package/templates/_shared/.serena/memories/services/.gitkeep +1 -0
- package/templates/_shared/AGENTS.md +75 -0
- package/templates/_shared/CLAUDE.md.partial.hbs +286 -0
- package/templates/_shared/docs/superpowers/plans/.gitkeep +1 -0
- package/templates/_shared/docs/superpowers/specs/.gitkeep +1 -0
- package/templates/flutter/.claude/agents/mobile-dev.md +21 -0
- package/templates/flutter/.mcp.json.partial.hbs +9 -0
- package/templates/flutter/CLAUDE.md.partial.hbs +26 -0
- package/templates/go/.claude/agents/backend-dev-go.md +22 -0
- package/templates/go/CLAUDE.md.partial.hbs +28 -0
- package/templates/local-root/CLAUDE.md.partial.hbs +50 -0
- package/templates/next/.claude/agents/web-dev.md +23 -0
- package/templates/next/.mcp.json.partial.hbs +13 -0
- package/templates/next/CLAUDE.md.partial.hbs +35 -0
- package/templates/python/.claude/agents/backend-dev-python.md +21 -0
- package/templates/python/CLAUDE.md.partial.hbs +27 -0
- package/templates/qa/.claude/agents/qa-tester.md +23 -0
- package/templates/qa/.claude/commands/qa-report.md +13 -0
- package/templates/qa/.claude/commands/qa-run.md +13 -0
- package/templates/qa/.mcp.json.partial.hbs +8 -0
- package/templates/qa/CLAUDE.md.partial.hbs +20 -0
- package/templates/qa/evidence/.gitkeep +1 -0
- package/templates/qa/qa/.gitkeep +1 -0
- package/templates/qa/test-cases/README.md +11 -0
- package/templates/qa/test-cases/TEMPLATE.md +25 -0
package/dist/index.js
ADDED
|
@@ -0,0 +1,674 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
// src/index.ts
|
|
4
|
+
import { cac } from "cac";
|
|
5
|
+
import { readFile as readFile4, access as access2 } from "fs/promises";
|
|
6
|
+
import { fileURLToPath as fileURLToPath2 } from "url";
|
|
7
|
+
import path7 from "path";
|
|
8
|
+
import { select, isCancel as isCancel3, cancel as cancel3 } from "@clack/prompts";
|
|
9
|
+
|
|
10
|
+
// src/profiles.ts
|
|
11
|
+
import { fileURLToPath } from "url";
|
|
12
|
+
import path from "path";
|
|
13
|
+
import { existsSync } from "fs";
|
|
14
|
+
var HERE = path.dirname(fileURLToPath(import.meta.url));
|
|
15
|
+
var CANDIDATE_PUBLISHED = path.join(HERE, "..", "templates");
|
|
16
|
+
var CANDIDATE_MONOREPO = path.join(HERE, "..", "..", "..", "templates");
|
|
17
|
+
var TEMPLATES = existsSync(CANDIDATE_PUBLISHED) ? CANDIDATE_PUBLISHED : CANDIDATE_MONOREPO;
|
|
18
|
+
var REGISTRY = {
|
|
19
|
+
next: {
|
|
20
|
+
name: "next",
|
|
21
|
+
description: "Next.js 16 App Router + React 19 + TS strict",
|
|
22
|
+
templateDir: path.join(TEMPLATES, "next"),
|
|
23
|
+
extraMcp: ["chrome-devtools", "figma"]
|
|
24
|
+
},
|
|
25
|
+
flutter: {
|
|
26
|
+
name: "flutter",
|
|
27
|
+
description: "Flutter 3.x + Dart + Riverpod/Bloc + dio",
|
|
28
|
+
templateDir: path.join(TEMPLATES, "flutter"),
|
|
29
|
+
extraMcp: ["figma"]
|
|
30
|
+
},
|
|
31
|
+
python: {
|
|
32
|
+
name: "python",
|
|
33
|
+
description: "Python 3.12 + FastAPI + uv + ruff + pytest",
|
|
34
|
+
templateDir: path.join(TEMPLATES, "python"),
|
|
35
|
+
extraMcp: []
|
|
36
|
+
},
|
|
37
|
+
go: {
|
|
38
|
+
name: "go",
|
|
39
|
+
description: "Go 1.24 + stdlib net/http + pgx + slog",
|
|
40
|
+
templateDir: path.join(TEMPLATES, "go"),
|
|
41
|
+
extraMcp: []
|
|
42
|
+
},
|
|
43
|
+
qa: {
|
|
44
|
+
name: "qa",
|
|
45
|
+
description: "QA workflow \u2014 test cases, evidence, chrome-devtools",
|
|
46
|
+
templateDir: path.join(TEMPLATES, "qa"),
|
|
47
|
+
extraMcp: ["chrome-devtools"]
|
|
48
|
+
},
|
|
49
|
+
"local-root": {
|
|
50
|
+
name: "local-root",
|
|
51
|
+
description: "Orchestration root \u2014 polyrepo coordinator, reads sub-platform .serena/ memories",
|
|
52
|
+
templateDir: path.join(TEMPLATES, "local-root"),
|
|
53
|
+
extraMcp: []
|
|
54
|
+
}
|
|
55
|
+
};
|
|
56
|
+
function getProfile(name) {
|
|
57
|
+
const p = REGISTRY[name];
|
|
58
|
+
if (!p) throw new Error(`Unknown profile: "${name}". Available: ${Object.keys(REGISTRY).join(", ")}`);
|
|
59
|
+
return p;
|
|
60
|
+
}
|
|
61
|
+
function listProfiles() {
|
|
62
|
+
return Object.values(REGISTRY);
|
|
63
|
+
}
|
|
64
|
+
function getSharedDir() {
|
|
65
|
+
return path.join(TEMPLATES, "_shared");
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// src/enumerate.ts
|
|
69
|
+
import path2 from "path";
|
|
70
|
+
import fg from "fast-glob";
|
|
71
|
+
|
|
72
|
+
// src/classify.ts
|
|
73
|
+
var RULES = [
|
|
74
|
+
{ match: (r) => r === "AGENTS.md", kind: "write-or-ask" },
|
|
75
|
+
{ match: (r) => r === "CLAUDE.md", kind: "append-marker" },
|
|
76
|
+
{ match: (r) => r === ".gitignore", kind: "append-lines" },
|
|
77
|
+
{ match: (r) => r === ".mcp.json", kind: "json-merge" },
|
|
78
|
+
{ match: (r) => r === ".claude/settings.json", kind: "json-merge" },
|
|
79
|
+
{ match: (r) => r.startsWith(".claude/hooks/"), kind: "write-or-ask" },
|
|
80
|
+
{ match: (r) => r.startsWith(".claude/commands/"), kind: "skip-if-exists" },
|
|
81
|
+
{ match: (r) => r.startsWith(".claude/agents/"), kind: "skip-if-exists" },
|
|
82
|
+
{ match: (r) => r.startsWith(".serena/"), kind: "skip-if-exists" },
|
|
83
|
+
{ match: (r) => r.startsWith("docs/superpowers/"), kind: "skip-if-exists" }
|
|
84
|
+
];
|
|
85
|
+
function classifyFile(relPath) {
|
|
86
|
+
const norm = relPath.replace(/\\/g, "/");
|
|
87
|
+
for (const r of RULES) if (r.match(norm)) return r.kind;
|
|
88
|
+
return "write-or-ask";
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// src/enumerate.ts
|
|
92
|
+
function targetRelPath(srcRel) {
|
|
93
|
+
return srcRel.replace(/\.partial\.hbs$/, "").replace(/\.hbs$/, "").replace(/\.append$/, "");
|
|
94
|
+
}
|
|
95
|
+
async function collect(dir) {
|
|
96
|
+
const files = await fg("**/*", { cwd: dir, dot: true, onlyFiles: true });
|
|
97
|
+
return files.map((rel) => ({ src: path2.join(dir, rel), rel }));
|
|
98
|
+
}
|
|
99
|
+
async function enumerateFiles(profile) {
|
|
100
|
+
const sharedDir = getSharedDir();
|
|
101
|
+
const shared = await collect(sharedDir);
|
|
102
|
+
const profileFiles = await collect(profile.templateDir);
|
|
103
|
+
const map = /* @__PURE__ */ new Map();
|
|
104
|
+
const markerPairs = /* @__PURE__ */ new Map();
|
|
105
|
+
const collectMarker = (src, rel, isShared) => {
|
|
106
|
+
if (!rel.endsWith(".partial.hbs")) return false;
|
|
107
|
+
const target = targetRelPath(rel);
|
|
108
|
+
if (target !== "CLAUDE.md") return false;
|
|
109
|
+
const entry = markerPairs.get(target) ?? {};
|
|
110
|
+
if (isShared) entry.sharedSrc = src;
|
|
111
|
+
else entry.profileSrc = src;
|
|
112
|
+
markerPairs.set(target, entry);
|
|
113
|
+
return true;
|
|
114
|
+
};
|
|
115
|
+
for (const { src, rel } of shared) {
|
|
116
|
+
if (collectMarker(src, rel, true)) continue;
|
|
117
|
+
if (src.endsWith(".partial.hbs")) continue;
|
|
118
|
+
const target = targetRelPath(rel);
|
|
119
|
+
map.set(target, {
|
|
120
|
+
srcAbs: src,
|
|
121
|
+
relPath: target,
|
|
122
|
+
isTemplate: src.endsWith(".hbs"),
|
|
123
|
+
kind: classifyFile(target)
|
|
124
|
+
});
|
|
125
|
+
}
|
|
126
|
+
for (const { src, rel } of profileFiles) {
|
|
127
|
+
if (collectMarker(src, rel, false)) continue;
|
|
128
|
+
if (src.endsWith(".partial.hbs")) continue;
|
|
129
|
+
const target = targetRelPath(rel);
|
|
130
|
+
map.set(target, {
|
|
131
|
+
srcAbs: src,
|
|
132
|
+
relPath: target,
|
|
133
|
+
isTemplate: src.endsWith(".hbs"),
|
|
134
|
+
kind: classifyFile(target)
|
|
135
|
+
});
|
|
136
|
+
}
|
|
137
|
+
for (const [target, pair] of markerPairs) {
|
|
138
|
+
if (!pair.sharedSrc) continue;
|
|
139
|
+
map.set(target, {
|
|
140
|
+
srcAbs: pair.sharedSrc,
|
|
141
|
+
relPath: target,
|
|
142
|
+
isTemplate: true,
|
|
143
|
+
kind: "append-marker",
|
|
144
|
+
extraSrcAbs: pair.profileSrc
|
|
145
|
+
});
|
|
146
|
+
}
|
|
147
|
+
const profileMcpPartial = profileFiles.find(({ rel }) => rel === ".mcp.json.partial.hbs");
|
|
148
|
+
if (profileMcpPartial) {
|
|
149
|
+
const existing = map.get(".mcp.json");
|
|
150
|
+
if (existing) {
|
|
151
|
+
existing.extraSrcAbs = profileMcpPartial.src;
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
return [...map.values()].sort((a, b) => a.relPath.localeCompare(b.relPath));
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// src/conflict.ts
|
|
158
|
+
import { readFile, access } from "fs/promises";
|
|
159
|
+
import path3 from "path";
|
|
160
|
+
async function scanConflicts(cwd, relPaths, provider) {
|
|
161
|
+
const out = /* @__PURE__ */ new Map();
|
|
162
|
+
for (const rel of relPaths) {
|
|
163
|
+
const abs = path3.join(cwd, rel);
|
|
164
|
+
let exists = true;
|
|
165
|
+
try {
|
|
166
|
+
await access(abs);
|
|
167
|
+
} catch {
|
|
168
|
+
exists = false;
|
|
169
|
+
}
|
|
170
|
+
if (!exists) {
|
|
171
|
+
out.set(rel, "absent");
|
|
172
|
+
continue;
|
|
173
|
+
}
|
|
174
|
+
if (!provider) {
|
|
175
|
+
out.set(rel, "differs");
|
|
176
|
+
continue;
|
|
177
|
+
}
|
|
178
|
+
const incoming = await provider(rel);
|
|
179
|
+
if (incoming === null) {
|
|
180
|
+
out.set(rel, "differs");
|
|
181
|
+
continue;
|
|
182
|
+
}
|
|
183
|
+
const existing = await readFile(abs, "utf8");
|
|
184
|
+
out.set(rel, existing === incoming ? "identical" : "differs");
|
|
185
|
+
}
|
|
186
|
+
return out;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// src/plan.ts
|
|
190
|
+
function buildPlan(input) {
|
|
191
|
+
const ops = [];
|
|
192
|
+
for (const e of input.entries) {
|
|
193
|
+
const state = input.conflicts.get(e.relPath) ?? "absent";
|
|
194
|
+
if (e.relPath === ".gitignore" && !input.hasGit) {
|
|
195
|
+
ops.push({
|
|
196
|
+
relPath: e.relPath,
|
|
197
|
+
src: e,
|
|
198
|
+
conflict: state,
|
|
199
|
+
op: "skip",
|
|
200
|
+
reason: "No .git detected \u2014 skipping .gitignore",
|
|
201
|
+
needsPrompt: false
|
|
202
|
+
});
|
|
203
|
+
continue;
|
|
204
|
+
}
|
|
205
|
+
if (e.kind === "append-marker") {
|
|
206
|
+
if (state === "identical") {
|
|
207
|
+
ops.push({ relPath: e.relPath, src: e, conflict: state, op: "skip", reason: "identical \u2014 skip", needsPrompt: false });
|
|
208
|
+
} else {
|
|
209
|
+
ops.push({ relPath: e.relPath, src: e, conflict: state, op: "merge-marker", reason: state === "absent" ? "absent \u2014 write marker block" : "differs \u2014 merge marker block", needsPrompt: false });
|
|
210
|
+
}
|
|
211
|
+
continue;
|
|
212
|
+
}
|
|
213
|
+
if (e.kind === "json-merge") {
|
|
214
|
+
if (state === "identical") {
|
|
215
|
+
ops.push({ relPath: e.relPath, src: e, conflict: state, op: "skip", reason: "identical \u2014 skip", needsPrompt: false });
|
|
216
|
+
} else {
|
|
217
|
+
ops.push({ relPath: e.relPath, src: e, conflict: state, op: "merge-json", reason: state === "absent" ? "absent \u2014 write json" : "differs \u2014 deep-merge (user wins)", needsPrompt: false });
|
|
218
|
+
}
|
|
219
|
+
continue;
|
|
220
|
+
}
|
|
221
|
+
if (e.kind === "append-lines") {
|
|
222
|
+
if (state === "identical") {
|
|
223
|
+
ops.push({ relPath: e.relPath, src: e, conflict: state, op: "skip", reason: "identical \u2014 skip", needsPrompt: false });
|
|
224
|
+
} else {
|
|
225
|
+
ops.push({ relPath: e.relPath, src: e, conflict: state, op: "merge-lines", reason: state === "absent" ? "absent \u2014 write lines" : "differs \u2014 append missing lines (dedup)", needsPrompt: false });
|
|
226
|
+
}
|
|
227
|
+
continue;
|
|
228
|
+
}
|
|
229
|
+
if (state === "absent") {
|
|
230
|
+
ops.push({ relPath: e.relPath, src: e, conflict: state, op: "write", reason: "absent \u2014 write", needsPrompt: false });
|
|
231
|
+
continue;
|
|
232
|
+
}
|
|
233
|
+
if (state === "identical") {
|
|
234
|
+
ops.push({ relPath: e.relPath, src: e, conflict: state, op: "skip", reason: "identical \u2014 skip", needsPrompt: false });
|
|
235
|
+
continue;
|
|
236
|
+
}
|
|
237
|
+
if (e.kind === "skip-if-exists") {
|
|
238
|
+
ops.push({ relPath: e.relPath, src: e, conflict: state, op: "skip", reason: "skip-if-exists kind", needsPrompt: false });
|
|
239
|
+
continue;
|
|
240
|
+
}
|
|
241
|
+
if (e.kind === "mkdir-only") {
|
|
242
|
+
ops.push({ relPath: e.relPath, src: e, conflict: state, op: "mkdir", reason: "mkdir-only kind", needsPrompt: false });
|
|
243
|
+
continue;
|
|
244
|
+
}
|
|
245
|
+
switch (input.strategy) {
|
|
246
|
+
case "overwrite":
|
|
247
|
+
ops.push({ relPath: e.relPath, src: e, conflict: state, op: "write", reason: "differs \u2014 overwrite (--merge-strategy=overwrite)", needsPrompt: false });
|
|
248
|
+
break;
|
|
249
|
+
case "skip":
|
|
250
|
+
ops.push({ relPath: e.relPath, src: e, conflict: state, op: "skip", reason: "differs \u2014 keep existing (--merge-strategy=skip)", needsPrompt: false });
|
|
251
|
+
break;
|
|
252
|
+
case "ask":
|
|
253
|
+
default:
|
|
254
|
+
ops.push({ relPath: e.relPath, src: e, conflict: state, op: "write", reason: "differs \u2014 will prompt at execute time", needsPrompt: true });
|
|
255
|
+
break;
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
return ops;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
// src/execute.ts
|
|
262
|
+
import { readFile as readFile3, writeFile, mkdir as mkdir2 } from "fs/promises";
|
|
263
|
+
import path6 from "path";
|
|
264
|
+
import { confirm, isCancel, cancel } from "@clack/prompts";
|
|
265
|
+
|
|
266
|
+
// src/render.ts
|
|
267
|
+
import path4 from "path";
|
|
268
|
+
import { readFile as readFile2 } from "fs/promises";
|
|
269
|
+
import Handlebars from "handlebars";
|
|
270
|
+
|
|
271
|
+
// src/merge/json.ts
|
|
272
|
+
function mergeJson(user, scaffold) {
|
|
273
|
+
const out = { ...user };
|
|
274
|
+
for (const key of Object.keys(scaffold)) {
|
|
275
|
+
if (!(key in user)) {
|
|
276
|
+
out[key] = deepClone(scaffold[key]);
|
|
277
|
+
continue;
|
|
278
|
+
}
|
|
279
|
+
const u = user[key];
|
|
280
|
+
const s = scaffold[key];
|
|
281
|
+
if (isPlainObject(u) && isPlainObject(s)) {
|
|
282
|
+
out[key] = mergeJson(u, s);
|
|
283
|
+
} else {
|
|
284
|
+
out[key] = deepClone(u);
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
return out;
|
|
288
|
+
}
|
|
289
|
+
function isPlainObject(v) {
|
|
290
|
+
return typeof v === "object" && v !== null && !Array.isArray(v);
|
|
291
|
+
}
|
|
292
|
+
function deepClone(v) {
|
|
293
|
+
return JSON.parse(JSON.stringify(v));
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
// src/render.ts
|
|
297
|
+
function buildContext(opts) {
|
|
298
|
+
const now = /* @__PURE__ */ new Date();
|
|
299
|
+
const date = `${now.getUTCFullYear()}-${String(now.getUTCMonth() + 1).padStart(2, "0")}-${String(now.getUTCDate()).padStart(2, "0")}`;
|
|
300
|
+
return {
|
|
301
|
+
scaffoldVersion: opts.version,
|
|
302
|
+
profile: opts.profile,
|
|
303
|
+
cwd: opts.cwd,
|
|
304
|
+
projectName: path4.basename(opts.cwd),
|
|
305
|
+
year: now.getUTCFullYear(),
|
|
306
|
+
date,
|
|
307
|
+
isWindows: process.platform === "win32"
|
|
308
|
+
};
|
|
309
|
+
}
|
|
310
|
+
Handlebars.registerHelper("json", (value) => {
|
|
311
|
+
return JSON.stringify(value);
|
|
312
|
+
});
|
|
313
|
+
function renderString(template, ctx) {
|
|
314
|
+
return Handlebars.compile(template, { noEscape: true })(ctx);
|
|
315
|
+
}
|
|
316
|
+
async function renderFileEntry(entry, ctx) {
|
|
317
|
+
const raw = await readFile2(entry.srcAbs, "utf8");
|
|
318
|
+
if (!entry.extraSrcAbs) {
|
|
319
|
+
return entry.isTemplate ? renderString(raw, ctx) : raw;
|
|
320
|
+
}
|
|
321
|
+
const profileRaw = await readFile2(entry.extraSrcAbs, "utf8");
|
|
322
|
+
const profileRendered = renderString(profileRaw, ctx);
|
|
323
|
+
const extendedCtx = { ...ctx, profileSection: profileRendered };
|
|
324
|
+
return renderString(raw, extendedCtx);
|
|
325
|
+
}
|
|
326
|
+
async function renderJsonContent(entry, ctx) {
|
|
327
|
+
const sharedRaw = await readFile2(entry.srcAbs, "utf8");
|
|
328
|
+
const sharedObj = JSON.parse(renderString(sharedRaw, ctx));
|
|
329
|
+
if (!entry.extraSrcAbs) return sharedObj;
|
|
330
|
+
const profileRaw = await readFile2(entry.extraSrcAbs, "utf8");
|
|
331
|
+
const profileObj = JSON.parse(renderString(profileRaw, ctx));
|
|
332
|
+
return mergeJson(
|
|
333
|
+
profileObj,
|
|
334
|
+
sharedObj
|
|
335
|
+
);
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
// src/merge/marker.ts
|
|
339
|
+
var BEGIN_RE = /<!--\s*ennam-agents-scaffold:begin v[^\s>]*\s*-->/;
|
|
340
|
+
var BEGIN_RE_GLOBAL = /<!--\s*ennam-agents-scaffold:begin v[^\s>]*\s*-->/g;
|
|
341
|
+
var END_MARKER = "<!-- ennam-agents-scaffold:end -->";
|
|
342
|
+
function mergeMarker(existing, block) {
|
|
343
|
+
if (existing.length === 0) {
|
|
344
|
+
return block.endsWith("\n") ? block : block + "\n";
|
|
345
|
+
}
|
|
346
|
+
const allBegins = existing.match(BEGIN_RE_GLOBAL);
|
|
347
|
+
if (allBegins && allBegins.length > 1) {
|
|
348
|
+
throw new Error(
|
|
349
|
+
"Multiple ennam-agents-scaffold begin markers found; refusing to merge. Please consolidate to a single managed block before re-running the scaffold."
|
|
350
|
+
);
|
|
351
|
+
}
|
|
352
|
+
const beginMatch = existing.match(BEGIN_RE);
|
|
353
|
+
if (!beginMatch) {
|
|
354
|
+
const sep = existing.endsWith("\n") ? "\n" : "\n\n";
|
|
355
|
+
const trailing = block.endsWith("\n") ? "" : "\n";
|
|
356
|
+
return existing + sep + block + trailing;
|
|
357
|
+
}
|
|
358
|
+
const beginStart = beginMatch.index;
|
|
359
|
+
const afterBegin = beginStart + beginMatch[0].length;
|
|
360
|
+
const endIndex = existing.indexOf(END_MARKER, afterBegin);
|
|
361
|
+
if (endIndex === -1) {
|
|
362
|
+
throw new Error("Marker block malformed: begin marker found but end marker not found");
|
|
363
|
+
}
|
|
364
|
+
const afterEnd = endIndex + END_MARKER.length;
|
|
365
|
+
const before = existing.slice(0, beginStart);
|
|
366
|
+
const after = existing.slice(afterEnd);
|
|
367
|
+
return before + block + after;
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
// src/merge/lines.ts
|
|
371
|
+
function mergeLines(user, incoming) {
|
|
372
|
+
const userLines = user.split("\n");
|
|
373
|
+
if (userLines.length > 0 && userLines[userLines.length - 1] === "") {
|
|
374
|
+
userLines.pop();
|
|
375
|
+
}
|
|
376
|
+
const userSet = new Set(userLines.map((l) => l.trim()));
|
|
377
|
+
const incomingLines = incoming.split("\n");
|
|
378
|
+
if (incomingLines.length > 0 && incomingLines[incomingLines.length - 1] === "") {
|
|
379
|
+
incomingLines.pop();
|
|
380
|
+
}
|
|
381
|
+
const toAppend = [];
|
|
382
|
+
for (const line of incomingLines) {
|
|
383
|
+
if (userSet.has(line.trim())) continue;
|
|
384
|
+
toAppend.push(line);
|
|
385
|
+
userSet.add(line.trim());
|
|
386
|
+
}
|
|
387
|
+
const all = [...userLines, ...toAppend];
|
|
388
|
+
return all.join("\n") + "\n";
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
// src/backup.ts
|
|
392
|
+
import { cp, mkdir, readdir, rm } from "fs/promises";
|
|
393
|
+
import path5 from "path";
|
|
394
|
+
var BACKUP_DIR = ".ennam-scaffold-backup";
|
|
395
|
+
async function backupFile(cwd, relPath, session) {
|
|
396
|
+
const sessionDir = session ?? (/* @__PURE__ */ new Date()).toISOString().replace(/[:.]/g, "-");
|
|
397
|
+
const dest = path5.join(cwd, BACKUP_DIR, sessionDir, relPath);
|
|
398
|
+
await mkdir(path5.dirname(dest), { recursive: true });
|
|
399
|
+
await cp(path5.join(cwd, relPath), dest);
|
|
400
|
+
return dest;
|
|
401
|
+
}
|
|
402
|
+
async function rotateBackups(cwd, keep) {
|
|
403
|
+
const root = path5.join(cwd, BACKUP_DIR);
|
|
404
|
+
let entries;
|
|
405
|
+
try {
|
|
406
|
+
entries = await readdir(root);
|
|
407
|
+
} catch {
|
|
408
|
+
return;
|
|
409
|
+
}
|
|
410
|
+
const sorted = entries.sort();
|
|
411
|
+
const toRemove = sorted.slice(0, Math.max(0, sorted.length - keep));
|
|
412
|
+
for (const name of toRemove) {
|
|
413
|
+
await rm(path5.join(root, name), { recursive: true, force: true });
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
function newSessionId() {
|
|
417
|
+
return (/* @__PURE__ */ new Date()).toISOString().replace(/[:.]/g, "-");
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
// src/execute.ts
|
|
421
|
+
async function maybeRender(op, ctx) {
|
|
422
|
+
const raw = await readFile3(op.src.srcAbs, "utf8");
|
|
423
|
+
if (!op.src.extraSrcAbs) {
|
|
424
|
+
return op.src.isTemplate ? renderString(raw, ctx) : raw;
|
|
425
|
+
}
|
|
426
|
+
const profileRaw = await readFile3(op.src.extraSrcAbs, "utf8");
|
|
427
|
+
const profileRendered = renderString(profileRaw, ctx);
|
|
428
|
+
const extendedCtx = { ...ctx, profileSection: profileRendered };
|
|
429
|
+
return renderString(raw, extendedCtx);
|
|
430
|
+
}
|
|
431
|
+
async function promptOverwrite(relPath) {
|
|
432
|
+
const ans = await confirm({
|
|
433
|
+
message: `File exists and differs: ${relPath}. Overwrite?`,
|
|
434
|
+
initialValue: false
|
|
435
|
+
});
|
|
436
|
+
if (isCancel(ans)) {
|
|
437
|
+
cancel("Aborted by user.");
|
|
438
|
+
process.exit(1);
|
|
439
|
+
}
|
|
440
|
+
return ans === true;
|
|
441
|
+
}
|
|
442
|
+
async function executeOps(input) {
|
|
443
|
+
const result = { written: 0, skipped: 0, mkdirs: 0 };
|
|
444
|
+
const session = newSessionId();
|
|
445
|
+
for (const op of input.ops) {
|
|
446
|
+
const target = path6.join(input.cwd, op.relPath);
|
|
447
|
+
if (op.op === "skip") {
|
|
448
|
+
result.skipped++;
|
|
449
|
+
continue;
|
|
450
|
+
}
|
|
451
|
+
if (op.op === "mkdir") {
|
|
452
|
+
await mkdir2(target, { recursive: true });
|
|
453
|
+
result.mkdirs++;
|
|
454
|
+
continue;
|
|
455
|
+
}
|
|
456
|
+
if (op.op === "merge-marker") {
|
|
457
|
+
await mkdir2(path6.dirname(target), { recursive: true });
|
|
458
|
+
let existing = "";
|
|
459
|
+
try {
|
|
460
|
+
existing = await readFile3(target, "utf8");
|
|
461
|
+
if (existing.length > 0) await backupFile(input.cwd, op.relPath, session);
|
|
462
|
+
} catch {
|
|
463
|
+
}
|
|
464
|
+
const block = (await maybeRender(op, input.ctx)).replace(/\n$/, "");
|
|
465
|
+
const merged = mergeMarker(existing, block);
|
|
466
|
+
await writeFile(target, merged, "utf8");
|
|
467
|
+
result.written++;
|
|
468
|
+
continue;
|
|
469
|
+
}
|
|
470
|
+
if (op.op === "merge-json") {
|
|
471
|
+
await mkdir2(path6.dirname(target), { recursive: true });
|
|
472
|
+
let existingObj = {};
|
|
473
|
+
let existed = false;
|
|
474
|
+
try {
|
|
475
|
+
const existingText = await readFile3(target, "utf8");
|
|
476
|
+
if (existingText.trim().length > 0) {
|
|
477
|
+
existingObj = JSON.parse(existingText);
|
|
478
|
+
existed = true;
|
|
479
|
+
}
|
|
480
|
+
} catch {
|
|
481
|
+
}
|
|
482
|
+
if (existed) await backupFile(input.cwd, op.relPath, session);
|
|
483
|
+
const scaffoldObj = await renderJsonContent(op.src, input.ctx);
|
|
484
|
+
const merged = mergeJson(
|
|
485
|
+
existingObj,
|
|
486
|
+
scaffoldObj
|
|
487
|
+
);
|
|
488
|
+
await writeFile(target, JSON.stringify(merged, null, 2) + "\n", "utf8");
|
|
489
|
+
result.written++;
|
|
490
|
+
continue;
|
|
491
|
+
}
|
|
492
|
+
if (op.op === "merge-lines") {
|
|
493
|
+
await mkdir2(path6.dirname(target), { recursive: true });
|
|
494
|
+
let existing = "";
|
|
495
|
+
try {
|
|
496
|
+
existing = await readFile3(target, "utf8");
|
|
497
|
+
if (existing.length > 0) await backupFile(input.cwd, op.relPath, session);
|
|
498
|
+
} catch {
|
|
499
|
+
}
|
|
500
|
+
const incoming = await maybeRender(op, input.ctx);
|
|
501
|
+
const merged = mergeLines(existing, incoming);
|
|
502
|
+
await writeFile(target, merged, "utf8");
|
|
503
|
+
result.written++;
|
|
504
|
+
continue;
|
|
505
|
+
}
|
|
506
|
+
if (op.needsPrompt) {
|
|
507
|
+
if (input.interactive) {
|
|
508
|
+
const yes = await promptOverwrite(op.relPath);
|
|
509
|
+
if (!yes) {
|
|
510
|
+
result.skipped++;
|
|
511
|
+
continue;
|
|
512
|
+
}
|
|
513
|
+
} else {
|
|
514
|
+
console.error(
|
|
515
|
+
`Error: ${op.relPath} differs from scaffold and --merge-strategy=ask is not safe in non-interactive mode.
|
|
516
|
+
Re-run with --merge-strategy=overwrite (or --force) to replace, or --merge-strategy=skip to keep the existing file.`
|
|
517
|
+
);
|
|
518
|
+
process.exit(2);
|
|
519
|
+
}
|
|
520
|
+
}
|
|
521
|
+
await mkdir2(path6.dirname(target), { recursive: true });
|
|
522
|
+
const content = await maybeRender(op, input.ctx);
|
|
523
|
+
await writeFile(target, content, "utf8");
|
|
524
|
+
result.written++;
|
|
525
|
+
}
|
|
526
|
+
await rotateBackups(input.cwd, 3);
|
|
527
|
+
return result;
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
// src/ux.ts
|
|
531
|
+
import pc from "picocolors";
|
|
532
|
+
import { intro, outro, log, confirm as confirm2, isCancel as isCancel2, cancel as cancel2 } from "@clack/prompts";
|
|
533
|
+
function printIntro(version) {
|
|
534
|
+
intro(pc.cyan(`Ennam Agents Scaffold v${version}`));
|
|
535
|
+
}
|
|
536
|
+
function printPlan(plan) {
|
|
537
|
+
const lines = [];
|
|
538
|
+
for (const op of plan.ops) {
|
|
539
|
+
const marker = op.op === "write" ? pc.green("+ write ") : op.op === "mkdir" ? pc.blue("+ mkdir ") : op.op === "merge-json" || op.op === "merge-marker" || op.op === "merge-lines" ? pc.yellow("~ merge ") : pc.gray(" skip ");
|
|
540
|
+
lines.push(`${marker} ${op.relPath} ${pc.dim(`(${op.reason})`)}`);
|
|
541
|
+
}
|
|
542
|
+
log.step(`Plan (${plan.ops.length} ops):
|
|
543
|
+
${lines.join("\n ")}`);
|
|
544
|
+
}
|
|
545
|
+
async function confirmProceed() {
|
|
546
|
+
const yes = await confirm2({ message: "Proceed?", initialValue: true });
|
|
547
|
+
if (isCancel2(yes)) {
|
|
548
|
+
cancel2("Aborted.");
|
|
549
|
+
return false;
|
|
550
|
+
}
|
|
551
|
+
return yes === true;
|
|
552
|
+
}
|
|
553
|
+
function printNextSteps(profile, result, hasGit) {
|
|
554
|
+
const envVars = ["JIRA_URL", "JIRA_TOKEN"];
|
|
555
|
+
if (profile.extraMcp.includes("figma")) envVars.push("FIGMA_TOKEN");
|
|
556
|
+
const steps = [];
|
|
557
|
+
if (hasGit) {
|
|
558
|
+
steps.push("Review changes: git diff");
|
|
559
|
+
} else {
|
|
560
|
+
steps.push("Inspect changes in your editor (no .git detected \u2014 run `git init` first if you want diff/version tracking)");
|
|
561
|
+
}
|
|
562
|
+
steps.push(`Set env vars in .env.local: ${envVars.join(", ")}`);
|
|
563
|
+
steps.push("Start Claude Code: claude");
|
|
564
|
+
steps.push("Inside Claude: run /boot");
|
|
565
|
+
outro(
|
|
566
|
+
pc.cyan(`Done.`) + `
|
|
567
|
+
Profile: ${pc.bold(profile.name)}
|
|
568
|
+
Written: ${result.written} Skipped: ${result.skipped} Mkdir: ${result.mkdirs}`
|
|
569
|
+
);
|
|
570
|
+
console.log();
|
|
571
|
+
steps.forEach((s, i) => console.log(` ${i + 1}. ${s}`));
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
// src/index.ts
|
|
575
|
+
var HERE2 = path7.dirname(fileURLToPath2(import.meta.url));
|
|
576
|
+
var PKG = JSON.parse(await readFile4(path7.join(HERE2, "..", "package.json"), "utf8"));
|
|
577
|
+
var cli = cac("ennam-agents-scaffold");
|
|
578
|
+
cli.command("[profile]", "Install Claude Code config into the current directory").option("--dry-run", "Print the plan without writing anything").option("--force", "Overwrite all conflicts without prompting (alias for --merge-strategy=overwrite)").option("--merge-strategy <s>", "ask | skip | overwrite (default: ask)", { default: "ask" }).option("--no-prompts", "Fail on missing info instead of prompting (CI mode)").option("--verbose", "Verbose output").action(async (profileArg, flags) => {
|
|
579
|
+
printIntro(PKG.version);
|
|
580
|
+
const interactive = flags.prompts !== false;
|
|
581
|
+
let profileName = profileArg;
|
|
582
|
+
if (!profileName) {
|
|
583
|
+
if (!interactive) {
|
|
584
|
+
console.error("Error: profile is required in --no-prompts mode");
|
|
585
|
+
process.exit(2);
|
|
586
|
+
}
|
|
587
|
+
const choices = listProfiles().map((p) => ({ value: p.name, label: `${p.name} \u2014 ${p.description}` }));
|
|
588
|
+
const picked = await select({ message: "Choose a profile:", options: choices });
|
|
589
|
+
if (isCancel3(picked)) {
|
|
590
|
+
cancel3("Aborted.");
|
|
591
|
+
process.exit(1);
|
|
592
|
+
}
|
|
593
|
+
profileName = picked;
|
|
594
|
+
}
|
|
595
|
+
let profile;
|
|
596
|
+
try {
|
|
597
|
+
profile = getProfile(profileName);
|
|
598
|
+
} catch (err) {
|
|
599
|
+
console.error(`Error: ${err.message}`);
|
|
600
|
+
process.exit(2);
|
|
601
|
+
}
|
|
602
|
+
const cwd = process.cwd();
|
|
603
|
+
let hasGit = true;
|
|
604
|
+
try {
|
|
605
|
+
await access2(path7.join(cwd, ".git"));
|
|
606
|
+
} catch {
|
|
607
|
+
hasGit = false;
|
|
608
|
+
}
|
|
609
|
+
const strategy = (flags.force ? "overwrite" : flags.mergeStrategy) ?? "ask";
|
|
610
|
+
const entries = await enumerateFiles(profile);
|
|
611
|
+
const ctx = buildContext({ profile: profileName, cwd, version: PKG.version });
|
|
612
|
+
const byRel = new Map(entries.map((e) => [e.relPath, e]));
|
|
613
|
+
const provider = async (rel) => {
|
|
614
|
+
const entry = byRel.get(rel);
|
|
615
|
+
if (!entry) return null;
|
|
616
|
+
if (entry.kind === "append-marker") {
|
|
617
|
+
const block = (await renderFileEntry(entry, ctx)).replace(/\n$/, "");
|
|
618
|
+
const abs = path7.join(cwd, rel);
|
|
619
|
+
let existing = "";
|
|
620
|
+
try {
|
|
621
|
+
existing = await readFile4(abs, "utf8");
|
|
622
|
+
} catch {
|
|
623
|
+
return null;
|
|
624
|
+
}
|
|
625
|
+
return mergeMarker(existing, block);
|
|
626
|
+
}
|
|
627
|
+
if (entry.kind === "json-merge") {
|
|
628
|
+
let existingObj = {};
|
|
629
|
+
try {
|
|
630
|
+
const existingText = await readFile4(path7.join(cwd, rel), "utf8");
|
|
631
|
+
if (existingText.trim().length > 0) {
|
|
632
|
+
existingObj = JSON.parse(existingText);
|
|
633
|
+
}
|
|
634
|
+
} catch {
|
|
635
|
+
return null;
|
|
636
|
+
}
|
|
637
|
+
const scaffoldObj = await renderJsonContent(entry, ctx);
|
|
638
|
+
const merged = mergeJson(
|
|
639
|
+
existingObj,
|
|
640
|
+
scaffoldObj
|
|
641
|
+
);
|
|
642
|
+
return JSON.stringify(merged, null, 2) + "\n";
|
|
643
|
+
}
|
|
644
|
+
if (entry.kind === "append-lines") {
|
|
645
|
+
let existing = "";
|
|
646
|
+
try {
|
|
647
|
+
existing = await readFile4(path7.join(cwd, rel), "utf8");
|
|
648
|
+
} catch {
|
|
649
|
+
return null;
|
|
650
|
+
}
|
|
651
|
+
const incoming = await renderFileEntry(entry, ctx);
|
|
652
|
+
return mergeLines(existing, incoming);
|
|
653
|
+
}
|
|
654
|
+
return renderFileEntry(entry, ctx);
|
|
655
|
+
};
|
|
656
|
+
const conflicts = await scanConflicts(cwd, entries.map((e) => e.relPath), provider);
|
|
657
|
+
const ops = buildPlan({ entries, conflicts, strategy, hasGit });
|
|
658
|
+
const plan = { cwd, profile, ops, hasGit };
|
|
659
|
+
printPlan(plan);
|
|
660
|
+
if (flags.dryRun) {
|
|
661
|
+
console.log("\n(dry-run \u2014 no files written)");
|
|
662
|
+
process.exit(0);
|
|
663
|
+
}
|
|
664
|
+
if (interactive) {
|
|
665
|
+
const proceed = await confirmProceed();
|
|
666
|
+
if (!proceed) process.exit(1);
|
|
667
|
+
}
|
|
668
|
+
const result = await executeOps({ cwd, ops, ctx, interactive });
|
|
669
|
+
printNextSteps(profile, result, hasGit);
|
|
670
|
+
});
|
|
671
|
+
cli.help();
|
|
672
|
+
cli.version(PKG.version);
|
|
673
|
+
cli.parse();
|
|
674
|
+
//# sourceMappingURL=index.js.map
|