claude-code-arcane 2.9.2 → 2.9.4

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.
Files changed (3) hide show
  1. package/CHANGELOG.md +15 -0
  2. package/dist/cli.js +129 -74
  3. package/package.json +1 -1
package/CHANGELOG.md CHANGED
@@ -1,3 +1,18 @@
1
+ ## [2.9.4](https://github.com/SebastianLuser/claude-code-arcane/compare/v2.9.3...v2.9.4) (2026-09-07)
2
+
3
+
4
+ ### Bug Fixes
5
+
6
+ * **add:** keep content_hashes instead of rebuilding the manifest ([1ecce98](https://github.com/SebastianLuser/claude-code-arcane/commit/1ecce98ebcb8a480dd4b748f41606badec9fb0fd))
7
+ * **cache:** resolve the arcane home in one place, honouring ARCANE_HOME ([4a7a24d](https://github.com/SebastianLuser/claude-code-arcane/commit/4a7a24d6a28df1abfe94be75f590c79d8428dca5))
8
+
9
+ ## [2.9.3](https://github.com/SebastianLuser/claude-code-arcane/compare/v2.9.2...v2.9.3) (2026-09-07)
10
+
11
+
12
+ ### Bug Fixes
13
+
14
+ * **update:** resolve the content version once per run, never fabricate it ([ef78ecf](https://github.com/SebastianLuser/claude-code-arcane/commit/ef78ecf6c5ab17f1a51ea6ae27ef9fa161594798))
15
+
1
16
  ## [2.9.2](https://github.com/SebastianLuser/claude-code-arcane/compare/v2.9.1...v2.9.2) (2026-09-07)
2
17
 
3
18
 
package/dist/cli.js CHANGED
@@ -391,6 +391,10 @@ import path8 from "path";
391
391
  import { fileURLToPath } from "url";
392
392
  import path3 from "path";
393
393
  import fs3 from "fs";
394
+ import os from "os";
395
+ function arcaneHome() {
396
+ return process.env.ARCANE_HOME ?? path3.join(os.homedir(), ".arcane");
397
+ }
394
398
  function getPackageRoot() {
395
399
  const thisFile = fileURLToPath(import.meta.url);
396
400
  return path3.resolve(path3.dirname(thisFile), "..");
@@ -1310,15 +1314,18 @@ import os2 from "os";
1310
1314
  // src/cache.ts
1311
1315
  import fs8 from "fs";
1312
1316
  import path9 from "path";
1313
- import os from "os";
1314
- var ARCANE_DIR = path9.join(os.homedir(), ".arcane");
1315
- var CACHE_DIR = path9.join(ARCANE_DIR, "cache");
1317
+ function cacheDir() {
1318
+ return path9.join(arcaneHome(), "cache");
1319
+ }
1316
1320
  function getCachePath(version) {
1317
- return path9.join(CACHE_DIR, version);
1321
+ return path9.join(cacheDir(), version);
1322
+ }
1323
+ function isUsableContentRoot(dir) {
1324
+ return fs8.existsSync(path9.join(dir, "profiles")) && fs8.existsSync(path9.join(dir, "skills"));
1318
1325
  }
1319
1326
  function isCached(version) {
1320
1327
  const versionDir = getCachePath(version);
1321
- return fs8.existsSync(versionDir) && fs8.existsSync(path9.join(versionDir, ".cache-meta.json"));
1328
+ return fs8.existsSync(versionDir) && fs8.existsSync(path9.join(versionDir, ".cache-meta.json")) && isUsableContentRoot(versionDir);
1322
1329
  }
1323
1330
  function getCachedContentRoot(version) {
1324
1331
  if (!isCached(version)) return null;
@@ -1347,9 +1354,9 @@ function storeInCache(version, contentDir) {
1347
1354
  return versionDir;
1348
1355
  }
1349
1356
  function pruneCache(keepCount = 3) {
1350
- if (!fs8.existsSync(CACHE_DIR)) return;
1351
- const entries = fs8.readdirSync(CACHE_DIR, { withFileTypes: true }).filter((e) => e.isDirectory()).map((e) => {
1352
- const metaPath = path9.join(CACHE_DIR, e.name, ".cache-meta.json");
1357
+ if (!fs8.existsSync(cacheDir())) return;
1358
+ const entries = fs8.readdirSync(cacheDir(), { withFileTypes: true }).filter((e) => e.isDirectory()).map((e) => {
1359
+ const metaPath = path9.join(cacheDir(), e.name, ".cache-meta.json");
1353
1360
  let cachedAt = 0;
1354
1361
  if (fs8.existsSync(metaPath)) {
1355
1362
  try {
@@ -1362,9 +1369,24 @@ function pruneCache(keepCount = 3) {
1362
1369
  return { name: e.name, cachedAt };
1363
1370
  }).sort((a, b) => b.cachedAt - a.cachedAt);
1364
1371
  for (const entry of entries.slice(keepCount)) {
1365
- fs8.rmSync(path9.join(CACHE_DIR, entry.name), { recursive: true, force: true });
1372
+ fs8.rmSync(path9.join(cacheDir(), entry.name), { recursive: true, force: true });
1366
1373
  }
1367
1374
  }
1375
+ function listCachedVersions() {
1376
+ if (!fs8.existsSync(cacheDir())) return [];
1377
+ return fs8.readdirSync(cacheDir(), { withFileTypes: true }).filter((e) => e.isDirectory()).map((e) => {
1378
+ const metaPath = path9.join(cacheDir(), e.name, ".cache-meta.json");
1379
+ let cachedAt = "unknown";
1380
+ if (fs8.existsSync(metaPath)) {
1381
+ try {
1382
+ const meta = JSON.parse(fs8.readFileSync(metaPath, "utf-8"));
1383
+ cachedAt = meta.cached_at;
1384
+ } catch {
1385
+ }
1386
+ }
1387
+ return { version: e.name, cachedAt };
1388
+ });
1389
+ }
1368
1390
  function copyDirRecursive(src, dest) {
1369
1391
  fs8.mkdirSync(dest, { recursive: true });
1370
1392
  for (const entry of fs8.readdirSync(src, { withFileTypes: true })) {
@@ -1439,22 +1461,18 @@ var GitHubContentSource = class {
1439
1461
  fs9.rmSync(tmpDir, { recursive: true, force: true });
1440
1462
  }
1441
1463
  }
1442
- async isAvailable() {
1443
- try {
1444
- const response = await fetch(
1445
- `https://api.github.com/repos/${this.owner}/${this.repo}/commits/${this.branch}`,
1446
- {
1447
- method: "HEAD",
1448
- headers: { "User-Agent": "arcane-cli" },
1449
- signal: AbortSignal.timeout(5e3)
1450
- }
1451
- );
1452
- return response.ok;
1453
- } catch {
1454
- return false;
1455
- }
1456
- }
1457
- async getVersion() {
1464
+ /**
1465
+ * The head commit of the branch, or null when GitHub cannot answer.
1466
+ *
1467
+ * One request, memoized, shared by isAvailable() and getVersion(). It used to be
1468
+ * two - a HEAD to answer "is it reachable?" and a GET to read the sha - against an
1469
+ * endpoint that allows 60 requests an hour unauthenticated. That doubled the cost of
1470
+ * every resolution and, worse, opened a window between the two calls: the HEAD spent
1471
+ * the last request of the hour, the GET came back 403, and the caller had already
1472
+ * committed to the github source. That window is where the fabricated versions came
1473
+ * from.
1474
+ */
1475
+ async resolveVersion() {
1458
1476
  if (this.resolvedVersion) return this.resolvedVersion;
1459
1477
  try {
1460
1478
  const response = await fetch(
@@ -1467,17 +1485,36 @@ var GitHubContentSource = class {
1467
1485
  signal: AbortSignal.timeout(1e4)
1468
1486
  }
1469
1487
  );
1470
- if (!response.ok) {
1471
- throw new Error(`GitHub API returned ${response.status}`);
1472
- }
1488
+ if (!response.ok) return null;
1473
1489
  const data = await response.json();
1474
1490
  this.resolvedVersion = data.sha.substring(0, 12);
1475
1491
  return this.resolvedVersion;
1476
1492
  } catch {
1477
- this.resolvedVersion = `github-${Date.now()}`;
1478
- return this.resolvedVersion;
1493
+ return null;
1479
1494
  }
1480
1495
  }
1496
+ async isAvailable() {
1497
+ return await this.resolveVersion() !== null;
1498
+ }
1499
+ /**
1500
+ * Throws when the version cannot be read, rather than inventing one.
1501
+ *
1502
+ * It used to answer `github-${Date.now()}` on any failure, and that string went on to
1503
+ * be an identity: it stamped `source_version` in the manifest, named a cache directory,
1504
+ * and made the install permanently un-diffable against any real version - it never
1505
+ * matched again, so every later run reported an update and no run ever settled. A
1506
+ * transient 403 became durable state on disk. Callers that cannot proceed without a
1507
+ * version now fall back to the cache, which knows a real one.
1508
+ */
1509
+ async getVersion() {
1510
+ const version = await this.resolveVersion();
1511
+ if (!version) {
1512
+ throw new Error(
1513
+ "Could not read the content version from GitHub (offline, or the unauthenticated API rate limit is exhausted - it resets hourly)."
1514
+ );
1515
+ }
1516
+ return version;
1517
+ }
1481
1518
  async downloadAndExtract(destDir) {
1482
1519
  const tarballUrl = `https://api.github.com/repos/${this.owner}/${this.repo}/tarball/${this.branch}`;
1483
1520
  const response = await fetch(tarballUrl, {
@@ -1548,7 +1585,9 @@ async function resolveContentSource(opts = {}) {
1548
1585
  return github;
1549
1586
  }
1550
1587
  if (preference === "github") {
1551
- throw new Error("GitHub source requested but not available. Check your internet connection.");
1588
+ throw new Error(
1589
+ "GitHub source requested but not available. Check your internet connection, or wait for the unauthenticated API rate limit to reset (hourly)."
1590
+ );
1552
1591
  }
1553
1592
  const cached = findLatestCache();
1554
1593
  if (cached) {
@@ -1578,31 +1617,16 @@ async function resolveContentSourceForVersion(version, opts = {}) {
1578
1617
  return source;
1579
1618
  }
1580
1619
  function findLatestCache() {
1581
- const cacheDir = path10.join(os2.homedir(), ".arcane", "cache");
1582
- if (!fs9.existsSync(cacheDir)) return null;
1583
- const entries = fs9.readdirSync(cacheDir, { withFileTypes: true }).filter((e) => e.isDirectory()).map((e) => {
1584
- const metaPath = path10.join(cacheDir, e.name, ".cache-meta.json");
1585
- let cachedAt = 0;
1586
- if (fs9.existsSync(metaPath)) {
1587
- try {
1588
- const meta = JSON.parse(fs9.readFileSync(metaPath, "utf-8"));
1589
- cachedAt = new Date(meta.cached_at).getTime();
1590
- } catch {
1591
- cachedAt = 0;
1592
- }
1593
- }
1594
- return { name: e.name, cachedAt };
1595
- }).sort((a, b) => b.cachedAt - a.cachedAt);
1596
- return entries.length > 0 ? entries[0].name : null;
1620
+ const usable = listCachedVersions().filter((entry) => isCached(entry.version)).map((entry) => ({
1621
+ version: entry.version,
1622
+ cachedAt: new Date(entry.cachedAt).getTime() || 0
1623
+ })).sort((a, b) => b.cachedAt - a.cachedAt);
1624
+ return usable.length > 0 ? usable[0].version : null;
1597
1625
  }
1598
1626
 
1599
1627
  // src/registry.ts
1600
1628
  import path11 from "path";
1601
- import os3 from "os";
1602
1629
  var REGISTRY_VERSION = 1;
1603
- function arcaneHome() {
1604
- return process.env.ARCANE_HOME ?? path11.join(os3.homedir(), ".arcane");
1605
- }
1606
1630
  function registryPath() {
1607
1631
  return path11.join(arcaneHome(), "installations.json");
1608
1632
  }
@@ -1881,16 +1905,11 @@ async function addCommand(items = []) {
1881
1905
  manifest.installed_skills.push(...added);
1882
1906
  manifest.total_skills = manifest.installed_skills.length;
1883
1907
  manifest.total_rules = manifest.installed_rules.length;
1884
- const merged = {
1885
- loaded: manifest.profiles,
1886
- skills: manifest.installed_skills,
1887
- rules: { universal: manifest.installed_rules, gamedev: [] },
1888
- agents: manifest.installed_agents,
1889
- hooks: [],
1890
- output_styles: [],
1891
- permissions: { allow: [], deny: [] }
1892
- };
1893
- writeManifest(target, merged, manifest.profile_command, root);
1908
+ persistAdditions(target, claudeDir, manifest, {
1909
+ skills: added,
1910
+ rules: addedRules,
1911
+ agents: addedAgents
1912
+ });
1894
1913
  const totalAdded = added.length + addedRules.length + addedAgents.length + (statuslineAdded ? 1 : 0);
1895
1914
  console.log(
1896
1915
  chalk3.bold(
@@ -1915,6 +1934,35 @@ Added ${totalAdded} items:`
1915
1934
  );
1916
1935
  }
1917
1936
  }
1937
+ function persistAdditions(target, claudeDir, manifest, addedItems) {
1938
+ const updates = {
1939
+ profiles: manifest.profiles,
1940
+ profile_command: manifest.profile_command,
1941
+ installed_skills: manifest.installed_skills,
1942
+ installed_rules: manifest.installed_rules,
1943
+ installed_agents: manifest.installed_agents,
1944
+ total_skills: manifest.total_skills,
1945
+ total_rules: manifest.total_rules,
1946
+ updated_at: (/* @__PURE__ */ new Date()).toISOString().replace(/\.\d{3}Z$/, "Z")
1947
+ };
1948
+ const hashes = manifest.content_hashes;
1949
+ if (hashes) {
1950
+ const fresh = computeContentHashes(claudeDir);
1951
+ for (const skill of addedItems.skills) {
1952
+ if (fresh.skills[skill]) hashes.skills[skill] = fresh.skills[skill];
1953
+ }
1954
+ for (const rule of addedItems.rules) {
1955
+ const key = rule.endsWith(".md") ? rule : `${rule}.md`;
1956
+ if (fresh.rules[key]) hashes.rules[key] = fresh.rules[key];
1957
+ }
1958
+ for (const entry of addedItems.agents) {
1959
+ const { division } = parseAgentEntry(entry);
1960
+ if (fresh.agents[division]) hashes.agents[division] = fresh.agents[division];
1961
+ }
1962
+ updates.content_hashes = hashes;
1963
+ }
1964
+ updateManifestFields(target, updates);
1965
+ }
1918
1966
  function printCatalog(root, installedProfiles, installedSkills) {
1919
1967
  const profiles = listProfiles(path14.join(root, "profiles")).filter(
1920
1968
  (pr) => !installedProfiles.includes(pr.name)
@@ -2261,7 +2309,7 @@ async function statusCommand() {
2261
2309
  // src/commands/update.ts
2262
2310
  import fs14 from "fs";
2263
2311
  import path17 from "path";
2264
- import os4 from "os";
2312
+ import os3 from "os";
2265
2313
  import chalk7 from "chalk";
2266
2314
 
2267
2315
  // src/self-update.ts
@@ -2341,7 +2389,7 @@ async function updateCommand(opts) {
2341
2389
  registerInstallation(cwd);
2342
2390
  }
2343
2391
  const targets = pruneRegistry().map((e) => e.path);
2344
- const globalTarget = os4.homedir();
2392
+ const globalTarget = os3.homedir();
2345
2393
  if (!process.env.VITEST && fs14.existsSync(manifestPath(globalTarget)) && !targets.includes(globalTarget)) {
2346
2394
  targets.push(globalTarget);
2347
2395
  }
@@ -2357,6 +2405,10 @@ async function updateCommand(opts) {
2357
2405
  Updating ${targets.length} installation${targets.length === 1 ? "" : "s"}...`)
2358
2406
  );
2359
2407
  }
2408
+ const sharedSource = await resolveContentSource({
2409
+ source: opts.source ?? "auto",
2410
+ quiet: opts.quiet
2411
+ });
2360
2412
  const results = [];
2361
2413
  for (const target of targets) {
2362
2414
  if (!opts.quiet) {
@@ -2364,7 +2416,7 @@ Updating ${targets.length} installation${targets.length === 1 ? "" : "s"}...`)
2364
2416
  \u2022 ${target}`));
2365
2417
  }
2366
2418
  try {
2367
- results.push(await updateTarget(target, opts));
2419
+ results.push(await updateTarget(target, opts, sharedSource));
2368
2420
  } catch (err) {
2369
2421
  if (!opts.quiet) {
2370
2422
  console.log(chalk7.red(` Update failed: ${err.message}`));
@@ -2382,7 +2434,7 @@ Updating ${targets.length} installation${targets.length === 1 ? "" : "s"}...`)
2382
2434
  printGeneralSummary(results, opts.dryRun ?? false);
2383
2435
  }
2384
2436
  }
2385
- async function updateTarget(target, opts) {
2437
+ async function updateTarget(target, opts, sharedSource) {
2386
2438
  const manifest = readManifest(target);
2387
2439
  if (!manifest) {
2388
2440
  if (!opts.quiet) {
@@ -2390,7 +2442,7 @@ async function updateTarget(target, opts) {
2390
2442
  }
2391
2443
  return { target, status: "no-manifest", updated: 0, skipped: 0, removed: 0 };
2392
2444
  }
2393
- const contentSource = await resolveContentSource({
2445
+ const contentSource = sharedSource ?? await resolveContentSource({
2394
2446
  source: opts.source ?? "auto",
2395
2447
  quiet: opts.quiet
2396
2448
  });
@@ -3001,9 +3053,9 @@ async function worktreeCommand(branch, opts) {
3001
3053
  // src/commands/global.ts
3002
3054
  import fs16 from "fs";
3003
3055
  import path19 from "path";
3004
- import os5 from "os";
3056
+ import os4 from "os";
3005
3057
  import chalk10 from "chalk";
3006
- var CLAUDE_HOME = path19.join(os5.homedir(), ".claude");
3058
+ var CLAUDE_HOME = path19.join(os4.homedir(), ".claude");
3007
3059
  var SCRIPTS_DIR = path19.join(CLAUDE_HOME, "scripts", "worktree-isolation");
3008
3060
  var SETTINGS_PATH = path19.join(CLAUDE_HOME, "settings.json");
3009
3061
  async function globalCommand(opts) {
@@ -3170,9 +3222,10 @@ function removeHookFromSettings() {
3170
3222
  // src/update-check.ts
3171
3223
  import fs17 from "fs";
3172
3224
  import path20 from "path";
3173
- import os6 from "os";
3174
3225
  import chalk11 from "chalk";
3175
- var CHECK_FILE = path20.join(os6.homedir(), ".arcane", "last-check.json");
3226
+ function checkFile() {
3227
+ return path20.join(arcaneHome(), "last-check.json");
3228
+ }
3176
3229
  var CHECK_INTERVAL_MS = 4 * 60 * 60 * 1e3;
3177
3230
  var GITHUB_OWNER2 = "SebastianLuser";
3178
3231
  var GITHUB_REPO2 = "Claude-Code-Arcane";
@@ -3297,19 +3350,21 @@ async function getLatestNpmVersion() {
3297
3350
  }
3298
3351
  function readCachedCheck() {
3299
3352
  try {
3300
- if (!fs17.existsSync(CHECK_FILE)) return null;
3301
- return JSON.parse(fs17.readFileSync(CHECK_FILE, "utf-8"));
3353
+ const file = checkFile();
3354
+ if (!fs17.existsSync(file)) return null;
3355
+ return JSON.parse(fs17.readFileSync(file, "utf-8"));
3302
3356
  } catch {
3303
3357
  return null;
3304
3358
  }
3305
3359
  }
3306
3360
  function writeCachedCheck(result) {
3307
3361
  try {
3308
- const dir = path20.dirname(CHECK_FILE);
3362
+ const file = checkFile();
3363
+ const dir = path20.dirname(file);
3309
3364
  if (!fs17.existsSync(dir)) {
3310
3365
  fs17.mkdirSync(dir, { recursive: true });
3311
3366
  }
3312
- fs17.writeFileSync(CHECK_FILE, JSON.stringify(result, null, 2) + "\n");
3367
+ fs17.writeFileSync(file, JSON.stringify(result, null, 2) + "\n");
3313
3368
  } catch {
3314
3369
  }
3315
3370
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-code-arcane",
3
- "version": "2.9.2",
3
+ "version": "2.9.4",
4
4
  "description": "Skills, agents, hooks and rules for Claude Code — installable via npx claude-code-arcane",
5
5
  "type": "module",
6
6
  "bin": {