tokentracker-cli 0.42.0 → 0.43.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.
Files changed (43) hide show
  1. package/dashboard/dist/assets/{ActivityHeatmap-CImSj13A.js → ActivityHeatmap-0IDxIr7i.js} +1 -1
  2. package/dashboard/dist/assets/{Card-DpjFlMHF.js → Card-hVCKEGdC.js} +1 -1
  3. package/dashboard/dist/assets/DashboardPage-C3QvJSI2.js +19 -0
  4. package/dashboard/dist/assets/{DevicePage-BIBlmBPl.js → DevicePage-UVSutySl.js} +1 -1
  5. package/dashboard/dist/assets/{DialogTitle-BMKzpys0.js → DialogTitle-D0S8c1BO.js} +1 -1
  6. package/dashboard/dist/assets/{FadeIn-B5-cy4YN.js → FadeIn-anio8NgI.js} +1 -1
  7. package/dashboard/dist/assets/{HeaderGithubStar-BM-sbvJG.js → HeaderGithubStar-wRjA_Fvu.js} +1 -1
  8. package/dashboard/dist/assets/{IpCheckPage-C7-NXbFK.js → IpCheckPage-vl0aIVv0.js} +1 -1
  9. package/dashboard/dist/assets/{LandingPage-ouOFlABY.js → LandingPage-CJO4q92s.js} +1 -1
  10. package/dashboard/dist/assets/{LeaderboardAvatar-DMXPtCkx.js → LeaderboardAvatar-CjPVgiDo.js} +1 -1
  11. package/dashboard/dist/assets/{LeaderboardPage-CC4CFYAe.js → LeaderboardPage-DiQn0DqL.js} +3 -3
  12. package/dashboard/dist/assets/{LeaderboardProfileModal-D-ncIr24.js → LeaderboardProfileModal-yovVjgnB.js} +1 -1
  13. package/dashboard/dist/assets/{LeaderboardProfilePage-DtDMfVuQ.js → LeaderboardProfilePage-C0vqt4m5.js} +1 -1
  14. package/dashboard/dist/assets/{LimitsPage-qaD7Reti.js → LimitsPage-8o294HKr.js} +1 -1
  15. package/dashboard/dist/assets/{LocalOnlyNotice-BCy18fW4.js → LocalOnlyNotice-BQsPhRvE.js} +1 -1
  16. package/dashboard/dist/assets/{LoginPage-BLPY2ZI-.js → LoginPage-D8hQf3xT.js} +1 -1
  17. package/dashboard/dist/assets/{PopoverPopup-CpIlpO0j.js → PopoverPopup-rhSAh0x6.js} +1 -1
  18. package/dashboard/dist/assets/{Select-BOxMknxo.js → Select-ByLRQqzg.js} +1 -1
  19. package/dashboard/dist/assets/{SelectItemText-B0SL3Wb5.js → SelectItemText-CHHy8UA1.js} +1 -1
  20. package/dashboard/dist/assets/{SettingsPage-De7GbtPf.js → SettingsPage-DGyuX3ua.js} +1 -1
  21. package/dashboard/dist/assets/SkillsPage-CMsfSZar.js +1 -0
  22. package/dashboard/dist/assets/{WidgetsPage-BaEYiZZH.js → WidgetsPage-DavgYHA1.js} +1 -1
  23. package/dashboard/dist/assets/{WrappedPage-BYhKuiTb.js → WrappedPage-BARpWxxH.js} +1 -1
  24. package/dashboard/dist/assets/{agent-logos-DSQgCNll.js → agent-logos-BsEMlmFc.js} +1 -1
  25. package/dashboard/dist/assets/{arrow-up-right-Brwh94cN.js → arrow-up-right-Nh5NXsRd.js} +1 -1
  26. package/dashboard/dist/assets/{download-kFiot71R.js → download-Bz-ad2Zi.js} +1 -1
  27. package/dashboard/dist/assets/{info-CGx4zWQF.js → info-Edlzr0qR.js} +1 -1
  28. package/dashboard/dist/assets/main-BfK9LoKV.css +1 -0
  29. package/dashboard/dist/assets/{main-9Ze1SUIZ.js → main-CGYVeoRd.js} +2 -2
  30. package/dashboard/dist/assets/{use-limits-display-prefs-Ehri95OP.js → use-limits-display-prefs-Cc82ZSkQ.js} +1 -1
  31. package/dashboard/dist/assets/{use-native-settings-BWqG5RDr.js → use-native-settings-rTdpowec.js} +1 -1
  32. package/dashboard/dist/assets/{use-usage-limits-2ZxmPfoP.js → use-usage-limits-DFjNciSe.js} +1 -1
  33. package/dashboard/dist/assets/{useCurrency-DQmyhw3i.js → useCurrency-BY5HnhWy.js} +1 -1
  34. package/dashboard/dist/assets/{useScrollLock-BcLeWqWK.js → useScrollLock-oNpe5Ufe.js} +1 -1
  35. package/dashboard/dist/index.html +2 -2
  36. package/dashboard/dist/share.html +2 -2
  37. package/package.json +1 -1
  38. package/src/lib/local-api.js +31 -10
  39. package/src/lib/pricing/seed-snapshot.json +1 -1
  40. package/src/lib/skills-manager.js +134 -41
  41. package/dashboard/dist/assets/DashboardPage-CZkSRYE8.js +0 -19
  42. package/dashboard/dist/assets/SkillsPage-jpoYzUBQ.js +0 -1
  43. package/dashboard/dist/assets/main-DMJJVAOH.css +0 -1
