@ipv9/tokentracker-cli 0.39.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 (192) hide show
  1. package/LICENSE +21 -0
  2. package/README.ja.md +464 -0
  3. package/README.ko.md +464 -0
  4. package/README.md +476 -0
  5. package/README.zh-CN.md +469 -0
  6. package/bin/tracker.js +52 -0
  7. package/dashboard/dist/app-icon.png +0 -0
  8. package/dashboard/dist/apple-touch-icon.png +0 -0
  9. package/dashboard/dist/assets/ActivityHeatmap-BxBGHY1z.js +42 -0
  10. package/dashboard/dist/assets/Card-GROxUc_6.js +1 -0
  11. package/dashboard/dist/assets/DashboardPage-BZ5VFoBk.js +19 -0
  12. package/dashboard/dist/assets/DevicePage-CQts7ivB.js +1 -0
  13. package/dashboard/dist/assets/DialogTitle-DqFsk26U.js +1 -0
  14. package/dashboard/dist/assets/FadeIn-HBBXW_3q.js +1 -0
  15. package/dashboard/dist/assets/HeaderGithubStar-COD2HfaF.js +1 -0
  16. package/dashboard/dist/assets/IpCheckPage-Cn0l8-BQ.js +15 -0
  17. package/dashboard/dist/assets/LandingPage-CmV1BMU4.js +4356 -0
  18. package/dashboard/dist/assets/LeaderboardAvatar-CiIpWZwb.js +1 -0
  19. package/dashboard/dist/assets/LeaderboardPage-BU0mlR3g.js +6 -0
  20. package/dashboard/dist/assets/LeaderboardProfileModal-C49_3Xm9.js +72 -0
  21. package/dashboard/dist/assets/LeaderboardProfilePage-Bh1n0BAM.js +1 -0
  22. package/dashboard/dist/assets/LimitsPage-fZAmwLWT.js +2 -0
  23. package/dashboard/dist/assets/LocalOnlyNotice-D3rQdA-P.js +1 -0
  24. package/dashboard/dist/assets/LoginPage-enO4XP-c.js +1 -0
  25. package/dashboard/dist/assets/PopoverPopup-pitJEpN1.js +1 -0
  26. package/dashboard/dist/assets/Select-tT9x4ntD.js +1 -0
  27. package/dashboard/dist/assets/SelectItemText-C7F7eIm0.js +1 -0
  28. package/dashboard/dist/assets/SettingsPage-pDasZNU2.js +1 -0
  29. package/dashboard/dist/assets/SkillsPage-DEu7FY-2.js +1 -0
  30. package/dashboard/dist/assets/WidgetsPage-DGcO7rbp.js +1 -0
  31. package/dashboard/dist/assets/WrappedPage-D_1ID213.js +1 -0
  32. package/dashboard/dist/assets/agent-logos-DXQVg1xy.js +1 -0
  33. package/dashboard/dist/assets/arrow-up-right-DNLo92Ba.js +1 -0
  34. package/dashboard/dist/assets/download-DbBBBpJF.js +1 -0
  35. package/dashboard/dist/assets/geist-mono-cyrillic-400-normal-BPBWmzPh.woff +0 -0
  36. package/dashboard/dist/assets/geist-mono-cyrillic-400-normal-Ce5q_31Z.woff2 +0 -0
  37. package/dashboard/dist/assets/geist-mono-cyrillic-500-normal-CJBLNVQT.woff2 +0 -0
  38. package/dashboard/dist/assets/geist-mono-cyrillic-500-normal-mNhfPmgl.woff +0 -0
  39. package/dashboard/dist/assets/geist-mono-cyrillic-700-normal-DH5Q319x.woff +0 -0
  40. package/dashboard/dist/assets/geist-mono-cyrillic-700-normal-VCNRadI3.woff2 +0 -0
  41. package/dashboard/dist/assets/geist-mono-cyrillic-900-normal-BKC3PZkM.woff +0 -0
  42. package/dashboard/dist/assets/geist-mono-cyrillic-900-normal-DdrQ8KBw.woff2 +0 -0
  43. package/dashboard/dist/assets/geist-mono-latin-400-normal-CoULgQGM.woff +0 -0
  44. package/dashboard/dist/assets/geist-mono-latin-400-normal-LC9RFr9I.woff2 +0 -0
  45. package/dashboard/dist/assets/geist-mono-latin-500-normal-D3o2eNa9.woff2 +0 -0
  46. package/dashboard/dist/assets/geist-mono-latin-500-normal-DOxI7kZ4.woff +0 -0
  47. package/dashboard/dist/assets/geist-mono-latin-700-normal-D6izGJRP.woff2 +0 -0
  48. package/dashboard/dist/assets/geist-mono-latin-700-normal-QGw08Lff.woff +0 -0
  49. package/dashboard/dist/assets/geist-mono-latin-900-normal-CmoKXrdK.woff +0 -0
  50. package/dashboard/dist/assets/geist-mono-latin-900-normal-Cu5MFKsu.woff2 +0 -0
  51. package/dashboard/dist/assets/geist-mono-latin-ext-400-normal-Cgks_Qgx.woff2 +0 -0
  52. package/dashboard/dist/assets/geist-mono-latin-ext-400-normal-CxNRRMGd.woff +0 -0
  53. package/dashboard/dist/assets/geist-mono-latin-ext-500-normal-CQcGuCNt.woff2 +0 -0
  54. package/dashboard/dist/assets/geist-mono-latin-ext-500-normal-diTenJ8L.woff +0 -0
  55. package/dashboard/dist/assets/geist-mono-latin-ext-700-normal-BX9f1BHp.woff +0 -0
  56. package/dashboard/dist/assets/geist-mono-latin-ext-700-normal-YOllDaLV.woff2 +0 -0
  57. package/dashboard/dist/assets/geist-mono-latin-ext-900-normal-Br6WNUGb.woff +0 -0
  58. package/dashboard/dist/assets/geist-mono-latin-ext-900-normal-CrmBTzU2.woff2 +0 -0
  59. package/dashboard/dist/assets/index-BeoRn2gJ.js +2 -0
  60. package/dashboard/dist/assets/info-Bh5qTz6k.js +1 -0
  61. package/dashboard/dist/assets/main-BIY3Nj6I.js +969 -0
  62. package/dashboard/dist/assets/main-G_F9Iu_x.css +1 -0
  63. package/dashboard/dist/assets/use-limits-display-prefs-B4iJ-bBc.js +1 -0
  64. package/dashboard/dist/assets/use-native-settings-DHjPFDv8.js +1 -0
  65. package/dashboard/dist/assets/use-usage-limits-odqe9OY7.js +1 -0
  66. package/dashboard/dist/assets/useCurrency-BsSZiZMP.js +1 -0
  67. package/dashboard/dist/assets/useOpenInteractionType-BQhgq4rv.js +12 -0
  68. package/dashboard/dist/brand-logos/antigravity.svg +1 -0
  69. package/dashboard/dist/brand-logos/claude-code.svg +1 -0
  70. package/dashboard/dist/brand-logos/codebuddy.svg +1 -0
  71. package/dashboard/dist/brand-logos/codex.svg +15 -0
  72. package/dashboard/dist/brand-logos/copilot.svg +1 -0
  73. package/dashboard/dist/brand-logos/cursor.svg +1 -0
  74. package/dashboard/dist/brand-logos/gemini.svg +1 -0
  75. package/dashboard/dist/brand-logos/grok.svg +1 -0
  76. package/dashboard/dist/brand-logos/hermes.svg +1 -0
  77. package/dashboard/dist/brand-logos/kilo.svg +4 -0
  78. package/dashboard/dist/brand-logos/kimi.svg +1 -0
  79. package/dashboard/dist/brand-logos/kiro.svg +1 -0
  80. package/dashboard/dist/brand-logos/openclaw.svg +22 -0
  81. package/dashboard/dist/brand-logos/opencode.svg +3 -0
  82. package/dashboard/dist/clawd/happy.svg +161 -0
  83. package/dashboard/dist/clawd/idle/collapse.svg +101 -0
  84. package/dashboard/dist/clawd/idle/doze.svg +72 -0
  85. package/dashboard/dist/clawd/idle/follow.svg +64 -0
  86. package/dashboard/dist/clawd/idle/living.svg +196 -0
  87. package/dashboard/dist/clawd/idle/look.svg +115 -0
  88. package/dashboard/dist/clawd/idle/yawn.svg +158 -0
  89. package/dashboard/dist/clawd/mini/alert.svg +129 -0
  90. package/dashboard/dist/clawd/mini/crabwalk.svg +76 -0
  91. package/dashboard/dist/clawd/mini/enter-sleep.svg +65 -0
  92. package/dashboard/dist/clawd/mini/enter.svg +115 -0
  93. package/dashboard/dist/clawd/mini/happy.svg +124 -0
  94. package/dashboard/dist/clawd/mini/idle-tight.svg +15 -0
  95. package/dashboard/dist/clawd/mini/idle.svg +83 -0
  96. package/dashboard/dist/clawd/mini/peek.svg +82 -0
  97. package/dashboard/dist/clawd/mini/sleep.svg +112 -0
  98. package/dashboard/dist/clawd/react/double.svg +108 -0
  99. package/dashboard/dist/clawd/react/drag.svg +102 -0
  100. package/dashboard/dist/clawd/react/left.svg +102 -0
  101. package/dashboard/dist/clawd/react/right.svg +102 -0
  102. package/dashboard/dist/clawd/sleep/collapse-sleep.svg +247 -0
  103. package/dashboard/dist/clawd/sleep/sleeping.svg +118 -0
  104. package/dashboard/dist/clawd/sleep/wake.svg +277 -0
  105. package/dashboard/dist/clawd/static-base.svg +21 -0
  106. package/dashboard/dist/clawd/status/disconnected.svg +136 -0
  107. package/dashboard/dist/clawd/status/error.svg +94 -0
  108. package/dashboard/dist/clawd/status/notification.svg +134 -0
  109. package/dashboard/dist/clawd/working/building.svg +231 -0
  110. package/dashboard/dist/clawd/working/carrying.svg +107 -0
  111. package/dashboard/dist/clawd/working/conducting.svg +118 -0
  112. package/dashboard/dist/clawd/working/confused.svg +113 -0
  113. package/dashboard/dist/clawd/working/debugger.svg +91 -0
  114. package/dashboard/dist/clawd/working/juggling.svg +82 -0
  115. package/dashboard/dist/clawd/working/overheated.svg +75 -0
  116. package/dashboard/dist/clawd/working/pushing.svg +125 -0
  117. package/dashboard/dist/clawd/working/sweeping.svg +79 -0
  118. package/dashboard/dist/clawd/working/thinking.svg +119 -0
  119. package/dashboard/dist/clawd/working/typing.svg +153 -0
  120. package/dashboard/dist/clawd/working/ultrathink.svg +166 -0
  121. package/dashboard/dist/clawd/working/wizard.svg +98 -0
  122. package/dashboard/dist/dashboard-dark.png +0 -0
  123. package/dashboard/dist/favicon-16.png +0 -0
  124. package/dashboard/dist/favicon-32.png +0 -0
  125. package/dashboard/dist/favicon.ico +0 -0
  126. package/dashboard/dist/feed.xml +34 -0
  127. package/dashboard/dist/icon-192.png +0 -0
  128. package/dashboard/dist/icon-512.png +0 -0
  129. package/dashboard/dist/index.html +355 -0
  130. package/dashboard/dist/llms.txt +53 -0
  131. package/dashboard/dist/neon_bg.png +0 -0
  132. package/dashboard/dist/robots.txt +7 -0
  133. package/dashboard/dist/share.html +140 -0
  134. package/dashboard/dist/sitemap.xml +27 -0
  135. package/dashboard/dist/widgets-overview.png +0 -0
  136. package/package.json +78 -0
  137. package/scripts/install-local-service.sh +140 -0
  138. package/scripts/uninstall-local-service.sh +21 -0
  139. package/src/cli.js +95 -0
  140. package/src/commands/device-login.js +161 -0
  141. package/src/commands/diagnostics.js +38 -0
  142. package/src/commands/doctor.js +96 -0
  143. package/src/commands/init.js +1195 -0
  144. package/src/commands/serve.js +351 -0
  145. package/src/commands/status.js +609 -0
  146. package/src/commands/sync.js +2285 -0
  147. package/src/commands/uninstall.js +189 -0
  148. package/src/commands/wrapped.js +150 -0
  149. package/src/lib/antigravity-paths.js +48 -0
  150. package/src/lib/browser-auth.js +281 -0
  151. package/src/lib/categorizer-utils.js +232 -0
  152. package/src/lib/claude-categorizer.js +1319 -0
  153. package/src/lib/claude-config.js +204 -0
  154. package/src/lib/cli-ui.js +179 -0
  155. package/src/lib/codex-config.js +350 -0
  156. package/src/lib/codex-context-breakdown.js +400 -0
  157. package/src/lib/codex-rollout-parser.js +623 -0
  158. package/src/lib/codex-token-refresh.js +112 -0
  159. package/src/lib/cursor-config.js +425 -0
  160. package/src/lib/debug-flags.js +7 -0
  161. package/src/lib/diagnostics.js +272 -0
  162. package/src/lib/doctor.js +338 -0
  163. package/src/lib/fs.js +91 -0
  164. package/src/lib/gemini-config.js +288 -0
  165. package/src/lib/grok-hook.js +351 -0
  166. package/src/lib/init-flow.js +42 -0
  167. package/src/lib/local-api.js +1861 -0
  168. package/src/lib/openclaw-hook.js +420 -0
  169. package/src/lib/openclaw-session-plugin.js +520 -0
  170. package/src/lib/opencode-config.js +99 -0
  171. package/src/lib/passive-mode.js +185 -0
  172. package/src/lib/pricing/curated-overrides.json +79 -0
  173. package/src/lib/pricing/index.js +143 -0
  174. package/src/lib/pricing/litellm-fetcher.js +172 -0
  175. package/src/lib/pricing/matcher.js +255 -0
  176. package/src/lib/pricing/seed-snapshot.json +1 -0
  177. package/src/lib/progress.js +76 -0
  178. package/src/lib/project-usage-purge.js +106 -0
  179. package/src/lib/prompt.js +24 -0
  180. package/src/lib/proxy-env.js +131 -0
  181. package/src/lib/rollout.js +8442 -0
  182. package/src/lib/runtime-config.js +122 -0
  183. package/src/lib/skill-usage.js +267 -0
  184. package/src/lib/skills-manager.js +1127 -0
  185. package/src/lib/source-metadata.js +48 -0
  186. package/src/lib/sqlite-reader.js +136 -0
  187. package/src/lib/static-server.js +59 -0
  188. package/src/lib/subscriptions.js +453 -0
  189. package/src/lib/tracker-paths.js +16 -0
  190. package/src/lib/upload-throttle.js +148 -0
  191. package/src/lib/usage-limits.js +1806 -0
  192. package/src/lib/wrapped-aggregator.js +225 -0
