tokentracker-cli 0.96.2 → 0.96.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 (141) hide show
  1. package/README.de.md +34 -25
  2. package/README.ja.md +38 -27
  3. package/README.ko.md +39 -28
  4. package/README.md +36 -28
  5. package/README.zh-CN.md +36 -25
  6. package/dashboard/dist/assets/{AchievementBadge-CgEitoX1.js → AchievementBadge-CCcvmz6R.js} +1 -1
  7. package/dashboard/dist/assets/{AchievementsPage-dnnea0JH.js → AchievementsPage-C5jdktOK.js} +2 -2
  8. package/dashboard/dist/assets/{AchievementsSection-CJqG0UP7.js → AchievementsSection-BFMtUzn1.js} +1 -1
  9. package/dashboard/dist/assets/{BadgeDetailModal-BLV6SJoI.js → BadgeDetailModal-Be8gM7tM.js} +1 -1
  10. package/dashboard/dist/assets/{BotAnimated-Cm78yEbz.js → BotAnimated-CXTGR71z.js} +1 -1
  11. package/dashboard/dist/assets/{Card-IPGSYT6O.js → Card-CsUsp3CV.js} +1 -1
  12. package/dashboard/dist/assets/{ClawdAnimated-04w1U1sm.js → ClawdAnimated-BEjpZjEE.js} +2 -2
  13. package/dashboard/dist/assets/{CommandPalette-gSSa1V_s.js → CommandPalette-CHc_Z1r8.js} +1 -1
  14. package/dashboard/dist/assets/{CommunityStatsModal-CICwUz14.js → CommunityStatsModal-Gs6kZ_Mp.js} +1 -1
  15. package/dashboard/dist/assets/{ConfirmModal-DaWC0cY7.js → ConfirmModal-niHkpv2O.js} +1 -1
  16. package/dashboard/dist/assets/{Controls-BDihA03F.js → Controls-X2Z1UNUx.js} +1 -1
  17. package/dashboard/dist/assets/DashboardPage-B66s3Exi.js +1 -0
  18. package/dashboard/dist/assets/{DevicePage-BPpo_eiY.js → DevicePage-DQdLdEI8.js} +1 -1
  19. package/dashboard/dist/assets/DialogClose-CCUjTxl8.js +1 -0
  20. package/dashboard/dist/assets/DialogDescription-CjMjYG3N.js +1 -0
  21. package/dashboard/dist/assets/DialogTitle-CgkUGAag.js +1 -0
  22. package/dashboard/dist/assets/{FadeIn-DmI9PRLa.js → FadeIn-D4QD1bPo.js} +1 -1
  23. package/dashboard/dist/assets/{HeaderGithubStar-CBO4cY7i.js → HeaderGithubStar-D0my-Z3a.js} +1 -1
  24. package/dashboard/dist/assets/{HoverTooltip-CPFGhi9K.js → HoverTooltip-nSWgrKlK.js} +1 -1
  25. package/dashboard/dist/assets/{IpCheckPage-Do7w_6z6.js → IpCheckPage-4hfqHAgi.js} +1 -1
  26. package/dashboard/dist/assets/{LandingPage-at_Ti1ZE.js → LandingPage-wIc4tVRI.js} +2 -2
  27. package/dashboard/dist/assets/{LeaderboardAvatar-BH1cnKPp.js → LeaderboardAvatar-DGlN8Vy8.js} +1 -1
  28. package/dashboard/dist/assets/{LeaderboardPage-Cd30aXwL.js → LeaderboardPage-CybuzE87.js} +2 -2
  29. package/dashboard/dist/assets/{LeaderboardProfileModal-CVsti_HN.js → LeaderboardProfileModal-D9la3tNd.js} +4 -4
  30. package/dashboard/dist/assets/{LeaderboardProfilePage-BfSpDM9e.js → LeaderboardProfilePage-gxXSeJYb.js} +1 -1
  31. package/dashboard/dist/assets/LimitsPage-CZH3G54Y.js +8 -0
  32. package/dashboard/dist/assets/{LocalOnlyNotice-9-vRgsdn.js → LocalOnlyNotice-Ckc7zohC.js} +1 -1
  33. package/dashboard/dist/assets/{LoginCard-DM_ZdFNl.js → LoginCard-BqeRHqWK.js} +1 -1
  34. package/dashboard/dist/assets/{LoginModal-BaNMznwh.js → LoginModal-BjH6aIBG.js} +1 -1
  35. package/dashboard/dist/assets/{LoginPage-BMMthUf4.js → LoginPage-BOyPLNU3.js} +1 -1
  36. package/dashboard/dist/assets/PetPage-BXRXG40D.js +1 -0
  37. package/dashboard/dist/assets/PopoverPopup-Dad1ZYVd.js +1 -0
  38. package/dashboard/dist/assets/ProviderIcon-BDmrNJSl.js +1 -0
  39. package/dashboard/dist/assets/{ResetPasswordPage-Ca4SULSV.js → ResetPasswordPage-CZpE6qfa.js} +1 -1
  40. package/dashboard/dist/assets/{SegmentedControl-Bbsn-qvZ.js → SegmentedControl-CE1hrQFi.js} +1 -1
  41. package/dashboard/dist/assets/Select-YVj7u6Nv.js +1 -0
  42. package/dashboard/dist/assets/SelectItemText-BMYGgQ3o.js +1 -0
  43. package/dashboard/dist/assets/{ServiceStatusPage-BPS8KJ3a.js → ServiceStatusPage-HehjDbZg.js} +1 -1
  44. package/dashboard/dist/assets/{SessionsPage-C1yt3TMZ.js → SessionsPage-Chvu4YqZ.js} +2 -2
  45. package/dashboard/dist/assets/SettingsPage-BXjS7u9_.js +2 -0
  46. package/dashboard/dist/assets/SkillsPage-q3Rjlj9h.js +1 -0
  47. package/dashboard/dist/assets/{TokenGalaxy-DKvbJbnv.js → TokenGalaxy-BqZyj82r.js} +1 -1
  48. package/dashboard/dist/assets/ToolbarRootContext-Ch8ueI_C.js +1 -0
  49. package/dashboard/dist/assets/{TrendMonitor-YF5C_-L1.js → TrendMonitor-C2aTL9p1.js} +1 -1
  50. package/dashboard/dist/assets/{WidgetsPage-BKV3DgXg.js → WidgetsPage-DYaIai8j.js} +1 -1
  51. package/dashboard/dist/assets/{WrappedPage-s4IRoLf7.js → WrappedPage-51B0v-Sr.js} +1 -1
  52. package/dashboard/dist/assets/{__vite-browser-external-CvMMIjhi.css → __vite-browser-external-5P7me0Qq.css} +1 -1
  53. package/dashboard/dist/assets/__vite-browser-external-Cp7dNuCm.js +45 -0
  54. package/dashboard/dist/assets/{agent-logos-Hbt-wyl2.js → agent-logos-DXrPSAdX.js} +1 -1
  55. package/dashboard/dist/assets/{arrow-up-right-KeIpKlw3.js → arrow-up-right-BFkf7l0U.js} +1 -1
  56. package/dashboard/dist/assets/{check-_QKyY1v_.js → check--N8wmgIJ.js} +1 -1
  57. package/dashboard/dist/assets/composite-zF5Pj94e.js +1 -0
  58. package/dashboard/dist/assets/{copy-DpLM3QMD.js → copy-BG0JMr6A.js} +1 -1
  59. package/dashboard/dist/assets/{download-C4NX-JnW.js → download-CPFJf-Sf.js} +1 -1
  60. package/dashboard/dist/assets/{external-link-CG-7FcOF.js → external-link-D2yF0kgP.js} +1 -1
  61. package/dashboard/dist/assets/{flame-CkWid7Ji.js → flame-l460AFx9.js} +1 -1
  62. package/dashboard/dist/assets/{format-tokens-CbwWhioZ.js → format-tokens-DKO4ljuH.js} +1 -1
  63. package/dashboard/dist/assets/{icons-BZq17rU3.js → icons-RoxRGkz4.js} +1 -1
  64. package/dashboard/dist/assets/{index-CR9tDiiC.js → index--ouT5L4N.js} +1 -1
  65. package/dashboard/dist/assets/{index-DDGLE0dz.js → index-DhyLCvvv.js} +1 -1
  66. package/dashboard/dist/assets/{index-CSwEiUn4.js → index-PTJ4368-.js} +1 -1
  67. package/dashboard/dist/assets/{info-mkeRjzqc.js → info-DNWMO4Ca.js} +1 -1
  68. package/dashboard/dist/assets/{link-2-BjMw8csC.js → link-2-DzWXAz1h.js} +1 -1
  69. package/dashboard/dist/assets/{loader-circle-cGWOJnWn.js → loader-circle-BzGMqXNu.js} +1 -1
  70. package/dashboard/dist/assets/{maximize-2-Cg_S73tD.js → maximize-2-Dkpyk4x3.js} +1 -1
  71. package/dashboard/dist/assets/{modifiers.esm-DNxTWxId.js → modifiers.esm-lggHUQpW.js} +1 -1
  72. package/dashboard/dist/assets/module-DUSXqJtA.js +3 -0
  73. package/dashboard/dist/assets/{monitor-smartphone-BvE9Tt2U.js → monitor-smartphone-Ck0-gz9l.js} +1 -1
  74. package/dashboard/dist/assets/{plus-BC2wdPRr.js → plus-qTJpnn43.js} +1 -1
  75. package/dashboard/dist/assets/provider-display-psY26qmp.js +42 -0
  76. package/dashboard/dist/assets/{react-D0kcslTz.js → react-CYS24KmX.js} +1 -1
  77. package/dashboard/dist/assets/{search-BuBNF3E3.js → search-CUzE6zxr.js} +1 -1
  78. package/dashboard/dist/assets/{skills-api-DxAN5pNx.js → skills-api-D9SzxPFG.js} +1 -1
  79. package/dashboard/dist/assets/{sortable.esm-DoqMcEX-.js → sortable.esm-Bl2hAUyR.js} +1 -1
  80. package/dashboard/dist/assets/store-mHSJAJtE.js +1 -0
  81. package/dashboard/dist/assets/{terminal-COWFUJdU.js → terminal-BlQNEvn1.js} +1 -1
  82. package/dashboard/dist/assets/{trash-2-Cqjki3RI.js → trash-CHdgmOyk.js} +1 -1
  83. package/dashboard/dist/assets/{use-community-stats-DsrlOPDM.js → use-community-stats-DUiCs91N.js} +1 -1
  84. package/dashboard/dist/assets/{use-latest-request-guard-ryRFL2gx.js → use-latest-request-guard-ehXfkvuU.js} +1 -1
  85. package/dashboard/dist/assets/use-limits-display-prefs-CcwwQ0Fq.js +1 -0
  86. package/dashboard/dist/assets/{use-ordered-list-D-Q86R1b.js → use-ordered-list-CCOtI3Wa.js} +1 -1
  87. package/dashboard/dist/assets/use-reduced-motion-CDlahPqe.js +1 -0
  88. package/dashboard/dist/assets/{use-session-efficiency-pref-CrNz45y7.js → use-session-efficiency-pref-CYRDpcc_.js} +1 -1
  89. package/dashboard/dist/assets/{use-transform-CoGM21px.js → use-transform-btLlTJpj.js} +1 -1
  90. package/dashboard/dist/assets/use-usage-limits-BGr5sR-c.js +1 -0
  91. package/dashboard/dist/assets/{useCurrency-CgCfDktb.js → useCurrency-CNutuEKd.js} +1 -1
  92. package/dashboard/dist/assets/{useTokenFormat-QKtzCKTL.js → useTokenFormat-C0bAJFMC.js} +1 -1
  93. package/dashboard/dist/assets/{zap-CTIobzgU.js → zap-B2fbyjbc.js} +1 -1
  94. package/dashboard/dist/brand-logos/acode-dark.png +0 -0
  95. package/dashboard/dist/brand-logos/acode.png +0 -0
  96. package/dashboard/dist/brand-logos/devin.svg +6 -0
  97. package/dashboard/dist/index.html +20 -16
  98. package/dashboard/dist/ip-check.html +2 -2
  99. package/dashboard/dist/leaderboard.html +2 -2
  100. package/dashboard/dist/llms.txt +4 -2
  101. package/dashboard/dist/share.html +2 -2
  102. package/package.json +2 -2
  103. package/src/commands/init.js +114 -4
  104. package/src/commands/status.js +37 -0
  105. package/src/commands/sync.js +92 -10
  106. package/src/commands/uninstall.js +23 -0
  107. package/src/lib/codex-config.js +42 -0
  108. package/src/lib/devin-limits.js +249 -0
  109. package/src/lib/devin-usage.js +212 -0
  110. package/src/lib/local-api.js +19 -0
  111. package/src/lib/passive-mode.js +13 -1
  112. package/src/lib/pricing/curated-overrides.json +67 -1
  113. package/src/lib/pricing/index.js +5 -2
  114. package/src/lib/pricing/matcher.js +28 -0
  115. package/src/lib/pricing/seed-snapshot.json +1 -1
  116. package/src/lib/rollout.js +1310 -144
  117. package/src/lib/skills-manager.js +231 -30
  118. package/src/lib/subscriptions.js +70 -16
  119. package/src/lib/usage-limits.js +199 -49
  120. package/dashboard/dist/assets/DashboardPage-hV7wncKF.js +0 -1
  121. package/dashboard/dist/assets/DialogClose-C_dF-r3s.js +0 -1
  122. package/dashboard/dist/assets/DialogDescription-GClOSCaE.js +0 -1
  123. package/dashboard/dist/assets/DialogTitle-Buh_5688.js +0 -1
  124. package/dashboard/dist/assets/LimitsPage-_-BadDAj.js +0 -8
  125. package/dashboard/dist/assets/PetPage-Iq8Hswai.js +0 -1
  126. package/dashboard/dist/assets/PopoverPopup-DXr3JxVV.js +0 -1
  127. package/dashboard/dist/assets/ProviderIcon-sb2keSY2.js +0 -1
  128. package/dashboard/dist/assets/Select-vD-yj-qa.js +0 -1
  129. package/dashboard/dist/assets/SelectItemText-CRtFEuvg.js +0 -1
  130. package/dashboard/dist/assets/SettingsPage-CWVHrMer.js +0 -2
  131. package/dashboard/dist/assets/SkillsPage-DGWEH1xm.js +0 -1
  132. package/dashboard/dist/assets/ToolbarRootContext-D8yov1dW.js +0 -1
  133. package/dashboard/dist/assets/__vite-browser-external-_LUatzwU.js +0 -45
  134. package/dashboard/dist/assets/composite-BidfcMeb.js +0 -1
  135. package/dashboard/dist/assets/limits-providers-DRkcWQup.js +0 -1
  136. package/dashboard/dist/assets/module-xpSFyNBK.js +0 -3
  137. package/dashboard/dist/assets/provider-display-B7GggHQy.js +0 -42
  138. package/dashboard/dist/assets/store-Cg1HeUFM.js +0 -1
  139. package/dashboard/dist/assets/use-limits-display-prefs-DkSuu12m.js +0 -1
  140. package/dashboard/dist/assets/use-reduced-motion-D7NNqavo.js +0 -1
  141. package/dashboard/dist/assets/use-usage-limits-C_6L1sO5.js +0 -1
