craftar 0.4.0 → 0.6.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/README.md +20 -4
- package/dist/cli.js +49 -11
- package/dist/cli.js.map +1 -1
- package/dist/core/classify.js +24 -9
- package/dist/core/classify.js.map +1 -1
- package/dist/core/extract.js +156 -0
- package/dist/core/extract.js.map +1 -0
- package/dist/core/param-writes.js +166 -0
- package/dist/core/param-writes.js.map +1 -0
- package/dist/core/resolve.js +17 -2
- package/dist/core/resolve.js.map +1 -1
- package/dist/core/sync.js +2 -2
- package/dist/core/sync.js.map +1 -1
- package/dist/core/template-import.js +188 -0
- package/dist/core/template-import.js.map +1 -0
- package/dist/core/unify.js +58 -4
- package/dist/core/unify.js.map +1 -1
- package/dist/core/yaml-edit.js +34 -0
- package/dist/core/yaml-edit.js.map +1 -0
- package/dist/emitters/claude-code.js +2 -1
- package/dist/emitters/claude-code.js.map +1 -1
- package/dist/emitters/kiro.js +3 -1
- package/dist/emitters/kiro.js.map +1 -1
- package/dist/importers/claude-code.js +345 -59
- package/dist/importers/claude-code.js.map +1 -1
- package/dist/importers/decide.js +146 -0
- package/dist/importers/decide.js.map +1 -0
- package/dist/schema/index.js +21 -1
- package/dist/schema/index.js.map +1 -1
- package/package.json +1 -1
|
@@ -6,7 +6,13 @@ import { exists, listFiles, typeFolder, FORGE_MANIFEST } from "../core/forge.js"
|
|
|
6
6
|
import { decodeForScan, findSecrets, hasUtf16Bom, secretValueKind } from "../core/secrets.js";
|
|
7
7
|
import { stripBom, toLf } from "../core/text.js";
|
|
8
8
|
import { fingerprintDir, fingerprintOf } from "../core/fingerprint.js";
|
|
9
|
-
import { IngredientSchema } from "../schema/index.js";
|
|
9
|
+
import { IngredientSchema, ProfileSchema, RecipeSchema, WorkspaceConfigSchema } from "../schema/index.js";
|
|
10
|
+
import { isDeepStrictEqual } from "node:util";
|
|
11
|
+
import { resolve } from "../core/resolve.js";
|
|
12
|
+
import { editYamlText } from "../core/yaml-edit.js";
|
|
13
|
+
import { resolvedBy } from "../core/param-writes.js";
|
|
14
|
+
import { decide, forgeBefore, pin, sourceKeys, workspaceParams } from "./decide.js";
|
|
15
|
+
import { renderMap } from "../core/template-import.js";
|
|
10
16
|
/** Rules that every workspace shares by intent — they seed the `base` recipe. */
|
|
11
17
|
const BASE_RULES = new Set([
|
|
12
18
|
"README",
|
|
@@ -38,28 +44,42 @@ export async function importClaudeCode(opts) {
|
|
|
38
44
|
throw new Error(`${e instanceof Error ? e.message : String(e)}\nThe Forge was left untouched.`, { cause: e });
|
|
39
45
|
}
|
|
40
46
|
await stage.flush();
|
|
41
|
-
const { report,
|
|
42
|
-
if (
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
+
const { report, config } = planned;
|
|
48
|
+
if (config && config.action !== "unchanged") {
|
|
49
|
+
try {
|
|
50
|
+
await fs.writeFile(config.abs, config.content);
|
|
51
|
+
}
|
|
52
|
+
catch (e) {
|
|
53
|
+
throw new Error(`The Forge was written in full and the import succeeded; writing craftar.yaml failed (${e instanceof Error ? e.message : String(e)}) — ` +
|
|
54
|
+
`fix the cause and re-run with --write-config, or edit craftar.yaml by hand.`, { cause: e });
|
|
55
|
+
}
|
|
47
56
|
}
|
|
57
|
+
if (config?.action === "created")
|
|
58
|
+
report.created.push("craftar.yaml (workspace)");
|
|
48
59
|
return report;
|
|
49
60
|
}
|
|
50
61
|
async function planImport(opts, stage) {
|
|
51
62
|
const ws = path.resolve(opts.workspaceRoot);
|
|
52
63
|
const forge = stage.root;
|
|
53
|
-
const report = {
|
|
64
|
+
const report = {
|
|
65
|
+
created: [], reused: [], variants: [], rejected: [], recipes: [], profile: opts.profileName, warnings: [], rendered: [], inferred: [], params: [], recipeSplits: [],
|
|
66
|
+
profileWrite: { path: `profiles/${opts.profileName}/profile.yaml`, action: "unchanged", fields: [] },
|
|
67
|
+
configWrite: null,
|
|
68
|
+
};
|
|
54
69
|
const claudeDir = path.join(ws, ".claude");
|
|
55
70
|
if (!(await exists(claudeDir)))
|
|
56
71
|
throw new Error(`${claudeDir} not found — is this a Claude Code workspace?`);
|
|
57
72
|
const manifest = path.join(forge, FORGE_MANIFEST);
|
|
73
|
+
const manifestOnDisk = manifest;
|
|
58
74
|
if (!(await stage.exists(manifest))) {
|
|
59
75
|
stage.write(manifest, YAML.stringify({ name: path.basename(forge), schema: 1, description: "Craftar Forge — shared harness ingredients, recipes and client profiles." }));
|
|
60
76
|
report.created.push(FORGE_MANIFEST);
|
|
61
77
|
}
|
|
62
78
|
const origin = (rel) => ({ workspace: path.basename(ws), path: rel.replace(/\\/g, "/") });
|
|
79
|
+
// Two passes (spec 10 §6.8): every source is read first, in today's order, then decided in that
|
|
80
|
+
// order. `after` does the bookkeeping today's code did right after each decision.
|
|
81
|
+
const queue = [];
|
|
82
|
+
const read = (meta, files, scan, after, skip) => queue.push({ meta, files, scan, after, skip });
|
|
63
83
|
const refs = { base: [], stacks: new Map(), steering: [] };
|
|
64
84
|
const ruleNames = [];
|
|
65
85
|
const scopedRules = new Map(); // rule name → fileMatchPattern
|
|
@@ -67,7 +87,7 @@ async function planImport(opts, stage) {
|
|
|
67
87
|
const steeringDir = path.join(ws, ".kiro", "steering");
|
|
68
88
|
const steeringMeta = new Map();
|
|
69
89
|
if (await exists(steeringDir)) {
|
|
70
|
-
for (const f of await fs.readdir(steeringDir)) {
|
|
90
|
+
for (const f of (await fs.readdir(steeringDir)).sort()) {
|
|
71
91
|
if (!f.endsWith(".md"))
|
|
72
92
|
continue;
|
|
73
93
|
const src = await readSource(path.join(steeringDir, f));
|
|
@@ -100,14 +120,13 @@ async function planImport(opts, stage) {
|
|
|
100
120
|
tags: [],
|
|
101
121
|
origin: origin(`.claude/rules/${f}`),
|
|
102
122
|
};
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
refs.base.push(ref);
|
|
123
|
+
read(meta, { "rule.md": text }, scanOf("rule.md", src), (ref) => {
|
|
124
|
+
ruleNames.push(ref.split("/")[1]);
|
|
125
|
+
if (meta.inclusion === "fileMatch")
|
|
126
|
+
scopedRules.set(ref, sm?.fileMatchPattern ?? "");
|
|
127
|
+
else
|
|
128
|
+
refs.base.push(ref);
|
|
129
|
+
});
|
|
111
130
|
}
|
|
112
131
|
/* ---- agents ---- */
|
|
113
132
|
const agentBodies = new Map();
|
|
@@ -130,11 +149,10 @@ async function planImport(opts, stage) {
|
|
|
130
149
|
tags: [],
|
|
131
150
|
origin: origin(`.claude/agents/${f}`),
|
|
132
151
|
};
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
refs.base.push(ref);
|
|
152
|
+
read(meta, { "agent.md": body }, scanOf("agent.md", src), (ref) => {
|
|
153
|
+
agentBodies.set(ref, (data.description ?? "") + "\n" + body);
|
|
154
|
+
refs.base.push(ref);
|
|
155
|
+
});
|
|
138
156
|
}
|
|
139
157
|
/* ---- commands ---- */
|
|
140
158
|
for (const f of await safeList(path.join(claudeDir, "commands"))) {
|
|
@@ -155,12 +173,13 @@ async function planImport(opts, stage) {
|
|
|
155
173
|
tags: [],
|
|
156
174
|
origin: origin(`.claude/commands/${f}`),
|
|
157
175
|
};
|
|
158
|
-
|
|
176
|
+
read(meta, { "command.md": body }, scanOf("command.md", src), (ref) => addRef(refs.base, ref));
|
|
159
177
|
}
|
|
160
178
|
/* ---- skills ---- */
|
|
161
179
|
const skillsDir = path.join(claudeDir, "skills");
|
|
162
180
|
if (await exists(skillsDir)) {
|
|
163
|
-
|
|
181
|
+
// Sorted, so the decision order — and which of two sources is "the later one" — is reproducible (spec 10 §14 Q6).
|
|
182
|
+
for (const e of (await fs.readdir(skillsDir, { withFileTypes: true })).sort((x, y) => x.name.localeCompare(y.name))) {
|
|
164
183
|
if (e.name === ".gitkeep")
|
|
165
184
|
continue;
|
|
166
185
|
if (e.isDirectory()) {
|
|
@@ -181,13 +200,13 @@ async function planImport(opts, stage) {
|
|
|
181
200
|
continue;
|
|
182
201
|
}
|
|
183
202
|
const meta = { type: "skill", name: e.name, layout: "dir", targets: "*", tags: [], origin: origin(`.claude/skills/${e.name}/`) };
|
|
184
|
-
|
|
203
|
+
read(meta, files, scan, (ref) => addRef(refs.base, ref));
|
|
185
204
|
}
|
|
186
205
|
else if (e.name.endsWith(".md")) {
|
|
187
206
|
const src = await readSource(path.join(skillsDir, e.name));
|
|
188
207
|
const text = toLf(stripBom(src.text));
|
|
189
208
|
const meta = { type: "skill", name: e.name.replace(/\.md$/, ""), layout: "file", targets: "*", tags: [], origin: origin(`.claude/skills/${e.name}`) };
|
|
190
|
-
|
|
209
|
+
read(meta, { "SKILL.md": text }, scanOf("SKILL.md", src), (ref) => addRef(refs.base, ref));
|
|
191
210
|
}
|
|
192
211
|
}
|
|
193
212
|
}
|
|
@@ -209,7 +228,7 @@ async function planImport(opts, stage) {
|
|
|
209
228
|
tags: [],
|
|
210
229
|
origin: origin(`.claude/${kind}/${f}`),
|
|
211
230
|
};
|
|
212
|
-
|
|
231
|
+
read(meta, { [f]: content }, src ? scanOf(f, src) : {}, (ref) => addRef(refs.base, ref));
|
|
213
232
|
}
|
|
214
233
|
}
|
|
215
234
|
/* ---- MCP servers ---- */
|
|
@@ -232,22 +251,45 @@ async function planImport(opts, stage) {
|
|
|
232
251
|
throw new Error(`.mcp.json server "${name}" is not an object — fix the file and re-run import`);
|
|
233
252
|
}
|
|
234
253
|
const meta = { type: "mcp", name, server, targets: "*", tags: [], origin: origin(".mcp.json") };
|
|
235
|
-
|
|
254
|
+
read(meta, {}, {}, (ref) => addRef(refs.base, ref));
|
|
236
255
|
}
|
|
237
256
|
}
|
|
238
257
|
/* ---- hand-written Kiro steering (no Claude counterpart) ---- */
|
|
258
|
+
// Whether a steering file is hand-written depends on the rules decided before it, so the check runs at decision time.
|
|
239
259
|
for (const [name, sm] of steeringMeta) {
|
|
240
|
-
if (ruleNames.includes(name) || sm.generated)
|
|
241
|
-
continue;
|
|
242
|
-
if (name === "commands")
|
|
243
|
-
continue;
|
|
244
260
|
const meta = { type: "steering", name, file: "steering.md", targets: ["kiro"], tags: ["client"], origin: origin(`.kiro/steering/${name}.md`) };
|
|
245
|
-
|
|
261
|
+
read(meta, { "steering.md": sm.text }, scanOf("steering.md", sm), (ref) => addRef(refs.steering, ref), () => ruleNames.includes(name) || sm.generated || name === "commands");
|
|
262
|
+
}
|
|
263
|
+
/* ---- what the importing profile renders (spec 10 §6.1, §6.8 step 2) ---- */
|
|
264
|
+
const loaded = await forgeBefore(forge, manifestOnDisk);
|
|
265
|
+
const ctx = {
|
|
266
|
+
P: Object.assign(Object.create(null), await existingProfileParams(forge, opts.profileName, loaded)),
|
|
267
|
+
W: await workspaceParams(ws, (abs) => fs.readFile(abs, "utf8")),
|
|
268
|
+
pinned: new Map(),
|
|
269
|
+
forge: loaded,
|
|
270
|
+
};
|
|
271
|
+
const runBases = new Set(queue.map((q) => `${q.meta.type}/${q.meta.name}`));
|
|
272
|
+
const literal = [];
|
|
273
|
+
/* ---- decide, in read order ---- */
|
|
274
|
+
for (const src of queue) {
|
|
275
|
+
if (src.skip?.())
|
|
276
|
+
continue;
|
|
277
|
+
const others = queue.filter((q) => q !== src).map((q) => ({ ref: `${q.meta.type}/${q.meta.name}`, meta: q.meta, files: q.files }));
|
|
278
|
+
const ref = await writeIngredient(stage, src.meta, src.files, opts.profileName, report, src.scan, { ctx, others, runBases, literal });
|
|
279
|
+
if (ref)
|
|
280
|
+
src.after(ref);
|
|
281
|
+
}
|
|
282
|
+
// I3: a created or variant ingredient is the workspace text itself; a key the profile now sets would change it at sync.
|
|
283
|
+
for (const l of literal) {
|
|
284
|
+
const map = renderMap(l.meta, ctx.P, ctx.W);
|
|
285
|
+
const key = [...sourceKeys(l.meta, l.files)].find((k) => Object.hasOwn(map, k));
|
|
286
|
+
if (key)
|
|
287
|
+
throw new Error(`import: ${l.source} holds {{${key}}} literally, but profile ${opts.profileName} sets ${key} — sync would render it`);
|
|
246
288
|
}
|
|
247
289
|
/* ---- recipes ---- */
|
|
248
290
|
const recipesDir = path.join(forge, "recipes");
|
|
249
291
|
const isVariant = (ref) => report.variants.some((v) => v.name === ref);
|
|
250
|
-
const
|
|
292
|
+
const recipeOpts = { stage, dir: recipesDir, profile: opts.profileName, forge: ctx.forge, report, currentRules: currentRuleOrder(ctx.forge, opts.profileName) };
|
|
251
293
|
const stackRecipes = [];
|
|
252
294
|
for (const [ruleRef, pattern] of scopedRules) {
|
|
253
295
|
const ruleName = ruleRef.split("/")[1].replace(/--.*$/, "");
|
|
@@ -255,17 +297,14 @@ async function planImport(opts, stage) {
|
|
|
255
297
|
for (const a of agents)
|
|
256
298
|
refs.base = refs.base.filter((r) => r !== a);
|
|
257
299
|
const ingredients = [ruleRef, ...agents];
|
|
258
|
-
|
|
259
|
-
await writeRecipe(stage, recipesDir, { name: recipeName, description: `Conventions + reviewer for repos matching ${pattern}`, extends: [], ingredients, params: {} }, report);
|
|
260
|
-
stackRecipes.push(recipeName);
|
|
300
|
+
stackRecipes.push(await placeRecipe(recipeOpts, `stack-${ruleName}`, ingredients, `Conventions + reviewer for repos matching ${pattern}`, ingredients.some(isVariant)));
|
|
261
301
|
}
|
|
262
302
|
const baseIngredients = unique(refs.base);
|
|
263
|
-
const baseName =
|
|
264
|
-
await writeRecipe(stage, recipesDir, { name: baseName, description: "Always-on conventions, commands, agents, scripts and MCP servers.", extends: [], ingredients: baseIngredients, params: {} }, report);
|
|
303
|
+
const baseName = await placeRecipe(recipeOpts, "base", baseIngredients, "Always-on conventions, commands, agents, scripts and MCP servers.", baseIngredients.some(isVariant));
|
|
265
304
|
const profileRecipes = [baseName, ...stackRecipes];
|
|
266
305
|
if (refs.steering.length) {
|
|
267
306
|
const n = `${opts.profileName}-steering`;
|
|
268
|
-
await
|
|
307
|
+
await writeOwnedRecipe(recipeOpts, n, refs.steering, `Hand-written Kiro steering specific to ${opts.profileName}.`);
|
|
269
308
|
profileRecipes.push(n);
|
|
270
309
|
}
|
|
271
310
|
/* ---- profile ---- */
|
|
@@ -284,12 +323,14 @@ async function planImport(opts, stage) {
|
|
|
284
323
|
frontend: {},
|
|
285
324
|
executor: (await exists(path.join(claudeDir, "rules", "kiro-execution.md"))) ? { kind: "kiro", terminal: "orca" } : {},
|
|
286
325
|
integrations: {},
|
|
287
|
-
|
|
326
|
+
// A new profile holds the values this run inferred, in acceptance order (spec 10 §6.6).
|
|
327
|
+
params: Object.fromEntries(report.params.map((x) => [x.key, ctx.P[x.key]])),
|
|
288
328
|
repos: [],
|
|
289
329
|
};
|
|
290
|
-
stage
|
|
291
|
-
|
|
292
|
-
|
|
330
|
+
await writeProfile(stage, forge, opts.profileName, profile, ctx, report);
|
|
331
|
+
const config = opts.writeWorkspaceConfig ? await planConfig(ws, forge, opts.profileName, targets) : null;
|
|
332
|
+
report.configWrite = config?.action ?? null;
|
|
333
|
+
return { report, targets, config };
|
|
293
334
|
}
|
|
294
335
|
/**
|
|
295
336
|
* The Forge as the import run sees it: the files on disk overlaid with the files this run has staged.
|
|
@@ -473,7 +514,7 @@ async function sniffScm(claudeDir) {
|
|
|
473
514
|
* identical content → reuse; different content → write a `<name>--<profile>` variant and report it,
|
|
474
515
|
* so the human decides whether to parameterize or keep a client-specific copy.
|
|
475
516
|
*/
|
|
476
|
-
async function writeIngredient(stage, meta, files, profile, report, scan = {}) {
|
|
517
|
+
async function writeIngredient(stage, meta, files, profile, report, scan = {}, run) {
|
|
477
518
|
const secret = secretIn(meta, files, scan);
|
|
478
519
|
if (secret) {
|
|
479
520
|
report.rejected.push({ name: `${meta.type}/${meta.name}`, reason: secret });
|
|
@@ -485,22 +526,55 @@ async function writeIngredient(stage, meta, files, profile, report, scan = {}) {
|
|
|
485
526
|
let dir = path.join(forge, "ingredients", folder, name);
|
|
486
527
|
// Hash what the Forge will load back, the way fingerprintDir hashes the other side.
|
|
487
528
|
const fingerprint = fingerprintOf(validateImported(meta), files);
|
|
529
|
+
const ref = `${meta.type}/${name}`;
|
|
530
|
+
const source = meta.origin?.path ?? ref;
|
|
531
|
+
const sourceMeta = validateImported(meta);
|
|
488
532
|
if (await stage.exists(path.join(dir, "ingredient.yaml"))) {
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
533
|
+
let why;
|
|
534
|
+
const d = run ? await decide(run.ctx, dir, stage.reader(), ref, sourceMeta, files, fingerprint, run.others, run.runBases) : { kind: "literal", warn: "" };
|
|
535
|
+
if (d.kind === "literal") {
|
|
536
|
+
if (d.warn)
|
|
537
|
+
report.warnings.push(d.warn);
|
|
538
|
+
if ((await fingerprintDir(dir, stage.reader())) === fingerprint) {
|
|
539
|
+
report.reused.push(ref);
|
|
540
|
+
return ref;
|
|
541
|
+
}
|
|
542
|
+
}
|
|
543
|
+
else if (d.kind === "reuse") {
|
|
544
|
+
report.reused.push(ref);
|
|
545
|
+
if (d.rendered)
|
|
546
|
+
report.rendered.push({ name: ref, keys: d.rendered });
|
|
547
|
+
if (d.inferred)
|
|
548
|
+
report.inferred.push({ name: ref, values: d.inferred });
|
|
549
|
+
for (const x of d.delta ?? [])
|
|
550
|
+
report.params.push({ ...x, from: ref });
|
|
551
|
+
return ref;
|
|
552
|
+
}
|
|
553
|
+
else {
|
|
554
|
+
why = d.why;
|
|
493
555
|
}
|
|
494
556
|
const as = meta.name;
|
|
495
557
|
name = `${meta.name}--${profile}`;
|
|
496
558
|
dir = path.join(forge, "ingredients", folder, name);
|
|
497
|
-
report.variants.push({ name: `${meta.type}/${name}`, reason: `differs from ${meta.type}/${as} already in the Forge` });
|
|
559
|
+
report.variants.push({ name: `${meta.type}/${name}`, reason: `differs from ${meta.type}/${as} already in the Forge${why ? ` (${why})` : ""}` });
|
|
498
560
|
meta = { ...meta, name, as };
|
|
499
561
|
validateImported(meta); // the variant name must be slug-like too (a `--profile` with a space is not)
|
|
562
|
+
// I8: rewriting an existing variant another profile resolves would change its files there.
|
|
563
|
+
if (run?.ctx.forge && (await stage.exists(path.join(dir, "ingredient.yaml"))) && (await fingerprintDir(dir, stage.reader())) !== fingerprintOf(validateImported(meta), files)) {
|
|
564
|
+
for (const q of run.ctx.forge.profiles.keys()) {
|
|
565
|
+
if (q !== profile && resolvedBy(run.ctx.forge, q).has(`${meta.type}/${name}`)) {
|
|
566
|
+
throw new Error(`import: ${meta.type}/${name} is also used by profile ${q} — its files would change there`);
|
|
567
|
+
}
|
|
568
|
+
}
|
|
569
|
+
}
|
|
500
570
|
}
|
|
501
571
|
else {
|
|
502
572
|
report.created.push(`${meta.type}/${name}`);
|
|
503
573
|
}
|
|
574
|
+
if (run) {
|
|
575
|
+
pin(run.ctx, sourceKeys(sourceMeta, files), renderMap(sourceMeta, run.ctx.P, run.ctx.W));
|
|
576
|
+
run.literal.push({ ref: `${meta.type}/${name}`, source, meta: sourceMeta, files });
|
|
577
|
+
}
|
|
504
578
|
const yamlMeta = { ...meta };
|
|
505
579
|
if (yamlMeta.targets === "*")
|
|
506
580
|
yamlMeta.targets = "*";
|
|
@@ -521,14 +595,226 @@ function validateImported(meta) {
|
|
|
521
595
|
const source = meta.origin?.path ?? `${meta.type}/${meta.name}`;
|
|
522
596
|
throw new Error(`${source} (${meta.type}/${meta.name}) does not fit the ingredient schema: ${r.error.message}`);
|
|
523
597
|
}
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
598
|
+
function currentRuleOrder(forge, profile) {
|
|
599
|
+
if (!forge?.profiles.has(profile))
|
|
600
|
+
return null;
|
|
601
|
+
try {
|
|
602
|
+
return resolve(forge, WorkspaceConfigSchema.parse({ forge: ".", profile })).ingredients.map((i) => i.ref).filter((r) => r.startsWith("rule/"));
|
|
603
|
+
}
|
|
604
|
+
catch {
|
|
605
|
+
return null;
|
|
606
|
+
}
|
|
607
|
+
}
|
|
608
|
+
const recipeText = (name, description, ingredients) => YAML.stringify(JSON.parse(JSON.stringify({ name, description, extends: [], ingredients, params: {} }))); // today's bytes
|
|
609
|
+
/**
|
|
610
|
+
* Where a shared recipe's computed list goes (spec 10 §6.7, Ruling 7): a shared recipe is never
|
|
611
|
+
* edited and never widened for one client. It is used only when its ingredients equal the list as
|
|
612
|
+
* a set — and, for a profile that already exists, in the same relative rule order, so AGENTS.md
|
|
613
|
+
* does not reorder (Q10). Otherwise the profile gets its own `<name>--<profile>`.
|
|
614
|
+
*/
|
|
615
|
+
async function placeRecipe(o, name, list, description, holdsVariant) {
|
|
616
|
+
const owned = `${name}--${o.profile}`;
|
|
617
|
+
if (holdsVariant)
|
|
618
|
+
return writeOwnedRecipe(o, owned, list, description);
|
|
619
|
+
const file = path.join(o.dir, `${name}.yaml`);
|
|
620
|
+
if (!(await o.stage.exists(file))) {
|
|
621
|
+
o.stage.write(file, recipeText(name, description, list));
|
|
622
|
+
o.report.recipes.push(name);
|
|
623
|
+
return name;
|
|
624
|
+
}
|
|
625
|
+
const existing = RecipeSchema.parse(YAML.parse(stripBom(await o.stage.readText(file))) ?? {}).ingredients;
|
|
626
|
+
const rules = (xs) => xs.filter((x) => x.startsWith("rule/"));
|
|
627
|
+
const lacks = existing.find((x) => !list.includes(x));
|
|
628
|
+
const extra = list.find((x) => !existing.includes(x));
|
|
629
|
+
// Q10: an existing profile moves to R only if R orders its rules as the profile resolves them today, so AGENTS.md keeps its order.
|
|
630
|
+
const reorders = o.currentRules !== null && JSON.stringify(rules(existing)) !== JSON.stringify(o.currentRules.filter((r) => existing.includes(r)));
|
|
631
|
+
if (!lacks && !extra && !reorders) {
|
|
632
|
+
o.report.recipes.push(name);
|
|
633
|
+
return name;
|
|
634
|
+
}
|
|
635
|
+
const reason = lacks
|
|
636
|
+
? `${name} lists ${lacks}, which this workspace lacks`
|
|
637
|
+
: extra
|
|
638
|
+
? `this workspace has ${extra}, which ${name} lacks`
|
|
639
|
+
: `${name} orders its rules differently`;
|
|
640
|
+
o.report.recipeSplits.push({ shared: name, owned, reason });
|
|
641
|
+
return writeOwnedRecipe(o, owned, list, description);
|
|
642
|
+
}
|
|
643
|
+
/**
|
|
644
|
+
* A recipe the importing profile owns holds exactly this workspace's list: a new one is written
|
|
645
|
+
* as today; an existing one is edited in place — entries kept in their order, dropped ones
|
|
646
|
+
* removed, new ones appended — refused if another profile resolves it (I7) or it cannot be
|
|
647
|
+
* edited in place (I2).
|
|
648
|
+
*/
|
|
649
|
+
async function writeOwnedRecipe(o, name, list, description) {
|
|
650
|
+
const file = path.join(o.dir, `${name}.yaml`);
|
|
651
|
+
o.report.recipes.push(name);
|
|
652
|
+
if (!(await o.stage.exists(file))) {
|
|
653
|
+
o.stage.write(file, recipeText(name, description, list));
|
|
654
|
+
return name;
|
|
655
|
+
}
|
|
656
|
+
const raw = await o.stage.readText(file);
|
|
657
|
+
const before = RecipeSchema.parse(YAML.parse(stripBom(raw)) ?? {});
|
|
658
|
+
const next = [...before.ingredients.filter((x) => list.includes(x)), ...list.filter((x) => !before.ingredients.includes(x))];
|
|
659
|
+
if (JSON.stringify(next) === JSON.stringify(before.ingredients))
|
|
660
|
+
return name;
|
|
661
|
+
if (o.forge) {
|
|
662
|
+
for (const q of o.forge.profiles.keys()) {
|
|
663
|
+
if (q !== o.profile && recipesOf(o.forge, q).has(name))
|
|
664
|
+
throw new Error(`import: recipe ${name} is also used by profile ${q} — its ingredients would change there`);
|
|
665
|
+
}
|
|
666
|
+
}
|
|
667
|
+
const label = `recipes/${name}.yaml`;
|
|
668
|
+
const content = editYamlText(raw, { command: "import", label, keys: ["ingredients"] }, (doc) => {
|
|
669
|
+
const seq = doc.get("ingredients", true);
|
|
670
|
+
if (!YAML.isSeq(seq)) {
|
|
671
|
+
doc.set("ingredients", next);
|
|
672
|
+
return;
|
|
673
|
+
}
|
|
674
|
+
seq.items = seq.items.filter((it) => list.includes(String(YAML.isScalar(it) ? it.value : it)));
|
|
675
|
+
for (const x of list)
|
|
676
|
+
if (!before.ingredients.includes(x))
|
|
677
|
+
seq.items.push(doc.createNode(x));
|
|
678
|
+
});
|
|
679
|
+
const after = RecipeSchema.safeParse(YAML.parse(stripBom(content)) ?? {});
|
|
680
|
+
if (!after.success || !isDeepStrictEqual(after.data, { ...before, ingredients: next })) {
|
|
681
|
+
throw new Error(`import: cannot edit ${label} in place (the edit does not read back as exactly the new ingredients) — reformat it by hand, commit, and re-run`);
|
|
682
|
+
}
|
|
683
|
+
o.stage.write(file, content);
|
|
684
|
+
return name;
|
|
685
|
+
}
|
|
686
|
+
/** The recipes a profile resolves inside the Forge; fails closed — a profile that does not resolve counts as using everything. */
|
|
687
|
+
function recipesOf(forge, profile) {
|
|
688
|
+
try {
|
|
689
|
+
return new Set(resolve(forge, WorkspaceConfigSchema.parse({ forge: ".", profile })).recipes);
|
|
690
|
+
}
|
|
691
|
+
catch {
|
|
692
|
+
return { has: () => true };
|
|
693
|
+
}
|
|
694
|
+
}
|
|
695
|
+
/**
|
|
696
|
+
* The importing profile's params before the run (`P`, spec 10 §6.1), and I4: import writes
|
|
697
|
+
* `profiles/<p>/profile.yaml`, so a profile named `<p>` elsewhere, or that file naming another
|
|
698
|
+
* profile, would leave two files for one name.
|
|
699
|
+
*/
|
|
700
|
+
async function existingProfileParams(forge, profile, loaded) {
|
|
701
|
+
if (!loaded)
|
|
702
|
+
return {};
|
|
703
|
+
const dir = path.join(forge, "profiles");
|
|
704
|
+
for (const d of (await exists(dir)) ? await fs.readdir(dir, { withFileTypes: true }) : []) {
|
|
705
|
+
if (!d.isDirectory())
|
|
706
|
+
continue;
|
|
707
|
+
const abs = path.join(dir, d.name, "profile.yaml");
|
|
708
|
+
if (!(await exists(abs)))
|
|
709
|
+
continue;
|
|
710
|
+
const name = ProfileSchema.parse(YAML.parse(stripBom(await fs.readFile(abs, "utf8"))) ?? {}).name;
|
|
711
|
+
if ((name === profile) !== (d.name === profile)) {
|
|
712
|
+
throw new Error(`import: profile ${profile} is profiles/${d.name}/profile.yaml — import writes profiles/${profile}/profile.yaml`);
|
|
713
|
+
}
|
|
714
|
+
}
|
|
715
|
+
return { ...(loaded.profiles.get(profile)?.params ?? {}) };
|
|
716
|
+
}
|
|
717
|
+
/** Recipe entries import owns in a profile's `recipes`: the ones it computes and replaces (spec 10 §6.6, §14 Q4). */
|
|
718
|
+
const importOwned = (name, profile) => name === "base" || name === `base--${profile}` || name === `${profile}-steering` || (/^stack-/.test(name) && (!name.includes("--") || name.endsWith(`--${profile}`)));
|
|
719
|
+
/**
|
|
720
|
+
* Write `profiles/<p>/profile.yaml` (spec 10 §6.6, Ruling 2): a new profile as today; an existing
|
|
721
|
+
* one edited in place — `params` gains this run's values, `recipes` gets the computed entries in
|
|
722
|
+
* place of the ones import owns, `targets` gains missing ones — every other field, comment and
|
|
723
|
+
* line kept. I1 when it cannot be edited in place.
|
|
724
|
+
*/
|
|
725
|
+
async function writeProfile(stage, forge, name, computed, ctx, report) {
|
|
726
|
+
const file = path.join(forge, "profiles", name, "profile.yaml");
|
|
727
|
+
const label = `profiles/${name}/profile.yaml`;
|
|
728
|
+
if (!(await stage.exists(file))) {
|
|
729
|
+
stage.write(file, YAML.stringify(computed));
|
|
730
|
+
report.created.push(label);
|
|
731
|
+
report.profileWrite = { path: label, action: "created", fields: [] };
|
|
732
|
+
return;
|
|
733
|
+
}
|
|
734
|
+
const raw = await stage.readText(file);
|
|
735
|
+
const before = ProfileSchema.parse(YAML.parse(stripBom(raw)) ?? {});
|
|
736
|
+
const values = Object.fromEntries(report.params.map((x) => [x.key, String(ctx.P[x.key])]));
|
|
737
|
+
const firstOwned = before.recipes.findIndex((r) => importOwned(r, name));
|
|
738
|
+
const kept = before.recipes.filter((r) => !importOwned(r, name));
|
|
739
|
+
const at = firstOwned === -1 ? kept.length : before.recipes.slice(0, firstOwned).filter((r) => !importOwned(r, name)).length;
|
|
740
|
+
const recipes = [...kept.slice(0, at), ...computed.recipes, ...kept.slice(at)];
|
|
741
|
+
const targets = [...before.targets, ...computed.targets.filter((t) => !before.targets.includes(t))];
|
|
742
|
+
const fields = [
|
|
743
|
+
...(Object.keys(values).length ? ["params"] : []),
|
|
744
|
+
...(JSON.stringify(recipes) !== JSON.stringify(before.recipes) ? ["recipes"] : []),
|
|
745
|
+
...(targets.length !== before.targets.length ? ["targets"] : []),
|
|
746
|
+
];
|
|
747
|
+
if (!fields.length) {
|
|
748
|
+
report.profileWrite = { path: label, action: "unchanged", fields: [] };
|
|
749
|
+
return;
|
|
750
|
+
}
|
|
751
|
+
const content = editYamlText(raw, { command: "import", label, keys: ["params", "recipes", "targets"] }, (doc) => {
|
|
752
|
+
for (const [k, v] of Object.entries(values))
|
|
753
|
+
doc.setIn(["params", k], v);
|
|
754
|
+
if (fields.includes("recipes")) {
|
|
755
|
+
const seq = doc.get("recipes", true);
|
|
756
|
+
if (YAML.isSeq(seq)) {
|
|
757
|
+
const nodes = seq.items.filter((it) => !importOwned(String(YAML.isScalar(it) ? it.value : it), name));
|
|
758
|
+
seq.items = [...nodes.slice(0, at), ...computed.recipes.map((r) => doc.createNode(r)), ...nodes.slice(at)];
|
|
759
|
+
}
|
|
760
|
+
else
|
|
761
|
+
doc.set("recipes", recipes);
|
|
762
|
+
}
|
|
763
|
+
if (fields.includes("targets")) {
|
|
764
|
+
const seq = doc.get("targets", true);
|
|
765
|
+
if (YAML.isSeq(seq))
|
|
766
|
+
for (const t of targets.slice(before.targets.length))
|
|
767
|
+
seq.items.push(doc.createNode(t));
|
|
768
|
+
else
|
|
769
|
+
doc.set("targets", targets);
|
|
770
|
+
}
|
|
771
|
+
});
|
|
772
|
+
const after = ProfileSchema.safeParse(YAML.parse(stripBom(content)) ?? {});
|
|
773
|
+
const expected = { ...before, recipes, targets, params: { ...before.params, ...values } };
|
|
774
|
+
if (!after.success || !isDeepStrictEqual(after.data, expected)) {
|
|
775
|
+
throw new Error(`import: cannot edit ${label} in place (the edit does not read back as exactly the intended change) — reformat it by hand, commit, and re-run`);
|
|
776
|
+
}
|
|
777
|
+
stage.write(file, content);
|
|
778
|
+
report.profileWrite = { path: label, action: "edited", fields };
|
|
779
|
+
for (const x of report.params) {
|
|
780
|
+
const was = before.params[x.key] === undefined ? "" : ` (was ${JSON.stringify(String(before.params[x.key]))})`;
|
|
781
|
+
report.warnings.push(`profile ${name} now sets ${x.key} to ${JSON.stringify(values[x.key])}${was} — every workspace on ${name} renders it at its next sync; import cannot reach them`);
|
|
782
|
+
}
|
|
783
|
+
}
|
|
784
|
+
/**
|
|
785
|
+
* `--write-config` (spec 10 §6.9, Ruling 8): a new or empty craftar.yaml is written as today; an
|
|
786
|
+
* existing one is edited in place — `forge`, `profile` and `targets` set, everything else, comments
|
|
787
|
+
* included, kept. Checked here, before the Forge flush; I9 when it cannot be edited in place.
|
|
788
|
+
*/
|
|
789
|
+
async function planConfig(ws, forge, profile, targets) {
|
|
790
|
+
const abs = path.join(ws, "craftar.yaml");
|
|
791
|
+
const rel = path.relative(ws, forge).replace(/\\/g, "/") || ".";
|
|
792
|
+
const fresh = YAML.stringify({ forge: rel, profile, targets });
|
|
793
|
+
const raw = (await exists(abs)) ? await fs.readFile(abs, "utf8") : null;
|
|
794
|
+
const before = raw === null ? null : YAML.parse(stripBom(raw));
|
|
795
|
+
if (raw === null || before === null || before === undefined)
|
|
796
|
+
return { abs, content: fresh, action: "created" };
|
|
797
|
+
const i9 = (why) => new Error(`import: cannot edit craftar.yaml in place (${why}) — reformat it by hand and re-run`);
|
|
798
|
+
if (typeof before !== "object" || Array.isArray(before))
|
|
799
|
+
throw i9("it is not a YAML mapping");
|
|
800
|
+
let content;
|
|
801
|
+
try {
|
|
802
|
+
content = editYamlText(raw, { command: "import", label: "craftar.yaml", keys: [] }, (doc) => {
|
|
803
|
+
doc.set("forge", rel);
|
|
804
|
+
doc.set("profile", profile);
|
|
805
|
+
if (JSON.stringify(before.targets) !== JSON.stringify(targets))
|
|
806
|
+
doc.set("targets", targets);
|
|
807
|
+
});
|
|
808
|
+
}
|
|
809
|
+
catch (e) {
|
|
810
|
+
throw i9(e.message.replace(/^.*in place \((.*)\) — .*$/s, "$1"));
|
|
811
|
+
}
|
|
812
|
+
const after = YAML.parse(stripBom(content));
|
|
813
|
+
if (!isDeepStrictEqual(after, { ...before, forge: rel, profile, targets }))
|
|
814
|
+
throw i9("the edit does not read back as exactly forge, profile and targets set");
|
|
815
|
+
const loaded = WorkspaceConfigSchema.safeParse(after);
|
|
816
|
+
if (!loaded.success)
|
|
817
|
+
throw i9(`it no longer loads: ${loaded.error.message}`);
|
|
818
|
+
return { abs, content, action: content === raw ? "unchanged" : "edited" };
|
|
533
819
|
}
|
|
534
820
|
//# sourceMappingURL=claude-code.js.map
|