craftar 0.5.0 → 0.6.1
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 +16 -2
- package/dist/cli.js +19 -10
- package/dist/cli.js.map +1 -1
- package/dist/core/merge.js +19 -0
- package/dist/core/merge.js.map +1 -0
- package/dist/core/param-writes.js +5 -22
- package/dist/core/param-writes.js.map +1 -1
- package/dist/core/sync.js +1 -14
- 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/yaml-edit.js +36 -0
- package/dist/core/yaml-edit.js.map +1 -0
- package/dist/importers/claude-code.js +373 -60
- package/dist/importers/claude-code.js.map +1 -1
- package/dist/importers/decide.js +159 -0
- package/dist/importers/decide.js.map +1 -0
- package/package.json +1 -1
|
@@ -2,11 +2,17 @@ import { promises as fs } from "node:fs";
|
|
|
2
2
|
import path from "node:path";
|
|
3
3
|
import YAML from "yaml";
|
|
4
4
|
import { parseFrontmatter } from "../core/frontmatter.js";
|
|
5
|
-
import { exists, listFiles, typeFolder, FORGE_MANIFEST } from "../core/forge.js";
|
|
5
|
+
import { exists, listFiles, parseYaml, 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,52 @@ 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
|
+
// F9 (§6.5): a source rejected for a secret or skipped is never decided. Its base stays in the Forge-wide scan (b),
|
|
272
|
+
// and its text leaves the literal check (c). `ruleNames` fills only as rules are decided, so each set errs on its
|
|
273
|
+
// safe side for a steering file a rule of its name may shadow: out of `runBases`, still in `others`.
|
|
274
|
+
const queuedRules = new Set(queue.filter((q) => q.meta.type === "rule").map((q) => q.meta.name));
|
|
275
|
+
const mayBeSkipped = (q) => q.skip?.() || (q.meta.type === "steering" && queuedRules.has(q.meta.name));
|
|
276
|
+
const clean = queue.filter((q) => !secretIn(q.meta, q.files, q.scan));
|
|
277
|
+
const runBases = new Set(clean.filter((q) => !mayBeSkipped(q)).map((q) => `${q.meta.type}/${q.meta.name}`));
|
|
278
|
+
const mayBeDecided = clean.filter((q) => !q.skip?.());
|
|
279
|
+
const literal = [];
|
|
280
|
+
/* ---- decide, in read order ---- */
|
|
281
|
+
for (const src of queue) {
|
|
282
|
+
if (src.skip?.())
|
|
283
|
+
continue;
|
|
284
|
+
const others = mayBeDecided.filter((q) => q !== src).map((q) => ({ ref: `${q.meta.type}/${q.meta.name}`, meta: q.meta, files: q.files }));
|
|
285
|
+
const ref = await writeIngredient(stage, src.meta, src.files, opts.profileName, report, src.scan, { ctx, others, runBases, literal });
|
|
286
|
+
if (ref)
|
|
287
|
+
src.after(ref);
|
|
288
|
+
}
|
|
289
|
+
// I3: a created or variant ingredient is the workspace text itself; a key the profile now sets would change it at sync.
|
|
290
|
+
for (const l of literal) {
|
|
291
|
+
const map = renderMap(l.meta, ctx.P, ctx.W);
|
|
292
|
+
const key = [...sourceKeys(l.meta, l.files)].find((k) => Object.hasOwn(map, k));
|
|
293
|
+
if (key)
|
|
294
|
+
throw new Error(`import: ${l.source} holds {{${key}}} literally, but profile ${opts.profileName} sets ${key} — sync would render it`);
|
|
246
295
|
}
|
|
247
296
|
/* ---- recipes ---- */
|
|
248
297
|
const recipesDir = path.join(forge, "recipes");
|
|
249
298
|
const isVariant = (ref) => report.variants.some((v) => v.name === ref);
|
|
250
|
-
const
|
|
299
|
+
const recipeOpts = { stage, dir: recipesDir, profile: opts.profileName, forge: ctx.forge, report, currentRules: currentRuleOrder(ctx.forge, opts.profileName) };
|
|
251
300
|
const stackRecipes = [];
|
|
252
301
|
for (const [ruleRef, pattern] of scopedRules) {
|
|
253
302
|
const ruleName = ruleRef.split("/")[1].replace(/--.*$/, "");
|
|
@@ -255,17 +304,14 @@ async function planImport(opts, stage) {
|
|
|
255
304
|
for (const a of agents)
|
|
256
305
|
refs.base = refs.base.filter((r) => r !== a);
|
|
257
306
|
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);
|
|
307
|
+
stackRecipes.push(await placeRecipe(recipeOpts, `stack-${ruleName}`, ingredients, `Conventions + reviewer for repos matching ${pattern}`, ingredients.some(isVariant)));
|
|
261
308
|
}
|
|
262
309
|
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);
|
|
310
|
+
const baseName = await placeRecipe(recipeOpts, "base", baseIngredients, "Always-on conventions, commands, agents, scripts and MCP servers.", baseIngredients.some(isVariant));
|
|
265
311
|
const profileRecipes = [baseName, ...stackRecipes];
|
|
266
312
|
if (refs.steering.length) {
|
|
267
313
|
const n = `${opts.profileName}-steering`;
|
|
268
|
-
await
|
|
314
|
+
await writeOwnedRecipe(recipeOpts, n, refs.steering, `Hand-written Kiro steering specific to ${opts.profileName}.`);
|
|
269
315
|
profileRecipes.push(n);
|
|
270
316
|
}
|
|
271
317
|
/* ---- profile ---- */
|
|
@@ -284,12 +330,14 @@ async function planImport(opts, stage) {
|
|
|
284
330
|
frontend: {},
|
|
285
331
|
executor: (await exists(path.join(claudeDir, "rules", "kiro-execution.md"))) ? { kind: "kiro", terminal: "orca" } : {},
|
|
286
332
|
integrations: {},
|
|
287
|
-
|
|
333
|
+
// A new profile holds the values this run inferred, in acceptance order (spec 10 §6.6).
|
|
334
|
+
params: Object.fromEntries(report.params.map((x) => [x.key, ctx.P[x.key]])),
|
|
288
335
|
repos: [],
|
|
289
336
|
};
|
|
290
|
-
stage
|
|
291
|
-
|
|
292
|
-
|
|
337
|
+
await writeProfile(stage, forge, opts.profileName, profile, ctx, report);
|
|
338
|
+
const config = opts.writeWorkspaceConfig ? await planConfig(ws, forge, opts.profileName, targets) : null;
|
|
339
|
+
report.configWrite = config?.action ?? null;
|
|
340
|
+
return { report, targets, config };
|
|
293
341
|
}
|
|
294
342
|
/**
|
|
295
343
|
* The Forge as the import run sees it: the files on disk overlaid with the files this run has staged.
|
|
@@ -473,7 +521,7 @@ async function sniffScm(claudeDir) {
|
|
|
473
521
|
* identical content → reuse; different content → write a `<name>--<profile>` variant and report it,
|
|
474
522
|
* so the human decides whether to parameterize or keep a client-specific copy.
|
|
475
523
|
*/
|
|
476
|
-
async function writeIngredient(stage, meta, files, profile, report, scan = {}) {
|
|
524
|
+
async function writeIngredient(stage, meta, files, profile, report, scan = {}, run) {
|
|
477
525
|
const secret = secretIn(meta, files, scan);
|
|
478
526
|
if (secret) {
|
|
479
527
|
report.rejected.push({ name: `${meta.type}/${meta.name}`, reason: secret });
|
|
@@ -485,22 +533,55 @@ async function writeIngredient(stage, meta, files, profile, report, scan = {}) {
|
|
|
485
533
|
let dir = path.join(forge, "ingredients", folder, name);
|
|
486
534
|
// Hash what the Forge will load back, the way fingerprintDir hashes the other side.
|
|
487
535
|
const fingerprint = fingerprintOf(validateImported(meta), files);
|
|
536
|
+
const ref = `${meta.type}/${name}`;
|
|
537
|
+
const source = meta.origin?.path ?? ref;
|
|
538
|
+
const sourceMeta = validateImported(meta);
|
|
488
539
|
if (await stage.exists(path.join(dir, "ingredient.yaml"))) {
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
540
|
+
let why;
|
|
541
|
+
const d = run ? await decide(run.ctx, dir, stage.reader(), ref, sourceMeta, files, fingerprint, run.others, run.runBases) : { kind: "literal", warn: "" };
|
|
542
|
+
if (d.kind === "literal") {
|
|
543
|
+
if (d.warn)
|
|
544
|
+
report.warnings.push(d.warn);
|
|
545
|
+
if ((await fingerprintDir(dir, stage.reader())) === fingerprint) {
|
|
546
|
+
report.reused.push(ref);
|
|
547
|
+
return ref;
|
|
548
|
+
}
|
|
549
|
+
}
|
|
550
|
+
else if (d.kind === "reuse") {
|
|
551
|
+
report.reused.push(ref);
|
|
552
|
+
if (d.rendered)
|
|
553
|
+
report.rendered.push({ name: ref, keys: d.rendered });
|
|
554
|
+
if (d.inferred)
|
|
555
|
+
report.inferred.push({ name: ref, values: d.inferred });
|
|
556
|
+
for (const x of d.delta ?? [])
|
|
557
|
+
report.params.push({ ...x, from: ref });
|
|
558
|
+
return ref;
|
|
559
|
+
}
|
|
560
|
+
else {
|
|
561
|
+
why = d.why;
|
|
493
562
|
}
|
|
494
563
|
const as = meta.name;
|
|
495
564
|
name = `${meta.name}--${profile}`;
|
|
496
565
|
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` });
|
|
566
|
+
report.variants.push({ name: `${meta.type}/${name}`, reason: `differs from ${meta.type}/${as} already in the Forge${why ? ` (${why})` : ""}` });
|
|
498
567
|
meta = { ...meta, name, as };
|
|
499
568
|
validateImported(meta); // the variant name must be slug-like too (a `--profile` with a space is not)
|
|
569
|
+
// I8: rewriting an existing variant another profile resolves would change its files there.
|
|
570
|
+
if (run?.ctx.forge && (await stage.exists(path.join(dir, "ingredient.yaml"))) && (await fingerprintDir(dir, stage.reader())) !== fingerprintOf(validateImported(meta), files)) {
|
|
571
|
+
for (const q of run.ctx.forge.profiles.keys()) {
|
|
572
|
+
if (q !== profile && resolvedBy(run.ctx.forge, q).has(`${meta.type}/${name}`)) {
|
|
573
|
+
throw new Error(`import: ${meta.type}/${name} is also used by profile ${q} — its files would change there`);
|
|
574
|
+
}
|
|
575
|
+
}
|
|
576
|
+
}
|
|
500
577
|
}
|
|
501
578
|
else {
|
|
502
579
|
report.created.push(`${meta.type}/${name}`);
|
|
503
580
|
}
|
|
581
|
+
if (run) {
|
|
582
|
+
pin(run.ctx, sourceKeys(sourceMeta, files), renderMap(sourceMeta, run.ctx.P, run.ctx.W));
|
|
583
|
+
run.literal.push({ ref: `${meta.type}/${name}`, source, meta: sourceMeta, files });
|
|
584
|
+
}
|
|
504
585
|
const yamlMeta = { ...meta };
|
|
505
586
|
if (yamlMeta.targets === "*")
|
|
506
587
|
yamlMeta.targets = "*";
|
|
@@ -521,14 +602,246 @@ function validateImported(meta) {
|
|
|
521
602
|
const source = meta.origin?.path ?? `${meta.type}/${meta.name}`;
|
|
522
603
|
throw new Error(`${source} (${meta.type}/${meta.name}) does not fit the ingredient schema: ${r.error.message}`);
|
|
523
604
|
}
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
605
|
+
function currentRuleOrder(forge, profile) {
|
|
606
|
+
if (!forge?.profiles.has(profile))
|
|
607
|
+
return null;
|
|
608
|
+
try {
|
|
609
|
+
return resolve(forge, WorkspaceConfigSchema.parse({ forge: ".", profile })).ingredients.map((i) => i.ref).filter((r) => r.startsWith("rule/"));
|
|
610
|
+
}
|
|
611
|
+
catch {
|
|
612
|
+
return null;
|
|
613
|
+
}
|
|
614
|
+
}
|
|
615
|
+
const recipeText = (name, description, ingredients) => YAML.stringify(JSON.parse(JSON.stringify({ name, description, extends: [], ingredients, params: {} }))); // today's bytes
|
|
616
|
+
/**
|
|
617
|
+
* Where a shared recipe's computed list goes (spec 10 §6.7, Ruling 7): a shared recipe is never
|
|
618
|
+
* edited and never widened for one client. It is used only when its ingredients equal the list as
|
|
619
|
+
* a set — and, for a profile that already exists, in the same relative rule order, so AGENTS.md
|
|
620
|
+
* does not reorder (Q10). Otherwise the profile gets its own `<name>--<profile>`.
|
|
621
|
+
*/
|
|
622
|
+
async function placeRecipe(o, name, list, description, holdsVariant) {
|
|
623
|
+
const owned = `${name}--${o.profile}`;
|
|
624
|
+
if (holdsVariant)
|
|
625
|
+
return writeOwnedRecipe(o, owned, list, description);
|
|
626
|
+
const file = await recipeFile(o, name);
|
|
627
|
+
if (!(await o.stage.exists(file))) {
|
|
628
|
+
o.stage.write(file, recipeText(name, description, list));
|
|
629
|
+
o.report.recipes.push(name);
|
|
630
|
+
return name;
|
|
631
|
+
}
|
|
632
|
+
const existing = parseYaml(file, stripBom(await o.stage.readText(file)), RecipeSchema).ingredients;
|
|
633
|
+
const rules = (xs) => xs.filter((x) => x.startsWith("rule/"));
|
|
634
|
+
const lacks = existing.find((x) => !list.includes(x));
|
|
635
|
+
const extra = list.find((x) => !existing.includes(x));
|
|
636
|
+
// 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.
|
|
637
|
+
const reorders = o.currentRules !== null && JSON.stringify(rules(existing)) !== JSON.stringify(o.currentRules.filter((r) => existing.includes(r)));
|
|
638
|
+
if (!lacks && !extra && !reorders) {
|
|
639
|
+
o.report.recipes.push(name);
|
|
640
|
+
return name;
|
|
641
|
+
}
|
|
642
|
+
const reason = lacks
|
|
643
|
+
? `${name} lists ${lacks}, which this workspace lacks`
|
|
644
|
+
: extra
|
|
645
|
+
? `this workspace has ${extra}, which ${name} lacks`
|
|
646
|
+
: `${name} orders its rules differently`;
|
|
647
|
+
o.report.recipeSplits.push({ shared: name, owned, reason });
|
|
648
|
+
return writeOwnedRecipe(o, owned, list, description);
|
|
649
|
+
}
|
|
650
|
+
/**
|
|
651
|
+
* A recipe the importing profile owns holds exactly this workspace's list: a new one is written
|
|
652
|
+
* as today; an existing one is edited in place — entries kept in their order, dropped ones
|
|
653
|
+
* removed, new ones appended — refused if another profile resolves it (I7) or it cannot be
|
|
654
|
+
* edited in place (I2).
|
|
655
|
+
*/
|
|
656
|
+
async function writeOwnedRecipe(o, name, list, description) {
|
|
657
|
+
const file = await recipeFile(o, name);
|
|
658
|
+
o.report.recipes.push(name);
|
|
659
|
+
if (!(await o.stage.exists(file))) {
|
|
660
|
+
o.stage.write(file, recipeText(name, description, list));
|
|
661
|
+
return name;
|
|
662
|
+
}
|
|
663
|
+
const raw = await o.stage.readText(file);
|
|
664
|
+
const before = parseYaml(file, stripBom(raw), RecipeSchema);
|
|
665
|
+
const next = [...before.ingredients.filter((x) => list.includes(x)), ...list.filter((x) => !before.ingredients.includes(x))];
|
|
666
|
+
if (JSON.stringify(next) === JSON.stringify(before.ingredients))
|
|
667
|
+
return name;
|
|
668
|
+
if (o.forge) {
|
|
669
|
+
for (const q of o.forge.profiles.keys()) {
|
|
670
|
+
if (q !== o.profile && recipesOf(o.forge, q).has(name))
|
|
671
|
+
throw new Error(`import: recipe ${name} is also used by profile ${q} — its ingredients would change there`);
|
|
672
|
+
}
|
|
673
|
+
}
|
|
674
|
+
const label = `recipes/${name}.yaml`;
|
|
675
|
+
const content = editYamlText(raw, { command: "import", label, keys: ["ingredients"] }, (doc) => {
|
|
676
|
+
const seq = doc.get("ingredients", true);
|
|
677
|
+
if (!YAML.isSeq(seq)) {
|
|
678
|
+
doc.set("ingredients", next);
|
|
679
|
+
return;
|
|
680
|
+
}
|
|
681
|
+
seq.items = seq.items.filter((it) => list.includes(String(YAML.isScalar(it) ? it.value : it)));
|
|
682
|
+
for (const x of list)
|
|
683
|
+
if (!before.ingredients.includes(x))
|
|
684
|
+
seq.items.push(doc.createNode(x));
|
|
685
|
+
});
|
|
686
|
+
const after = RecipeSchema.safeParse(YAML.parse(stripBom(content)) ?? {});
|
|
687
|
+
if (!after.success || !isDeepStrictEqual(after.data, { ...before, ingredients: next })) {
|
|
688
|
+
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`);
|
|
689
|
+
}
|
|
690
|
+
o.stage.write(file, content);
|
|
691
|
+
return name;
|
|
692
|
+
}
|
|
693
|
+
/**
|
|
694
|
+
* `recipes/<name>.yaml`, the file import reads and writes for recipe `<name>`. loadForge keys
|
|
695
|
+
* recipes by their `name` field, not by file, so a recipe `<name>` in another file, or that file
|
|
696
|
+
* declaring another name, would leave two recipes under one name and loadForge would keep one.
|
|
697
|
+
* Refused before the first write, as I4 refuses the same split for a profile.
|
|
698
|
+
*/
|
|
699
|
+
async function recipeFile(o, name) {
|
|
700
|
+
const file = path.join(o.dir, `${name}.yaml`);
|
|
701
|
+
for (const f of await o.stage.reader().list(o.dir)) {
|
|
702
|
+
if (f.includes("/") || !/\.ya?ml$/.test(f))
|
|
703
|
+
continue;
|
|
704
|
+
const abs = path.join(o.dir, f);
|
|
705
|
+
const declared = parseYaml(abs, stripBom(await o.stage.readText(abs)), RecipeSchema).name;
|
|
706
|
+
if (abs === file && declared !== name)
|
|
707
|
+
throw new Error(`import: recipes/${f} is recipe ${declared} — import writes recipe ${name} there`);
|
|
708
|
+
if (abs !== file && declared === name)
|
|
709
|
+
throw new Error(`import: recipe ${name} is recipes/${f} — import writes recipes/${name}.yaml`);
|
|
710
|
+
}
|
|
711
|
+
return file;
|
|
712
|
+
}
|
|
713
|
+
/** The recipes a profile resolves inside the Forge; fails closed — a profile that does not resolve counts as using everything. */
|
|
714
|
+
function recipesOf(forge, profile) {
|
|
715
|
+
try {
|
|
716
|
+
return new Set(resolve(forge, WorkspaceConfigSchema.parse({ forge: ".", profile })).recipes);
|
|
717
|
+
}
|
|
718
|
+
catch {
|
|
719
|
+
return { has: () => true };
|
|
720
|
+
}
|
|
721
|
+
}
|
|
722
|
+
/**
|
|
723
|
+
* The importing profile's params before the run (`P`, spec 10 §6.1), and I4: import writes
|
|
724
|
+
* `profiles/<p>/profile.yaml`, so a profile named `<p>` elsewhere, or that file naming another
|
|
725
|
+
* profile, would leave two files for one name.
|
|
726
|
+
*/
|
|
727
|
+
async function existingProfileParams(forge, profile, loaded) {
|
|
728
|
+
if (!loaded)
|
|
729
|
+
return {};
|
|
730
|
+
const dir = path.join(forge, "profiles");
|
|
731
|
+
for (const d of (await exists(dir)) ? await fs.readdir(dir, { withFileTypes: true }) : []) {
|
|
732
|
+
if (!d.isDirectory())
|
|
733
|
+
continue;
|
|
734
|
+
const abs = path.join(dir, d.name, "profile.yaml");
|
|
735
|
+
if (!(await exists(abs)))
|
|
736
|
+
continue;
|
|
737
|
+
const name = parseYaml(abs, stripBom(await fs.readFile(abs, "utf8")), ProfileSchema).name;
|
|
738
|
+
if ((name === profile) !== (d.name === profile)) {
|
|
739
|
+
throw new Error(`import: profile ${profile} is profiles/${d.name}/profile.yaml — import writes profiles/${profile}/profile.yaml`);
|
|
740
|
+
}
|
|
741
|
+
}
|
|
742
|
+
return { ...(loaded.profiles.get(profile)?.params ?? {}) };
|
|
743
|
+
}
|
|
744
|
+
/** Recipe entries import owns in a profile's `recipes`: the ones it computes and replaces (spec 10 §6.6, §14 Q4). */
|
|
745
|
+
const importOwned = (name, profile) => name === "base" || name === `base--${profile}` || name === `${profile}-steering` || (/^stack-/.test(name) && (!name.includes("--") || name.endsWith(`--${profile}`)));
|
|
746
|
+
/**
|
|
747
|
+
* Write `profiles/<p>/profile.yaml` (spec 10 §6.6, Ruling 2): a new profile as today; an existing
|
|
748
|
+
* one edited in place — `params` gains this run's values, `recipes` gets the computed entries in
|
|
749
|
+
* place of the ones import owns, `targets` gains missing ones — every other field, comment and
|
|
750
|
+
* line kept. I1 when it cannot be edited in place.
|
|
751
|
+
*/
|
|
752
|
+
async function writeProfile(stage, forge, name, computed, ctx, report) {
|
|
753
|
+
const file = path.join(forge, "profiles", name, "profile.yaml");
|
|
754
|
+
const label = `profiles/${name}/profile.yaml`;
|
|
755
|
+
if (!(await stage.exists(file))) {
|
|
756
|
+
stage.write(file, YAML.stringify(computed));
|
|
757
|
+
report.created.push(label);
|
|
758
|
+
report.profileWrite = { path: label, action: "created", fields: [] };
|
|
759
|
+
return;
|
|
760
|
+
}
|
|
761
|
+
const raw = await stage.readText(file);
|
|
762
|
+
const before = parseYaml(file, stripBom(raw), ProfileSchema);
|
|
763
|
+
const values = Object.fromEntries(report.params.map((x) => [x.key, String(ctx.P[x.key])]));
|
|
764
|
+
const firstOwned = before.recipes.findIndex((r) => importOwned(r, name));
|
|
765
|
+
const kept = before.recipes.filter((r) => !importOwned(r, name));
|
|
766
|
+
const at = firstOwned === -1 ? kept.length : before.recipes.slice(0, firstOwned).filter((r) => !importOwned(r, name)).length;
|
|
767
|
+
const recipes = [...kept.slice(0, at), ...computed.recipes, ...kept.slice(at)];
|
|
768
|
+
const targets = [...before.targets, ...computed.targets.filter((t) => !before.targets.includes(t))];
|
|
769
|
+
const fields = [
|
|
770
|
+
...(Object.keys(values).length ? ["params"] : []),
|
|
771
|
+
...(JSON.stringify(recipes) !== JSON.stringify(before.recipes) ? ["recipes"] : []),
|
|
772
|
+
...(targets.length !== before.targets.length ? ["targets"] : []),
|
|
773
|
+
];
|
|
774
|
+
if (!fields.length) {
|
|
775
|
+
report.profileWrite = { path: label, action: "unchanged", fields: [] };
|
|
776
|
+
return;
|
|
777
|
+
}
|
|
778
|
+
const content = editYamlText(raw, { command: "import", label, keys: ["params", "recipes", "targets"] }, (doc) => {
|
|
779
|
+
for (const [k, v] of Object.entries(values))
|
|
780
|
+
doc.setIn(["params", k], v);
|
|
781
|
+
if (fields.includes("recipes")) {
|
|
782
|
+
const seq = doc.get("recipes", true);
|
|
783
|
+
if (YAML.isSeq(seq)) {
|
|
784
|
+
const nodes = seq.items.filter((it) => !importOwned(String(YAML.isScalar(it) ? it.value : it), name));
|
|
785
|
+
seq.items = [...nodes.slice(0, at), ...computed.recipes.map((r) => doc.createNode(r)), ...nodes.slice(at)];
|
|
786
|
+
}
|
|
787
|
+
else
|
|
788
|
+
doc.set("recipes", recipes);
|
|
789
|
+
}
|
|
790
|
+
if (fields.includes("targets")) {
|
|
791
|
+
const seq = doc.get("targets", true);
|
|
792
|
+
if (YAML.isSeq(seq))
|
|
793
|
+
for (const t of targets.slice(before.targets.length))
|
|
794
|
+
seq.items.push(doc.createNode(t));
|
|
795
|
+
else
|
|
796
|
+
doc.set("targets", targets);
|
|
797
|
+
}
|
|
798
|
+
});
|
|
799
|
+
const after = ProfileSchema.safeParse(YAML.parse(stripBom(content)) ?? {});
|
|
800
|
+
const expected = { ...before, recipes, targets, params: { ...before.params, ...values } };
|
|
801
|
+
if (!after.success || !isDeepStrictEqual(after.data, expected)) {
|
|
802
|
+
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`);
|
|
803
|
+
}
|
|
804
|
+
stage.write(file, content);
|
|
805
|
+
report.profileWrite = { path: label, action: "edited", fields };
|
|
806
|
+
for (const x of report.params) {
|
|
807
|
+
const was = before.params[x.key] === undefined ? "" : ` (was ${JSON.stringify(String(before.params[x.key]))})`;
|
|
808
|
+
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`);
|
|
809
|
+
}
|
|
810
|
+
}
|
|
811
|
+
/**
|
|
812
|
+
* `--write-config` (spec 10 §6.9, Ruling 8): a new or empty craftar.yaml is written as today; an
|
|
813
|
+
* existing one is edited in place — `forge`, `profile` and `targets` set, everything else, comments
|
|
814
|
+
* included, kept. Checked here, before the Forge flush; I9 when it cannot be edited in place.
|
|
815
|
+
*/
|
|
816
|
+
async function planConfig(ws, forge, profile, targets) {
|
|
817
|
+
const abs = path.join(ws, "craftar.yaml");
|
|
818
|
+
const rel = path.relative(ws, forge).replace(/\\/g, "/") || ".";
|
|
819
|
+
const fresh = YAML.stringify({ forge: rel, profile, targets });
|
|
820
|
+
const raw = (await exists(abs)) ? await fs.readFile(abs, "utf8") : null;
|
|
821
|
+
const before = raw === null ? null : YAML.parse(stripBom(raw));
|
|
822
|
+
if (raw === null || before === null || before === undefined)
|
|
823
|
+
return { abs, content: fresh, action: "created" };
|
|
824
|
+
const i9 = (why) => new Error(`import: cannot edit craftar.yaml in place (${why}) — reformat it by hand and re-run`);
|
|
825
|
+
if (typeof before !== "object" || Array.isArray(before))
|
|
826
|
+
throw i9("it is not a YAML mapping");
|
|
827
|
+
let content;
|
|
828
|
+
try {
|
|
829
|
+
content = editYamlText(raw, { command: "import", label: "craftar.yaml", keys: [] }, (doc) => {
|
|
830
|
+
doc.set("forge", rel);
|
|
831
|
+
doc.set("profile", profile);
|
|
832
|
+
if (JSON.stringify(before.targets) !== JSON.stringify(targets))
|
|
833
|
+
doc.set("targets", targets);
|
|
834
|
+
});
|
|
835
|
+
}
|
|
836
|
+
catch (e) {
|
|
837
|
+
throw i9(e.message.replace(/^.*in place \((.*)\) — .*$/s, "$1"));
|
|
838
|
+
}
|
|
839
|
+
const after = YAML.parse(stripBom(content));
|
|
840
|
+
if (!isDeepStrictEqual(after, { ...before, forge: rel, profile, targets }))
|
|
841
|
+
throw i9("the edit does not read back as exactly forge, profile and targets set");
|
|
842
|
+
const loaded = WorkspaceConfigSchema.safeParse(after);
|
|
843
|
+
if (!loaded.success)
|
|
844
|
+
throw i9(`it no longer loads: ${loaded.error.message}`);
|
|
845
|
+
return { abs, content, action: content === raw ? "unchanged" : "edited" };
|
|
533
846
|
}
|
|
534
847
|
//# sourceMappingURL=claude-code.js.map
|