@@ -0,0 +1,1127 @@
1
+ const fs = require("node:fs");
2
+ const os = require("node:os");
3
+ const path = require("node:path");
4
+ const crypto = require("node:crypto");
5
+ const { resolveGrokHome } = require("./grok-hook");
6
+ const { resolveAntigravitySkillDirs } = require("./antigravity-paths");
7
+
8
+ const DEFAULT_REPOS = [
9
+ { owner: "anthropics", name: "skills", branch: "main", enabled: true },
10
+ { owner: "ComposioHQ", name: "awesome-claude-skills", branch: "master", enabled: true },
11
+ { owner: "cexll", name: "myclaude", branch: "master", enabled: true },
12
+ { owner: "JimLiu", name: "baoyu-skills", branch: "main", enabled: true },
13
+ ];
14
+
15
+ const TARGETS = {
16
+ claude: { id: "claude", label: "Claude", dir: () => path.join(os.homedir(), ".claude", "skills") },
17
+ codex: { id: "codex", label: "Codex", dir: () => path.join(os.homedir(), ".codex", "skills") },
18
+ grok: { id: "grok", label: "Grok", dir: () => path.join(resolveGrokHome(process.env), "skills") },
19
+ antigravity: { id: "antigravity", label: "Antigravity", dirs: () => resolveAntigravitySkillDirs(process.env) },
20
+ gemini: { id: "gemini", label: "Gemini", dir: () => path.join(os.homedir(), ".gemini", "skills") },
21
+ opencode: { id: "opencode", label: "OpenCode", dir: () => path.join(os.homedir(), ".config", "opencode", "skills") },
22
+ hermes: { id: "hermes", label: "Hermes", dir: () => path.join(os.homedir(), ".hermes", "skills") },
23
+ agents: { id: "agents", label: "Agents", visible: false, dir: () => path.join(os.homedir(), ".agents", "skills") },
24
+ };
25
+
26
+ // Dual contract: a target exposes either dir() → string (single path) or
27
+ // dirs() → string[] (parallel-write to multiple paths, e.g. Antigravity which
28
+ // has separate user-skills dirs for the main app and the IDE). Consumers must
29
+ // route through these helpers so the single-path targets stay zero-overhead.
30
+ function targetDirs(target) {
31
+ if (typeof target.dirs === "function") return target.dirs();
32
+ return [target.dir()];
33
+ }
34
+
35
+ // Used for surfacing one path in UI/local-api responses. For multi-dir targets
36
+ // this is the canonical "first" entry by convention (main app before IDE).
37
+ function targetPrimaryDir(target) {
38
+ return targetDirs(target)[0];
39
+ }
40
+
41
+ const FETCH_TIMEOUT_MS = 20_000;
42
+ const DISCOVER_CONCURRENCY = 4;
43
+ const DISCOVER_CACHE_TTL_MS = 60 * 60 * 1000; // 1 hour
44
+ const OWNER_NAME_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]{0,99}$/;
45
+
46
+ class RateLimitError extends Error {
47
+ constructor(message) {
48
+ super(message);
49
+ this.name = "RateLimitError";
50
+ this.code = "RATE_LIMITED";
51
+ this.status = 429;
52
+ }
53
+ }
54
+
55
+ async function mapWithConcurrency(items, limit, worker) {
56
+ const results = new Array(items.length);
57
+ let cursor = 0;
58
+ async function runNext() {
59
+ while (true) {
60
+ const index = cursor++;
61
+ if (index >= items.length) return;
62
+ results[index] = await worker(items[index], index);
63
+ }
64
+ }
65
+ const pool = new Array(Math.min(limit, items.length)).fill(0).map(runNext);
66
+ await Promise.all(pool);
67
+ return results;
68
+ }
69
+
70
+ function dataDir() {
71
+ return path.join(os.homedir(), ".tokentracker", "skills");
72
+ }
73
+
74
+ function registryPath() {
75
+ return path.join(dataDir(), "registry.json");
76
+ }
77
+
78
+ function ssotDir() {
79
+ return path.join(dataDir(), "managed");
80
+ }
81
+
82
+ function trashDir() {
83
+ return path.join(dataDir(), ".trash");
84
+ }
85
+
86
+ const TRASH_TTL_MS = 5 * 60 * 1000; // 5 minutes
87
+
88
+ function discoverCachePath() {
89
+ return path.join(dataDir(), "discover-cache.json");
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
+
137
+ function ensureDir(dir) {
138
+ fs.mkdirSync(dir, { recursive: true });
139
+ }
140
+
141
+ function readJson(file, fallback) {
142
+ try {
143
+ return JSON.parse(fs.readFileSync(file, "utf8"));
144
+ } catch (_e) {
145
+ return fallback;
146
+ }
147
+ }
148
+
149
+ function writeJson(file, value) {
150
+ ensureDir(path.dirname(file));
151
+ fs.writeFileSync(file, `${JSON.stringify(value, null, 2)}\n`, { mode: 0o600 });
152
+ }
153
+
154
+ function readRegistry() {
155
+ const registry = readJson(registryPath(), null);
156
+ if (registry && typeof registry === "object") {
157
+ return {
158
+ repos: Array.isArray(registry.repos) ? registry.repos : DEFAULT_REPOS,
159
+ skills: Array.isArray(registry.skills) ? registry.skills : [],
160
+ };
161
+ }
162
+ return { repos: DEFAULT_REPOS, skills: [] };
163
+ }
164
+
165
+ function saveRegistry(registry) {
166
+ writeJson(registryPath(), registry);
167
+ }
168
+
169
+ function sanitizePathSegment(value) {
170
+ const segment = String(value || "").trim();
171
+ if (!segment || segment === "." || segment === "..") return null;
172
+ if (segment.includes("/") || segment.includes("\\") || segment.includes("\0")) return null;
173
+ return segment;
174
+ }
175
+
176
+ function sanitizeRelativePath(value) {
177
+ const raw = String(value || "").replace(/\\/g, "/").trim();
178
+ if (!raw || raw.startsWith("/") || raw.includes("\0")) return null;
179
+ const parts = raw.split("/").filter(Boolean);
180
+ if (!parts.length || parts.some((part) => part === "." || part === "..")) return null;
181
+ return parts.join("/");
182
+ }
183
+
184
+ function installNameFromDirectory(directory) {
185
+ const safe = sanitizeRelativePath(directory);
186
+ if (!safe) return null;
187
+ return sanitizePathSegment(safe.split("/").pop());
188
+ }
189
+
190
+ function targetList() {
191
+ return Object.values(TARGETS)
192
+ .filter((target) => target.visible !== false)
193
+ .map((target) => ({
194
+ id: target.id,
195
+ label: target.label,
196
+ path: targetPrimaryDir(target),
197
+ }));
198
+ }
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
+
231
+ function readSkillMetadata(markdown, fallbackName) {
232
+ const raw = String(markdown || "");
233
+ const frontmatter = raw.match(/^---\s*\n([\s\S]*?)\n---/);
234
+ const source = frontmatter ? frontmatter[1] : raw;
235
+ const name = readYamlField(source, "name") || fallbackName || "Skill";
236
+ const description = readYamlField(source, "description");
237
+ return {
238
+ name: name.trim(),
239
+ description: description.replace(/\s+/g, " ").trim(),
240
+ };
241
+ }
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
+
324
+ async function fetchJson(url) {
325
+ const controller = new AbortController();
326
+ const timeout = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
327
+ try {
328
+ const response = await fetch(url, {
329
+ headers: {
330
+ Accept: "application/vnd.github+json",
331
+ "User-Agent": "tokentracker-skills",
332
+ },
333
+ signal: controller.signal,
334
+ });
335
+ if (response.status === 429 || response.status === 403) {
336
+ throw new RateLimitError(`GitHub rate-limited this request (HTTP ${response.status}). Try again later.`);
337
+ }
338
+ if (!response.ok) throw new Error(`HTTP ${response.status}`);
339
+ return response.json();
340
+ } finally {
341
+ clearTimeout(timeout);
342
+ }
343
+ }
344
+
345
+ async function fetchText(url) {
346
+ const controller = new AbortController();
347
+ const timeout = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
348
+ try {
349
+ const response = await fetch(url, {
350
+ headers: { Accept: "text/plain", "User-Agent": "tokentracker-skills" },
351
+ signal: controller.signal,
352
+ });
353
+ if (response.status === 429 || response.status === 403) {
354
+ throw new RateLimitError(`GitHub rate-limited this request (HTTP ${response.status}). Try again later.`);
355
+ }
356
+ if (!response.ok) throw new Error(`HTTP ${response.status}`);
357
+ return response.text();
358
+ } finally {
359
+ clearTimeout(timeout);
360
+ }
361
+ }
362
+
363
+ function githubRawUrl(owner, name, branch, filePath) {
364
+ return `https://raw.githubusercontent.com/${owner}/${name}/${branch}/${filePath
365
+ .split("/")
366
+ .map(encodeURIComponent)
367
+ .join("/")}`;
368
+ }
369
+
370
+ function githubDocUrl(owner, name, branch, filePath) {
371
+ return `https://github.com/${owner}/${name}/blob/${branch}/${filePath
372
+ .split("/")
373
+ .map(encodeURIComponent)
374
+ .join("/")}`;
375
+ }
376
+
377
+ async function getRepoTree(repo) {
378
+ const branches = [];
379
+ if (repo.branch && !String(repo.branch).match(/^head$/i)) branches.push(repo.branch);
380
+ if (!branches.includes("main")) branches.push("main");
381
+ if (!branches.includes("master")) branches.push("master");
382
+
383
+ let lastError = null;
384
+ for (const branch of branches) {
385
+ try {
386
+ const data = await fetchJson(
387
+ `https://api.github.com/repos/${repo.owner}/${repo.name}/git/trees/${encodeURIComponent(branch)}?recursive=1`,
388
+ );
389
+ if (Array.isArray(data?.tree)) return { branch, tree: data.tree };
390
+ } catch (error) {
391
+ lastError = error;
392
+ }
393
+ }
394
+ throw lastError || new Error(`Unable to read ${repo.owner}/${repo.name}`);
395
+ }
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
+
417
+ function buildSkillKey(skill) {
418
+ return `${skill.repoOwner}/${skill.repoName}:${skill.directory}`;
419
+ }
420
+
421
+ function normalizeRepo(repo) {
422
+ return {
423
+ owner: String(repo?.owner || "").trim(),
424
+ name: String(repo?.name || "").trim(),
425
+ branch: String(repo?.branch || "main").trim() || "main",
426
+ enabled: repo?.enabled !== false,
427
+ };
428
+ }
429
+
430
+ async function discoverRepoSkills(repoInput) {
431
+ const repo = normalizeRepo(repoInput);
432
+ if (!repo.owner || !repo.name || !repo.enabled) return [];
433
+ const { branch, tree } = await getRepoTree(repo);
434
+ const skillFiles = tree
435
+ .filter((entry) => entry?.type === "blob" && /(^|\/)SKILL\.md$/i.test(entry.path || ""))
436
+ .slice(0, 200);
437
+
438
+ const skills = await mapWithConcurrency(skillFiles, DISCOVER_CONCURRENCY, async (entry) => {
439
+ const docPath = entry.path.replace(/\\/g, "/");
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;
444
+ const installName = installNameFromDirectory(directory || repo.name);
445
+ if (!installName) return null;
446
+ let metadata = { name: installName, description: "" };
447
+ try {
448
+ metadata = readSkillMetadata(await fetchText(githubRawUrl(repo.owner, repo.name, branch, docPath)), installName);
449
+ } catch (error) {
450
+ if (error instanceof RateLimitError) throw error;
451
+ // Keep the skill discoverable even if metadata fetch fails.
452
+ }
453
+ return {
454
+ key: `${repo.owner}/${repo.name}:${directory || repo.name}`,
455
+ name: metadata.name,
456
+ description: metadata.description,
457
+ directory: directory || repo.name,
458
+ readmeUrl: githubDocUrl(repo.owner, repo.name, branch, docPath),
459
+ repoOwner: repo.owner,
460
+ repoName: repo.name,
461
+ repoBranch: branch,
462
+ };
463
+ });
464
+ return skills.filter(Boolean);
465
+ }
466
+
467
+ function dedupeSkills(skills) {
468
+ const byKey = new Map();
469
+ for (const skill of skills) byKey.set(buildSkillKey(skill).toLowerCase(), skill);
470
+ return Array.from(byKey.values()).sort((a, b) => a.name.localeCompare(b.name));
471
+ }
472
+
473
+ function readDiscoverCache(fingerprint) {
474
+ const data = readJson(discoverCachePath(), null);
475
+ if (!data || typeof data !== "object" || !Array.isArray(data.skills)) return null;
476
+ if (data.fingerprint !== fingerprint) return null;
477
+ if (!Number.isFinite(data.generatedAt)) return null;
478
+ if (Date.now() - data.generatedAt > DISCOVER_CACHE_TTL_MS) return null;
479
+ return data;
480
+ }
481
+
482
+ function writeDiscoverCache(fingerprint, skills) {
483
+ writeJson(discoverCachePath(), { fingerprint, generatedAt: Date.now(), skills });
484
+ }
485
+
486
+ function invalidateDiscoverCache() {
487
+ try {
488
+ fs.rmSync(discoverCachePath(), { force: true });
489
+ } catch (_e) {
490
+ // ignore
491
+ }
492
+ }
493
+
494
+ async function discoverSkills({ force = false } = {}) {
495
+ const registry = readRegistry();
496
+ const enabled = registry.repos.map(normalizeRepo).filter((repo) => repo.enabled);
497
+ if (!enabled.length) return { skills: [], cached: false, generatedAt: Date.now() };
498
+
499
+ const fingerprint = enabled
500
+ .map((repo) => `${repo.owner}/${repo.name}@${repo.branch}`)
501
+ .sort()
502
+ .join("|");
503
+
504
+ if (!force) {
505
+ const cached = readDiscoverCache(fingerprint);
506
+ if (cached) return { skills: cached.skills, cached: true, generatedAt: cached.generatedAt };
507
+ }
508
+
509
+ const settled = await Promise.allSettled(enabled.map(discoverRepoSkills));
510
+ const merged = dedupeSkills(settled.flatMap((result) => (result.status === "fulfilled" ? result.value : [])));
511
+ if (!merged.length) {
512
+ const rateLimited = settled.find(
513
+ (result) => result.status === "rejected" && result.reason instanceof RateLimitError,
514
+ );
515
+ if (rateLimited) throw rateLimited.reason;
516
+ }
517
+ writeDiscoverCache(fingerprint, merged);
518
+ return { skills: merged, cached: false, generatedAt: Date.now() };
519
+ }
520
+
521
+ function removePath(targetPath) {
522
+ if (!fs.existsSync(targetPath) && !isSymlink(targetPath)) return;
523
+ fs.rmSync(targetPath, { recursive: true, force: true });
524
+ }
525
+
526
+ function isSymlink(targetPath) {
527
+ try {
528
+ return fs.lstatSync(targetPath).isSymbolicLink();
529
+ } catch (_e) {
530
+ return false;
531
+ }
532
+ }
533
+
534
+ function copyDir(source, dest) {
535
+ assertNotNested(source, dest);
536
+ removePath(dest);
537
+ fs.cpSync(source, dest, { recursive: true, force: true });
538
+ }
539
+
540
+ function syncSkillToTarget(directory, targetId) {
541
+ const target = TARGETS[targetId];
542
+ if (!target) throw new Error(`Unsupported target: ${targetId}`);
543
+ const source = path.join(ssotDir(), directory);
544
+ if (!fs.existsSync(source)) throw new Error(`Managed skill not found: ${directory}`);
545
+ for (const baseDir of targetDirs(target)) {
546
+ const dest = path.join(baseDir, directory);
547
+ assertNotNested(source, dest);
548
+ ensureDir(path.dirname(dest));
549
+ removePath(dest);
550
+ try {
551
+ fs.symlinkSync(source, dest, "dir");
552
+ } catch (_e) {
553
+ copyDir(source, dest);
554
+ }
555
+ }
556
+ }
557
+
558
+ function removeSkillFromTarget(directory, targetId) {
559
+ const target = TARGETS[targetId];
560
+ if (!target) return;
561
+ for (const baseDir of targetDirs(target)) {
562
+ removePath(path.join(baseDir, directory));
563
+ }
564
+ }
565
+
566
+ function scanTargetSkill(directory, targetId) {
567
+ const target = TARGETS[targetId];
568
+ if (!target) return false;
569
+ for (const baseDir of targetDirs(target)) {
570
+ const candidate = path.join(baseDir, directory);
571
+ if (fs.existsSync(candidate) || isSymlink(candidate)) return true;
572
+ }
573
+ return false;
574
+ }
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
+
592
+ function listInstalledSkills() {
593
+ purgeExpiredTrash();
594
+ const registry = readRegistry();
595
+ const managed = registry.skills
596
+ .filter((skill) => !skill.trashedAt)
597
+ .map((skill) => {
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 };
609
+ });
610
+
611
+ const managedDirs = new Set(managed.map((skill) => skill.directory.toLowerCase()));
612
+ const unmanaged = new Map();
613
+ for (const target of Object.values(TARGETS)) {
614
+ 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;
625
+ const skillPath = findSkillMarker(path.join(dir, directory));
626
+ if (!skillPath) continue;
627
+ const metadata = readSkillMetadata(fs.readFileSync(skillPath, "utf8"), directory);
628
+ const key = directory.toLowerCase();
629
+ if (!unmanaged.has(key)) {
630
+ unmanaged.set(key, {
631
+ id: `local:${directory}`,
632
+ key: `local:${directory}`,
633
+ name: metadata.name,
634
+ description: metadata.description,
635
+ directory,
636
+ readmeUrl: null,
637
+ repoOwner: null,
638
+ repoName: null,
639
+ repoBranch: null,
640
+ installedAt: null,
641
+ managed: false,
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"])),
646
+ targetPaths: {},
647
+ });
648
+ }
649
+ const skill = unmanaged.get(key);
650
+ if (!skill.targets.includes(target.id)) skill.targets.push(target.id);
651
+ skill.targetStates[target.id] = "synced";
652
+ if (!skill.targetPaths[target.id]) skill.targetPaths[target.id] = path.join(dir, directory);
653
+ }
654
+ }
655
+ }
656
+
657
+ return [...managed, ...unmanaged.values()].sort((a, b) => a.name.localeCompare(b.name));
658
+ }
659
+
660
+ async function installSkill(skillInput, targetIds = ["claude", "codex"]) {
661
+ const skill = {
662
+ key: String(skillInput?.key || ""),
663
+ name: String(skillInput?.name || ""),
664
+ description: String(skillInput?.description || ""),
665
+ directory: String(skillInput?.directory || ""),
666
+ readmeUrl: skillInput?.readmeUrl || null,
667
+ repoOwner: String(skillInput?.repoOwner || ""),
668
+ repoName: String(skillInput?.repoName || ""),
669
+ repoBranch: String(skillInput?.repoBranch || "main") || "main",
670
+ };
671
+ if (!skill.repoOwner || !skill.repoName) throw new Error("Missing GitHub repository information");
672
+ const sourceDir = sanitizeRelativePath(skill.directory);
673
+ const installName = installNameFromDirectory(sourceDir);
674
+ if (!sourceDir || !installName) throw new Error("Invalid skill directory");
675
+
676
+ const registry = readRegistry();
677
+ const existingConflict = registry.skills.find(
678
+ (entry) =>
679
+ entry.directory.toLowerCase() === installName.toLowerCase() &&
680
+ `${entry.repoOwner}/${entry.repoName}`.toLowerCase() !== `${skill.repoOwner}/${skill.repoName}`.toLowerCase(),
681
+ );
682
+ if (existingConflict) {
683
+ throw new Error(
684
+ `Skill directory "${installName}" is already managed by ${existingConflict.repoOwner}/${existingConflict.repoName}`,
685
+ );
686
+ }
687
+
688
+ const { branch, tree } = await getRepoTree({
689
+ owner: skill.repoOwner,
690
+ name: skill.repoName,
691
+ branch: skill.repoBranch,
692
+ });
693
+ const files = tree.filter(
694
+ (entry) => entry?.type === "blob" && (entry.path === sourceDir || String(entry.path || "").startsWith(`${sourceDir}/`)),
695
+ );
696
+ if (!files.some((entry) => /(^|\/)SKILL\.md$/i.test(entry.path))) throw new Error("SKILL.md not found in selected directory");
697
+
698
+ const dest = path.join(ssotDir(), installName);
699
+ const temp = path.join(dataDir(), "tmp", `${installName}-${Date.now()}`);
700
+ removePath(temp);
701
+ ensureDir(temp);
702
+ try {
703
+ for (const entry of files) {
704
+ const relative = entry.path === sourceDir ? path.basename(entry.path) : entry.path.slice(sourceDir.length + 1);
705
+ const safeRelative = sanitizeRelativePath(relative);
706
+ if (!safeRelative) continue;
707
+ const out = path.join(temp, safeRelative);
708
+ ensureDir(path.dirname(out));
709
+ fs.writeFileSync(out, await fetchText(githubRawUrl(skill.repoOwner, skill.repoName, branch, entry.path)));
710
+ }
711
+ removePath(dest);
712
+ ensureDir(path.dirname(dest));
713
+ fs.renameSync(temp, dest);
714
+ } catch (error) {
715
+ removePath(temp);
716
+ throw error;
717
+ }
718
+
719
+ const skillMarker = findSkillMarker(dest);
720
+ const skillMd = skillMarker ? fs.readFileSync(skillMarker, "utf8") : "";
721
+ const metadata = readSkillMetadata(skillMd, skill.name || installName);
722
+ const selectedTargets = targetIds.filter((id) => TARGETS[id]);
723
+ const installed = {
724
+ id: `${skill.repoOwner}/${skill.repoName}:${sourceDir}`,
725
+ key: `${skill.repoOwner}/${skill.repoName}:${sourceDir}`,
726
+ name: metadata.name,
727
+ description: metadata.description || skill.description,
728
+ directory: installName,
729
+ sourceDirectory: sourceDir,
730
+ readmeUrl: githubDocUrl(skill.repoOwner, skill.repoName, branch, `${sourceDir}/SKILL.md`),
731
+ repoOwner: skill.repoOwner,
732
+ repoName: skill.repoName,
733
+ repoBranch: branch,
734
+ installedAt: Date.now(),
735
+ // Fingerprints power idempotent reinstall + the "update available" badge.
736
+ contentHash: hashDirectory(dest),
737
+ sourceSignature: sourceSignatureFromTree(tree, sourceDir),
738
+ targets: selectedTargets,
739
+ };
740
+
741
+ registry.skills = registry.skills.filter((entry) => entry.id !== installed.id && entry.directory.toLowerCase() !== installName.toLowerCase());
742
+ registry.skills.push(installed);
743
+ saveRegistry(registry);
744
+
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}` });
747
+ return { ...installed, managed: true, targets: selectedTargets };
748
+ }
749
+
750
+ function uninstallSkill(id) {
751
+ const registry = readRegistry();
752
+ const skill = registry.skills.find((entry) => entry.id === id || entry.key === id);
753
+ if (!skill) throw new Error("Managed skill not found");
754
+ for (const targetId of Object.keys(TARGETS)) removeSkillFromTarget(skill.directory, targetId);
755
+ // Move SSOT copy into a trash bucket so it can be restored briefly. The
756
+ // registry entry is retained but flagged so restoreSkill can re-link it.
757
+ const ssotPath = path.join(ssotDir(), skill.directory);
758
+ if (fs.existsSync(ssotPath)) {
759
+ ensureDir(trashDir());
760
+ const stamp = Date.now();
761
+ const trashPath = path.join(trashDir(), `${skill.directory}-${stamp}`);
762
+ try {
763
+ fs.renameSync(ssotPath, trashPath);
764
+ skill.trashedAt = stamp;
765
+ skill.trashedDirectory = path.basename(trashPath);
766
+ skill.previousTargets = skill.targets || [];
767
+ skill.targets = [];
768
+ const others = registry.skills.filter((entry) => entry.id !== skill.id);
769
+ registry.skills = [...others, skill];
770
+ saveRegistry(registry);
771
+ purgeExpiredTrash();
772
+ appendActivity({ action: "uninstall", name: skill.name, directory: skill.directory });
773
+ return { ok: true, trashed: true, restoreId: skill.id, ttlMs: TRASH_TTL_MS };
774
+ } catch (_e) {
775
+ removePath(ssotPath);
776
+ }
777
+ }
778
+ registry.skills = registry.skills.filter((entry) => entry.id !== skill.id);
779
+ saveRegistry(registry);
780
+ appendActivity({ action: "uninstall", name: skill.name, directory: skill.directory });
781
+ return { ok: true, trashed: false };
782
+ }
783
+
784
+ function purgeExpiredTrash() {
785
+ try {
786
+ const registry = readRegistry();
787
+ const now = Date.now();
788
+ let dirty = false;
789
+ registry.skills = registry.skills.filter((skill) => {
790
+ if (!skill.trashedAt) return true;
791
+ if (now - skill.trashedAt < TRASH_TTL_MS) return true;
792
+ const trashPath = skill.trashedDirectory ? path.join(trashDir(), skill.trashedDirectory) : null;
793
+ if (trashPath) removePath(trashPath);
794
+ dirty = true;
795
+ return false;
796
+ });
797
+ if (dirty) saveRegistry(registry);
798
+ } catch (_e) {
799
+ // best-effort
800
+ }
801
+ }
802
+
803
+ function restoreSkill(id) {
804
+ const registry = readRegistry();
805
+ const skill = registry.skills.find((entry) => entry.id === id || entry.key === id);
806
+ if (!skill || !skill.trashedAt) throw new Error("Nothing to restore");
807
+ if (Date.now() - skill.trashedAt > TRASH_TTL_MS) {
808
+ throw new Error("Restore window expired");
809
+ }
810
+ const trashPath = path.join(trashDir(), skill.trashedDirectory || "");
811
+ const ssotPath = path.join(ssotDir(), skill.directory);
812
+ if (!fs.existsSync(trashPath)) throw new Error("Trashed copy is missing");
813
+ ensureDir(path.dirname(ssotPath));
814
+ removePath(ssotPath);
815
+ fs.renameSync(trashPath, ssotPath);
816
+ const targets = Array.isArray(skill.previousTargets) ? skill.previousTargets : [];
817
+ skill.targets = targets;
818
+ delete skill.trashedAt;
819
+ delete skill.trashedDirectory;
820
+ delete skill.previousTargets;
821
+ saveRegistry(registry);
822
+ for (const targetId of targets) syncSkillToTarget(skill.directory, targetId);
823
+ appendActivity({ action: "restore", name: skill.name, directory: skill.directory, targets });
824
+ return { ...skill, managed: true, targets };
825
+ }
826
+
827
+ function setSkillTargets(id, targetIds) {
828
+ const registry = readRegistry();
829
+ const skill = registry.skills.find((entry) => entry.id === id || entry.key === id);
830
+ if (!skill) throw new Error("Managed skill not found");
831
+ const selectedTargets = targetIds.filter((targetId) => TARGETS[targetId]);
832
+ for (const targetId of Object.keys(TARGETS)) {
833
+ if (selectedTargets.includes(targetId)) syncSkillToTarget(skill.directory, targetId);
834
+ else removeSkillFromTarget(skill.directory, targetId);
835
+ }
836
+ skill.targets = selectedTargets;
837
+ saveRegistry(registry);
838
+ appendActivity({ action: "set_targets", name: skill.name, directory: skill.directory, targets: selectedTargets });
839
+ return { ...skill, managed: true, targets: selectedTargets };
840
+ }
841
+
842
+ function findLocalSkillSource(directory) {
843
+ const installName = sanitizePathSegment(directory);
844
+ if (!installName) return null;
845
+ for (const target of Object.values(TARGETS)) {
846
+ for (const baseDir of targetDirs(target)) {
847
+ const skillPath = path.join(baseDir, installName);
848
+ if (findSkillMarker(skillPath)) {
849
+ return { path: skillPath, targetId: target.id };
850
+ }
851
+ }
852
+ }
853
+ return null;
854
+ }
855
+
856
+ function importLocalSkill(directory, targetIds = []) {
857
+ const installName = sanitizePathSegment(directory);
858
+ if (!installName) throw new Error("Invalid skill directory");
859
+ const registry = readRegistry();
860
+ const existing = registry.skills.find((entry) => entry.directory.toLowerCase() === installName.toLowerCase());
861
+ if (existing) {
862
+ if (!targetIds || !targetIds.length) {
863
+ return { ...existing, managed: true, targets: existing.targets || [] };
864
+ }
865
+ return setSkillTargets(existing.id, targetIds);
866
+ }
867
+
868
+ const source = findLocalSkillSource(installName);
869
+ if (!source) throw new Error("Local skill not found");
870
+
871
+ const dest = path.join(ssotDir(), installName);
872
+ copyDir(source.path, dest);
873
+ 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));
876
+ const selectedTargets = (targetIds.length ? targetIds : discoveredTargets).filter((targetId) => TARGETS[targetId]);
877
+ const skill = {
878
+ id: `local:${installName}`,
879
+ key: `local:${installName}`,
880
+ name: metadata.name,
881
+ description: metadata.description,
882
+ directory: installName,
883
+ sourceDirectory: installName,
884
+ readmeUrl: null,
885
+ repoOwner: null,
886
+ repoName: null,
887
+ repoBranch: null,
888
+ installedAt: Date.now(),
889
+ contentHash: hashDirectory(dest),
890
+ targets: selectedTargets,
891
+ };
892
+
893
+ registry.skills.push(skill);
894
+ saveRegistry(registry);
895
+ for (const targetId of Object.keys(TARGETS)) {
896
+ if (selectedTargets.includes(targetId)) syncSkillToTarget(installName, targetId);
897
+ else removeSkillFromTarget(installName, targetId);
898
+ }
899
+ appendActivity({ action: "import", name: skill.name, directory: installName, targets: selectedTargets });
900
+ return { ...skill, managed: true, targets: selectedTargets };
901
+ }
902
+
903
+ function deleteLocalSkill(directory, targetIds = []) {
904
+ const installName = sanitizePathSegment(directory);
905
+ if (!installName) throw new Error("Invalid skill directory");
906
+ const selectedTargets = targetIds.length ? targetIds : Object.keys(TARGETS);
907
+ for (const targetId of selectedTargets) removeSkillFromTarget(installName, targetId);
908
+ appendActivity({ action: "delete_local", directory: installName, targets: selectedTargets });
909
+ return { ok: true };
910
+ }
911
+
912
+ function listRepos() {
913
+ return readRegistry().repos.map(normalizeRepo);
914
+ }
915
+
916
+ function addRepo(repoInput) {
917
+ const repo = normalizeRepo(repoInput);
918
+ if (!repo.owner || !repo.name) throw new Error("Repository owner and name are required");
919
+ if (!OWNER_NAME_PATTERN.test(repo.owner) || !OWNER_NAME_PATTERN.test(repo.name)) {
920
+ throw new Error("Repository owner and name may only contain letters, digits, '.', '_', or '-'");
921
+ }
922
+ if (!OWNER_NAME_PATTERN.test(repo.branch)) {
923
+ throw new Error("Repository branch contains unsupported characters");
924
+ }
925
+ const registry = readRegistry();
926
+ registry.repos = registry.repos.filter(
927
+ (entry) => `${entry.owner}/${entry.name}`.toLowerCase() !== `${repo.owner}/${repo.name}`.toLowerCase(),
928
+ );
929
+ registry.repos.push(repo);
930
+ saveRegistry(registry);
931
+ invalidateDiscoverCache();
932
+ return repo;
933
+ }
934
+
935
+ function removeRepo(owner, name) {
936
+ const registry = readRegistry();
937
+ registry.repos = registry.repos.filter(
938
+ (entry) => `${entry.owner}/${entry.name}`.toLowerCase() !== `${owner}/${name}`.toLowerCase(),
939
+ );
940
+ saveRegistry(registry);
941
+ invalidateDiscoverCache();
942
+ return { ok: true };
943
+ }
944
+
945
+ async function searchSkillsSh(query, limit = 20, offset = 0) {
946
+ const q = String(query || "").trim();
947
+ if (q.length < 2) return { query: q, totalCount: 0, skills: [] };
948
+ const url = new URL("https://skills.sh/api/search");
949
+ url.searchParams.set("q", q);
950
+ url.searchParams.set("limit", String(Math.max(1, Math.min(50, Number(limit) || 20))));
951
+ url.searchParams.set("offset", String(Math.max(0, Number(offset) || 0)));
952
+ const data = await fetchJson(url.toString());
953
+ const skills = Array.isArray(data?.skills)
954
+ ? data.skills
955
+ .map((entry) => {
956
+ const [owner, repoName] = String(entry?.source || "").split("/", 2);
957
+ if (!owner || !repoName || owner.includes(".") || repoName.includes(".")) return null;
958
+ return {
959
+ key: String(entry.id || `${owner}/${repoName}:${entry.skillId || entry.name}`),
960
+ name: String(entry.name || entry.skillId || "Skill"),
961
+ description: "",
962
+ directory: String(entry.skillId || entry.name || ""),
963
+ repoOwner: owner,
964
+ repoName,
965
+ repoBranch: "main",
966
+ readmeUrl: `https://github.com/${owner}/${repoName}`,
967
+ installs: Number(entry.installs || 0),
968
+ };
969
+ })
970
+ .filter(Boolean)
971
+ : [];
972
+ return {
973
+ query: String(data?.query || q),
974
+ totalCount: Number(data?.count || skills.length),
975
+ skills,
976
+ };
977
+ }
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
+
1106
+ module.exports = {
1107
+ addRepo,
1108
+ assertNotNested,
1109
+ checkUpdates,
1110
+ discoverSkills,
1111
+ deleteLocalSkill,
1112
+ fetchPopularSkillsSh,
1113
+ findSkillMarker,
1114
+ hashDirectory,
1115
+ importLocalSkill,
1116
+ installSkill,
1117
+ listInstalledSkills,
1118
+ listRepos,
1119
+ readActivity,
1120
+ removeRepo,
1121
+ restoreSkill,
1122
+ searchSkillsSh,
1123
+ setSkillTargets,
1124
+ sourceSignatureFromTree,
1125
+ targetList,
1126
+ uninstallSkill,
1127
+ };