tokentracker-cli 0.29.2 → 0.31.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 (63) hide show
  1. package/README.ja.md +3 -1
  2. package/README.ko.md +3 -1
  3. package/README.md +3 -1
  4. package/README.zh-CN.md +3 -1
  5. package/dashboard/dist/assets/ActivityHeatmap-D8lXQtTi.js +42 -0
  6. package/dashboard/dist/assets/{Card-DSgpCS30.js → Card-CzL-eInd.js} +1 -1
  7. package/dashboard/dist/assets/DashboardPage-CsR8XWxj.js +19 -0
  8. package/dashboard/dist/assets/{DevicePage-CvrCNfjD.js → DevicePage-XTngLOwj.js} +1 -1
  9. package/dashboard/dist/assets/DialogTitle-Csg36lAf.js +12 -0
  10. package/dashboard/dist/assets/FadeIn-Db32waF2.js +1 -0
  11. package/dashboard/dist/assets/{HeaderGithubStar-B98VaHbR.js → HeaderGithubStar-DliHDaOa.js} +1 -1
  12. package/dashboard/dist/assets/{IpCheckPage-CJxv_Sd8.js → IpCheckPage-F5CeRlub.js} +1 -1
  13. package/dashboard/dist/assets/LandingPage-2_sEF9mN.js +4356 -0
  14. package/dashboard/dist/assets/{LeaderboardAvatar-D6qRteef.js → LeaderboardAvatar-C9VP8195.js} +1 -1
  15. package/dashboard/dist/assets/{LeaderboardPage-CgtoFmMi.js → LeaderboardPage-Za_4Qq0N.js} +3 -3
  16. package/dashboard/dist/assets/{LeaderboardProfileModal-OTLhvzV6.js → LeaderboardProfileModal-BGDd6bjd.js} +2 -2
  17. package/dashboard/dist/assets/LeaderboardProfilePage-CuGbG8_n.js +1 -0
  18. package/dashboard/dist/assets/LimitsPage-DlcSiBHP.js +2 -0
  19. package/dashboard/dist/assets/{LocalOnlyNotice-3LPVUJ2h.js → LocalOnlyNotice-BNFZWAB2.js} +1 -1
  20. package/dashboard/dist/assets/LoginPage-UkyGRtTz.js +1 -0
  21. package/dashboard/dist/assets/PopoverPopup-Bg0LqxYF.js +1 -0
  22. package/dashboard/dist/assets/SettingsPage-D3gNWvI6.js +1 -0
  23. package/dashboard/dist/assets/SkillsPage-ChO2jmQM.js +1 -0
  24. package/dashboard/dist/assets/WidgetsPage-BzPEEeI3.js +1 -0
  25. package/dashboard/dist/assets/{WrappedPage-Cp8uyLbu.js → WrappedPage-DpP5X0ug.js} +1 -1
  26. package/dashboard/dist/assets/agent-logos-BgjfCDVs.js +1 -0
  27. package/dashboard/dist/assets/{arrow-up-right-CBzDSqD7.js → arrow-up-right-BTb5Q4za.js} +1 -1
  28. package/dashboard/dist/assets/check-L6OoQyFg.js +1 -0
  29. package/dashboard/dist/assets/{chevron-down--Hb5e71i.js → chevron-down-DaLDjB50.js} +1 -1
  30. package/dashboard/dist/assets/{download-BaVXaxbw.js → download-B3izgOD2.js} +1 -1
  31. package/dashboard/dist/assets/{info-WGRGGnfx.js → info-D9m3SBry.js} +1 -1
  32. package/dashboard/dist/assets/main-CvZdsRCc.css +1 -0
  33. package/dashboard/dist/assets/main-y2PTR9Ni.js +959 -0
  34. package/dashboard/dist/assets/{use-limits-display-prefs-C1AkRZcO.js → use-limits-display-prefs--sTkiVYR.js} +1 -1
  35. package/dashboard/dist/assets/{use-native-settings-DdooJHwm.js → use-native-settings-CI8nzpC4.js} +1 -1
  36. package/dashboard/dist/assets/{use-usage-limits-D1mepldk.js → use-usage-limits-DHoQOqOL.js} +1 -1
  37. package/dashboard/dist/assets/useCurrency-DyV36y25.js +1 -0
  38. package/dashboard/dist/dashboard-dark.png +0 -0
  39. package/dashboard/dist/index.html +2 -2
  40. package/dashboard/dist/share.html +2 -2
  41. package/package.json +1 -1
  42. package/src/lib/local-api.js +67 -0
  43. package/src/lib/pricing/seed-snapshot.json +1 -1
  44. package/src/lib/skill-usage.js +267 -0
  45. package/src/lib/skills-manager.js +371 -13
  46. package/dashboard/dist/assets/ActivityHeatmap-C7-tnMYf.js +0 -42
  47. package/dashboard/dist/assets/DashboardPage-CB6VzM2W.js +0 -1
  48. package/dashboard/dist/assets/DialogTitle-B-afswln.js +0 -12
  49. package/dashboard/dist/assets/FadeIn-D1-QPLIW.js +0 -1
  50. package/dashboard/dist/assets/LandingPage-CIOqWNDm.js +0 -4356
  51. package/dashboard/dist/assets/LeaderboardProfilePage-C7OaZcf2.js +0 -1
  52. package/dashboard/dist/assets/LimitsPage-pyyJbh3F.js +0 -2
  53. package/dashboard/dist/assets/LoginPage-CDLz8PnP.js +0 -1
  54. package/dashboard/dist/assets/PopoverPopup-97Rik7mb.js +0 -1
  55. package/dashboard/dist/assets/ProviderIcon-XOgnf2pw.js +0 -1
  56. package/dashboard/dist/assets/SettingsPage-KVjKUepK.js +0 -1
  57. package/dashboard/dist/assets/SkillsPage-DyIY1U9O.js +0 -1
  58. package/dashboard/dist/assets/WidgetsPage-BU7ejAYd.js +0 -1
  59. package/dashboard/dist/assets/check-Bsgzg2T8.js +0 -1
  60. package/dashboard/dist/assets/main-ClqhPPd3.css +0 -1
  61. package/dashboard/dist/assets/main-DDTfmaoo.js +0 -854
  62. package/dashboard/dist/assets/use-reduced-motion-wvh7iFTi.js +0 -1
  63. package/dashboard/dist/assets/useCurrency-C3szwbkj.js +0 -1