@@ -15,6 +15,7 @@ const DEFAULT_REPOS = [
15
15
  const TARGETS = {
16
16
  claude: { id: "claude", label: "Claude", dir: () => path.join(os.homedir(), ".claude", "skills") },
17
17
  codex: { id: "codex", label: "Codex", dir: () => path.join(resolveCodexHome(), "skills") },
18
+ acode: { id: "acode", label: "AStudio", dir: () => path.join(resolveAcodeHome(), "skills") },
18
19
  grok: { id: "grok", label: "Grok", dir: () => path.join(resolveGrokHome(process.env), "skills") },
19
20
  antigravity: { id: "antigravity", label: "Antigravity", dirs: () => resolveAntigravitySkillDirs(process.env) },
20
21
  gemini: { id: "gemini", label: "Gemini", dir: () => path.join(os.homedir(), ".gemini", "skills") },
@@ -32,6 +33,10 @@ function resolveCodexHome() {
32
33
  return String(process.env.CODEX_HOME || "").trim() || path.join(os.homedir(), ".codex");
33
34
  }
34
35
 
36
+ function resolveAcodeHome() {
37
+ return String(process.env.TOKENTRACKER_ACODE_HOME || "").trim() || path.join(os.homedir(), ".acode");
38
+ }
39
+
35
40
  function resolveZcodeHome() {
36
41
  return String(process.env.ZCODE_HOME || "").trim() || path.join(os.homedir(), ".zcode");
37
42
  }
@@ -904,7 +909,12 @@ function listInstalledSkills() {
904
909
  });
905
910
  }
