craftar 0.33.0 → 0.34.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/dist/cli.js CHANGED
@@ -1,14 +1,14 @@
1
1
  import { Command } from "commander";
2
2
  import pc from "picocolors";
3
3
  import path from "node:path";
4
- import { promises as fs } from "node:fs";
4
+ import { promises as fs, statSync } from "node:fs";
5
5
  import YAML from "yaml";
6
6
  import { importClaudeCode } from "./importers/claude-code.js";
7
7
  import { parsePreferKiro } from "./importers/prefer-kiro.js";
8
8
  import { planPromote, applyPromote, UnheldError } from "./importers/drift-promote.js";
9
9
  import { providedRefs } from "./importers/provided.js";
10
10
  import { holdsBaseCitations, rendersServers } from "./core/mcp-server.js";
11
- import { classifyForge } from "./core/remote.js";
11
+ import { assertNotCacheTree, classifyForge } from "./core/remote.js";
12
12
  import { forget, listWorkspaces, prune, readRegistry, register, registryFile } from "./core/registry.js";
13
13
  import { forgeWorkspaces, planAll, nextSync, impactOf, concerned, refineRegistryState, countStates, countStatesObject, NEXT_SYNC_STATES } from "./core/impact.js";
14
14
  import { runDoctor } from "./core/doctor.js";
@@ -16,7 +16,7 @@ import { launchRefusal, pinOf } from "./core/mcp-protocol.js";
16
16
  import { serve } from "./mcp.js";
17
17
  import { pruneCache } from "./core/cache.js";
18
18
  import { resolveHome } from "./core/home-lock.js";
19
- import { loadWorkspace, readWorkspaceConfig, plan, readLock, status, apply, unsetDeclared, unsetRefusal, unsetRefused, agentsMdOverBudget, agentsMdRefusal, resolveForge, resolveForgeSource, WORKSPACE_FILE, LOCAL_FILE } from "./core/sync.js";
19
+ import { loadWorkspace, loadForgeSource, readWorkspaceConfig, plan, readLock, status, apply, unsetDeclared, unsetRefusal, unsetRefused, agentsMdOverBudget, agentsMdRefusal, resolveForge, resolveForgeSource, WORKSPACE_FILE, LOCAL_FILE } from "./core/sync.js";
20
20
  import { sectionKey } from "./core/resolve.js";
21
21
  import { catalogueContext, listRecipes, listIngredients, checkType, withLoadWarnings } from "./core/catalogue.js";
22
22
  import { canonicalValue } from "./core/sections.js";
@@ -30,7 +30,10 @@ import { applyBasePromotion, planBasePromotion, proveBasePromotion } from "./cor
30
30
  import { fingerprintDir } from "./core/fingerprint.js";
31
31
  import { hunkAt, planFrom, applyPlan, writeUnified, checkRecipeCascade, rewriteRecipes, pruneCandidates, pruneRecipes, note, } from "./core/unify.js";
32
32
  import { checkParamWrites, writeParamFile } from "./core/param-writes.js";
33
- import { checkInitFlags, initLine, planInit } from "./core/init.js";
33
+ import { checkInitFlags, checkLocalKeys, initLine, planInit, forgeSource } from "./core/init.js";
34
+ import { profileName } from "./core/new-profile.js";
35
+ import { newProfileGate } from "./core/new-profile-gate.js";
36
+ import { writeNewProfile, reproveFromDisk, NewProfileRaceLost, NewProfileLateFailure } from "./core/new-profile-write.js";
34
37
  import { askInit, askClone, confirmInit, againLine, NOT_CLONED_REFUSED } from "./core/init-flow.js";
35
38
  import { findGit, cloneRepos, listRepos, planClone } from "./core/clone.js";
36
39
  import { byHand, profileRepos } from "./core/repos.js";