@@ -174,13 +174,22 @@ function sanitizePathSegment(value) {
174
174
  }
175
175
 
176
176
  function sanitizeRelativePath(value) {
177
- const raw = String(value || "").replace(/\\/g, "/").trim();
178
- if (!raw || raw.startsWith("/") || raw.includes("\0")) return null;
177
+ const input = String(value || "").trim();
178
+ const raw = input.replace(/\\/g, "/");
179
+ if (!raw || raw.includes("\0")) return null;
180
+ if (path.posix.isAbsolute(raw) || path.win32.isAbsolute(input) || path.win32.isAbsolute(raw)) return null;
179
181
  const parts = raw.split("/").filter(Boolean);
180
- if (!parts.length || parts.some((part) => part === "." || part === "..")) return null;
182
+ if (!parts.length || parts.some((part) => part === "." || part === ".." || part.includes(":"))) return null;
181
183
  return parts.join("/");
182
184
  }
183
185
 
186
+ function sanitizeLocalSkillPath(value) {
187
+ const safe = sanitizeRelativePath(value);
188
+ if (!safe) return null;
189
+ if (safe.split("/").some((part) => part.startsWith("."))) return null;
190
+ return safe;
191
+ }
192
+
184
193
  function installNameFromDirectory(directory) {
185
194
  const safe = sanitizeRelativePath(directory);
186
195
  if (!safe) return null;
@@ -256,6 +265,36 @@ function findSkillMarker(dir) {
256
265
  return null;
257
266
  }
258
267
 
268
+ const MAX_LOCAL_SKILL_SCAN_DEPTH = 3;
269
+
270
+ function scanSkillDirectories(rootDir) {
271
+ const found = [];
272
+ const walk = (dir, relDir = "", depth = 0) => {
273
+ let entries = [];
274
+ try {
275
+ entries = fs.readdirSync(dir, { withFileTypes: true });
276
+ } catch (_e) {
277
+ return;
278
+ }
279
+ entries.sort((a, b) => a.name.localeCompare(b.name));
280
+ for (const entry of entries) {
281
+ if (!entry.isDirectory() && !entry.isSymbolicLink()) continue;
282
+ if (!entry.name || entry.name.startsWith(".")) continue;
283
+ const rel = relDir ? `${relDir}/${entry.name}` : entry.name;
284
+ const full = path.join(dir, entry.name);
285
+ if (findSkillMarker(full)) {
286
+ found.push(rel);
287
+ continue;
288
+ }
289
+ // Direct symlinked skills are accepted above, but symlinked group folders
290
+ // are not traversed so the scan stays within the target skills tree.
291
+ if (entry.isDirectory() && depth + 1 < MAX_LOCAL_SKILL_SCAN_DEPTH) walk(full, rel, depth + 1);
292
+ }
293
+ };
294
+ walk(rootDir);
295
+ return found;
296
+ }
297
+
259
298
  const HASH_IGNORE = new Set([".git", ".DS_Store", "Thumbs.db", ".gitignore"]);
260
299
 
261
300
  // Stable content fingerprint of a skill directory: walk files in sorted order,
@@ -531,19 +570,67 @@ function isSymlink(targetPath) {
531
570
  }
532
571
  }