@@ -1,6 +1,7 @@
1
1
  const fs = require("node:fs");
2
2
  const os = require("node:os");
3
3
  const path = require("node:path");
4
+ const crypto = require("node:crypto");
4
5
  const { resolveGrokHome } = require("./grok-hook");
5
6
  const { resolveAntigravitySkillDirs } = require("./antigravity-paths");
6
7
 
@@ -88,6 +89,51 @@ function discoverCachePath() {
88
89
  return path.join(dataDir(), "discover-cache.json");
89
90
  }
90
91
 
92
+ function activityPath() {
93
+ return path.join(dataDir(), "activity.jsonl");
94
+ }
95
+
96
+ const ACTIVITY_MAX = 500;
97
+
98
+ // Append-only skill activity log, mirroring the queue.jsonl culture: best-effort,
99
+ // auto-capped, latest-wins on read. Privacy: verbs + skill name + targets only —
100
+ // never prompts or file contents. Never throws (logging must not block a mutation).
101
+ function appendActivity(event) {
102
+ try {
103
+ ensureDir(dataDir());
104
+ const record = JSON.stringify({ ts: Date.now(), ...event });
105
+ fs.appendFileSync(activityPath(), `${record}\n`, { mode: 0o600 });
106
+ const stat = fs.statSync(activityPath());
107
+ if (stat.size > 256 * 1024) {
108
+ const lines = fs.readFileSync(activityPath(), "utf8").split("\n").filter(Boolean).slice(-ACTIVITY_MAX);
109
+ fs.writeFileSync(activityPath(), `${lines.join("\n")}\n`, { mode: 0o600 });
110
+ }
111
+ } catch (_e) {
112
+ // best-effort
113
+ }
114
+ }
115
+
116
+ function readActivity(limit = 100) {
117
+ try {
118
+ const raw = fs.readFileSync(activityPath(), "utf8");
119
+ const lines = raw.split("\n").filter(Boolean);
120
+ const want = Math.max(1, Math.min(ACTIVITY_MAX, Number(limit) || 100));
121
+ return lines
122
+ .slice(-want)
123
+ .map((line) => {
124
+ try {
125
+ return JSON.parse(line);
126
+ } catch (_e) {
127
+ return null;
128
+ }
129
+ })
130
+ .filter(Boolean)
131
+ .reverse();
132
+ } catch (_e) {
133
+ return [];
134
+ }
135
+ }
136
+
91
137
  function ensureDir(dir) {
92
138
  fs.mkdirSync(dir, { recursive: true });
93
139
  }
