tokentracker-cli 0.30.0 → 0.31.1
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.
- package/README.ja.md +3 -1
- package/README.ko.md +3 -1
- package/README.md +3 -1
- package/README.zh-CN.md +3 -1
- package/dashboard/dist/assets/{ActivityHeatmap-BzW0rtoR.js → ActivityHeatmap-BjiEMIl2.js} +1 -1
- package/dashboard/dist/assets/{Card-wgEcKm5u.js → Card-Nlgzr57Q.js} +1 -1
- package/dashboard/dist/assets/DashboardPage-z7rLj1gX.js +19 -0
- package/dashboard/dist/assets/{DevicePage-D82cqG-v.js → DevicePage-B1NhBt9Q.js} +1 -1
- package/dashboard/dist/assets/DialogTitle-CEBn-oBo.js +12 -0
- package/dashboard/dist/assets/FadeIn-Dbk47nhk.js +1 -0
- package/dashboard/dist/assets/{HeaderGithubStar-CSBGOKw2.js → HeaderGithubStar-B0OAKXVw.js} +1 -1
- package/dashboard/dist/assets/{IpCheckPage-WXEmhuVW.js → IpCheckPage-C0wQLtdK.js} +1 -1
- package/dashboard/dist/assets/{LandingPage-ap4xIJva.js → LandingPage-DWSbmVip.js} +2 -2
- package/dashboard/dist/assets/{LeaderboardAvatar-CjaHOh8E.js → LeaderboardAvatar-CLMnffKE.js} +1 -1
- package/dashboard/dist/assets/LeaderboardPage-CZvefN8H.js +6 -0
- package/dashboard/dist/assets/{LeaderboardProfileModal-BX4b5Uqc.js → LeaderboardProfileModal-DasxA8Lo.js} +3 -3
- package/dashboard/dist/assets/LeaderboardProfilePage-DJJ7l3Ix.js +1 -0
- package/dashboard/dist/assets/LimitsPage-xUmhxOqT.js +2 -0
- package/dashboard/dist/assets/{LocalOnlyNotice-D43ZmQJl.js → LocalOnlyNotice-B3uM29rj.js} +1 -1
- package/dashboard/dist/assets/{LoginPage-gqSOnPUr.js → LoginPage-BejL6eIO.js} +1 -1
- package/dashboard/dist/assets/PopoverPopup-V6vxWbIV.js +1 -0
- package/dashboard/dist/assets/SettingsPage-DossFM4s.js +1 -0
- package/dashboard/dist/assets/SkillsPage-COwiRUo1.js +1 -0
- package/dashboard/dist/assets/WidgetsPage-CtkM_RQn.js +1 -0
- package/dashboard/dist/assets/WrappedPage-BpXrsSoG.js +1 -0
- package/dashboard/dist/assets/agent-logos-D6Jmq7E1.js +1 -0
- package/dashboard/dist/assets/{arrow-up-right-BXWeIpuU.js → arrow-up-right-BS0sCPTz.js} +1 -1
- package/dashboard/dist/assets/check-DKwh5hl-.js +1 -0
- package/dashboard/dist/assets/{chevron-down-u-JNpncG.js → chevron-down-DyiXbuAL.js} +1 -1
- package/dashboard/dist/assets/{download-DvfHniid.js → download-CqDWRO99.js} +1 -1
- package/dashboard/dist/assets/{info-BdrJIus6.js → info-DMW1GFmI.js} +1 -1
- package/dashboard/dist/assets/main-CjFrTufR.css +1 -0
- package/dashboard/dist/assets/main-uqC1VAKB.js +959 -0
- package/dashboard/dist/assets/{use-limits-display-prefs-DU4WDeXF.js → use-limits-display-prefs-C04zvO7N.js} +1 -1
- package/dashboard/dist/assets/{use-native-settings-CefuRE0t.js → use-native-settings-BvFr6zgZ.js} +1 -1
- package/dashboard/dist/assets/{use-usage-limits-BvZsTD3e.js → use-usage-limits-5fwy0xeB.js} +1 -1
- package/dashboard/dist/assets/useCurrency-vRzRfd7L.js +1 -0
- package/dashboard/dist/index.html +2 -2
- package/dashboard/dist/share.html +2 -2
- package/package.json +1 -1
- package/src/lib/local-api.js +67 -0
- package/src/lib/pricing/seed-snapshot.json +1 -1
- package/src/lib/skill-usage.js +267 -0
- package/src/lib/skills-manager.js +371 -13
- package/dashboard/dist/assets/DashboardPage-BH_InuuJ.js +0 -19
- package/dashboard/dist/assets/DialogTitle-QumMNQjm.js +0 -12
- package/dashboard/dist/assets/FadeIn-B2rj5E5l.js +0 -1
- package/dashboard/dist/assets/LeaderboardPage-DN4Sd56j.js +0 -6
- package/dashboard/dist/assets/LeaderboardProfilePage-hkIYzooc.js +0 -1
- package/dashboard/dist/assets/LimitsPage-BgoAXuwU.js +0 -2
- package/dashboard/dist/assets/PopoverPopup-BkMR2-Ij.js +0 -1
- package/dashboard/dist/assets/ProviderIcon-No-nxvR1.js +0 -1
- package/dashboard/dist/assets/SettingsPage-BVpnWPq9.js +0 -1
- package/dashboard/dist/assets/SkillsPage-DnIxIt1d.js +0 -1
- package/dashboard/dist/assets/WidgetsPage-DY7qT8jg.js +0 -1
- package/dashboard/dist/assets/WrappedPage-BTkoEQPM.js +0 -1
- package/dashboard/dist/assets/agent-logos-Dp5LiRgu.js +0 -1
- package/dashboard/dist/assets/check-B_JRaxbD.js +0 -1
- package/dashboard/dist/assets/main-BojOq9Ae.css +0 -1
- package/dashboard/dist/assets/main-DhcQGvkP.js +0 -917
- package/dashboard/dist/assets/use-reduced-motion-nzFM1j1r.js +0 -1
- package/dashboard/dist/assets/useCurrency-C62kk8HZ.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
|
|
159
|
-
const
|
|
235
|
+
const name = readYamlField(source, "name") || fallbackName || "Skill";
|
|
236
|
+
const description = readYamlField(source, "description");
|
|
160
237
|
return {
|
|
161
|
-
name:
|
|
162
|
-
description:
|
|
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
|
-
|
|
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
|
|
400
|
-
|
|
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
|
|
418
|
-
if (!
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
};
|