533
572
 
573
+ function targetSkillPath(baseDir, directory) {
574
+ const safe = sanitizeRelativePath(directory);
575
+ if (!safe) return null;
576
+ const root = path.resolve(baseDir);
577
+ const targetPath = path.resolve(root, safe);
578
+ if (!pathStrictlyWithin(root, targetPath)) return null;
579
+ try {
580
+ const rootStat = fs.lstatSync(root);
581
+ if (rootStat.isSymbolicLink() || !rootStat.isDirectory()) return null;
582
+ } catch (e) {
583
+ if (e?.code !== "ENOENT") return null;
584
+ }
585
+ const parts = safe.split("/");
586
+ let current = root;
587
+ for (let i = 0; i < parts.length - 1; i += 1) {
588
+ current = path.join(current, parts[i]);
589
+ let stat;
590
+ try {
591
+ stat = fs.lstatSync(current);
592
+ } catch (e) {
593
+ if (e?.code === "ENOENT") continue;
594
+ return null;
595
+ }
596
+ if (stat.isSymbolicLink() || !stat.isDirectory()) return null;
597
+ }
598
+ return targetPath;
599
+ }
600
+
601
+ function managedSkillPath(directory) {
602
+ const skillPath = targetSkillPath(ssotDir(), directory);
603
+ if (!skillPath) throw new Error(`Invalid skill directory: ${directory}`);
604
+ return skillPath;
605
+ }
606
+
534
607
  function copyDir(source, dest) {
535
608
  assertNotNested(source, dest);
536
609
  removePath(dest);
537
610
  fs.cpSync(source, dest, { recursive: true, force: true });
538
611
  }
539
612
 