906
911
 
907
- async function installSkill(skillInput, targetIds = ["claude", "codex"]) {
912
+ async function installSkill(
913
+ skillInput,
914
+ targetIds = ["claude", "codex"],
915
+ prefetchedTree = null,
916
+ { expectExistingId = null } = {},
917
+ ) {
908
918
  const skill = {
909
919
  key: String(skillInput?.key || ""),
910
920
  name: String(skillInput?.name || ""),
@@ -934,7 +944,9 @@ async function installSkill(skillInput, targetIds = ["claude", "codex"]) {
934
944
  );
935
945
  }
936
946
 
937
- const { branch, tree } = await getRepoTree({
947
+ // Bulk update hands in the repo tree it already fetched, so N skills from one
948
+ // repo cost one GitHub call instead of N.
949
+ const { branch, tree } = prefetchedTree || await getRepoTree({
938
950
  owner: skill.repoOwner,
939
951
  name: skill.repoName,
940
952
  branch: skill.repoBranch,
@@ -945,9 +957,10 @@ async function installSkill(skillInput, targetIds = ["claude", "codex"]) {
945
957
  if (!files.some((entry) => /(^|\/)SKILL\.md$/i.test(entry.path))) throw new Error("SKILL.md not found in selected directory");
946
958
 
947
959
  const dest = managedSkillPath(installName);
948
- const temp = path.join(dataDir(), "tmp", `${installName}-${Date.now()}`);
949
- removePath(temp);
950
- ensureDir(temp);
960
+ let selectedTargets = targetIds.filter((id) => TARGETS[id]);
961
+ const tempRoot = path.join(dataDir(), "tmp");
962
+ ensureDir(tempRoot);
963
+ const temp = fs.mkdtempSync(path.join(tempRoot, `${installName}-`));
951
964
  try {
952
965
  for (const entry of files) {
953
966
  const relative = entry.path === sourceDir ? path.basename(entry.path) : entry.path.slice(sourceDir.length + 1);
@@ -957,6 +970,17 @@ async function installSkill(skillInput, targetIds = ["claude", "codex"]) {
957
970
  ensureDir(path.dirname(out));
958
971
  fs.writeFileSync(out, await fetchText(githubRawUrl(skill.repoOwner, skill.repoName, branch, entry.path)));
959
972
  }
973
+ // An update must not recreate a skill that was uninstalled while we were
974
+ // downloading, nor overwrite whatever took its install directory in the
975
+ // meantime. Checked here because everything from this point to
976
+ // saveRegistry() is synchronous — no await can interleave a change we would
977
+ // then clobber.
978
+ if (expectExistingId) {
979
+ const current = readRegistry().skills.find((entry) => !entry.trashedAt && entry.id === expectExistingId);
980
+ if (!current) throw new Error("Skill is no longer installed");
981
+ // Another page or API caller may have changed target intent during a download.
982
+ selectedTargets = (current.targets || []).filter((id) => TARGETS[id]);
983
+ }
960
984
  removePath(dest);
961
985
  ensureDir(path.dirname(dest));
962
986
  fs.renameSync(temp, dest);
@@ -968,7 +992,6 @@ async function installSkill(skillInput, targetIds = ["claude", "codex"]) {
968
992
  const skillMarker = findSkillMarker(dest);
969
993
  const skillMd = skillMarker ? fs.readFileSync(skillMarker, "utf8") : "";
970
994
  const metadata = readSkillMetadata(skillMd, skill.name || installName);
971
- const selectedTargets = targetIds.filter((id) => TARGETS[id]);
972
995
  const installed = {
973
996
  id: `${skill.repoOwner}/${skill.repoName}:${sourceDir}`,
974
997
  key: `${skill.repoOwner}/${skill.repoName}:${sourceDir}`,
@@ -987,9 +1010,13 @@ async function installSkill(skillInput, targetIds = ["claude", "codex"]) {
987
1010
  targets: selectedTargets,
988
1011
  };
989
1012
 
990
- registry.skills = registry.skills.filter((entry) => entry.id !== installed.id && entry.directory.toLowerCase() !== installName.toLowerCase());
991
- registry.skills.push(installed);
992
- saveRegistry(registry);
1013
+ // Re-read: the downloads above can take minutes and the registry is shared
1014
+ // with every other mutation path, so the copy fetched before them is stale by
1015
+ // now. Reading here keeps the read/modify/write window down to this block.
1016
+ const latest = readRegistry();
1017
+ latest.skills = latest.skills.filter((entry) => entry.id !== installed.id && entry.directory.toLowerCase() !== installName.toLowerCase());
1018
+ latest.skills.push(installed);
1019
+ saveRegistry(latest);
993
1020
 
994
1021
  for (const id of selectedTargets) syncSkillToTarget(installName, id);
995
1022
  appendActivity({ action: "install", name: installed.name, directory: installName, targets: selectedTargets, source: `${skill.repoOwner}/${skill.repoName}` });
@@ -1239,20 +1266,89 @@ function updateCachePath() {
1239
1266
  return path.join(dataDir(), "updates-cache.json");
1240
1267
  }
1241
1268
 
1269
+ // checkUpdates() and updateSkills() have to agree on three things: which skills
1270
+ // are candidates, how a cached verdict is keyed, and which tree a skill's
1271
+ // signature comes from. They share these so the badge and the update can never
1272
+ // disagree about the same skill.
1273
+ function managedUpdateCandidates(registry) {
1274
+ return registry.skills.filter(
1275
+ (skill) => !skill.trashedAt && skill.repoOwner && skill.repoName && skill.sourceSignature,
1276
+ );
1277
+ }
1278
+
1279
+ function updateFingerprint(managed) {
1280
+ return managed
1281
+ .map((skill) => `${skill.id}@${skill.sourceSignature}`)
1282
+ .sort()
1283
+ .join("|");
1284
+ }
1285
+
1286
+ // Write the verdicts a round derived, carrying forward the previous map for
1287
+ // anything it could not derive — a repo whose tree failed, everything past a
1288
+ // rate-limited stop. "We could not check" must not render as "nothing is
1289
+ // stale": that clears every badge and with it the way to retry. Returns the
1290
+ // merged map. Entries for skills that are no longer installed are dropped.
1291
+ //
1292
+ // `full` says the round attempted the whole library, which only checkUpdates()
1293
+ // does. An update attempts the ids it was handed, so it may only publish an
1294
+ // entry when the carried map fills in the rest — see the guard below.
1295
+ function writeUpdateCache(verdicts, { full = false } = {}) {
1296
+ const managed = managedUpdateCandidates(readRegistry());
1297
+ const live = new Set(managed.map((skill) => skill.id));
1298
+ const cached = readJson(updateCachePath(), null);
1299
+ const carriedFresh = Boolean(
1300
+ cached &&
1301
+ cached.updates &&
1302
+ typeof cached.updates === "object" &&
1303
+ Number.isFinite(cached.checkedAt) &&
1304
+ Date.now() - cached.checkedAt < UPDATE_CACHE_TTL_MS,
1305
+ );
1306
+ const updates = {};
1307
+ if (carriedFresh) {
1308
+ for (const [id, stale] of Object.entries(cached.updates)) if (live.has(id)) updates[id] = Boolean(stale);
1309
+ }
1310
+ for (const [id, stale] of Object.entries(verdicts)) if (live.has(id)) updates[id] = Boolean(stale);
1311
+
1312
+ // The entry is read back as a verdict for the entire library, and a missing
1313
+ // key reads as "not stale". A partial round that stamped the library
1314
+ // fingerprint would therefore publish silence about skills nobody looked at,
1315
+ // and the next check would hit it. Leave the cache alone instead and let that
1316
+ // check do the work.
1317
+ if (!full && !managed.every((skill) => Object.prototype.hasOwnProperty.call(updates, skill.id))) {
1318
+ return updates;
1319
+ }
1320
+
1321
+ writeJson(updateCachePath(), {
1322
+ fingerprint: updateFingerprint(managed),
1323
+ // A prime inherits the window of the check it built on: it re-derived some
1324
+ // ids, not the library, so it must not restart the hour for the rest.
1325
+ checkedAt: full || !carriedFresh ? Date.now() : cached.checkedAt,
1326
+ updates,
1327
+ });
1328
+ return updates;
1329
+ }
1330
+
1331
+ function groupSkillsByRepo(skills) {
1332
+ const byRepo = new Map();
1333
+ for (const skill of skills) {
1334
+ const branch = skill.repoBranch || "main";
1335
+ const key = `${skill.repoOwner}/${skill.repoName}@${branch}`.toLowerCase();
1336
+ if (!byRepo.has(key)) {
1337
+ byRepo.set(key, { owner: skill.repoOwner, name: skill.repoName, branch, skills: [] });
1338
+ }
1339
+ byRepo.get(key).skills.push(skill);
1340
+ }
1341
+ return Array.from(byRepo.values());
1342
+ }
1343
+
1242
1344
  // Compare each managed GitHub/skills.sh skill's stored source signature against a
1243
1345
  // freshly fetched repo tree. One tree call per repo (skills from the same repo
1244
1346
  // share it), concurrency-limited and cached for an hour so a background check
1245
1347
  // can't trip GitHub's unauthenticated rate limit. Returns { updates: {id:bool} }.
1246
1348
  // Read-only: never mutates the registry or the on-disk skills.
1247
1349
  async function checkUpdates({ force = false } = {}) {
1248
- const registry = readRegistry();
1249
- const managed = registry.skills.filter(
1250
- (skill) => !skill.trashedAt && skill.repoOwner && skill.repoName && skill.sourceSignature,
1251
- );
1252
- const fingerprint = managed
1253
- .map((skill) => `${skill.id}@${skill.sourceSignature}`)
1254
- .sort()
1255
- .join("|");
1350
+ const managed = managedUpdateCandidates(readRegistry());
1351
+ const fingerprint = updateFingerprint(managed);
1256
1352
 
1257
1353
  if (!force) {
1258
1354
  const cached = readJson(updateCachePath(), null);
@@ -1268,18 +1364,8 @@ async function checkUpdates({ force = false } = {}) {
1268
1364
  }
1269
1365
  }
1270
1366
 
1271
- const byRepo = new Map();
1272
- for (const skill of managed) {
1273
- const branch = skill.repoBranch || "main";
1274
- const key = `${skill.repoOwner}/${skill.repoName}@${branch}`.toLowerCase();
1275
- if (!byRepo.has(key)) {
1276
- byRepo.set(key, { owner: skill.repoOwner, name: skill.repoName, branch, skills: [] });
1277
- }
1278
- byRepo.get(key).skills.push(skill);
1279
- }
1280
-
1281
1367
  const updates = {};
1282
- await mapWithConcurrency(Array.from(byRepo.values()), UPDATE_CHECK_CONCURRENCY, async (repo) => {
1368
+ await mapWithConcurrency(groupSkillsByRepo(managed), UPDATE_CHECK_CONCURRENCY, async (repo) => {
1283
1369
  let tree;
1284
1370
  try {
1285
1371
  ({ tree } = await getRepoTree(repo));
@@ -1294,8 +1380,122 @@ async function checkUpdates({ force = false } = {}) {
1294
1380
  });
1295
1381
 
1296
1382
  const checkedAt = Date.now();
1297
- writeJson(updateCachePath(), { fingerprint, checkedAt, updates });
1298
- return { updates, checkedAt, cached: false };
1383
+ return { updates: writeUpdateCache(updates, { full: true }), checkedAt, cached: false };
1384
+ }
1385
+
1386
+ // Re-install managed skills from upstream, grouped by repo like checkUpdates()
1387
+ // so N skills from one repo cost one tree call. Sequential by necessity:
1388
+ // installSkill() read/modify/writes the registry, so parallel installs would
1389
+ // drop each other's entries. Every requested id gets a result row — including
1390
+ // ones that are no longer installed — so updated + skipped + failed always
1391
+ // reconciles with what the caller asked for.
1392
+ async function updateSkills(ids = []) {
1393
+ // An empty or malformed list means "nothing was asked for", never "every
1394
+ // managed skill" — the permissive reading turned one bad request field into a
1395
+ // full re-install of the library.
1396
+ const wanted = new Set(
1397
+ (Array.isArray(ids) ? ids : []).map((id) => String(id || "")).filter(Boolean),
1398
+ );
1399
+ if (!wanted.size) return { results: [], updated: 0, skipped: 0, failed: 0, rateLimited: null };
1400
+ const managed = readRegistry().skills.filter(
1401
+ (skill) => !skill.trashedAt && skill.repoOwner && skill.repoName && wanted.has(skill.id),
1402
+ );
1403
+
1404
+ const results = [];
1405
+ const verdicts = {};
1406
+ let rateLimited = null;
1407
+
1408
+ const known = new Set(managed.map((skill) => skill.id));
1409
+ for (const id of wanted) {
1410
+ if (!known.has(id)) results.push({ id, name: null, ok: false, error: "Skill is no longer installed" });
1411
+ }
1412
+
1413
+ for (const repo of groupSkillsByRepo(managed)) {
1414
+ let branch;
1415
+ let tree;
1416
+ try {
1417
+ ({ branch, tree } = await getRepoTree(repo));
1418
+ } catch (error) {
1419
+ if (error instanceof RateLimitError) {
1420
+ rateLimited = error;
1421
+ break;
1422
+ }
1423
+ for (const skill of repo.skills) {
1424
+ results.push({ id: skill.id, name: skill.name, ok: false, error: error?.message || "Unable to read repository" });
1425
+ }
1426
+ continue;
1427
+ }
1428
+
1429
+ for (const skill of repo.skills) {
1430
+ const sourceDir = skill.sourceDirectory || skill.directory;
1431
+ const fresh = sourceSignatureFromTree(tree, sourceDir);
1432
+ if (skill.sourceSignature && fresh === skill.sourceSignature) {
1433
+ results.push({ id: skill.id, name: skill.name, ok: true, skipped: true });
1434
+ verdicts[skill.id] = false;
1435
+ continue;
1436
+ }
1437
+ // This loop runs for as long as the downloads take, and the registry is
1438
+ // shared with every other mutation path — re-check rather than write back
1439
+ // a skill that was uninstalled while we were working.
1440
+ if (!readRegistry().skills.some((entry) => !entry.trashedAt && entry.id === skill.id)) {
1441
+ results.push({ id: skill.id, name: skill.name, ok: false, error: "Skill is no longer installed" });
1442
+ continue;
1443
+ }
1444
+ try {
1445
+ await installSkill(
1446
+ {
1447
+ key: skill.key,
1448
+ name: skill.name,
1449
+ description: skill.description,
1450
+ // installSkill() wants the repo path, not the flat install name.
1451
+ directory: sourceDir,
1452
+ readmeUrl: skill.readmeUrl,
1453
+ repoOwner: skill.repoOwner,
1454
+ repoName: skill.repoName,
1455
+ repoBranch: branch,
1456
+ },
1457
+ skill.targets || [],
1458
+ { branch, tree },
1459
+ { expectExistingId: skill.id },
1460
+ );
1461
+ results.push({ id: skill.id, name: skill.name, ok: true, skipped: false });
1462
+ if (fresh) verdicts[skill.id] = false;
1463
+ } catch (error) {
1464
+ if (error instanceof RateLimitError) {
1465
+ rateLimited = error;
1466
+ break;
1467
+ }
1468
+ results.push({ id: skill.id, name: skill.name, ok: false, error: error?.message || "Update failed" });
1469
+ if (fresh) verdicts[skill.id] = true;
1470
+ }
1471
+ }
1472
+ if (rateLimited) break;
1473
+ }
1474
+
1475
+ // A rate-limited stop breaks out of both loops, leaving the skill it happened
1476
+ // on and everything after it unreported. Backfill so every requested id comes
1477
+ // back with a row and the counts still reconcile with what was asked for.
1478
+ if (rateLimited) {
1479
+ const reported = new Set(results.map((row) => row.id));
1480
+ const nameById = new Map(managed.map((skill) => [skill.id, skill.name]));
1481
+ for (const id of wanted) {
1482
+ if (reported.has(id)) continue;
1483
+ results.push({ id, name: nameById.get(id) || null, ok: false, error: rateLimited.message });
1484
+ }
1485
+ }
1486
+
1487
+ // Prime the cache from the trees this run already holds, so the refresh that
1488
+ // follows is a cache hit rather than a second tree call per repo. Skipped when
1489
+ // the run derived nothing — there is no fresh verdict to record.
1490
+ if (Object.keys(verdicts).length) writeUpdateCache(verdicts);
1491
+
1492
+ return {
1493
+ results,
1494
+ updated: results.filter((entry) => entry.ok && !entry.skipped).length,
1495
+ skipped: results.filter((entry) => entry.skipped).length,
1496
+ failed: results.filter((entry) => !entry.ok).length,
1497
+ rateLimited: rateLimited ? rateLimited.message : null,
1498
+ };
1299
1499
  }
1300
1500
 
1301
1501
  // skills.sh exposes only /api/search (no leaderboard endpoint), so "Popular" is
@@ -1380,4 +1580,5 @@ module.exports = {
1380
1580
  sourceSignatureFromTree,
1381
1581
  targetList,
1382
1582
  uninstallSkill,
1583
+ updateSkills,
1383
1584
  };
@@ -19,6 +19,9 @@ const CLAUDE_CODE_CREDENTIALS_FILE = ".credentials.json";
19
19
  // Platforms where Claude Code stores credentials in the plain JSON file above
20
20
  // rather than the macOS Keychain.
21
21
  const CLAUDE_CODE_CREDENTIALS_FILE_PLATFORMS = new Set(["linux", "win32"]);
22
+ // Refresh slightly before wall-clock expiry so Limits does not spend a request
23
+ // that Anthropic answers with 429 for a real-but-expired Claude Code token.
24
+ const CLAUDE_TOKEN_EXPIRY_SKEW_MS = 60_000;
22
25
 
23
26
  function usesClaudeCodeCredentialsFile(platform) {
24
27
  return CLAUDE_CODE_CREDENTIALS_FILE_PLATFORMS.has(platform);
@@ -164,14 +167,23 @@ async function detectOpencodeChatgptSubscription({ home, env }) {
164
167
  };
165
168
  }
166
169
 
167
- function probeMacosKeychainGenericPassword({ service, securityRunner, timeoutMs } = {}) {
170
+ // Match Claude Code 2.1.267's account derivation. A service-only lookup may
171
+ // select credentials from an older installation before the current account.
172
+ function resolveClaudeKeychainAccount({ env = process.env, userInfo = os.userInfo } = {}) {
173
+ let account;
174
+ try { account = env.USER || userInfo().username; } catch (_error) { return "claude-code-user"; }
175
+ return typeof account === "string" && /^[a-zA-Z0-9._-]+$/.test(account)
176
+ ? account : "claude-code-user";
177
+ }
178
+
179
+ function probeMacosKeychainGenericPassword({ service, securityRunner, timeoutMs, env } = {}) {
168
180
  const svc = normalizeString(service);
169
181
  if (!svc) return false;
170
182
 
171
183
  const runner = typeof securityRunner === "function" ? securityRunner : cp.spawnSync;
172
184
  if (runner === cp.spawnSync && !fs.existsSync(MACOS_SECURITY_BIN)) return false;
173
185
 
174
- const result = runner(MACOS_SECURITY_BIN, ["find-generic-password", "-s", svc], {
186
+ const result = runner(MACOS_SECURITY_BIN, ["find-generic-password", "-s", svc, "-a", resolveClaudeKeychainAccount({ env })], {
175
187
  stdio: "ignore",
176
188
  timeout: Number.isFinite(timeoutMs) ? timeoutMs : 2000,
177
189
  });
@@ -180,14 +192,14 @@ function probeMacosKeychainGenericPassword({ service, securityRunner, timeoutMs
180
192
  return result.status === 0;
181
193
  }
182
194
 
183
- function readMacosKeychainPassword({ service, securityRunner, timeoutMs } = {}) {
195
+ function readMacosKeychainPassword({ service, securityRunner, timeoutMs, env } = {}) {
184
196
  const svc = normalizeString(service);
185
197
  if (!svc) return null;
186
198
 
187
199
  const runner = typeof securityRunner === "function" ? securityRunner : cp.spawnSync;
188
200
  if (runner === cp.spawnSync && !fs.existsSync(MACOS_SECURITY_BIN)) return null;
189
201
 
190
- const result = runner(MACOS_SECURITY_BIN, ["find-generic-password", "-s", svc, "-w"], {
202
+ const result = runner(MACOS_SECURITY_BIN, ["find-generic-password", "-s", svc, "-a", resolveClaudeKeychainAccount({ env }), "-w"], {
191
203
  stdio: ["ignore", "pipe", "ignore"],
192
204
  timeout: Number.isFinite(timeoutMs) ? timeoutMs : 2000,
193
205
  encoding: "utf8",
@@ -217,12 +229,13 @@ function readClaudeCodeCredentialsFile({ home, fsReader } = {}) {
217
229
  }
218
230
  }
219
231
 
220
- function detectClaudeCodeCredentialsPresence({ platform = process.platform, securityRunner, home, fsReader } = {}) {
232
+ function detectClaudeCodeCredentialsPresence({ platform = process.platform, securityRunner, home, fsReader, env } = {}) {
221
233
  if (platform === "darwin") {
222
234
  for (const service of CLAUDE_CODE_KEYCHAIN_SERVICES) {
223
235
  const present = probeMacosKeychainGenericPassword({
224
236
  service,
225
237
  securityRunner,
238
+ env,
226
239
  });
227
240
  if (!present) continue;
228
241
 
@@ -260,6 +273,31 @@ function detectClaudeCodeCredentialsPresence({ platform = process.platform, secu
260
273
  return null;
261
274
  }
262
275
 
276
+ function parseClaudeOauthExpiryMs(value) {
277
+ if (typeof value === "number") {
278
+ if (!Number.isFinite(value)) return null;
279
+ if (value <= 0) return 0;
280
+ return value < 1e12 ? value * 1000 : value;
281
+ }
282
+ if (typeof value === "string") {
283
+ const trimmed = value.trim();
284
+ if (!trimmed) return null;
285
+ if (/^-?\d+(\.\d+)?$/.test(trimmed)) return parseClaudeOauthExpiryMs(Number(trimmed));
286
+ const ms = Date.parse(trimmed);
287
+ return Number.isFinite(ms) ? ms : null;
288
+ }
289
+ return null;
290
+ }
291
+
292
+ function extractClaudeCodeOauth(payload, nowMs = Date.now()) {
293
+ const oauth = payload?.claudeAiOauth;
294
+ const accessToken = normalizeString(oauth?.accessToken);
295
+ if (!accessToken) return null;
296
+ const expiresAtMs = parseClaudeOauthExpiryMs(oauth?.expiresAt);
297
+ if (expiresAtMs != null && expiresAtMs <= nowMs + CLAUDE_TOKEN_EXPIRY_SKEW_MS) return null;
298
+ return { accessToken, expiresAtMs };
299
+ }
300
+
263
301
  function extractClaudeKeychainSubscription(payload) {
264
302
  if (!payload || typeof payload !== "object" || Array.isArray(payload)) return null;
265
303
 
@@ -273,11 +311,11 @@ function extractClaudeKeychainSubscription(payload) {
273
311
  return { subscriptionType, rateLimitTier };
274
312
  }
275
313
 
276
- function detectClaudeCodeSubscriptionDetails({ platform = process.platform, securityRunner, home, fsReader } = {}) {
314
+ function detectClaudeCodeSubscriptionDetails({ platform = process.platform, securityRunner, home, fsReader, env } = {}) {
277
315
  const rawPayloads = [];
278
316
  if (platform === "darwin") {
279
317
  for (const service of CLAUDE_CODE_KEYCHAIN_SERVICES) {
280
- const raw = readMacosKeychainPassword({ service, securityRunner });
318
+ const raw = readMacosKeychainPassword({ service, securityRunner, env });
281
319
  if (raw) rawPayloads.push(raw);
282
320
  }
283
321
  } else if (usesClaudeCodeCredentialsFile(platform)) {
@@ -327,14 +365,14 @@ async function collectLocalSubscriptions({
327
365
  if (opencode) out.push(opencode);
328
366
 
329
367
  if (probeKeychainDetails) {
330
- const claude = detectClaudeCodeSubscriptionDetails({ platform, securityRunner, home });
368
+ const claude = detectClaudeCodeSubscriptionDetails({ platform, securityRunner, home, env });
331
369
  if (claude) out.push(claude);
332
370
  else if (probeKeychain) {
333
- const present = detectClaudeCodeCredentialsPresence({ platform, securityRunner, home });
371
+ const present = detectClaudeCodeCredentialsPresence({ platform, securityRunner, home, env });
334
372
  if (present) out.push(present);
335
373
  }
336
374
  } else if (probeKeychain) {
337
- const claude = detectClaudeCodeCredentialsPresence({ platform, securityRunner, home });
375
+ const claude = detectClaudeCodeCredentialsPresence({ platform, securityRunner, home, env });
338
376
  if (claude) out.push(claude);
339
377
  }
340
378
 
@@ -364,14 +402,25 @@ async function detectOpenclawSessionIntegration({ home, env }) {
364
402
  };
365
403
  }
366
404
 
367
- function readClaudeCodeAccessToken({ platform = process.platform, securityRunner, home, fsReader } = {}) {
405
+ // Returns the live OAuth token plus its expiry stamp. The expiry doubles as a
406
+ // rotation marker for the 429 cool-down: it changes on every refresh and, unlike
407
+ // the token itself, is not a secret, so nothing derived from a credential has to
408
+ // be written to disk.
409
+ function readClaudeCodeOauthToken({
410
+ platform = process.platform,
411
+ securityRunner,
412
+ home,
413
+ fsReader,
414
+ env,
415
+ nowMs = Date.now(),
416
+ } = {}) {
368
417
  if (platform === "darwin") {
369
418
  for (const service of CLAUDE_CODE_KEYCHAIN_SERVICES) {
370
419
  try {
371
- const raw = readMacosKeychainPassword({ service, securityRunner });
420
+ const raw = readMacosKeychainPassword({ service, securityRunner, env });
372
421
  if (!raw) continue;
373
- const payload = JSON.parse(raw);
374
- return normalizeString(payload?.claudeAiOauth?.accessToken);
422
+ const oauth = extractClaudeCodeOauth(JSON.parse(raw), nowMs);
423
+ if (oauth) return oauth;
375
424
  } catch (_e) {
376
425
  continue;
377
426
  }
@@ -386,13 +435,16 @@ function readClaudeCodeAccessToken({ platform = process.platform, securityRunner
386
435
  const raw = readClaudeCodeCredentialsFile({ home, fsReader });
387
436
  if (!raw) return null;
388
437
  try {
389
- const payload = JSON.parse(raw);
390
- return normalizeString(payload?.claudeAiOauth?.accessToken);
438
+ return extractClaudeCodeOauth(JSON.parse(raw), nowMs);
391
439
  } catch (_e) {
392
440
  return null;
393
441
  }
394
442
  }
395
443
 
444
+ function readClaudeCodeAccessToken(options = {}) {
445
+ return readClaudeCodeOauthToken(options)?.accessToken ?? null;
446
+ }
447
+
396
448
  async function readCodexAccessToken({ home, env } = {}) {
397
449
  try {
398
450
  const codexHome = resolveCodexHome({ home, env });
@@ -445,10 +497,12 @@ async function readCodexAuthBundle({ home, env } = {}) {
445
497
  }
446
498
 
447
499
  module.exports = {
500
+ resolveClaudeKeychainAccount,
448
501
  collectLocalSubscriptions,
449
502
  detectClaudeCodeCredentialsPresence,
450
503
  detectClaudeCodeSubscriptionDetails,
451
504
  readClaudeCodeAccessToken,
505
+ readClaudeCodeOauthToken,
452
506
  readCodexAccessToken,
453
507
  readCodexAuthBundle,
454
508
  };