claude-code-arcane 2.9.1 → 2.9.2

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/CHANGELOG.md CHANGED
@@ -1,3 +1,10 @@
1
+ ## [2.9.2](https://github.com/SebastianLuser/claude-code-arcane/compare/v2.9.1...v2.9.2) (2026-09-07)
2
+
3
+
4
+ ### Bug Fixes
5
+
6
+ * **update:** only remove content the manifest owns ([d6eec05](https://github.com/SebastianLuser/claude-code-arcane/commit/d6eec05475bb491c8853078d9920c7dc4544e7b3))
7
+
1
8
  ## [2.9.1](https://github.com/SebastianLuser/claude-code-arcane/compare/v2.9.0...v2.9.1) (2026-09-04)
2
9
 
3
10
 
package/dist/cli.js CHANGED
@@ -742,6 +742,7 @@ function computeContentHashes(claudeDir) {
742
742
  const skillsDir = path7.join(claudeDir, "skills");
743
743
  if (fs6.existsSync(skillsDir)) {
744
744
  for (const entry of fs6.readdirSync(skillsDir, { withFileTypes: true })) {
745
+ if (entry.name.startsWith("_")) continue;
745
746
  if (entry.isDirectory()) {
746
747
  const dirHash = hashDirectory(path7.join(skillsDir, entry.name));
747
748
  const combined = createHash("sha256").update(JSON.stringify(dirHash)).digest("hex");
@@ -2315,6 +2316,7 @@ function safeVersion() {
2315
2316
  }
2316
2317
 
2317
2318
  // src/commands/update.ts
2319
+ var TRASH_DIR = ".arcane-trash";
2318
2320
  async function updateCommand(opts) {
2319
2321
  if (opts.here) {
2320
2322
  await updateTarget(process.cwd(), opts);
@@ -2444,7 +2446,9 @@ Update available: ${chalk7.red(installedVersion)} -> ${chalk7.green(currentVersi
2444
2446
  const items = computeUpdatePlan(
2445
2447
  manifest.content_hashes ?? null,
2446
2448
  installedHashes,
2447
- sourceHashes
2449
+ sourceHashes,
2450
+ makeOwnershipCheck(manifest),
2451
+ granularAgentDivisions(merged.agents)
2448
2452
  );
2449
2453
  const updates = items.filter((i) => i.action === "update" || i.action === "add" || i.action === "conflict");
2450
2454
  const skipped = items.filter((i) => i.action === "skip-customized" || i.action === "skip-unchanged");
@@ -2459,7 +2463,7 @@ Update available: ${chalk7.red(installedVersion)} -> ${chalk7.green(currentVersi
2459
2463
  arcane_version: currentVersion,
2460
2464
  source_version: currentVersion,
2461
2465
  updated_at: (/* @__PURE__ */ new Date()).toISOString().replace(/\.\d{3}Z$/, "Z"),
2462
- content_hashes: installedHashes,
2466
+ content_hashes: stripForeign(installedHashes, items),
2463
2467
  installed_skills: merged.skills,
2464
2468
  installed_rules: allRules,
2465
2469
  installed_agents: merged.agents,
@@ -2500,7 +2504,7 @@ Refreshed ${unhashed.length} file(s); no other changes to apply.`) : chalk7.gree
2500
2504
  arcane_version: currentVersion,
2501
2505
  source_version: currentVersion,
2502
2506
  updated_at: (/* @__PURE__ */ new Date()).toISOString().replace(/\.\d{3}Z$/, "Z"),
2503
- content_hashes: newHashes,
2507
+ content_hashes: stripForeign(newHashes, items),
2504
2508
  installed_skills: merged.skills,
2505
2509
  installed_rules: allRules,
2506
2510
  installed_agents: merged.agents,
@@ -2530,7 +2534,56 @@ function printGeneralSummary(results, isDryRun) {
2530
2534
  ${prefix}Done. ${results.length} installation${results.length === 1 ? "" : "s"} processed:`));
2531
2535
  console.log(` ${chalk7.green(updated)} ${isDryRun ? "would change" : "updated"}, ${chalk7.dim(unchanged + " unchanged")}`);
2532
2536
  }
2533
- function computeUpdatePlan(manifestHashes, installedHashes, sourceHashes) {
2537
+ function makeOwnershipCheck(manifest) {
2538
+ const hashes = manifest.content_hashes;
2539
+ if (hashes) {
2540
+ return (type, name) => name in (hashes[type] ?? {});
2541
+ }
2542
+ const skills = new Set(manifest.installed_skills ?? []);
2543
+ const rules = new Set(
2544
+ (manifest.installed_rules ?? []).map((r) => r.endsWith(".md") ? r : `${r}.md`)
2545
+ );
2546
+ const agents = new Set(
2547
+ (manifest.installed_agents ?? []).map((a) => parseAgentEntry(a).division)
2548
+ );
2549
+ return (type, name) => {
2550
+ switch (type) {
2551
+ case "skills":
2552
+ return skills.has(name);
2553
+ case "rules":
2554
+ return rules.has(name);
2555
+ case "agents":
2556
+ return agents.has(name);
2557
+ case "hooks":
2558
+ return true;
2559
+ }
2560
+ };
2561
+ }
2562
+ function granularAgentDivisions(entries) {
2563
+ const whole = /* @__PURE__ */ new Set();
2564
+ const partial = /* @__PURE__ */ new Set();
2565
+ for (const raw of entries) {
2566
+ const { division, agent } = parseAgentEntry(raw);
2567
+ (agent ? partial : whole).add(division);
2568
+ }
2569
+ for (const division of whole) partial.delete(division);
2570
+ return partial;
2571
+ }
2572
+ function stripForeign(hashes, items) {
2573
+ const stripped = {
2574
+ skills: { ...hashes.skills },
2575
+ rules: { ...hashes.rules },
2576
+ agents: { ...hashes.agents },
2577
+ hooks: { ...hashes.hooks }
2578
+ };
2579
+ for (const item of items) {
2580
+ if (item.action !== "keep-foreign") continue;
2581
+ const bucket = `${item.type}s`;
2582
+ delete stripped[bucket][item.name];
2583
+ }
2584
+ return stripped;
2585
+ }
2586
+ function computeUpdatePlan(manifestHashes, installedHashes, sourceHashes, owned, exemptAgentDivisions = /* @__PURE__ */ new Set()) {
2534
2587
  const items = [];
2535
2588
  for (const contentType of ["skills", "rules", "agents", "hooks"]) {
2536
2589
  const itemType = contentType === "skills" ? "skill" : contentType === "rules" ? "rule" : contentType === "agents" ? "agent" : "hook";
@@ -2543,10 +2596,11 @@ function computeUpdatePlan(manifestHashes, installedHashes, sourceHashes) {
2543
2596
  ...Object.keys(source)
2544
2597
  ]);
2545
2598
  for (const key of allKeys) {
2599
+ if (contentType === "agents" && exemptAgentDivisions.has(key)) continue;
2546
2600
  const mHash = manifest[key] ?? null;
2547
2601
  const iHash = installed[key] ?? null;
2548
2602
  const sHash = source[key] ?? null;
2549
- const action = resolveAction(mHash, iHash, sHash);
2603
+ const action = resolveAction(mHash, iHash, sHash, owned(contentType, key));
2550
2604
  if (action) {
2551
2605
  items.push({ type: itemType, name: key, action });
2552
2606
  }
@@ -2554,12 +2608,13 @@ function computeUpdatePlan(manifestHashes, installedHashes, sourceHashes) {
2554
2608
  }
2555
2609
  return items;
2556
2610
  }
2557
- function resolveAction(manifestHash, installedHash, sourceHash) {
2611
+ function resolveAction(manifestHash, installedHash, sourceHash, owned) {
2558
2612
  if (!sourceHash && !installedHash) return null;
2559
2613
  if (sourceHash && !installedHash) return "add";
2560
- if (!sourceHash && installedHash) return "remove";
2614
+ if (!sourceHash && installedHash) return owned ? "remove" : "keep-foreign";
2561
2615
  if (!manifestHash) {
2562
2616
  if (installedHash === sourceHash) return "skip-unchanged";
2617
+ if (!owned) return "conflict";
2563
2618
  return "update";
2564
2619
  }
2565
2620
  if (manifestHash === installedHash && installedHash === sourceHash) return "skip-unchanged";
@@ -2575,6 +2630,7 @@ function printUpdateSummary(items, isDryRun) {
2575
2630
  const conflicts = items.filter((i) => i.action === "conflict");
2576
2631
  const customized = items.filter((i) => i.action === "skip-customized");
2577
2632
  const removed = items.filter((i) => i.action === "remove");
2633
+ const foreign = items.filter((i) => i.action === "keep-foreign");
2578
2634
  if (updates.length > 0) {
2579
2635
  console.log(`
2580
2636
  ${prefix}${chalk7.cyan("Will update:")}`);
@@ -2591,14 +2647,23 @@ ${prefix}${chalk7.cyan("Will add:")}`);
2591
2647
  }
2592
2648
  if (removed.length > 0) {
2593
2649
  console.log(`
2594
- ${prefix}${chalk7.cyan("Will remove:")}`);
2650
+ ${prefix}${chalk7.cyan("Will remove")} ${chalk7.dim(`(moved to .claude/${TRASH_DIR}/)`)}:`);
2595
2651
  for (const item of removed) {
2596
2652
  console.log(` ${chalk7.red("-")} ${item.type}: ${item.name}`);
2597
2653
  }
2598
2654
  }
2599
- if (conflicts.length > 0) {
2655
+ if (foreign.length > 0) {
2600
2656
  console.log(`
2601
- ${prefix}${chalk7.yellow("Conflicts (will backup + update):")}`);
2657
+ ${chalk7.cyan("Left alone")} ${chalk7.dim("(not installed by Arcane)")}:`);
2658
+ for (const item of foreign) {
2659
+ console.log(` ${chalk7.dim("=")} ${item.type}: ${item.name}`);
2660
+ }
2661
+ }
2662
+ if (conflicts.length > 0) {
2663
+ console.log(
2664
+ `
2665
+ ${prefix}${chalk7.yellow("Conflicts (will update)")} ${chalk7.dim(`(old copy to .claude/${TRASH_DIR}/)`)}:`
2666
+ );
2602
2667
  for (const item of conflicts) {
2603
2668
  console.log(` ${chalk7.yellow("!")} ${item.type}: ${item.name}`);
2604
2669
  }
@@ -2613,6 +2678,7 @@ ${prefix}${chalk7.dim("Skipped (locally customized):")}`);
2613
2678
  }
2614
2679
  function applyUpdates(items, root, claudeDir, merged, force) {
2615
2680
  for (const item of items) {
2681
+ if (item.action === "keep-foreign") continue;
2616
2682
  if (item.action === "skip-unchanged" || item.action === "skip-customized") {
2617
2683
  if (item.action === "skip-customized" && force) {
2618
2684
  applyItem(item, root, claudeDir);
@@ -2620,7 +2686,7 @@ function applyUpdates(items, root, claudeDir, merged, force) {
2620
2686
  continue;
2621
2687
  }
2622
2688
  if (item.action === "conflict") {
2623
- backupItem(item, claudeDir);
2689
+ quarantineItem(item, claudeDir);
2624
2690
  applyItem(item, root, claudeDir);
2625
2691
  continue;
2626
2692
  }
@@ -2629,7 +2695,7 @@ function applyUpdates(items, root, claudeDir, merged, force) {
2629
2695
  continue;
2630
2696
  }
2631
2697
  if (item.action === "remove") {
2632
- removeItem(item, claudeDir);
2698
+ quarantineItem(item, claudeDir);
2633
2699
  }
2634
2700
  }
2635
2701
  }
@@ -2650,6 +2716,29 @@ function syncUnhashedFiles(root, claudeDir, merged, dryRun) {
2650
2716
  label: `output-styles/${style}.md`
2651
2717
  });
2652
2718
  }
2719
+ for (const raw of merged.agents) {
2720
+ const { division, agent } = parseAgentEntry(raw);
2721
+ if (!agent) continue;
2722
+ targets.push({
2723
+ src: path17.join(root, "agents", division, `${agent}.md`),
2724
+ dst: path17.join(claudeDir, "agents", division, `${agent}.md`),
2725
+ label: `agents/${division}/${agent}.md`
2726
+ });
2727
+ }
2728
+ if (merged.rules.gamedev.length > 0) {
2729
+ const templatesSrc = path17.join(root, "templates", "gamedev");
2730
+ if (fs14.existsSync(templatesSrc)) {
2731
+ for (const rel of fs14.readdirSync(templatesSrc, { recursive: true })) {
2732
+ const src = path17.join(templatesSrc, rel);
2733
+ if (!fs14.statSync(src).isFile()) continue;
2734
+ targets.push({
2735
+ src,
2736
+ dst: path17.join(claudeDir, "skills", "_templates", rel),
2737
+ label: `skills/_templates/${String(rel).split(path17.sep).join("/")}`
2738
+ });
2739
+ }
2740
+ }
2741
+ }
2653
2742
  const changed = [];
2654
2743
  for (const t of targets) {
2655
2744
  if (!fs14.existsSync(t.src)) continue;
@@ -2702,20 +2791,15 @@ function applyItem(item, root, claudeDir) {
2702
2791
  fs14.copyFileSync(src, dst);
2703
2792
  }
2704
2793
  }
2705
- function backupItem(item, claudeDir) {
2794
+ function quarantineItem(item, claudeDir) {
2706
2795
  const installed = getInstalledPath(item, claudeDir);
2707
2796
  if (!fs14.existsSync(installed)) return;
2708
- const backupPath = installed + ".bak";
2709
- if (fs14.existsSync(backupPath)) {
2710
- fs14.rmSync(backupPath, { recursive: true, force: true });
2711
- }
2712
- fs14.renameSync(installed, backupPath);
2713
- }
2714
- function removeItem(item, claudeDir) {
2715
- const installed = getInstalledPath(item, claudeDir);
2716
- if (fs14.existsSync(installed)) {
2717
- fs14.rmSync(installed, { recursive: true, force: true });
2797
+ const quarantine = path17.join(claudeDir, TRASH_DIR, `${item.type}s`, item.name);
2798
+ ensureDir(path17.dirname(quarantine));
2799
+ if (fs14.existsSync(quarantine)) {
2800
+ fs14.rmSync(quarantine, { recursive: true, force: true });
2718
2801
  }
2802
+ fs14.renameSync(installed, quarantine);
2719
2803
  }
2720
2804
  function getSourcePath(item, root) {
2721
2805
  switch (item.type) {
@@ -146,10 +146,26 @@ Shows installed profiles, skill count, rules, agents, installation date, and wor
146
146
  ```bash
147
147
  npx claude-code-arcane update
148
148
 
149
+ # Only this repo, instead of every registered installation
150
+ npx claude-code-arcane update --here
151
+
152
+ # Preview
153
+ npx claude-code-arcane update --dry-run
154
+
149
155
  # Quiet mode (for hooks — fails silently)
150
156
  npx claude-code-arcane update --quiet
151
157
  ```
152
158
 
159
+ **What it will and will not touch.** Arcane only updates or removes content its own
160
+ manifest records having installed. A skill or agent you wrote by hand in `.claude/`
161
+ is reported as `Left alone (not installed by Arcane)` and never modified — including
162
+ under `--force`, which only exists to overwrite *Arcane's* files that you edited
163
+ locally. Files you edited that Arcane does own are skipped as `customized`.
164
+
165
+ When a skill really does leave the profile, it is moved to `.claude/.arcane-trash/`
166
+ rather than deleted, so a wrong call is recoverable. The trash sits outside
167
+ `.claude/skills/`, so a removed skill stops loading immediately.
168
+
153
169
  ### `npx claude-code-arcane clean` — Remove Arcane from a project
154
170
 
155
171
  ```bash
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-code-arcane",
3
- "version": "2.9.1",
3
+ "version": "2.9.2",
4
4
  "description": "Skills, agents, hooks and rules for Claude Code — installable via npx claude-code-arcane",
5
5
  "type": "module",
6
6
  "bin": {