613
+ function removeEmptyAncestors(startDir, stopDir) {
614
+ let current = path.resolve(startDir);
615
+ const stop = path.resolve(stopDir);
616
+ while (pathStrictlyWithin(stop, current)) {
617
+ try {
618
+ fs.rmdirSync(current);
619
+ } catch (_e) {
620
+ return;
621
+ }
622
+ current = path.dirname(current);
623
+ }
624
+ }
625
+
540
626
  function syncSkillToTarget(directory, targetId) {
541
627
  const target = TARGETS[targetId];
542
628
  if (!target) throw new Error(`Unsupported target: ${targetId}`);
543
- const source = path.join(ssotDir(), directory);
629
+ const source = managedSkillPath(directory);
544
630
  if (!fs.existsSync(source)) throw new Error(`Managed skill not found: ${directory}`);
545
631
  for (const baseDir of targetDirs(target)) {
546
- const dest = path.join(baseDir, directory);
632
+ const dest = targetSkillPath(baseDir, directory);
633
+ if (!dest) throw new Error(`Invalid skill directory: ${directory}`);
547
634
  assertNotNested(source, dest);
548
635
  ensureDir(path.dirname(dest));
549
636
  removePath(dest);
@@ -559,7 +646,10 @@ function removeSkillFromTarget(directory, targetId) {
559
646
  const target = TARGETS[targetId];
560
647
  if (!target) return;
561
648
  for (const baseDir of targetDirs(target)) {
562
- removePath(path.join(baseDir, directory));
649
+ const targetPath = targetSkillPath(baseDir, directory);
650
+ if (!targetPath) continue;
651
+ removePath(targetPath);
652
+ removeEmptyAncestors(path.dirname(targetPath), baseDir);
563
653
  }
564
654
  }
565
655
 
@@ -567,7 +657,8 @@ function scanTargetSkill(directory, targetId) {
567
657
  const target = TARGETS[targetId];
568
658
  if (!target) return false;
569
659
  for (const baseDir of targetDirs(target)) {
570
- const candidate = path.join(baseDir, directory);
660
+ const candidate = targetSkillPath(baseDir, directory);
661
+ if (!candidate) continue;
571
662
  if (fs.existsSync(candidate) || isSymlink(candidate)) return true;
572
663
  }
573
664
  return false;
@@ -582,7 +673,8 @@ function classifyTargetSkill(directory, targetId) {
582
673
  if (!target) return "off";
583
674
  let state = "off";
584
675
  for (const baseDir of targetDirs(target)) {
585
- const candidate = path.join(baseDir, directory);
676
+ const candidate = targetSkillPath(baseDir, directory);
677
+ if (!candidate) continue;
586
678
  if (fs.existsSync(candidate)) return "synced";
587
679
  if (isSymlink(candidate)) state = "orphan";
588
680
  }
@@ -612,19 +704,11 @@ function listInstalledSkills() {
612
704
  const unmanaged = new Map();
613
705
  for (const target of Object.values(TARGETS)) {
614
706
  for (const dir of targetDirs(target)) {
615
- let entries = [];
616
- try {
617
- entries = fs.readdirSync(dir, { withFileTypes: true });
618
- } catch (_e) {
619
- continue;
620
- }
621
- for (const entry of entries) {
622
- if (!entry.isDirectory() && !entry.isSymbolicLink()) continue;
623
- const directory = entry.name;
624
- if (!directory || directory.startsWith(".") || managedDirs.has(directory.toLowerCase())) continue;
707
+ for (const directory of scanSkillDirectories(dir)) {
708
+ if (!directory || managedDirs.has(directory.toLowerCase())) continue;
625
709
  const skillPath = findSkillMarker(path.join(dir, directory));
626
710
  if (!skillPath) continue;
627
- const metadata = readSkillMetadata(fs.readFileSync(skillPath, "utf8"), directory);
711
+ const metadata = readSkillMetadata(fs.readFileSync(skillPath, "utf8"), installNameFromDirectory(directory) || directory);
628
712
  const key = directory.toLowerCase();
629
713
  if (!unmanaged.has(key)) {
630
714
  unmanaged.set(key, {
@@ -670,6 +754,8 @@ async function installSkill(skillInput, targetIds = ["claude", "codex"]) {
670
754
  };
671
755
  if (!skill.repoOwner || !skill.repoName) throw new Error("Missing GitHub repository information");
672
756
  const sourceDir = sanitizeRelativePath(skill.directory);
757
+ // GitHub-sourced skills keep the historical flat install name even when
758
+ // sourceDirectory is nested; local nested-skill support uses importLocalSkill().
673
759
  const installName = installNameFromDirectory(sourceDir);
674
760
  if (!sourceDir || !installName) throw new Error("Invalid skill directory");
675
761
 
@@ -695,7 +781,7 @@ async function installSkill(skillInput, targetIds = ["claude", "codex"]) {
695
781
  );
696
782
  if (!files.some((entry) => /(^|\/)SKILL\.md$/i.test(entry.path))) throw new Error("SKILL.md not found in selected directory");
697
783
 
698
- const dest = path.join(ssotDir(), installName);
784
+ const dest = managedSkillPath(installName);
699
785
  const temp = path.join(dataDir(), "tmp", `${installName}-${Date.now()}`);
700
786
  removePath(temp);
701
787
  ensureDir(temp);
@@ -751,16 +837,18 @@ function uninstallSkill(id) {
751
837
  const registry = readRegistry();
752
838
  const skill = registry.skills.find((entry) => entry.id === id || entry.key === id);
753
839
  if (!skill) throw new Error("Managed skill not found");
840
+ const ssotPath = managedSkillPath(skill.directory);
754
841
  for (const targetId of Object.keys(TARGETS)) removeSkillFromTarget(skill.directory, targetId);
755
842
  // Move SSOT copy into a trash bucket so it can be restored briefly. The
756
843
  // registry entry is retained but flagged so restoreSkill can re-link it.
757
- const ssotPath = path.join(ssotDir(), skill.directory);
758
844
  if (fs.existsSync(ssotPath)) {
759
845
  ensureDir(trashDir());
760
846
  const stamp = Date.now();
761
- const trashPath = path.join(trashDir(), `${skill.directory}-${stamp}`);
847
+ const trashName = `${Buffer.from(String(skill.directory || ""), "utf8").toString("base64url")}-${stamp}`;
848
+ const trashPath = path.join(trashDir(), trashName);
762
849
  try {
763
850
  fs.renameSync(ssotPath, trashPath);
851
+ removeEmptyAncestors(path.dirname(ssotPath), ssotDir());
764
852
  skill.trashedAt = stamp;
765
853
  skill.trashedDirectory = path.basename(trashPath);
766
854
  skill.previousTargets = skill.targets || [];
@@ -773,6 +861,7 @@ function uninstallSkill(id) {
773
861
  return { ok: true, trashed: true, restoreId: skill.id, ttlMs: TRASH_TTL_MS };
774
862
  } catch (_e) {
775
863
  removePath(ssotPath);
864
+ removeEmptyAncestors(path.dirname(ssotPath), ssotDir());
776
865
  }
777
866
  }
778
867
  registry.skills = registry.skills.filter((entry) => entry.id !== skill.id);
@@ -808,7 +897,7 @@ function restoreSkill(id) {
808
897
  throw new Error("Restore window expired");
809
898
  }
810
899
  const trashPath = path.join(trashDir(), skill.trashedDirectory || "");
811
- const ssotPath = path.join(ssotDir(), skill.directory);
900
+ const ssotPath = managedSkillPath(skill.directory);
812
901
  if (!fs.existsSync(trashPath)) throw new Error("Trashed copy is missing");
813
902
  ensureDir(path.dirname(ssotPath));
814
903
  removePath(ssotPath);
@@ -840,11 +929,12 @@ function setSkillTargets(id, targetIds) {
840
929
  }
841
930
 
842
931
  function findLocalSkillSource(directory) {
843
- const installName = sanitizePathSegment(directory);
844
- if (!installName) return null;
932
+ const sourceDir = sanitizeLocalSkillPath(directory);
933
+ if (!sourceDir) return null;
845
934
  for (const target of Object.values(TARGETS)) {
846
935
  for (const baseDir of targetDirs(target)) {
847
- const skillPath = path.join(baseDir, installName);
936
+ const skillPath = targetSkillPath(baseDir, sourceDir);
937
+ if (!skillPath) continue;
848
938
  if (findSkillMarker(skillPath)) {
849
939
  return { path: skillPath, targetId: target.id };
850
940
  }
@@ -854,33 +944,36 @@ function findLocalSkillSource(directory) {
854
944
  }
855
945
 
856
946
  function importLocalSkill(directory, targetIds = []) {
857
- const installName = sanitizePathSegment(directory);
858
- if (!installName) throw new Error("Invalid skill directory");
947
+ const sourceDir = sanitizeLocalSkillPath(directory);
948
+ if (!sourceDir) throw new Error("Invalid skill directory");
859
949
  const registry = readRegistry();
860
- const existing = registry.skills.find((entry) => entry.directory.toLowerCase() === installName.toLowerCase());
950
+ const existing = registry.skills.find((entry) => String(entry.directory || "").toLowerCase() === sourceDir.toLowerCase());
861
951
  if (existing) {
952
+ if (!String(existing.id || existing.key || "").startsWith("local:")) {
953
+ throw new Error(`Skill directory "${sourceDir}" is already managed by another installed skill`);
954
+ }
862
955
  if (!targetIds || !targetIds.length) {
863
956
  return { ...existing, managed: true, targets: existing.targets || [] };
864
957
  }
865
958
  return setSkillTargets(existing.id, targetIds);
866
959
  }
867
960
 
868
- const source = findLocalSkillSource(installName);
961
+ const source = findLocalSkillSource(sourceDir);
869
962
  if (!source) throw new Error("Local skill not found");
870
963
 
871
- const dest = path.join(ssotDir(), installName);
964
+ const dest = managedSkillPath(sourceDir);
872
965
  copyDir(source.path, dest);
873
966
  const skillMarker = findSkillMarker(dest);
874
- const metadata = readSkillMetadata(skillMarker ? fs.readFileSync(skillMarker, "utf8") : "", installName);
875
- const discoveredTargets = Object.keys(TARGETS).filter((targetId) => scanTargetSkill(installName, targetId));
967
+ const metadata = readSkillMetadata(skillMarker ? fs.readFileSync(skillMarker, "utf8") : "", installNameFromDirectory(sourceDir));
968
+ const discoveredTargets = Object.keys(TARGETS).filter((targetId) => scanTargetSkill(sourceDir, targetId));
876
969
  const selectedTargets = (targetIds.length ? targetIds : discoveredTargets).filter((targetId) => TARGETS[targetId]);
877
970
  const skill = {
878
- id: `local:${installName}`,
879
- key: `local:${installName}`,
971
+ id: `local:${sourceDir}`,
972
+ key: `local:${sourceDir}`,
880
973
  name: metadata.name,
881
974
  description: metadata.description,
882
- directory: installName,
883
- sourceDirectory: installName,
975
+ directory: sourceDir,
976
+ sourceDirectory: sourceDir,
884
977
  readmeUrl: null,
885
978
  repoOwner: null,
886
979
  repoName: null,
@@ -893,15 +986,15 @@ function importLocalSkill(directory, targetIds = []) {
893
986
  registry.skills.push(skill);
894
987
  saveRegistry(registry);
895
988
  for (const targetId of Object.keys(TARGETS)) {
896
- if (selectedTargets.includes(targetId)) syncSkillToTarget(installName, targetId);
897
- else removeSkillFromTarget(installName, targetId);
989
+ if (selectedTargets.includes(targetId)) syncSkillToTarget(sourceDir, targetId);
990
+ else removeSkillFromTarget(sourceDir, targetId);
898
991
  }
899
- appendActivity({ action: "import", name: skill.name, directory: installName, targets: selectedTargets });
992
+ appendActivity({ action: "import", name: skill.name, directory: sourceDir, targets: selectedTargets });
900
993
  return { ...skill, managed: true, targets: selectedTargets };
901
994
  }
902
995
 
903
996
  function deleteLocalSkill(directory, targetIds = []) {
904
- const installName = sanitizePathSegment(directory);
997
+ const installName = sanitizeLocalSkillPath(directory);
905
998
  if (!installName) throw new Error("Invalid skill directory");
906
999
  const selectedTargets = targetIds.length ? targetIds : Object.keys(TARGETS);
907
1000
  for (const targetId of selectedTargets) removeSkillFromTarget(installName, targetId);