@@ -151,18 +197,130 @@ function targetList() {
151
197
  }));
152
198
  }
153
199
 
200
+ // Read a single scalar field from YAML frontmatter. Handles inline values
201
+ // (`key: value`, optionally quoted) AND block scalars (`key: >` / `key: |`, with
202
+ // optional chomping `+`/`-`), where the value lives on the following indented
203
+ // lines. Without block-scalar support, `description: >` skills surfaced their
204
+ // description as a bare ">" or "|" (the block indicator itself).
205
+ function readYamlField(yaml, key) {
206
+ const lines = String(yaml).split("\n");
207
+ const header = new RegExp(`^(\\s*)${key}:[ \\t]*(.*)$`);
208
+ for (let i = 0; i < lines.length; i += 1) {
209
+ const match = lines[i].match(header);
210
+ if (!match) continue;
211
+ const indent = match[1].length;
212
+ const inline = match[2].trim();
213
+ if (/^[>|][+-]?$/.test(inline)) {
214
+ const collected = [];
215
+ for (let j = i + 1; j < lines.length; j += 1) {
216
+ if (lines[j].trim() === "") {
217
+ collected.push("");
218
+ continue;
219
+ }
220
+ const lineIndent = lines[j].match(/^(\s*)/)[1].length;
221
+ if (lineIndent <= indent) break; // dedent ends the block
222
+ collected.push(lines[j].trim());
223
+ }
224
+ return collected.join(" ");
225
+ }
226
+ return inline.replace(/^["']/, "").replace(/["']$/, "");
227
+ }
228
+ return "";
229
+ }
230
+
154
231
  function readSkillMetadata(markdown, fallbackName) {
155
232
  const raw = String(markdown || "");
156
233
  const frontmatter = raw.match(/^---\s*\n([\s\S]*?)\n---/);
157
234
  const source = frontmatter ? frontmatter[1] : raw;
158
- const nameMatch = source.match(/^name:\s*["']?(.+?)["']?\s*$/m);
159
- const descriptionMatch = source.match(/^description:\s*["']?([\s\S]+?)["']?\s*$/m);
235
+ const name = readYamlField(source, "name") || fallbackName || "Skill";
236
+ const description = readYamlField(source, "description");
160
237
  return {
161
- name: (nameMatch?.[1] || fallbackName || "Skill").trim(),
162
- description: (descriptionMatch?.[1] || "").replace(/\n\s+/g, " ").trim(),
238
+ name: name.trim(),
239
+ description: description.replace(/\s+/g, " ").trim(),
163
240
  };
164
241
  }
165
242
 
243
+ // Skills are marked by SKILL.md (canonical) or skill.md (legacy). Discovery uses
244
+ // a case-insensitive regex, so detection/adoption MUST accept both spellings —
245
+ // otherwise a lowercase skill.md installs but is invisible to the unmanaged scan
246
+ // and to local-skill adoption. Returns the marker's absolute path, or null.
247
+ function findSkillMarker(dir) {
248
+ for (const name of ["SKILL.md", "skill.md"]) {
249
+ const candidate = path.join(dir, name);
250
+ try {
251
+ if (fs.statSync(candidate).isFile()) return candidate;
252
+ } catch (_e) {
253
+ // not present under this spelling
254
+ }
255
+ }
256
+ return null;
257
+ }
258
+
259
+ const HASH_IGNORE = new Set([".git", ".DS_Store", "Thumbs.db", ".gitignore"]);
260
+
261
+ // Stable content fingerprint of a skill directory: walk files in sorted order,
262
+ // hashing each relative path + exec bit + bytes. Normalization-tolerant
263
+ // (ignores VCS/OS noise) so it answers "did this skill change?" cheaply. Used to
264
+ // record what was installed so checkUpdates() can detect upstream drift.
265
+ function hashDirectory(dir) {
266
+ const hash = crypto.createHash("sha256");
267
+ const walk = (relDir) => {
268
+ const absDir = relDir ? path.join(dir, relDir) : dir;
269
+ let entries;
270
+ try {
271
+ entries = fs.readdirSync(absDir, { withFileTypes: true });
272
+ } catch (_e) {
273
+ return;
274
+ }
275
+ entries.sort((a, b) => a.name.localeCompare(b.name));
276
+ for (const entry of entries) {
277
+ if (HASH_IGNORE.has(entry.name)) continue;
278
+ const rel = relDir ? `${relDir}/${entry.name}` : entry.name;
279
+ if (entry.isDirectory()) {
280
+ walk(rel);
281
+ } else if (entry.isFile()) {
282
+ const abs = path.join(dir, rel);
283
+ let stat;
284
+ try {
285
+ stat = fs.statSync(abs);
286
+ } catch (_e) {
287
+ continue;
288
+ }
289
+ const execBit = process.platform === "win32" ? 0 : stat.mode & 0o111 ? 1 : 0;
290
+ hash.update(`${rel}${execBit}`);
291
+ try {
292
+ hash.update(fs.readFileSync(abs));
293
+ } catch (_e) {
294
+ // unreadable file — fold its absence in deterministically
295
+ }
296
+ hash.update("");
297
+ }
298
+ }
299
+ };
300
+ walk("");
301
+ return hash.digest("hex");
302
+ }
303
+
304
+ function pathStrictlyWithin(parent, child) {
305
+ const rel = path.relative(parent, child);
306
+ return rel !== "" && !rel.startsWith("..") && !path.isAbsolute(rel);
307
+ }
308
+
309
+ // Guard against copying a directory into a DESCENDANT of itself, which makes
310
+ // cpSync recurse infinitely (dst/dst/dst…; issue #61 in the reference manager).
311
+ // Uses literal resolved paths, not realpath: a target that is a symlink pointing
312
+ // back at the SSOT source is a legitimate idempotent re-link (we removePath it
313
+ // before re-creating), not recursion — resolving symlinks here would wrongly
314
+ // reject every re-sync.
315
+ function assertNotNested(source, dest) {
316
+ const a = path.resolve(source);
317
+ const b = path.resolve(dest);
318
+ if (a === b) return; // same literal path = idempotent overwrite, not nesting
319
+ if (pathStrictlyWithin(a, b) || pathStrictlyWithin(b, a)) {
320
+ throw new Error("Refusing to sync a skill into its own directory tree");
321
+ }
322
+ }
323
+
166
324
  async function fetchJson(url) {
167
325
  const controller = new AbortController();
168
326
  const timeout = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
@@ -236,6 +394,26 @@ async function getRepoTree(repo) {
236
394
  throw lastError || new Error(`Unable to read ${repo.owner}/${repo.name}`);
237
395
  }
238
396
 
397
+ // Upstream signature for one skill subtree: hash the sorted "path:blobSha" pairs
398
+ // of every file under `sourceDir`. Git blob SHAs change iff content changes, so
399
+ // comparing a stored signature against a freshly fetched tree detects an
400
+ // "update available" using a SINGLE tree API call per repo — no per-file fetch.
401
+ function sourceSignatureFromTree(tree, sourceDir) {
402
+ if (!Array.isArray(tree) || !sourceDir) return null;
403
+ const prefix = `${sourceDir}/`;
404
+ const rels = tree
405
+ .filter(
406
+ (entry) =>
407
+ entry?.type === "blob" &&
408
+ entry.sha &&
409
+ (entry.path === sourceDir || String(entry.path || "").startsWith(prefix)),
410
+ )
411
+ .map((entry) => `${entry.path}:${entry.sha}`)
412
+ .sort();
413
+ if (!rels.length) return null;
414
+ return crypto.createHash("sha256").update(rels.join("\n")).digest("hex");
415
+ }
416
+
239
417
  function buildSkillKey(skill) {
240
418
  return `${skill.repoOwner}/${skill.repoName}:${skill.directory}`;
241
419
  }
@@ -259,7 +437,10 @@ async function discoverRepoSkills(repoInput) {
259
437
 
260
438
  const skills = await mapWithConcurrency(skillFiles, DISCOVER_CONCURRENCY, async (entry) => {
261
439
  const docPath = entry.path.replace(/\\/g, "/");
262
- const directory = docPath.endsWith("/SKILL.md") ? docPath.slice(0, -"/SKILL.md".length) : repo.name;
440
+ // Strip the marker case-insensitively (SKILL.md or legacy skill.md) so a
441
+ // lowercase-marker repo derives the right directory instead of falling
442
+ // through to repo.name — mirrors findSkillMarker() on the local side.
443
+ const directory = docPath.replace(/(^|\/)(?:SKILL|skill)\.md$/i, "") || repo.name;
263
444
  const installName = installNameFromDirectory(directory || repo.name);
264
445
  if (!installName) return null;
265
446
  let metadata = { name: installName, description: "" };
@@ -351,6 +532,7 @@ function isSymlink(targetPath) {
351
532
  }
352
533
 
353
534
  function copyDir(source, dest) {
535
+ assertNotNested(source, dest);
354
536
  removePath(dest);
355
537
  fs.cpSync(source, dest, { recursive: true, force: true });
356
538
  }
@@ -362,6 +544,7 @@ function syncSkillToTarget(directory, targetId) {
362
544
  if (!fs.existsSync(source)) throw new Error(`Managed skill not found: ${directory}`);
363
545
  for (const baseDir of targetDirs(target)) {
364
546
  const dest = path.join(baseDir, directory);
547
+ assertNotNested(source, dest);
365
548
  ensureDir(path.dirname(dest));
366
549
  removePath(dest);
367
550
  try {
@@ -390,14 +573,39 @@ function scanTargetSkill(directory, targetId) {
390
573
  return false;
391
574
  }
392
575
 
576
+ // Disk truth for one (skill, target): "synced" (present + resolvable),
577
+ // "orphan" (a dangling symlink whose SSOT source was deleted), or "off".
578
+ // Powers the tri-state agent dots so the UI never claims a skill is synced when
579
+ // its link is broken. For multi-dir targets, the healthiest state wins.
580
+ function classifyTargetSkill(directory, targetId) {
581
+ const target = TARGETS[targetId];
582
+ if (!target) return "off";
583
+ let state = "off";
584
+ for (const baseDir of targetDirs(target)) {
585
+ const candidate = path.join(baseDir, directory);
586
+ if (fs.existsSync(candidate)) return "synced";
587
+ if (isSymlink(candidate)) state = "orphan";
588
+ }
589
+ return state;
590
+ }
591
+
393
592
  function listInstalledSkills() {
394
593
  purgeExpiredTrash();
395
594
  const registry = readRegistry();
396
595
  const managed = registry.skills
397
596
  .filter((skill) => !skill.trashedAt)
398
597
  .map((skill) => {
399
- const targets = Object.keys(TARGETS).filter((id) => scanTargetSkill(skill.directory, id));
400
- return { ...skill, managed: true, targets };
598
+ const intended = new Set(skill.targets || []);
599
+ const targetStates = {};
600
+ const targets = [];
601
+ for (const id of Object.keys(TARGETS)) {
602
+ let state = classifyTargetSkill(skill.directory, id);
603
+ // Registry says it should be synced here, but disk lost it → orphan.
604
+ if (state === "off" && intended.has(id)) state = "orphan";
605
+ targetStates[id] = state;
606
+ if (state === "synced") targets.push(id);
607
+ }
608
+ return { ...skill, managed: true, targets, targetStates };
401
609
  });
402
610
 
403
611
  const managedDirs = new Set(managed.map((skill) => skill.directory.toLowerCase()));
@@ -414,8 +622,8 @@ function listInstalledSkills() {
414
622
  if (!entry.isDirectory() && !entry.isSymbolicLink()) continue;
415
623
  const directory = entry.name;
416
624
  if (!directory || directory.startsWith(".") || managedDirs.has(directory.toLowerCase())) continue;
417
- const skillPath = path.join(dir, directory, "SKILL.md");
418
- if (!fs.existsSync(skillPath)) continue;
625
+ const skillPath = findSkillMarker(path.join(dir, directory));
626
+ if (!skillPath) continue;
419
627
  const metadata = readSkillMetadata(fs.readFileSync(skillPath, "utf8"), directory);
420
628
  const key = directory.toLowerCase();
421
629
  if (!unmanaged.has(key)) {
@@ -432,11 +640,15 @@ function listInstalledSkills() {
432
640
  installedAt: null,
433
641
  managed: false,
434
642
  targets: [],
643
+ // Complete map (all agents default "off") so the frontend can trust
644
+ // targetStates as the single source of truth — same shape as managed.
645
+ targetStates: Object.fromEntries(Object.keys(TARGETS).map((id) => [id, "off"])),
435
646
  targetPaths: {},
436
647
  });
437
648
  }
438
649
  const skill = unmanaged.get(key);
439
650
  if (!skill.targets.includes(target.id)) skill.targets.push(target.id);
651
+ skill.targetStates[target.id] = "synced";
440
652
  if (!skill.targetPaths[target.id]) skill.targetPaths[target.id] = path.join(dir, directory);
441
653
  }
442
654
  }
@@ -504,7 +716,8 @@ async function installSkill(skillInput, targetIds = ["claude", "codex"]) {
504
716
  throw error;
505
717
  }
506
718
 
507
- const skillMd = fs.readFileSync(path.join(dest, "SKILL.md"), "utf8");
719
+ const skillMarker = findSkillMarker(dest);
720
+ const skillMd = skillMarker ? fs.readFileSync(skillMarker, "utf8") : "";
508
721
  const metadata = readSkillMetadata(skillMd, skill.name || installName);
509
722
  const selectedTargets = targetIds.filter((id) => TARGETS[id]);
510
723
  const installed = {
@@ -519,6 +732,9 @@ async function installSkill(skillInput, targetIds = ["claude", "codex"]) {
519
732
  repoName: skill.repoName,
520
733
  repoBranch: branch,
521
734
  installedAt: Date.now(),
735
+ // Fingerprints power idempotent reinstall + the "update available" badge.
736
+ contentHash: hashDirectory(dest),
737
+ sourceSignature: sourceSignatureFromTree(tree, sourceDir),
522
738
  targets: selectedTargets,
523
739
  };
524
740
 
@@ -527,6 +743,7 @@ async function installSkill(skillInput, targetIds = ["claude", "codex"]) {
527
743
  saveRegistry(registry);
528
744
 
529
745
  for (const id of selectedTargets) syncSkillToTarget(installName, id);
746
+ appendActivity({ action: "install", name: installed.name, directory: installName, targets: selectedTargets, source: `${skill.repoOwner}/${skill.repoName}` });
530
747
  return { ...installed, managed: true, targets: selectedTargets };
531
748
  }
532
749
 
@@ -552,6 +769,7 @@ function uninstallSkill(id) {
552
769
  registry.skills = [...others, skill];
553
770
  saveRegistry(registry);
554
771
  purgeExpiredTrash();
772
+ appendActivity({ action: "uninstall", name: skill.name, directory: skill.directory });
555
773
  return { ok: true, trashed: true, restoreId: skill.id, ttlMs: TRASH_TTL_MS };
556
774
  } catch (_e) {
557
775
  removePath(ssotPath);
@@ -559,6 +777,7 @@ function uninstallSkill(id) {
559
777
  }
560
778
  registry.skills = registry.skills.filter((entry) => entry.id !== skill.id);
561
779
  saveRegistry(registry);
780
+ appendActivity({ action: "uninstall", name: skill.name, directory: skill.directory });
562
781
  return { ok: true, trashed: false };
563
782
  }
564
783
 
@@ -601,6 +820,7 @@ function restoreSkill(id) {
601
820
  delete skill.previousTargets;
602
821
  saveRegistry(registry);
603
822
  for (const targetId of targets) syncSkillToTarget(skill.directory, targetId);
823
+ appendActivity({ action: "restore", name: skill.name, directory: skill.directory, targets });
604
824
  return { ...skill, managed: true, targets };
605
825
  }
606
826
 
@@ -615,6 +835,7 @@ function setSkillTargets(id, targetIds) {
615
835
  }
616
836
  skill.targets = selectedTargets;
617
837
  saveRegistry(registry);
838
+ appendActivity({ action: "set_targets", name: skill.name, directory: skill.directory, targets: selectedTargets });
618
839
  return { ...skill, managed: true, targets: selectedTargets };
619
840
  }
620
841
 
@@ -624,8 +845,7 @@ function findLocalSkillSource(directory) {
624
845
  for (const target of Object.values(TARGETS)) {
625
846
  for (const baseDir of targetDirs(target)) {
626
847
  const skillPath = path.join(baseDir, installName);
627
- const docPath = path.join(skillPath, "SKILL.md");
628
- if (fs.existsSync(docPath)) {
848
+ if (findSkillMarker(skillPath)) {
629
849
  return { path: skillPath, targetId: target.id };
630
850
  }
631
851
  }
@@ -650,7 +870,8 @@ function importLocalSkill(directory, targetIds = []) {
650
870
 
651
871
  const dest = path.join(ssotDir(), installName);
652
872
  copyDir(source.path, dest);
653
- const metadata = readSkillMetadata(fs.readFileSync(path.join(dest, "SKILL.md"), "utf8"), installName);
873
+ const skillMarker = findSkillMarker(dest);
874
+ const metadata = readSkillMetadata(skillMarker ? fs.readFileSync(skillMarker, "utf8") : "", installName);
654
875
  const discoveredTargets = Object.keys(TARGETS).filter((targetId) => scanTargetSkill(installName, targetId));
655
876
  const selectedTargets = (targetIds.length ? targetIds : discoveredTargets).filter((targetId) => TARGETS[targetId]);
656
877
  const skill = {
@@ -665,6 +886,7 @@ function importLocalSkill(directory, targetIds = []) {
665
886
  repoName: null,
666
887
  repoBranch: null,
667
888
  installedAt: Date.now(),
889
+ contentHash: hashDirectory(dest),
668
890
  targets: selectedTargets,
669
891
  };
670
892
 
@@ -674,6 +896,7 @@ function importLocalSkill(directory, targetIds = []) {
674
896
  if (selectedTargets.includes(targetId)) syncSkillToTarget(installName, targetId);
675
897
  else removeSkillFromTarget(installName, targetId);
676
898
  }
899
+ appendActivity({ action: "import", name: skill.name, directory: installName, targets: selectedTargets });
677
900
  return { ...skill, managed: true, targets: selectedTargets };
678
901
  }
679
902
 
@@ -682,6 +905,7 @@ function deleteLocalSkill(directory, targetIds = []) {
682
905
  if (!installName) throw new Error("Invalid skill directory");
683
906
  const selectedTargets = targetIds.length ? targetIds : Object.keys(TARGETS);
684
907
  for (const targetId of selectedTargets) removeSkillFromTarget(installName, targetId);
908
+ appendActivity({ action: "delete_local", directory: installName, targets: selectedTargets });
685
909
  return { ok: true };
686
910
  }
687
911
 
@@ -752,18 +976,152 @@ async function searchSkillsSh(query, limit = 20, offset = 0) {
752
976
  };
753
977
  }
754
978
 
979
+ const UPDATE_CACHE_TTL_MS = 60 * 60 * 1000; // 1 hour — bounds GitHub tree calls
980
+ const UPDATE_CHECK_CONCURRENCY = 2;
981
+
982
+ function updateCachePath() {
983
+ return path.join(dataDir(), "updates-cache.json");
984
+ }
985
+
986
+ // Compare each managed GitHub/skills.sh skill's stored source signature against a
987
+ // freshly fetched repo tree. One tree call per repo (skills from the same repo
988
+ // share it), concurrency-limited and cached for an hour so a background check
989
+ // can't trip GitHub's unauthenticated rate limit. Returns { updates: {id:bool} }.
990
+ // Read-only: never mutates the registry or the on-disk skills.
991
+ async function checkUpdates({ force = false } = {}) {
992
+ const registry = readRegistry();
993
+ const managed = registry.skills.filter(
994
+ (skill) => !skill.trashedAt && skill.repoOwner && skill.repoName && skill.sourceSignature,
995
+ );
996
+ const fingerprint = managed
997
+ .map((skill) => `${skill.id}@${skill.sourceSignature}`)
998
+ .sort()
999
+ .join("|");
1000
+
1001
+ if (!force) {
1002
+ const cached = readJson(updateCachePath(), null);
1003
+ if (
1004
+ cached &&
1005
+ cached.fingerprint === fingerprint &&
1006
+ Number.isFinite(cached.checkedAt) &&
1007
+ Date.now() - cached.checkedAt < UPDATE_CACHE_TTL_MS &&
1008
+ cached.updates &&
1009
+ typeof cached.updates === "object"
1010
+ ) {
1011
+ return { updates: cached.updates, checkedAt: cached.checkedAt, cached: true };
1012
+ }
1013
+ }
1014
+
1015
+ const byRepo = new Map();
1016
+ for (const skill of managed) {
1017
+ const branch = skill.repoBranch || "main";
1018
+ const key = `${skill.repoOwner}/${skill.repoName}@${branch}`.toLowerCase();
1019
+ if (!byRepo.has(key)) {
1020
+ byRepo.set(key, { owner: skill.repoOwner, name: skill.repoName, branch, skills: [] });
1021
+ }
1022
+ byRepo.get(key).skills.push(skill);
1023
+ }
1024
+
1025
+ const updates = {};
1026
+ await mapWithConcurrency(Array.from(byRepo.values()), UPDATE_CHECK_CONCURRENCY, async (repo) => {
1027
+ let tree;
1028
+ try {
1029
+ ({ tree } = await getRepoTree(repo));
1030
+ } catch (error) {
1031
+ if (error instanceof RateLimitError) throw error;
1032
+ return; // leave this repo's skills as unknown (omitted)
1033
+ }
1034
+ for (const skill of repo.skills) {
1035
+ const signature = sourceSignatureFromTree(tree, skill.sourceDirectory || skill.directory);
1036
+ if (signature) updates[skill.id] = signature !== skill.sourceSignature;
1037
+ }
1038
+ });
1039
+
1040
+ const checkedAt = Date.now();
1041
+ writeJson(updateCachePath(), { fingerprint, checkedAt, updates });
1042
+ return { updates, checkedAt, cached: false };
1043
+ }
1044
+
1045
+ // skills.sh exposes only /api/search (no leaderboard endpoint), so "Popular" is
1046
+ // built honestly on top of it: fan a handful of broad seed queries, merge by
1047
+ // skill key keeping the highest install count, sort by installs. Cached for 6h.
1048
+ const POPULAR_SEED_QUERIES = [
1049
+ "agent",
1050
+ "code",
1051
+ "test",
1052
+ "review",
1053
+ "git",
1054
+ "web",
1055
+ "design",
1056
+ "data",
1057
+ "docs",
1058
+ "python",
1059
+ "api",
1060
+ "deploy",
1061
+ ];
1062
+ const POPULAR_CACHE_TTL_MS = 6 * 60 * 60 * 1000;
1063
+
1064
+ function popularCachePath() {
1065
+ return path.join(dataDir(), "popular-cache.json");
1066
+ }
1067
+
1068
+ async function fetchPopularSkillsSh({ force = false, limit = 60 } = {}) {
1069
+ const cap = Math.max(1, Math.min(200, Number(limit) || 60));
1070
+ if (!force) {
1071
+ const cached = readJson(popularCachePath(), null);
1072
+ if (
1073
+ cached &&
1074
+ Array.isArray(cached.skills) &&
1075
+ Number.isFinite(cached.generatedAt) &&
1076
+ Date.now() - cached.generatedAt < POPULAR_CACHE_TTL_MS
1077
+ ) {
1078
+ return { skills: cached.skills.slice(0, cap), cached: true, generatedAt: cached.generatedAt };
1079
+ }
1080
+ }
1081
+
1082
+ const lists = await mapWithConcurrency(POPULAR_SEED_QUERIES, DISCOVER_CONCURRENCY, async (q) => {
1083
+ try {
1084
+ return (await searchSkillsSh(q, 30, 0)).skills;
1085
+ } catch (error) {
1086
+ if (error instanceof RateLimitError) throw error;
1087
+ return [];
1088
+ }
1089
+ });
1090
+
1091
+ const byKey = new Map();
1092
+ for (const list of lists) {
1093
+ for (const skill of list) {
1094
+ const key = String(skill.key || `${skill.repoOwner}/${skill.repoName}:${skill.directory}`).toLowerCase();
1095
+ const prev = byKey.get(key);
1096
+ if (!prev || (skill.installs || 0) > (prev.installs || 0)) byKey.set(key, skill);
1097
+ }
1098
+ }
1099
+ const skills = Array.from(byKey.values())
1100
+ .sort((a, b) => (b.installs || 0) - (a.installs || 0))
1101
+ .slice(0, 200);
1102
+ writeJson(popularCachePath(), { generatedAt: Date.now(), skills });
1103
+ return { skills: skills.slice(0, cap), cached: false, generatedAt: Date.now() };
1104
+ }
1105
+
755
1106
  module.exports = {
756
1107
  addRepo,
1108
+ assertNotNested,
1109
+ checkUpdates,
757
1110
  discoverSkills,
758
1111
  deleteLocalSkill,
1112
+ fetchPopularSkillsSh,
1113
+ findSkillMarker,
1114
+ hashDirectory,
759
1115
  importLocalSkill,
760
1116
  installSkill,
761
1117
  listInstalledSkills,
762
1118
  listRepos,
1119
+ readActivity,
763
1120
  removeRepo,
764
1121
  restoreSkill,
765
1122
  searchSkillsSh,
766
1123
  setSkillTargets,
1124
+ sourceSignatureFromTree,
767
1125
  targetList,
768
1126
  uninstallSkill,
769
1127
  };