claude-code-arcane 2.9.1 → 2.9.3

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,17 @@
1
+ ## [2.9.3](https://github.com/SebastianLuser/claude-code-arcane/compare/v2.9.2...v2.9.3) (2026-09-07)
2
+
3
+
4
+ ### Bug Fixes
5
+
6
+ * **update:** resolve the content version once per run, never fabricate it ([ef78ecf](https://github.com/SebastianLuser/claude-code-arcane/commit/ef78ecf6c5ab17f1a51ea6ae27ef9fa161594798))
7
+
8
+ ## [2.9.2](https://github.com/SebastianLuser/claude-code-arcane/compare/v2.9.1...v2.9.2) (2026-09-07)
9
+
10
+
11
+ ### Bug Fixes
12
+
13
+ * **update:** only remove content the manifest owns ([d6eec05](https://github.com/SebastianLuser/claude-code-arcane/commit/d6eec05475bb491c8853078d9920c7dc4544e7b3))
14
+
1
15
  ## [2.9.1](https://github.com/SebastianLuser/claude-code-arcane/compare/v2.9.0...v2.9.1) (2026-09-04)
2
16
 
3
17
 
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");
@@ -1438,22 +1439,18 @@ var GitHubContentSource = class {
1438
1439
  fs9.rmSync(tmpDir, { recursive: true, force: true });
1439
1440
  }
1440
1441
  }
1441
- async isAvailable() {
1442
- try {
1443
- const response = await fetch(
1444
- `https://api.github.com/repos/${this.owner}/${this.repo}/commits/${this.branch}`,
1445
- {
1446
- method: "HEAD",
1447
- headers: { "User-Agent": "arcane-cli" },
1448
- signal: AbortSignal.timeout(5e3)
1449
- }
1450
- );
1451
- return response.ok;
1452
- } catch {
1453
- return false;
1454
- }
1455
- }
1456
- async getVersion() {
1442
+ /**
1443
+ * The head commit of the branch, or null when GitHub cannot answer.
1444
+ *
1445
+ * One request, memoized, shared by isAvailable() and getVersion(). It used to be
1446
+ * two - a HEAD to answer "is it reachable?" and a GET to read the sha - against an
1447
+ * endpoint that allows 60 requests an hour unauthenticated. That doubled the cost of
1448
+ * every resolution and, worse, opened a window between the two calls: the HEAD spent
1449
+ * the last request of the hour, the GET came back 403, and the caller had already
1450
+ * committed to the github source. That window is where the fabricated versions came
1451
+ * from.
1452
+ */
1453
+ async resolveVersion() {
1457
1454
  if (this.resolvedVersion) return this.resolvedVersion;
1458
1455
  try {
1459
1456
  const response = await fetch(
@@ -1466,17 +1463,36 @@ var GitHubContentSource = class {
1466
1463
  signal: AbortSignal.timeout(1e4)
1467
1464
  }
1468
1465
  );
1469
- if (!response.ok) {
1470
- throw new Error(`GitHub API returned ${response.status}`);
1471
- }
1466
+ if (!response.ok) return null;
1472
1467
  const data = await response.json();
1473
1468
  this.resolvedVersion = data.sha.substring(0, 12);
1474
1469
  return this.resolvedVersion;
1475
1470
  } catch {
1476
- this.resolvedVersion = `github-${Date.now()}`;
1477
- return this.resolvedVersion;
1471
+ return null;
1478
1472
  }
1479
1473
  }
1474
+ async isAvailable() {
1475
+ return await this.resolveVersion() !== null;
1476
+ }
1477
+ /**
1478
+ * Throws when the version cannot be read, rather than inventing one.
1479
+ *
1480
+ * It used to answer `github-${Date.now()}` on any failure, and that string went on to
1481
+ * be an identity: it stamped `source_version` in the manifest, named a cache directory,
1482
+ * and made the install permanently un-diffable against any real version - it never
1483
+ * matched again, so every later run reported an update and no run ever settled. A
1484
+ * transient 403 became durable state on disk. Callers that cannot proceed without a
1485
+ * version now fall back to the cache, which knows a real one.
1486
+ */
1487
+ async getVersion() {
1488
+ const version = await this.resolveVersion();
1489
+ if (!version) {
1490
+ throw new Error(
1491
+ "Could not read the content version from GitHub (offline, or the unauthenticated API rate limit is exhausted - it resets hourly)."
1492
+ );
1493
+ }
1494
+ return version;
1495
+ }
1480
1496
  async downloadAndExtract(destDir) {
1481
1497
  const tarballUrl = `https://api.github.com/repos/${this.owner}/${this.repo}/tarball/${this.branch}`;
1482
1498
  const response = await fetch(tarballUrl, {
@@ -1547,7 +1563,9 @@ async function resolveContentSource(opts = {}) {
1547
1563
  return github;
1548
1564
  }
1549
1565
  if (preference === "github") {
1550
- throw new Error("GitHub source requested but not available. Check your internet connection.");
1566
+ throw new Error(
1567
+ "GitHub source requested but not available. Check your internet connection, or wait for the unauthenticated API rate limit to reset (hourly)."
1568
+ );
1551
1569
  }
1552
1570
  const cached = findLatestCache();
1553
1571
  if (cached) {
@@ -2315,6 +2333,7 @@ function safeVersion() {
2315
2333
  }
2316
2334
 
2317
2335
  // src/commands/update.ts
2336
+ var TRASH_DIR = ".arcane-trash";
2318
2337
  async function updateCommand(opts) {
2319
2338
  if (opts.here) {
2320
2339
  await updateTarget(process.cwd(), opts);
@@ -2355,6 +2374,10 @@ async function updateCommand(opts) {
2355
2374
  Updating ${targets.length} installation${targets.length === 1 ? "" : "s"}...`)
2356
2375
  );
2357
2376
  }
2377
+ const sharedSource = await resolveContentSource({
2378
+ source: opts.source ?? "auto",
2379
+ quiet: opts.quiet
2380
+ });
2358
2381
  const results = [];
2359
2382
  for (const target of targets) {
2360
2383
  if (!opts.quiet) {
@@ -2362,7 +2385,7 @@ Updating ${targets.length} installation${targets.length === 1 ? "" : "s"}...`)
2362
2385
  \u2022 ${target}`));
2363
2386
  }
2364
2387
  try {
2365
- results.push(await updateTarget(target, opts));
2388
+ results.push(await updateTarget(target, opts, sharedSource));
2366
2389
  } catch (err) {
2367
2390
  if (!opts.quiet) {
2368
2391
  console.log(chalk7.red(` Update failed: ${err.message}`));
@@ -2380,7 +2403,7 @@ Updating ${targets.length} installation${targets.length === 1 ? "" : "s"}...`)
2380
2403
  printGeneralSummary(results, opts.dryRun ?? false);
2381
2404
  }
2382
2405
  }
2383
- async function updateTarget(target, opts) {
2406
+ async function updateTarget(target, opts, sharedSource) {
2384
2407
  const manifest = readManifest(target);
2385
2408
  if (!manifest) {
2386
2409
  if (!opts.quiet) {
@@ -2388,7 +2411,7 @@ async function updateTarget(target, opts) {
2388
2411
  }
2389
2412
  return { target, status: "no-manifest", updated: 0, skipped: 0, removed: 0 };
2390
2413
  }
2391
- const contentSource = await resolveContentSource({
2414
+ const contentSource = sharedSource ?? await resolveContentSource({
2392
2415
  source: opts.source ?? "auto",
2393
2416
  quiet: opts.quiet
2394
2417
  });
@@ -2444,7 +2467,9 @@ Update available: ${chalk7.red(installedVersion)} -> ${chalk7.green(currentVersi
2444
2467
  const items = computeUpdatePlan(
2445
2468
  manifest.content_hashes ?? null,
2446
2469
  installedHashes,
2447
- sourceHashes
2470
+ sourceHashes,
2471
+ makeOwnershipCheck(manifest),
2472
+ granularAgentDivisions(merged.agents)
2448
2473
  );
2449
2474
  const updates = items.filter((i) => i.action === "update" || i.action === "add" || i.action === "conflict");
2450
2475
  const skipped = items.filter((i) => i.action === "skip-customized" || i.action === "skip-unchanged");
@@ -2459,7 +2484,7 @@ Update available: ${chalk7.red(installedVersion)} -> ${chalk7.green(currentVersi
2459
2484
  arcane_version: currentVersion,
2460
2485
  source_version: currentVersion,
2461
2486
  updated_at: (/* @__PURE__ */ new Date()).toISOString().replace(/\.\d{3}Z$/, "Z"),
2462
- content_hashes: installedHashes,
2487
+ content_hashes: stripForeign(installedHashes, items),
2463
2488
  installed_skills: merged.skills,
2464
2489
  installed_rules: allRules,
2465
2490
  installed_agents: merged.agents,
@@ -2500,7 +2525,7 @@ Refreshed ${unhashed.length} file(s); no other changes to apply.`) : chalk7.gree
2500
2525
  arcane_version: currentVersion,
2501
2526
  source_version: currentVersion,
2502
2527
  updated_at: (/* @__PURE__ */ new Date()).toISOString().replace(/\.\d{3}Z$/, "Z"),
2503
- content_hashes: newHashes,
2528
+ content_hashes: stripForeign(newHashes, items),
2504
2529
  installed_skills: merged.skills,
2505
2530
  installed_rules: allRules,
2506
2531
  installed_agents: merged.agents,
@@ -2530,7 +2555,56 @@ function printGeneralSummary(results, isDryRun) {
2530
2555
  ${prefix}Done. ${results.length} installation${results.length === 1 ? "" : "s"} processed:`));
2531
2556
  console.log(` ${chalk7.green(updated)} ${isDryRun ? "would change" : "updated"}, ${chalk7.dim(unchanged + " unchanged")}`);
2532
2557
  }
2533
- function computeUpdatePlan(manifestHashes, installedHashes, sourceHashes) {
2558
+ function makeOwnershipCheck(manifest) {
2559
+ const hashes = manifest.content_hashes;
2560
+ if (hashes) {
2561
+ return (type, name) => name in (hashes[type] ?? {});
2562
+ }
2563
+ const skills = new Set(manifest.installed_skills ?? []);
2564
+ const rules = new Set(
2565
+ (manifest.installed_rules ?? []).map((r) => r.endsWith(".md") ? r : `${r}.md`)
2566
+ );
2567
+ const agents = new Set(
2568
+ (manifest.installed_agents ?? []).map((a) => parseAgentEntry(a).division)
2569
+ );
2570
+ return (type, name) => {
2571
+ switch (type) {
2572
+ case "skills":
2573
+ return skills.has(name);
2574
+ case "rules":
2575
+ return rules.has(name);
2576
+ case "agents":
2577
+ return agents.has(name);
2578
+ case "hooks":
2579
+ return true;
2580
+ }
2581
+ };
2582
+ }
2583
+ function granularAgentDivisions(entries) {
2584
+ const whole = /* @__PURE__ */ new Set();
2585
+ const partial = /* @__PURE__ */ new Set();
2586
+ for (const raw of entries) {
2587
+ const { division, agent } = parseAgentEntry(raw);
2588
+ (agent ? partial : whole).add(division);
2589
+ }
2590
+ for (const division of whole) partial.delete(division);
2591
+ return partial;
2592
+ }
2593
+ function stripForeign(hashes, items) {
2594
+ const stripped = {
2595
+ skills: { ...hashes.skills },
2596
+ rules: { ...hashes.rules },
2597
+ agents: { ...hashes.agents },
2598
+ hooks: { ...hashes.hooks }
2599
+ };
2600
+ for (const item of items) {
2601
+ if (item.action !== "keep-foreign") continue;
2602
+ const bucket = `${item.type}s`;
2603
+ delete stripped[bucket][item.name];
2604
+ }
2605
+ return stripped;
2606
+ }
2607
+ function computeUpdatePlan(manifestHashes, installedHashes, sourceHashes, owned, exemptAgentDivisions = /* @__PURE__ */ new Set()) {
2534
2608
  const items = [];
2535
2609
  for (const contentType of ["skills", "rules", "agents", "hooks"]) {
2536
2610
  const itemType = contentType === "skills" ? "skill" : contentType === "rules" ? "rule" : contentType === "agents" ? "agent" : "hook";
@@ -2543,10 +2617,11 @@ function computeUpdatePlan(manifestHashes, installedHashes, sourceHashes) {
2543
2617
  ...Object.keys(source)
2544
2618
  ]);
2545
2619
  for (const key of allKeys) {
2620
+ if (contentType === "agents" && exemptAgentDivisions.has(key)) continue;
2546
2621
  const mHash = manifest[key] ?? null;
2547
2622
  const iHash = installed[key] ?? null;
2548
2623
  const sHash = source[key] ?? null;
2549
- const action = resolveAction(mHash, iHash, sHash);
2624
+ const action = resolveAction(mHash, iHash, sHash, owned(contentType, key));
2550
2625
  if (action) {
2551
2626
  items.push({ type: itemType, name: key, action });
2552
2627
  }
@@ -2554,12 +2629,13 @@ function computeUpdatePlan(manifestHashes, installedHashes, sourceHashes) {
2554
2629
  }
2555
2630
  return items;
2556
2631
  }
2557
- function resolveAction(manifestHash, installedHash, sourceHash) {
2632
+ function resolveAction(manifestHash, installedHash, sourceHash, owned) {
2558
2633
  if (!sourceHash && !installedHash) return null;
2559
2634
  if (sourceHash && !installedHash) return "add";
2560
- if (!sourceHash && installedHash) return "remove";
2635
+ if (!sourceHash && installedHash) return owned ? "remove" : "keep-foreign";
2561
2636
  if (!manifestHash) {
2562
2637
  if (installedHash === sourceHash) return "skip-unchanged";
2638
+ if (!owned) return "conflict";
2563
2639
  return "update";
2564
2640
  }
2565
2641
  if (manifestHash === installedHash && installedHash === sourceHash) return "skip-unchanged";
@@ -2575,6 +2651,7 @@ function printUpdateSummary(items, isDryRun) {
2575
2651
  const conflicts = items.filter((i) => i.action === "conflict");
2576
2652
  const customized = items.filter((i) => i.action === "skip-customized");
2577
2653
  const removed = items.filter((i) => i.action === "remove");
2654
+ const foreign = items.filter((i) => i.action === "keep-foreign");
2578
2655
  if (updates.length > 0) {
2579
2656
  console.log(`
2580
2657
  ${prefix}${chalk7.cyan("Will update:")}`);
@@ -2591,14 +2668,23 @@ ${prefix}${chalk7.cyan("Will add:")}`);
2591
2668
  }
2592
2669
  if (removed.length > 0) {
2593
2670
  console.log(`
2594
- ${prefix}${chalk7.cyan("Will remove:")}`);
2671
+ ${prefix}${chalk7.cyan("Will remove")} ${chalk7.dim(`(moved to .claude/${TRASH_DIR}/)`)}:`);
2595
2672
  for (const item of removed) {
2596
2673
  console.log(` ${chalk7.red("-")} ${item.type}: ${item.name}`);
2597
2674
  }
2598
2675
  }
2599
- if (conflicts.length > 0) {
2676
+ if (foreign.length > 0) {
2600
2677
  console.log(`
2601
- ${prefix}${chalk7.yellow("Conflicts (will backup + update):")}`);
2678
+ ${chalk7.cyan("Left alone")} ${chalk7.dim("(not installed by Arcane)")}:`);
2679
+ for (const item of foreign) {
2680
+ console.log(` ${chalk7.dim("=")} ${item.type}: ${item.name}`);
2681
+ }
2682
+ }
2683
+ if (conflicts.length > 0) {
2684
+ console.log(
2685
+ `
2686
+ ${prefix}${chalk7.yellow("Conflicts (will update)")} ${chalk7.dim(`(old copy to .claude/${TRASH_DIR}/)`)}:`
2687
+ );
2602
2688
  for (const item of conflicts) {
2603
2689
  console.log(` ${chalk7.yellow("!")} ${item.type}: ${item.name}`);
2604
2690
  }
@@ -2613,6 +2699,7 @@ ${prefix}${chalk7.dim("Skipped (locally customized):")}`);
2613
2699
  }
2614
2700
  function applyUpdates(items, root, claudeDir, merged, force) {
2615
2701
  for (const item of items) {
2702
+ if (item.action === "keep-foreign") continue;
2616
2703
  if (item.action === "skip-unchanged" || item.action === "skip-customized") {
2617
2704
  if (item.action === "skip-customized" && force) {
2618
2705
  applyItem(item, root, claudeDir);
@@ -2620,7 +2707,7 @@ function applyUpdates(items, root, claudeDir, merged, force) {
2620
2707
  continue;
2621
2708
  }
2622
2709
  if (item.action === "conflict") {
2623
- backupItem(item, claudeDir);
2710
+ quarantineItem(item, claudeDir);
2624
2711
  applyItem(item, root, claudeDir);
2625
2712
  continue;
2626
2713
  }
@@ -2629,7 +2716,7 @@ function applyUpdates(items, root, claudeDir, merged, force) {
2629
2716
  continue;
2630
2717
  }
2631
2718
  if (item.action === "remove") {
2632
- removeItem(item, claudeDir);
2719
+ quarantineItem(item, claudeDir);
2633
2720
  }
2634
2721
  }
2635
2722
  }
@@ -2650,6 +2737,29 @@ function syncUnhashedFiles(root, claudeDir, merged, dryRun) {
2650
2737
  label: `output-styles/${style}.md`
2651
2738
  });
2652
2739
  }
2740
+ for (const raw of merged.agents) {
2741
+ const { division, agent } = parseAgentEntry(raw);
2742
+ if (!agent) continue;
2743
+ targets.push({
2744
+ src: path17.join(root, "agents", division, `${agent}.md`),
2745
+ dst: path17.join(claudeDir, "agents", division, `${agent}.md`),
2746
+ label: `agents/${division}/${agent}.md`
2747
+ });
2748
+ }
2749
+ if (merged.rules.gamedev.length > 0) {
2750
+ const templatesSrc = path17.join(root, "templates", "gamedev");
2751
+ if (fs14.existsSync(templatesSrc)) {
2752
+ for (const rel of fs14.readdirSync(templatesSrc, { recursive: true })) {
2753
+ const src = path17.join(templatesSrc, rel);
2754
+ if (!fs14.statSync(src).isFile()) continue;
2755
+ targets.push({
2756
+ src,
2757
+ dst: path17.join(claudeDir, "skills", "_templates", rel),
2758
+ label: `skills/_templates/${String(rel).split(path17.sep).join("/")}`
2759
+ });
2760
+ }
2761
+ }
2762
+ }
2653
2763
  const changed = [];
2654
2764
  for (const t of targets) {
2655
2765
  if (!fs14.existsSync(t.src)) continue;
@@ -2702,20 +2812,15 @@ function applyItem(item, root, claudeDir) {
2702
2812
  fs14.copyFileSync(src, dst);
2703
2813
  }
2704
2814
  }
2705
- function backupItem(item, claudeDir) {
2815
+ function quarantineItem(item, claudeDir) {
2706
2816
  const installed = getInstalledPath(item, claudeDir);
2707
2817
  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 });
2818
+ const quarantine = path17.join(claudeDir, TRASH_DIR, `${item.type}s`, item.name);
2819
+ ensureDir(path17.dirname(quarantine));
2820
+ if (fs14.existsSync(quarantine)) {
2821
+ fs14.rmSync(quarantine, { recursive: true, force: true });
2718
2822
  }
2823
+ fs14.renameSync(installed, quarantine);
2719
2824
  }
2720
2825
  function getSourcePath(item, root) {
2721
2826
  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.3",
4
4
  "description": "Skills, agents, hooks and rules for Claude Code — installable via npx claude-code-arcane",
5
5
  "type": "module",
6
6
  "bin": {