arcane-cli 1.9.2 → 1.10.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 CHANGED
@@ -218,8 +218,20 @@ Install everything, or just the slice you want:
218
218
  ```bash
219
219
  spell init --profile full
220
220
  spell add agent-policies # or install any component à la carte
221
+ spell add spells-build compliance-standards # or several, in order
221
222
  ```
222
223
 
224
+ `spell add` takes one or more names. It checks each component's files before writing any of them: if one already exists, it prints one line naming the file and `--force`, exits 1, and writes nothing from that component. With several names it stops at the first failure and says which were added and which were not attempted; a name that is already installed is skipped. A component that keeps your own file (`.mcp.json`) leaves an existing one alone and creates a missing one. `--dry-run` lists each file as identical, differing or missing, or as one that would be kept or refused, and writes nothing.
225
+
226
+ ### What `spell update` installs
227
+
228
+ Beyond refreshing what you have, `spell update` installs two things, and reports each by name ([ARC-052](DECISIONS.md#arc-052--spell-update-installs-the-requires-prerequisites-of-installed-components)):
229
+
230
+ - **Missing prerequisites.** A component that cites another one in `requires` (`spells-build` cites the standards documents) gets it installed when it is missing. It is tracked with hashes like any other component. If a file at its destination already exists, or it is an `initOnly` component, `update` leaves it alone and keeps it in the list with the reason.
231
+ - **Newly available components, only when you ask.** In a terminal, `spell update` offers them as a checklist with nothing ticked. Without one, pass `spell update --add-new`. With neither, nothing is installed and the list prints as before. `--add-new` is for repositories, not the user tier.
232
+
233
+ `spell update` never installs an `initOnly` component (`line-ending-baseline`, `docs-baseline`): adding one changes how Git treats files you already have, so it stays a `spell add` you run yourself. `--dry-run` names exactly the components a real run would install. `spell doctor` installs nothing; it names the command.
234
+
223
235
  ### Setup questions, answered by flags
224
236
 
225
237
  `spell init` asks a few questions about the repository and records the answers in `.arcane.json`; `spell update` asks any that an older install predates. Each question has a flag, on both commands, so a script or an agent harness can answer without a terminal:
package/dist/index.js CHANGED
@@ -121,6 +121,15 @@ async function copyDirectory(srcDir, targetDir, relativeDir, opts = {}) {
121
121
  }
122
122
  return copiedFiles;
123
123
  }
124
+ async function listDirectoryFiles(srcDir, relativeDir) {
125
+ const files = [];
126
+ for (const entry of await readdir(srcDir, { withFileTypes: true })) {
127
+ const rel = `${relativeDir}/${entry.name}`;
128
+ if (entry.isDirectory()) files.push(...await listDirectoryFiles(path.join(srcDir, entry.name), rel));
129
+ else files.push(rel);
130
+ }
131
+ return files;
132
+ }
124
133
 
125
134
  // src/modules/manifest.ts
126
135
  import { readFile as readFile2, writeFile as writeFile2 } from "fs/promises";
@@ -1470,7 +1479,7 @@ async function printWelcome(version, cwd) {
1470
1479
  }
1471
1480
  printFooter(version);
1472
1481
  console.log();
1473
- const label = (s) => chalk.hex(STEEL_DIM)(s.padEnd(11));
1482
+ const label2 = (s) => chalk.hex(STEEL_DIM)(s.padEnd(11));
1474
1483
  const dot = chalk.hex(FAINT)("\xB7");
1475
1484
  let repoLine;
1476
1485
  try {
@@ -1481,11 +1490,11 @@ async function printWelcome(version, cwd) {
1481
1490
  } catch {
1482
1491
  repoLine = `${chalk.hex(STEEL_DIM)("\u25CB")} not initialized ${chalk.hex(FAINT)("\u2014")} run ${chalk.hex(STEEL).inverse(" spell init ")} ${chalk.hex(STEEL_DIM)("to scaffold")}`;
1483
1492
  }
1484
- console.log(` ${label("This repo")}${repoLine}`);
1493
+ console.log(` ${label2("This repo")}${repoLine}`);
1485
1494
  const phases = ["plan", "architect", "implement", "test", "review", "ship"].join(
1486
1495
  chalk.hex(STEEL_DEEP)(" \u2192 ")
1487
1496
  );
1488
- console.log(` ${label("Spell Loop")}${chalk.hex("#dbe3f7")(phases)}`);
1497
+ console.log(` ${label2("Spell Loop")}${chalk.hex("#dbe3f7")(phases)}`);
1489
1498
  const flat = ["plan", "architect", "implement", "test", "review", "ship"].join(" \u2192 ");
1490
1499
  const loopStart = 13 + flat.indexOf("implement");
1491
1500
  const loopEnd = 13 + flat.indexOf("review") + 5;
@@ -2469,7 +2478,7 @@ async function runAgentsInit(options, targetDir, assetsDir) {
2469
2478
  const scope = options.scope ?? "repo";
2470
2479
  if (scope === "user") await requireUserStore(targetDir);
2471
2480
  const baseDir = agentsBaseDir(targetDir, scope);
2472
- const label = scope === "user" ? "~/.arcane" : ".arcane";
2481
+ const label2 = scope === "user" ? "~/.arcane" : ".arcane";
2473
2482
  if (!options.force && await rosterExists(targetDir, scope)) {
2474
2483
  console.log(
2475
2484
  'Agent roster already exists at .arcane/agents.yaml. Run "spell agents sync" to regenerate outputs, or use --force to reinitialize.'
@@ -2569,7 +2578,7 @@ All 12 roles across ${allCategories.join(", ")}` : "\nPick individual roles from
2569
2578
  await mkdir4(arcaneDir, { recursive: true });
2570
2579
  await writeFile6(join4(baseDir, "agents.yaml"), stringify(roster), "utf8");
2571
2580
  } else {
2572
- console.log(` [dry-run] Would write: ${label}/agents.yaml`);
2581
+ console.log(` [dry-run] Would write: ${label2}/agents.yaml`);
2573
2582
  }
2574
2583
  for (const roleId of selectedRoleIds) {
2575
2584
  const src = join4(assetsDir, "agents", `${roleId}.yaml`);
@@ -2581,7 +2590,7 @@ All 12 roles across ${allCategories.join(", ")}` : "\nPick individual roles from
2581
2590
  console.warn(` ! Could not copy definition for "${roleId}" \u2014 skipping`);
2582
2591
  }
2583
2592
  } else {
2584
- console.log(` [dry-run] Would copy: ${label}/agents/${roleId}.yaml`);
2593
+ console.log(` [dry-run] Would copy: ${label2}/agents/${roleId}.yaml`);
2585
2594
  }
2586
2595
  }
2587
2596
  if (options.dryRun) {
@@ -2608,7 +2617,7 @@ All 12 roles across ${allCategories.join(", ")}` : "\nPick individual roles from
2608
2617
  }
2609
2618
  console.log();
2610
2619
  console.log(
2611
- ` \u{1F4A1} Customize: edit ${label}/agents.yaml \u2192 spell agents sync${scope === "user" ? " --user" : ""}`
2620
+ ` \u{1F4A1} Customize: edit ${label2}/agents.yaml \u2192 spell agents sync${scope === "user" ? " --user" : ""}`
2612
2621
  );
2613
2622
  console.log();
2614
2623
  if (hasUnresolvedRoles) {
@@ -4407,30 +4416,77 @@ async function runInitUser(options, storeRoot, assetsDir, packageVersion) {
4407
4416
  console.log();
4408
4417
  }
4409
4418
 
4410
- // src/commands/add.ts
4419
+ // src/modules/component-install.ts
4420
+ import { stat } from "fs/promises";
4411
4421
  import { join as join8 } from "path";
4412
- async function runAdd(name, options, targetDir, assetsDir, packageVersion) {
4413
- let manifest;
4414
- try {
4415
- manifest = await readManifest(targetDir);
4416
- } catch (err) {
4417
- if (err instanceof ManifestNotFoundError) {
4418
- console.error(
4419
- 'Not initialized. Run "spell init" first before adding components.'
4420
- );
4421
- process.exit(1);
4422
- return;
4422
+ async function planComponentInstall(component, targetDir, assetsDir, opts = {}) {
4423
+ const files = [];
4424
+ const stateOf = async (file, src) => {
4425
+ const dest = join8(targetDir, file);
4426
+ if (!await fileExists(dest)) return { file, src, state: "missing" };
4427
+ if ((await stat(dest)).isDirectory()) return { file, src, state: "differs" };
4428
+ const same = await hashFile(dest) === await hashFile(src);
4429
+ return { file, src, state: same ? "identical" : "differs" };
4430
+ };
4431
+ for (const file of component.files) {
4432
+ files.push(await stateOf(file, join8(assetsDir, component.sourceOverrides?.[file] ?? file)));
4433
+ }
4434
+ for (const dir of component.directories ?? []) {
4435
+ const srcDir = join8(assetsDir, dir);
4436
+ for (const file of await listDirectoryFiles(srcDir, dir)) {
4437
+ files.push(await stateOf(file, join8(assetsDir, file)));
4423
4438
  }
4424
- throw err;
4425
4439
  }
4440
+ const present = files.filter((f) => f.state !== "missing");
4441
+ const missing = files.filter((f) => f.state === "missing");
4442
+ if (opts.force) return { component, files, toCopy: files, kept: [], conflicts: [] };
4443
+ return component.skipExisting ? { component, files, toCopy: missing, kept: present, conflicts: [] } : { component, files, toCopy: missing, kept: [], conflicts: present };
4444
+ }
4445
+ async function executeInstallPlan(plan, targetDir) {
4446
+ const files = [];
4447
+ const fileHashes = {};
4448
+ for (const { file, src } of plan.toCopy) {
4449
+ fileHashes[file] = await copyFile(src, targetDir, file, { force: true });
4450
+ files.push(file);
4451
+ }
4452
+ return { files, fileHashes };
4453
+ }
4454
+
4455
+ // src/commands/add.ts
4456
+ function describeExisting(f) {
4457
+ return f.state === "identical" ? "identical to the packaged file" : "differs from the packaged file";
4458
+ }
4459
+ function printDryRun2(name, plan, options) {
4460
+ for (const f of plan.files) {
4461
+ if (f.state === "missing") {
4462
+ console.log(` [dry-run] Would copy: ${f.file}`);
4463
+ } else if (options.force) {
4464
+ console.log(` [dry-run] Would overwrite: ${f.file} (exists, ${describeExisting(f)})`);
4465
+ } else if (plan.component.skipExisting) {
4466
+ console.log(` [dry-run] Would keep: ${f.file} (exists, yours, ${describeExisting(f)})`);
4467
+ } else {
4468
+ console.log(` [dry-run] Would refuse: ${f.file} (exists, ${describeExisting(f)}) \u2014 needs --force`);
4469
+ }
4470
+ }
4471
+ if (plan.conflicts.length > 0) {
4472
+ console.log(
4473
+ `
4474
+ [dry-run] Would refuse component "${name}": ${plan.conflicts.length} existing file${plan.conflicts.length === 1 ? "" : "s"}. Re-run with --force to overwrite.`
4475
+ );
4476
+ return;
4477
+ }
4478
+ console.log(`
4479
+ [dry-run] Would add component "${name}" \u2014 ${plan.toCopy.length} files`);
4480
+ }
4481
+ async function addOne(name, options, targetDir, assetsDir, packageVersion) {
4482
+ const manifest = await readManifest(targetDir);
4426
4483
  let component;
4427
4484
  try {
4428
4485
  component = getComponent(name);
4429
4486
  } catch (err) {
4430
4487
  if (err instanceof ComponentNotFoundError) {
4431
4488
  console.error(err.message);
4432
- process.exit(1);
4433
- return;
4489
+ return "failed";
4434
4490
  }
4435
4491
  throw err;
4436
4492
  }
@@ -4438,53 +4494,69 @@ async function runAdd(name, options, targetDir, assetsDir, packageVersion) {
4438
4494
  console.log(
4439
4495
  `Component "${name}" is already installed. Use "spell update" to update it.`
4440
4496
  );
4441
- return;
4442
- }
4443
- let fileCount = 0;
4444
- const installedFiles = [];
4445
- const fileHashes = {};
4446
- for (const file of component.files) {
4447
- const srcPath = join8(assetsDir, component.sourceOverrides?.[file] ?? file);
4448
- if (options.dryRun) {
4449
- console.log(` [dry-run] Would copy: ${file}`);
4450
- } else {
4451
- fileHashes[file] = await copyFile(srcPath, targetDir, file, { force: options.force });
4452
- }
4453
- installedFiles.push(file);
4454
- fileCount++;
4455
- }
4456
- for (const dir of component.directories ?? []) {
4457
- const srcDirPath = join8(assetsDir, dir);
4458
- if (options.dryRun) {
4459
- console.log(` [dry-run] Would copy directory: ${dir}/`);
4460
- } else {
4461
- const copied = await copyDirectory(srcDirPath, targetDir, dir, { force: options.force });
4462
- for (const { path: copiedPath, hash } of copied) {
4463
- installedFiles.push(copiedPath);
4464
- fileHashes[copiedPath] = hash;
4465
- }
4466
- fileCount += copied.length;
4467
- }
4497
+ return "skipped";
4468
4498
  }
4499
+ const plan = await planComponentInstall(component, targetDir, assetsDir, { force: options.force });
4469
4500
  if (options.dryRun) {
4470
- console.log(
4471
- `
4472
- [dry-run] Would add component "${name}" \u2014 ${fileCount} files`
4501
+ printDryRun2(name, plan, options);
4502
+ return plan.conflicts.length > 0 ? "failed" : "added";
4503
+ }
4504
+ if (plan.conflicts.length > 0) {
4505
+ const others = plan.conflicts.length - 1;
4506
+ console.error(
4507
+ `Cannot add "${name}": "${plan.conflicts[0].file}" already exists${others > 0 ? ` (and ${others} more)` : ""}. Nothing was written. Use --force to overwrite.`
4473
4508
  );
4474
- return;
4509
+ return "failed";
4475
4510
  }
4511
+ const { files, fileHashes } = await executeInstallPlan(plan, targetDir);
4512
+ for (const f of plan.kept) console.log(` Kept existing (yours): ${f.file}`);
4476
4513
  const newComponent = {
4477
4514
  name,
4478
- files: installedFiles,
4515
+ files,
4479
4516
  installedVersion: packageVersion,
4480
4517
  fileHashes
4481
4518
  };
4482
- const updated = addComponent(manifest, newComponent);
4483
- await writeManifest(targetDir, updated);
4519
+ await writeManifest(targetDir, addComponent(manifest, newComponent));
4484
4520
  console.log(
4485
4521
  `
4486
- \u2713 Added component "${name}" \u2014 ${fileCount} files copied`
4522
+ \u2713 Added component "${name}" \u2014 ${files.length} files copied${plan.kept.length > 0 ? `, ${plan.kept.length} kept` : ""}`
4487
4523
  );
4524
+ return "added";
4525
+ }
4526
+ async function runAdd(names, options, targetDir, assetsDir, packageVersion) {
4527
+ try {
4528
+ await readManifest(targetDir);
4529
+ } catch (err) {
4530
+ if (err instanceof ManifestNotFoundError) {
4531
+ console.error(
4532
+ 'Not initialized. Run "spell init" first before adding components.'
4533
+ );
4534
+ process.exit(1);
4535
+ return;
4536
+ }
4537
+ throw err;
4538
+ }
4539
+ const added = [];
4540
+ const refused = [];
4541
+ for (const [index, name] of names.entries()) {
4542
+ const outcome = await addOne(name, options, targetDir, assetsDir, packageVersion);
4543
+ if (outcome === "added") added.push(name);
4544
+ if (outcome !== "failed") continue;
4545
+ refused.push(name);
4546
+ if (options.dryRun) continue;
4547
+ if (names.length > 1) {
4548
+ const notAttempted = names.slice(index + 1);
4549
+ console.error(
4550
+ `Added: ${added.length > 0 ? added.join(", ") : "none"}.${notAttempted.length > 0 ? ` Not attempted: ${notAttempted.join(", ")}.` : ""}`
4551
+ );
4552
+ }
4553
+ process.exit(1);
4554
+ return;
4555
+ }
4556
+ if (refused.length > 0) {
4557
+ console.error(`[dry-run] Would refuse: ${refused.join(", ")}.`);
4558
+ process.exit(1);
4559
+ }
4488
4560
  }
4489
4561
 
4490
4562
  // src/commands/update.ts
@@ -4693,27 +4765,105 @@ function formatRegistryChanges(changes) {
4693
4765
  }
4694
4766
  if (newlyAvailable.length > 0) {
4695
4767
  const profile = changes.profile ? ` "${changes.profile}"` : "";
4696
- lines.push(` Newly available for your${profile} profile, not installed \u2014 nothing was added:`);
4768
+ lines.push(
4769
+ changes.installedByUpdate ? ` Newly available for your${profile} profile, not installed by this run:` : ` Newly available for your${profile} profile, not installed \u2014 nothing was added:`
4770
+ );
4697
4771
  for (const component of newlyAvailable) {
4698
4772
  lines.push(` ${component.name} \u2014 ${component.description}`);
4699
4773
  lines.push(` spell add ${component.name}`);
4700
4774
  if (component.alreadyPresent.length > 0) {
4701
4775
  lines.push(
4702
- ` (already here and yours: ${component.alreadyPresent.join(", ")} \u2014 \`spell add\` stops at an existing file rather than overwrite it)`
4776
+ ` (already here and yours: ${component.alreadyPresent.join(", ")} \u2014 \`spell add\` keeps it rather than overwrite it)`
4703
4777
  );
4704
4778
  }
4705
4779
  }
4780
+ if (!changes.addNewUsed) lines.push(" To install them in one step: `spell update --add-new`");
4706
4781
  }
4707
4782
  if (missingRequires.length > 0) {
4708
4783
  lines.push(" Missing prerequisites \u2014 installed components cite these, and they are not installed:");
4709
- for (const { name, requiredBy } of missingRequires) {
4784
+ for (const { name, requiredBy, leftAloneReason } of missingRequires) {
4710
4785
  lines.push(` ${name} (required by ${requiredBy.join(", ")})`);
4711
4786
  lines.push(` spell add ${name}`);
4787
+ if (leftAloneReason) lines.push(` (not installed by update: ${leftAloneReason})`);
4712
4788
  }
4713
4789
  }
4714
4790
  return lines;
4715
4791
  }
4716
4792
 
4793
+ // src/modules/update-installs.ts
4794
+ import { checkbox as checkbox2 } from "@inquirer/prompts";
4795
+ function offerableNewComponents(newlyAvailable, missingRequires) {
4796
+ return newlyAvailable.filter(
4797
+ (c) => !getComponent(c.name).initOnly && !missingRequires.some((m) => m.name === c.name)
4798
+ );
4799
+ }
4800
+ async function selectNewComponents(candidates, mode) {
4801
+ if (candidates.length === 0) return [];
4802
+ if (mode.addNew) return candidates;
4803
+ if (!mode.interactive || mode.dryRun) return [];
4804
+ const chosen = await checkbox2({
4805
+ message: "Newly available components. Space to select, Enter to confirm (none selected installs nothing):",
4806
+ choices: candidates.map((c) => ({ name: `${c.name} \u2014 ${c.description}`, value: c.name, checked: false }))
4807
+ });
4808
+ return candidates.filter((c) => chosen.includes(c.name));
4809
+ }
4810
+ async function planUpdateInstalls(args) {
4811
+ const items = [];
4812
+ const leftAlone = [];
4813
+ for (const { name, requiredBy } of args.missingRequires) {
4814
+ const component = componentForSpellScope(getComponent(name), args.spellScope);
4815
+ if (component.initOnly) {
4816
+ leftAlone.push({ name, kind: "prerequisite", reason: "it is an initOnly component: adding it mid-life changes how Git treats existing files, so it is your call" });
4817
+ continue;
4818
+ }
4819
+ if (component.files.length === 0) continue;
4820
+ const plan = await planComponentInstall(component, args.targetDir, args.assetsDir, {});
4821
+ if (plan.conflicts.length > 0) {
4822
+ leftAlone.push({ name, kind: "prerequisite", reason: `${plan.conflicts[0].file} already exists${plan.conflicts.length > 1 ? ` (and ${plan.conflicts.length - 1} more)` : ""}, and update does not overwrite` });
4823
+ continue;
4824
+ }
4825
+ items.push({ plan, kind: "prerequisite", requiredBy });
4826
+ }
4827
+ for (const candidate of args.selectedNew) {
4828
+ const component = componentForSpellScope(getComponent(candidate.name), args.spellScope);
4829
+ if (component.initOnly || component.files.length === 0) continue;
4830
+ const plan = await planComponentInstall(component, args.targetDir, args.assetsDir, {});
4831
+ if (plan.conflicts.length > 0) {
4832
+ leftAlone.push({ name: candidate.name, kind: "new", reason: `${plan.conflicts[0].file} already exists, and update does not overwrite` });
4833
+ continue;
4834
+ }
4835
+ items.push({ plan, kind: "new", requiredBy: [] });
4836
+ }
4837
+ return { items, leftAlone };
4838
+ }
4839
+ function label(item) {
4840
+ return item.kind === "prerequisite" ? `prerequisite of ${item.requiredBy.join(", ")}` : "newly available";
4841
+ }
4842
+ function describeInstallPlan(plan) {
4843
+ const lines = [];
4844
+ for (const item of plan.items) {
4845
+ lines.push(` [dry-run] Would install: ${item.plan.component.name} (${label(item)}) \u2014 ${item.plan.toCopy.length} files`);
4846
+ for (const f of item.plan.kept) lines.push(` [dry-run] Would keep: ${f.file} (exists, yours)`);
4847
+ }
4848
+ return lines;
4849
+ }
4850
+ async function executeUpdateInstalls(plan, targetDir, packageVersion) {
4851
+ const installed = [];
4852
+ const lines = [];
4853
+ for (const item of plan.items) {
4854
+ const { files, fileHashes } = await executeInstallPlan(item.plan, targetDir);
4855
+ installed.push({
4856
+ name: item.plan.component.name,
4857
+ files,
4858
+ installedVersion: packageVersion,
4859
+ fileHashes
4860
+ });
4861
+ lines.push(` Installed: ${item.plan.component.name} (${label(item)}) \u2014 ${files.length} files`);
4862
+ for (const f of item.plan.kept) lines.push(` Kept existing (yours): ${f.file}`);
4863
+ }
4864
+ return { installed, lines };
4865
+ }
4866
+
4717
4867
  // src/modules/npm-registry.ts
4718
4868
  var NPM_PACKAGE_NAME = "arcane-cli";
4719
4869
  async function fetchPublishedFile(version, relativeAssetPath) {
@@ -4947,6 +5097,13 @@ async function runUpdate(options, targetDir, assetsDir, packageVersion) {
4947
5097
  return;
4948
5098
  }
4949
5099
  const scope = resolvedScope.scope;
5100
+ if (options.addNew && scope !== "repo") {
5101
+ console.error(
5102
+ "\n \u2717 --add-new offers the components a repository's profile gained; the user tier has no profile. Run it without --user, inside a repository.\n"
5103
+ );
5104
+ process.exit(1);
5105
+ return;
5106
+ }
4950
5107
  let flagPatch = {};
4951
5108
  if (hasManifestFlags(options.manifestFlags)) {
4952
5109
  if (scope !== "repo") {
@@ -5015,19 +5172,49 @@ async function runUpdate(options, targetDir, assetsDir, packageVersion) {
5015
5172
  missingRequires: findMissingRequires(manifest.components, manifest.profile)
5016
5173
  } : {}
5017
5174
  };
5175
+ let installPlan = { items: [], leftAlone: [] };
5176
+ if (scope === "repo" && manifest.components.length > 0) {
5177
+ const missingRequires = registryChanges.missingRequires ?? [];
5178
+ const selectedNew = await selectNewComponents(
5179
+ offerableNewComponents(registryChanges.newlyAvailable ?? [], missingRequires),
5180
+ { addNew: Boolean(options.addNew), interactive: Boolean(process.stdin.isTTY), dryRun: Boolean(options.dryRun) }
5181
+ );
5182
+ installPlan = await planUpdateInstalls({ targetDir, assetsDir, spellScope, missingRequires, selectedNew });
5183
+ const installing = new Set(installPlan.items.map((i) => i.plan.component.name));
5184
+ registryChanges.installedByUpdate = installing.size > 0;
5185
+ registryChanges.addNewUsed = Boolean(options.addNew);
5186
+ registryChanges.newlyAvailable = (registryChanges.newlyAvailable ?? []).filter((c) => !installing.has(c.name));
5187
+ registryChanges.missingRequires = missingRequires.filter((m) => !installing.has(m.name)).map((m) => ({ ...m, leftAloneReason: installPlan.leftAlone.find((l) => l.name === m.name)?.reason }));
5188
+ }
5018
5189
  const printRegistryChanges = () => {
5019
5190
  for (const line of formatRegistryChanges(registryChanges)) console.log(line);
5020
5191
  };
5192
+ const applyInstalls = async () => {
5193
+ const declined = installPlan.leftAlone.filter((l) => l.kind === "new");
5194
+ if (installPlan.items.length === 0 && declined.length === 0) return [];
5195
+ console.log(options.dryRun ? "\n[dry-run] Components this update would install (ARC-052):" : "\nComponents installed by this update (ARC-052):");
5196
+ for (const l of declined) console.log(` ! Not installed: ${l.name} \u2014 ${l.reason}`);
5197
+ if (options.dryRun) {
5198
+ for (const line of describeInstallPlan(installPlan)) console.log(line);
5199
+ return [];
5200
+ }
5201
+ const { installed, lines } = await executeUpdateInstalls(installPlan, targetDir, packageVersion);
5202
+ for (const line of lines) console.log(line);
5203
+ return installed;
5204
+ };
5021
5205
  let sameVersionRestore = false;
5022
5206
  if (manifest.version === packageVersion && manifest.components.length > 0) {
5023
5207
  const missing = await findMissingTrackedFiles(targetDir, manifest.components, scope, spellScope, readopted);
5024
5208
  const unmanaged = findUnmanagedTrackedFiles(manifest.components, scope, spellScope);
5025
5209
  if (missing.length === 0 && unmanaged.length === 0) {
5026
- console.log("Already up to date.");
5210
+ console.log(installPlan.items.length === 0 ? "Already up to date." : "Installed components are at the current version.");
5211
+ const newlyInstalled = await applyInstalls();
5027
5212
  printRegistryChanges();
5028
- if (scope === "repo" && Object.keys(flagPatch).length > 0) {
5029
- if (!options.dryRun) await writeManifest(targetDir, answered);
5030
- reportFlagPatch(flagPatch, manifest, answered, options.dryRun);
5213
+ if (scope === "repo" && (Object.keys(flagPatch).length > 0 || newlyInstalled.length > 0)) {
5214
+ if (!options.dryRun) {
5215
+ await writeManifest(targetDir, { ...answered, components: [...answered.components, ...newlyInstalled] });
5216
+ }
5217
+ if (Object.keys(flagPatch).length > 0) reportFlagPatch(flagPatch, manifest, answered, options.dryRun);
5031
5218
  }
5032
5219
  if (scope === "user") {
5033
5220
  const fanout = await syncUserTierFanout({
@@ -5214,6 +5401,7 @@ async function runUpdate(options, targetDir, assetsDir, packageVersion) {
5214
5401
  fileHashes
5215
5402
  });
5216
5403
  }
5404
+ updatedComponents.push(...await applyInstalls());
5217
5405
  const claimed = new Set(updatedComponents.flatMap((c) => c.files));
5218
5406
  const resolvedOrphans = /* @__PURE__ */ new Set();
5219
5407
  for (const { owner, file, recordedHash } of orphanCandidates) {
@@ -5510,11 +5698,11 @@ async function runStatus(targetDir, packageVersion, options = {}) {
5510
5698
  ` ${"-".repeat(nameWidth)} ${"-".repeat(filesWidth)} ${"-".repeat(versionWidth)}`
5511
5699
  );
5512
5700
  for (const component of manifest.components) {
5513
- const label = component.name;
5701
+ const label2 = component.name;
5514
5702
  const fileCount = component.files.length.toString();
5515
5703
  const displayVersion = component.installedVersion;
5516
5704
  console.log(
5517
- ` ${pad(label, nameWidth)} ${rpad(fileCount, filesWidth)} ${displayVersion}`
5705
+ ` ${pad(label2, nameWidth)} ${rpad(fileCount, filesWidth)} ${displayVersion}`
5518
5706
  );
5519
5707
  }
5520
5708
  console.log("");
@@ -6276,8 +6464,8 @@ async function checkNodeVersion() {
6276
6464
  message: `${raw} \u2014 Node.js 18 or later is required`
6277
6465
  };
6278
6466
  }
6279
- async function checkVSCodeExtension(id, label, homeDir = resolveHomeDir()) {
6280
- const name = `VS Code extension: ${label}`;
6467
+ async function checkVSCodeExtension(id, label2, homeDir = resolveHomeDir()) {
6468
+ const name = `VS Code extension: ${label2}`;
6281
6469
  const flavours = [
6282
6470
  { dir: join15(homeDir, ".vscode", "extensions"), bin: "code" },
6283
6471
  { dir: join15(homeDir, ".vscode-insiders", "extensions"), bin: "code-insiders" }
@@ -6619,9 +6807,39 @@ async function checkComponentRequires(targetDir) {
6619
6807
  name,
6620
6808
  passed: false,
6621
6809
  blocking: false,
6622
- message: missing.map(({ name: required, requiredBy }) => `${requiredBy.join(", ")} cites ${required}, which is not installed \u2014 \`spell add ${required}\``).join("; ")
6810
+ message: missing.map(({ name: required, requiredBy }) => `${requiredBy.join(", ")} cites ${required}, which is not installed \u2014 ${getComponent(required).initOnly ? "" : "`spell update` installs it, or "}\`spell add ${required}\``).join("; ")
6623
6811
  };
6624
6812
  }
6813
+ async function checkNewlyAvailableComponents(targetDir) {
6814
+ const name = "Newly available components";
6815
+ let manifest;
6816
+ try {
6817
+ manifest = await readManifest(targetDir);
6818
+ } catch {
6819
+ return { name, passed: true, blocking: false, skipped: true, message: "skipped \u2014 no readable .arcane.json" };
6820
+ }
6821
+ if (manifest.scope === "user" || !Array.isArray(manifest.components)) {
6822
+ return { name, passed: true, blocking: false, skipped: true, message: "skipped \u2014 no repository install to compare" };
6823
+ }
6824
+ const installed = manifest.components.flatMap(
6825
+ (c) => (LEGACY_COMPONENT_MIGRATIONS[c.name] ?? [c.name]).map((n) => ({ ...c, name: n }))
6826
+ );
6827
+ const prerequisites = findMissingRequires(manifest.components, manifest.profile).map((m) => m.name);
6828
+ const fresh = (await findNewlyAvailable(targetDir, manifest.profile, installed, effectiveSpellScope(manifest))).filter((c) => !prerequisites.includes(c.name));
6829
+ if (fresh.length === 0) {
6830
+ return { name, passed: true, blocking: false, message: "the profile has nothing this install lacks" };
6831
+ }
6832
+ const offerable = fresh.filter((c) => !getComponent(c.name).initOnly).map((c) => c.name);
6833
+ const byHand = fresh.filter((c) => getComponent(c.name).initOnly).map((c) => c.name);
6834
+ const parts = [];
6835
+ if (offerable.length > 0) {
6836
+ parts.push(`${offerable.join(", ")} \u2014 \`spell update --add-new\` installs ${offerable.length === 1 ? "it" : "them"}`);
6837
+ }
6838
+ if (byHand.length > 0) {
6839
+ parts.push(`${byHand.join(", ")} \u2014 initOnly, so only ${byHand.map((n) => `\`spell add ${n}\``).join(", ")}`);
6840
+ }
6841
+ return { name, passed: false, blocking: false, message: `newly available for your profile, not installed: ${parts.join("; ")}` };
6842
+ }
6625
6843
  async function checkMcpConfig(targetDir) {
6626
6844
  const name = "MCP config (per-server timeout, I12)";
6627
6845
  const filePath = join15(targetDir, ".mcp.json");
@@ -6874,7 +7092,8 @@ async function runDoctor(targetDir, options = {}, assetsDir) {
6874
7092
  checkUserTier(resolveHomeDir(), options.packageVersion),
6875
7093
  checkSpellScope(targetDir),
6876
7094
  checkGovernancePlaceholders(targetDir),
6877
- checkComponentRequires(targetDir)
7095
+ checkComponentRequires(targetDir),
7096
+ checkNewlyAvailableComponents(targetDir)
6878
7097
  ]);
6879
7098
  const sessionResults = await checkSessionContinuity(targetDir);
6880
7099
  results.push(...sessionResults);
@@ -6882,8 +7101,8 @@ async function runDoctor(targetDir, options = {}, assetsDir) {
6882
7101
  for (const result of results) {
6883
7102
  const skipped = result.passed && result.skipped === true;
6884
7103
  const icon = skipped ? "\u2013" : result.passed ? "\u2713" : result.blocking === false ? "\u26A0" : "\u2717";
6885
- const label = skipped ? "skip" : result.passed ? "pass" : result.blocking === false ? "warn" : "FAIL";
6886
- console.log(` ${icon} [${label}] ${result.name}`);
7104
+ const label2 = skipped ? "skip" : result.passed ? "pass" : result.blocking === false ? "warn" : "FAIL";
7105
+ console.log(` ${icon} [${label2}] ${result.name}`);
6887
7106
  if (!result.passed || skipped) {
6888
7107
  console.log(` ${result.message}`);
6889
7108
  }
@@ -8084,10 +8303,10 @@ withManifestFlags(
8084
8303
  );
8085
8304
  }
8086
8305
  );
8087
- program.command("add <component>").description("Add a component to an existing Arcane installation").option("--force", "Overwrite existing files without error").option("--dry-run", "Preview what would be added without making changes").action(
8088
- async (component, opts) => {
8306
+ program.command("add <components...>").description("Add one or more components to an existing Arcane installation, in order; stops at the first failure").option("--force", "Overwrite existing files without error").option("--dry-run", "Preview what would be added without making changes").action(
8307
+ async (components, opts) => {
8089
8308
  await runAdd(
8090
- component,
8309
+ components,
8091
8310
  { force: opts.force, dryRun: opts.dryRun },
8092
8311
  process.cwd(),
8093
8312
  ASSETS_DIR,
@@ -8100,6 +8319,9 @@ withManifestFlags(
8100
8319
  "--prune",
8101
8320
  "Also delete orphaned managed files (tracked before this update, no longer part of any current component). Hash-checked: an edited file is reported, never silently deleted"
8102
8321
  ).option("--user", USER_FLAG_DESCRIPTION).option(
8322
+ "--add-new",
8323
+ "Also install the components your profile gained since your install (never an initOnly component, never over an existing file). Without it a terminal asks and a script installs nothing"
8324
+ ).option(
8103
8325
  "--default-scope <scope>",
8104
8326
  "With --user: remember repo | user as the answer `spell init` pre-selects for NEW repositories on this machine. Never rewrites an existing repository, and the question is still asked"
8105
8327
  )
@@ -8123,6 +8345,7 @@ withManifestFlags(
8123
8345
  dryRun: opts.dryRun,
8124
8346
  prune: opts.prune,
8125
8347
  user: opts.user,
8348
+ addNew: opts.addNew,
8126
8349
  manifestFlags: manifestFlagsFrom(opts),
8127
8350
  ...opts.defaultScope === void 0 ? {} : { defaultScope: opts.defaultScope }
8128
8351
  },