@@ -54,7 +57,7 @@ process.stdout.on("error", (e) => { if (e.code === "EPIPE")
54
57
  */
55
58
  const DIFF_EXIT_CODE_FETCH_MODE = "sync";
56
59
  const program = new Command();
57
- program.name("craftar").description("Craft, sync and convert AI-coding workspace harnesses.").version("0.33.0");
60
+ program.name("craftar").description("Craft, sync and convert AI-coding workspace harnesses.").version("0.34.1");
58
61
  const SAVE_PLAN_GATE = { what: "plan", who: "unify", outside: "save plans outside the Forge", never: "unify never overwrites" };
59
62
  const REPORT_GATE = { what: "report", who: "import", outside: "write the report outside the Forge", never: "import never overwrites" };
60
63
  program
@@ -73,8 +76,10 @@ program
73
76
  // import writes a local Forge; a URL would become a directory named after it (spec 13 §4.4).
74
77
  if (classifyForge(o.forge) === "url")
75
78
  fail("--forge takes a directory; to read a remote Forge, run inside a workspace that names it");
79
+ // A cache tree is refused first: it reads only the real paths of --forge and of the cache, nothing of the workspace.
80
+ await assertNotCacheTree(path.resolve(o.forge), craftarHome()).catch((e) => fail(e instanceof Error ? e.message : String(e)));
76
81
  // Spec 29 §4.2: the report's path is refused before the import reads anything, so a refusal leaves the Forge untouched.
77
- // Spec 29 §4.3: PK5, before anything is read.
82
+ // Spec 29 §4.3: PK5, before the workspace is read.
78
83
  const preferKiro = o.preferKiro === undefined ? undefined : parsePreferKiro(o.preferKiro);
79
84
  const reportAbs = o.report === undefined ? null : await gateOutsideForge(o.report, o.forge, REPORT_GATE);
80
85
  const r = await importClaudeCode({ workspaceRoot: o.workspace, forgeRoot: o.forge, profileName: o.profile, writeWorkspaceConfig: o.writeConfig, report: reportAbs !== null, preferKiro });
@@ -294,13 +299,15 @@ async function runCloneStep(root, profile, entries, git, again) {
294
299
  /* ---------------------------------------------------------------- init */
295
300
  program
296
301
  .command("init")
297
- .description("Start a workspace from a Forge and a profile: write a craftar.yaml proved to resolve and plan, then run the first sync; with --clone, clone the profile's repositories into the workspace (after the first sync, or with --no-sync after writing craftar.yaml); in a terminal, asks for the Forge and the profile not given as flags; refused when craftar.yaml already exists")
302
+ .description("Start a workspace from a Forge and a profile: write a craftar.yaml proved to resolve and plan, then run the first sync; with --new-profile, create the profile in the Forge directory first (recipes and targets from --like and the recipe/target flags), then start the workspace on it — nothing is committed; with --clone, clone the profile's repositories into the workspace (after the first sync, or with --no-sync after writing craftar.yaml); in a terminal, asks for the Forge and the profile not given as flags; refused when craftar.yaml already exists")
298
303
  .option("--forge <dir|url>", "the Forge: a directory (written relative to the workspace) or a git URL; asked for in a terminal when omitted")
299
304
  .option("--profile <name>", "the client profile in the Forge; asked for in a terminal when omitted")
305
+ .option("--new-profile <name>", "create this profile in the Forge directory (recipes and targets from --like and the recipe flags), then start the workspace on it; nothing is committed")
306
+ .option("--like <profile>", "with --new-profile: start the new profile's recipes and targets from this profile's — nothing else of it is copied")
300
307
  .option("--ref <ref>", "branch, tag or full SHA of a remote Forge")
301
- .option("--targets <a,b>", "targets to write in craftar.yaml (claude-code, kiro, agents-md); omitted, the workspace follows the profile's")
302
- .option("--add-recipe <name>", "add a recipe to the profile's (repeatable)", collect, [])
303
- .option("--remove-recipe <name>", "remove a recipe of the profile's (repeatable)", collect, [])
308
+ .option("--targets <a,b>", "targets to write in craftar.yaml, or of the new profile with --new-profile (claude-code, kiro, agents-md); omitted, the workspace follows the profile's")
309
+ .option("--add-recipe <name>", "add a recipe to the profile's, or to the new profile's with --new-profile (repeatable)", collect, [])
310
+ .option("--remove-recipe <name>", "remove a recipe from the profile's, or from the new profile's with --new-profile (repeatable)", collect, [])
304
311
  .option("--replace", "let --add-recipe take a slot another recipe holds", false)
305
312
  .option("--no-sync", "write craftar.yaml only, and say what the first sync would do")
306
313
  .option("--clone", "after the first sync, clone the profile's repositories (its repos list) into the workspace")
@@ -310,7 +317,8 @@ program
310
317
  .action(async (o, cmd) => {
311
318
  const root = path.resolve(o.workspace);
312
319
  // N1: craftar.yaml already exists
313
- const n1 = () => fail(`${WORKSPACE_FILE} already exists in ${root} — change recipes with craftar add recipe / remove recipe, or edit it`);
320
+ const n1Text = `${WORKSPACE_FILE} already exists in ${root} — change recipes with craftar add recipe / remove recipe, or edit it`;
321
+ const n1 = () => fail(n1Text);
314
322
  const file = path.join(root, WORKSPACE_FILE);
315
323
  if (await exists(file))
316
324
  n1();
@@ -351,15 +359,29 @@ program
351
359
  if (o.clone === true && git === null) {
352
360
  fail("cloning needs git — install it, or leave the clone out");
353
361
  }
362
+ // P1–P4: --new-profile checks (before anything is read)
363
+ if (o.newProfile !== undefined && o.profile !== undefined) {
364
+ fail("pass --profile or --new-profile, not both");
365
+ }
366
+ if (o.like !== undefined && o.newProfile === undefined) {
367
+ fail("--like goes with --new-profile");
368
+ }
369
+ if (o.newProfile !== undefined) {
370
+ profileName(o.newProfile);
371
+ }
372
+ if (o.newProfile !== undefined && o.forge !== undefined && classifyForge(o.forge) === "url") {
373
+ fail("a new profile is written into a Forge directory — clone the Forge, pass that directory as --forge, then commit and push the profile");
374
+ }
354
375
  // Mode: flags when both given; else interactive when both streams are TTYs; else N2
355
- const bothGiven = o.forge !== undefined && o.profile !== undefined;
376
+ // --new-profile counts as --profile for bothGiven and N2
377
+ const bothGiven = o.forge !== undefined && (o.profile !== undefined || o.newProfile !== undefined);
356
378
  const isTTY = process.stdin.isTTY && process.stdout.isTTY;
357
379
  if (!bothGiven && !isTTY) {
358
380
  // N2: refuse with our message
359
- if (o.forge === undefined && o.profile === undefined) {
381
+ if (o.forge === undefined && o.profile === undefined && o.newProfile === undefined) {
360
382
  fail("--forge and --profile are required when craftar init does not run in a terminal — pass them, or run craftar init in a terminal to be asked");
361
383
  }
362
- else if (o.profile === undefined) {
384
+ else if (o.profile === undefined && o.newProfile === undefined) {
363
385
  fail("--profile is required when craftar init does not run in a terminal — pass it, or run craftar init in a terminal to be asked");
364
386
  }
365
387
  else {
@@ -375,6 +397,8 @@ program
375
397
  const result = await askInit(root, {
376
398
  forge: o.forge,
377
399
  profile: o.profile,
400
+ newProfile: o.newProfile,
401
+ like: o.like,
378
402
  ref: o.ref,
379
403
  targets: o.targets === undefined ? undefined : o.targets.split(",").map((t) => t.trim()),
380
404
  addRecipes: o.addRecipe,
@@ -387,6 +411,32 @@ program
387
411
  input = result.input;
388
412
  answers = result.answers;
389
413
  }
414
+ else if (o.newProfile !== undefined) {
415
+ // --forge and --new-profile given (flags mode with new profile)
416
+ // N10 (spec 38 §4.2): the one check, before the load — --new-profile counts as --profile there
417
+ checkLocalKeys(local.keys, {
418
+ ref: o.ref !== undefined,
419
+ recipes: o.addRecipe.length > 0 || o.removeRecipe.length > 0,
420
+ targets: o.targets !== undefined,
421
+ });
422
+ // Load the Forge
423
+ const loaded = await loadForgeSource(root, forgeSource(root, o.forge), o.ref ?? null, { ...load(o, "sync") });
424
+ // Gate: P5-P8
425
+ await newProfileGate(loaded.forge, resolveHome(process.env.CRAFTAR_HOME || undefined), o.newProfile);
426
+ // Build input
427
+ input = {
428
+ forge: o.forge,
429
+ profile: o.newProfile,
430
+ newProfile: { name: o.newProfile, like: o.like },
431
+ ref: o.ref,
432
+ targets: o.targets === undefined ? undefined : o.targets.split(",").map((t) => t.trim()),
433
+ addRecipes: o.addRecipe,
434
+ removeRecipes: o.removeRecipe,
435
+ replace: o.replace,
436
+ clone: o.clone === true,
437
+ loaded,
438
+ };
439
+ }
390
440
  else {
391
441
  input = {
392
442
  forge: o.forge,
@@ -399,7 +449,7 @@ program
399
449
  clone: o.clone === true,
400
450
  };
401
451
  }
402
- const init = await planInit(root, input, { ...load(o, "sync"), local });
452
+ let init = await planInit(root, input, { ...load(o, "sync"), local });
403
453
  // Interactive mode: when o.clone is undefined, ask about cloning between planInit and confirmInit
404
454
  // --clone typed: planInit has checked C3–C5 and returned the entries, in either mode
405
455
  let cloneEntries = o.clone === true ? init.repos : [];
@@ -422,26 +472,84 @@ program
422
472
  if (confirm === "cancelled")
423
473
  fail("init cancelled — nothing written");
424
474
  }
425
- const { ws, plan: p } = init;
475
+ let { ws, plan: p } = init;
476
+ const forgeRoot = ws.forge.root;
426
477
  const workspaceGiven = cmd.getOptionValueSource("workspace") === "cli";
427
478
  const again = () => {
428
479
  if (answers !== null) {
429
480
  console.log(againLine(answers, { workspace: workspaceGiven ? o.workspace : undefined, sync: o.sync, offline: o.offline, clone: cloneEntries.length > 0 }));
430
481
  }
431
482
  };
483
+ // When init.newProfile: write the profile, then reprove from disk
484
+ let journal = [];
485
+ // Step 1's notes are printed after the initLine line (spec 38), so keep them before reprove
486
+ const step1Notes = init.notes;
487
+ if (init.newProfile) {
488
+ try {
489
+ journal = await writeNewProfile(forgeRoot, init);
490
+ }
491
+ catch (e) {
492
+ if (e instanceof NewProfileRaceLost) {
493
+ // P7's first message, no recover lines
494
+ fail(e.message);
495
+ }
496
+ if (e instanceof NewProfileLateFailure) {
497
+ fail(lateFailure(e, forgeRoot, e.journal, "init"));
498
+ }
499
+ throw e;
500
+ }
501
+ // Reprove from disk (step 4)
502
+ try {
503
+ init = await reproveFromDisk(root, input.forge, init, { ...load(o, "sync") }, journal, input.ref);
504
+ }
505
+ catch (e) {
506
+ if (e instanceof NewProfileLateFailure) {
507
+ fail(lateFailure(e, forgeRoot, e.journal, "init"));
508
+ }
509
+ throw e;
510
+ }
511
+ // Update ws and p from the second init
512
+ ws = init.ws;
513
+ p = init.plan;
514
+ // Print the profile creation lines
515
+ console.log(pc.bold(`craftar init — created profile ${ws.config.profile} in ${forgeRoot}`));
516
+ console.log(` + profiles/${ws.config.profile}/profile.yaml (not committed — commit it in the Forge, on a branch, before anyone else syncs)`);
517
+ }
432
518
  // Every refusal is above: only now does the directory, and craftar.yaml, come to exist
433
- await fs.mkdir(root, { recursive: true });
434
- try {
435
- await fs.writeFile(file, init.text, { flag: "wx" });
519
+ // When journal is not empty: mkdir inside the try, with late failure handling
520
+ if (journal.length > 0) {
521
+ try {
522
+ await fs.mkdir(root, { recursive: true });
523
+ }
524
+ catch (e) {
525
+ fail(lateFailure(e, forgeRoot, journal, "init"));
526
+ }
527
+ try {
528
+ await fs.writeFile(file, init.text, { flag: "wx" });
529
+ }
530
+ catch (e) {
531
+ if (e.code === "EEXIST") {
532
+ // N1's text, then lateFailure
533
+ fail(`${n1Text}\n${lateFailure(e, forgeRoot, journal, "init")}`);
534
+ }
535
+ fail(lateFailure(e, forgeRoot, journal, "init"));
536
+ }
436
537
  }
437
- catch (e) {
438
- if (e.code === "EEXIST")
439
- n1();
440
- throw e;
538
+ else {
539
+ // No journal: existing behaviour unchanged
540
+ await fs.mkdir(root, { recursive: true });
541
+ try {
542
+ await fs.writeFile(file, init.text, { flag: "wx" });
543
+ }
544
+ catch (e) {
545
+ if (e.code === "EEXIST")
546
+ n1();
547
+ throw e;
548
+ }
441
549
  }
442
550
  console.log(pc.bold(`craftar init — wrote ${WORKSPACE_FILE} in ${root}`));
443
551
  console.log(` ${initLine(init)}`);
444
- for (const n of init.notes)
552
+ for (const n of step1Notes)
445
553
  console.log(` note ${n}`);
446
554
  const unset = unsetDeclared(p);
447
555
  const cloning = cloneEntries.length > 0;
@@ -1966,8 +2074,8 @@ forge
1966
2074
  fail("pass exactly one of --take, --plan or --save-plan");
1967
2075
  if (o.take !== undefined && o.take !== "base" && o.take !== "variant")
1968
2076
  fail(`--take must be "base" or "variant"`);
1969
- // Never through the cache: a tree there is clean and tracked, so gitUnheld would pass and the edit
1970
- // would be lost at the next cleanup without reaching the remote (spec 13 §4.4).
2077
+ // Never through the cache: the URL form is refused by refuseRemote and the directory form by assertNotCacheTree;
2078
+ // a tree there is clean and tracked, so gitUnheld would pass and the edit would be lost at the next cleanup without reaching the remote (spec 13 §4.4).
1971
2079
  const f = await resolveForge({
1972
2080
  forge: o.forge,
1973
2081
  workspace: o.workspace,
@@ -1984,6 +2092,7 @@ forge
1984
2092
  fail(`${ref} has no variant for profile ${o.profile}`);
1985
2093
  // Only when about to write — --save-plan touches nothing inside the Forge, so it is exempt.
1986
2094
  if (!o.savePlan) {
2095
+ await assertNotCacheTree(f.root, craftarHome()).catch((e) => fail(e instanceof Error ? e.message : String(e)));
1987
2096
  if (f.commit === null) {
1988
2097
  // `gitHead` (and so `f.commit`) is also `null` for a real git repo with no commits yet —
1989
2098
  // that is not "not a git repository" (Ruling 22), so tell the two apart before wording it.
@@ -2448,12 +2557,13 @@ forge
2448
2557
  .option("--dry-run", "run every check and the first impact pass, print what would be written, and write nothing", false)
2449
2558
  .option("--json", "machine-readable output", false)
2450
2559
  .action(async (ref, o) => {
2451
- // Never through the cache, as `forge unify` (spec 13 §4.4).
2560
+ // Never through the cache, as `forge unify` (spec 13 §4.4): the URL form is refused by refuseRemote and the directory form by assertNotCacheTree.
2452
2561
  const f = await resolveForge({
2453
2562
  forge: o.forge,
2454
2563
  workspace: o.workspace,
2455
2564
  refuseRemote: (url) => `the Forge of this workspace is remote (${url}) — clone it and pass --forge <dir>`,
2456
2565
  });
2566
+ await assertNotCacheTree(f.root, craftarHome()).catch((e) => fail(e instanceof Error ? e.message : String(e)));
2457
2567
  // P2 (spec 29 §4.4): git is the undo, so it must be there, hold a commit and be clean — before anything is planned.
2458
2568
  if (f.commit === null) {
2459
2569
  if (await gitIsRepo(f.root))
@@ -3001,6 +3111,7 @@ function unheldMessage(command, root, unheld) {
3001
3111
  * The message for a `forge unify` failure after writing began (Ruling 33): the original error, the
3002
3112
  * Forge-relative paths already touched, and the git commands that undo them — `checkout` for what
3003
3113
  * git tracks, `clean` for files unify created (checkout refuses a path git does not know).
3114
+ * For created paths, directories get `clean -fd`, files (or missing paths) get `clean -f`.
3004
3115
  */
3005
3116
  function lateFailure(e, root, journal, command = "unify") {
3006
3117
  const msg = errorText(e);
@@ -3009,15 +3120,35 @@ function lateFailure(e, root, journal, command = "unify") {
3009
3120
  const rel = (abs) => path.relative(root, abs).split(path.sep).join("/");
3010
3121
  const quote = (p) => (/^[\w./-]+$/.test(p) ? p : `"${p}"`);
3011
3122
  const restore = [...new Set(journal.filter((j) => !j.created).map((j) => rel(j.abs)))];
3012
- const created = [...new Set(journal.filter((j) => j.created).map((j) => rel(j.abs)))];
3123
+ const created = [...new Set(journal.filter((j) => j.created).map((j) => j.abs))];
3124
+ // For created paths, check if they are directories
3125
+ const createdDirs = [];
3126
+ const createdFiles = [];
3127
+ for (const abs of created) {
3128
+ try {
3129
+ const st = statSync(abs);
3130
+ if (st.isDirectory()) {
3131
+ createdDirs.push(rel(abs));
3132
+ }
3133
+ else {
3134
+ createdFiles.push(rel(abs));
3135
+ }
3136
+ }
3137
+ catch {
3138
+ // Path doesn't exist, treat as file
3139
+ createdFiles.push(rel(abs));
3140
+ }
3141
+ }
3013
3142
  const lines = [msg, `${command} had already started changing the Forge (${root}) when this failed:`];
3014
- for (const p of [...restore, ...created])
3143
+ for (const p of [...restore, ...createdDirs, ...createdFiles])
3015
3144
  lines.push(` ${p}`);
3016
3145
  lines.push("recover with:");
3017
3146
  if (restore.length)
3018
3147
  lines.push(` git -C ${quote(root)} checkout -- ${restore.map(quote).join(" ")}`);
3019
- if (created.length)
3020
- lines.push(` git -C ${quote(root)} clean -f -- ${created.map(quote).join(" ")}`);
3148
+ if (createdDirs.length)
3149
+ lines.push(` git -C ${quote(root)} clean -fd -- ${createdDirs.map(quote).join(" ")}`);
3150
+ if (createdFiles.length)
3151
+ lines.push(` git -C ${quote(root)} clean -f -- ${createdFiles.map(quote).join(" ")}`);
3021
3152
  return lines.join("\n");
3022
3153
  }
3023
3154
  /** ` [1 evolution · 2 block]` in the fixed class order, zero counts omitted; empty for a variant without hunks (spec 08 §4.2). */