tokentracker-cli 0.5.87 → 0.5.89
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.md +12 -1
- package/README.zh-CN.md +12 -1
- package/dashboard/dist/assets/{Card-BbXzY-fw.js → Card-CnqcfvmO.js} +1 -1
- package/dashboard/dist/assets/DashboardPage-CL9LvAb4.js +1 -0
- package/dashboard/dist/assets/FadeIn-DnSCeL3V.js +1 -0
- package/dashboard/dist/assets/{IpCheckPage-FulDeMPC.js → IpCheckPage-BzBH9Pzc.js} +1 -1
- package/dashboard/dist/assets/{LeaderboardPage-BQSqJVlP.js → LeaderboardPage--a89meL8.js} +2 -2
- package/dashboard/dist/assets/LeaderboardProfilePage-CPlgciKL.js +1 -0
- package/dashboard/dist/assets/LimitsPage-gI3NxbAg.js +2 -0
- package/dashboard/dist/assets/PopoverPopup-DSxDP7iu.js +12 -0
- package/dashboard/dist/assets/{ProviderIcon-DChRpzX_.js → ProviderIcon-CXJvW9mJ.js} +1 -1
- package/dashboard/dist/assets/SettingsPage-CSBDnXij.js +1 -0
- package/dashboard/dist/assets/SkillsPage-ZSajczMn.js +1 -0
- package/dashboard/dist/assets/WidgetsPage-COk7qbro.js +1 -0
- package/dashboard/dist/assets/chevron-down-DoncrDIk.js +1 -0
- package/dashboard/dist/assets/{download-w3j__M5u.js → download-DjhGgU2Q.js} +1 -1
- package/dashboard/dist/assets/leaderboard-columns-eCqUMOqF.js +1 -0
- package/dashboard/dist/assets/{main-CJeNIc4Q.js → main-BkcFhXuV.js} +257 -189
- package/dashboard/dist/assets/main-DDHZqhEq.css +1 -0
- package/dashboard/dist/assets/refresh-cw-BvjQfCG5.js +1 -0
- package/dashboard/dist/assets/{use-limits-display-prefs-BzI5WJEo.js → use-limits-display-prefs-D8X45HMo.js} +1 -1
- package/dashboard/dist/assets/use-native-settings-BHpLvxeC.js +1 -0
- package/dashboard/dist/assets/{use-usage-limits-CuoJnC-V.js → use-usage-limits-B6fyz2im.js} +1 -1
- package/dashboard/dist/brand-logos/hermes.svg +11 -0
- package/dashboard/dist/clawd/mini/idle-tight.svg +15 -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 +83 -0
- package/src/lib/skills-manager.js +739 -0
- package/dashboard/dist/assets/DashboardPage-B_Yvna3-.js +0 -12
- package/dashboard/dist/assets/FadeIn-Dz3MVtMG.js +0 -1
- package/dashboard/dist/assets/LeaderboardProfilePage-DP8bewz7.js +0 -1
- package/dashboard/dist/assets/LimitsPage-BXDoE-jU.js +0 -2
- package/dashboard/dist/assets/SettingsPage-XFSmA7VB.js +0 -1
- package/dashboard/dist/assets/WidgetsPage-D5guzG01.js +0 -1
- package/dashboard/dist/assets/leaderboard-columns-ChIW6uNZ.js +0 -1
- package/dashboard/dist/assets/main-Cyta3wIj.css +0 -1
|
@@ -0,0 +1,739 @@
|
|
|
1
|
+
const fs = require("node:fs");
|
|
2
|
+
const os = require("node:os");
|
|
3
|
+
const path = require("node:path");
|
|
4
|
+
|
|
5
|
+
const DEFAULT_REPOS = [
|
|
6
|
+
{ owner: "anthropics", name: "skills", branch: "main", enabled: true },
|
|
7
|
+
{ owner: "ComposioHQ", name: "awesome-claude-skills", branch: "master", enabled: true },
|
|
8
|
+
{ owner: "cexll", name: "myclaude", branch: "master", enabled: true },
|
|
9
|
+
{ owner: "JimLiu", name: "baoyu-skills", branch: "main", enabled: true },
|
|
10
|
+
];
|
|
11
|
+
|
|
12
|
+
const TARGETS = {
|
|
13
|
+
claude: { id: "claude", label: "Claude", dir: () => path.join(os.homedir(), ".claude", "skills") },
|
|
14
|
+
codex: { id: "codex", label: "Codex", dir: () => path.join(os.homedir(), ".codex", "skills") },
|
|
15
|
+
gemini: { id: "gemini", label: "Gemini", dir: () => path.join(os.homedir(), ".gemini", "skills") },
|
|
16
|
+
opencode: { id: "opencode", label: "OpenCode", dir: () => path.join(os.homedir(), ".config", "opencode", "skills") },
|
|
17
|
+
hermes: { id: "hermes", label: "Hermes", dir: () => path.join(os.homedir(), ".hermes", "skills") },
|
|
18
|
+
agents: { id: "agents", label: "Agents", visible: false, dir: () => path.join(os.homedir(), ".agents", "skills") },
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
const FETCH_TIMEOUT_MS = 20_000;
|
|
22
|
+
const DISCOVER_CONCURRENCY = 4;
|
|
23
|
+
const DISCOVER_CACHE_TTL_MS = 60 * 60 * 1000; // 1 hour
|
|
24
|
+
const OWNER_NAME_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]{0,99}$/;
|
|
25
|
+
|
|
26
|
+
class RateLimitError extends Error {
|
|
27
|
+
constructor(message) {
|
|
28
|
+
super(message);
|
|
29
|
+
this.name = "RateLimitError";
|
|
30
|
+
this.code = "RATE_LIMITED";
|
|
31
|
+
this.status = 429;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
async function mapWithConcurrency(items, limit, worker) {
|
|
36
|
+
const results = new Array(items.length);
|
|
37
|
+
let cursor = 0;
|
|
38
|
+
async function runNext() {
|
|
39
|
+
while (true) {
|
|
40
|
+
const index = cursor++;
|
|
41
|
+
if (index >= items.length) return;
|
|
42
|
+
results[index] = await worker(items[index], index);
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
const pool = new Array(Math.min(limit, items.length)).fill(0).map(runNext);
|
|
46
|
+
await Promise.all(pool);
|
|
47
|
+
return results;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function dataDir() {
|
|
51
|
+
return path.join(os.homedir(), ".tokentracker", "skills");
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function registryPath() {
|
|
55
|
+
return path.join(dataDir(), "registry.json");
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function ssotDir() {
|
|
59
|
+
return path.join(dataDir(), "managed");
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function trashDir() {
|
|
63
|
+
return path.join(dataDir(), ".trash");
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const TRASH_TTL_MS = 5 * 60 * 1000; // 5 minutes
|
|
67
|
+
|
|
68
|
+
function discoverCachePath() {
|
|
69
|
+
return path.join(dataDir(), "discover-cache.json");
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function ensureDir(dir) {
|
|
73
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function readJson(file, fallback) {
|
|
77
|
+
try {
|
|
78
|
+
return JSON.parse(fs.readFileSync(file, "utf8"));
|
|
79
|
+
} catch (_e) {
|
|
80
|
+
return fallback;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function writeJson(file, value) {
|
|
85
|
+
ensureDir(path.dirname(file));
|
|
86
|
+
fs.writeFileSync(file, `${JSON.stringify(value, null, 2)}\n`, { mode: 0o600 });
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function readRegistry() {
|
|
90
|
+
const registry = readJson(registryPath(), null);
|
|
91
|
+
if (registry && typeof registry === "object") {
|
|
92
|
+
return {
|
|
93
|
+
repos: Array.isArray(registry.repos) ? registry.repos : DEFAULT_REPOS,
|
|
94
|
+
skills: Array.isArray(registry.skills) ? registry.skills : [],
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
return { repos: DEFAULT_REPOS, skills: [] };
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function saveRegistry(registry) {
|
|
101
|
+
writeJson(registryPath(), registry);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function sanitizePathSegment(value) {
|
|
105
|
+
const segment = String(value || "").trim();
|
|
106
|
+
if (!segment || segment === "." || segment === "..") return null;
|
|
107
|
+
if (segment.includes("/") || segment.includes("\\") || segment.includes("\0")) return null;
|
|
108
|
+
return segment;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function sanitizeRelativePath(value) {
|
|
112
|
+
const raw = String(value || "").replace(/\\/g, "/").trim();
|
|
113
|
+
if (!raw || raw.startsWith("/") || raw.includes("\0")) return null;
|
|
114
|
+
const parts = raw.split("/").filter(Boolean);
|
|
115
|
+
if (!parts.length || parts.some((part) => part === "." || part === "..")) return null;
|
|
116
|
+
return parts.join("/");
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function installNameFromDirectory(directory) {
|
|
120
|
+
const safe = sanitizeRelativePath(directory);
|
|
121
|
+
if (!safe) return null;
|
|
122
|
+
return sanitizePathSegment(safe.split("/").pop());
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function targetList() {
|
|
126
|
+
return Object.values(TARGETS)
|
|
127
|
+
.filter((target) => target.visible !== false)
|
|
128
|
+
.map((target) => ({
|
|
129
|
+
id: target.id,
|
|
130
|
+
label: target.label,
|
|
131
|
+
path: target.dir(),
|
|
132
|
+
}));
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function readSkillMetadata(markdown, fallbackName) {
|
|
136
|
+
const raw = String(markdown || "");
|
|
137
|
+
const frontmatter = raw.match(/^---\s*\n([\s\S]*?)\n---/);
|
|
138
|
+
const source = frontmatter ? frontmatter[1] : raw;
|
|
139
|
+
const nameMatch = source.match(/^name:\s*["']?(.+?)["']?\s*$/m);
|
|
140
|
+
const descriptionMatch = source.match(/^description:\s*["']?([\s\S]+?)["']?\s*$/m);
|
|
141
|
+
return {
|
|
142
|
+
name: (nameMatch?.[1] || fallbackName || "Skill").trim(),
|
|
143
|
+
description: (descriptionMatch?.[1] || "").replace(/\n\s+/g, " ").trim(),
|
|
144
|
+
};
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
async function fetchJson(url) {
|
|
148
|
+
const controller = new AbortController();
|
|
149
|
+
const timeout = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
|
|
150
|
+
try {
|
|
151
|
+
const response = await fetch(url, {
|
|
152
|
+
headers: {
|
|
153
|
+
Accept: "application/vnd.github+json",
|
|
154
|
+
"User-Agent": "tokentracker-skills",
|
|
155
|
+
},
|
|
156
|
+
signal: controller.signal,
|
|
157
|
+
});
|
|
158
|
+
if (response.status === 429 || response.status === 403) {
|
|
159
|
+
throw new RateLimitError(`GitHub rate-limited this request (HTTP ${response.status}). Try again later.`);
|
|
160
|
+
}
|
|
161
|
+
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
|
162
|
+
return response.json();
|
|
163
|
+
} finally {
|
|
164
|
+
clearTimeout(timeout);
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
async function fetchText(url) {
|
|
169
|
+
const controller = new AbortController();
|
|
170
|
+
const timeout = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
|
|
171
|
+
try {
|
|
172
|
+
const response = await fetch(url, {
|
|
173
|
+
headers: { Accept: "text/plain", "User-Agent": "tokentracker-skills" },
|
|
174
|
+
signal: controller.signal,
|
|
175
|
+
});
|
|
176
|
+
if (response.status === 429 || response.status === 403) {
|
|
177
|
+
throw new RateLimitError(`GitHub rate-limited this request (HTTP ${response.status}). Try again later.`);
|
|
178
|
+
}
|
|
179
|
+
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
|
180
|
+
return response.text();
|
|
181
|
+
} finally {
|
|
182
|
+
clearTimeout(timeout);
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
function githubRawUrl(owner, name, branch, filePath) {
|
|
187
|
+
return `https://raw.githubusercontent.com/${owner}/${name}/${branch}/${filePath
|
|
188
|
+
.split("/")
|
|
189
|
+
.map(encodeURIComponent)
|
|
190
|
+
.join("/")}`;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
function githubDocUrl(owner, name, branch, filePath) {
|
|
194
|
+
return `https://github.com/${owner}/${name}/blob/${branch}/${filePath
|
|
195
|
+
.split("/")
|
|
196
|
+
.map(encodeURIComponent)
|
|
197
|
+
.join("/")}`;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
async function getRepoTree(repo) {
|
|
201
|
+
const branches = [];
|
|
202
|
+
if (repo.branch && !String(repo.branch).match(/^head$/i)) branches.push(repo.branch);
|
|
203
|
+
if (!branches.includes("main")) branches.push("main");
|
|
204
|
+
if (!branches.includes("master")) branches.push("master");
|
|
205
|
+
|
|
206
|
+
let lastError = null;
|
|
207
|
+
for (const branch of branches) {
|
|
208
|
+
try {
|
|
209
|
+
const data = await fetchJson(
|
|
210
|
+
`https://api.github.com/repos/${repo.owner}/${repo.name}/git/trees/${encodeURIComponent(branch)}?recursive=1`,
|
|
211
|
+
);
|
|
212
|
+
if (Array.isArray(data?.tree)) return { branch, tree: data.tree };
|
|
213
|
+
} catch (error) {
|
|
214
|
+
lastError = error;
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
throw lastError || new Error(`Unable to read ${repo.owner}/${repo.name}`);
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
function buildSkillKey(skill) {
|
|
221
|
+
return `${skill.repoOwner}/${skill.repoName}:${skill.directory}`;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
function normalizeRepo(repo) {
|
|
225
|
+
return {
|
|
226
|
+
owner: String(repo?.owner || "").trim(),
|
|
227
|
+
name: String(repo?.name || "").trim(),
|
|
228
|
+
branch: String(repo?.branch || "main").trim() || "main",
|
|
229
|
+
enabled: repo?.enabled !== false,
|
|
230
|
+
};
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
async function discoverRepoSkills(repoInput) {
|
|
234
|
+
const repo = normalizeRepo(repoInput);
|
|
235
|
+
if (!repo.owner || !repo.name || !repo.enabled) return [];
|
|
236
|
+
const { branch, tree } = await getRepoTree(repo);
|
|
237
|
+
const skillFiles = tree
|
|
238
|
+
.filter((entry) => entry?.type === "blob" && /(^|\/)SKILL\.md$/i.test(entry.path || ""))
|
|
239
|
+
.slice(0, 200);
|
|
240
|
+
|
|
241
|
+
const skills = await mapWithConcurrency(skillFiles, DISCOVER_CONCURRENCY, async (entry) => {
|
|
242
|
+
const docPath = entry.path.replace(/\\/g, "/");
|
|
243
|
+
const directory = docPath.endsWith("/SKILL.md") ? docPath.slice(0, -"/SKILL.md".length) : repo.name;
|
|
244
|
+
const installName = installNameFromDirectory(directory || repo.name);
|
|
245
|
+
if (!installName) return null;
|
|
246
|
+
let metadata = { name: installName, description: "" };
|
|
247
|
+
try {
|
|
248
|
+
metadata = readSkillMetadata(await fetchText(githubRawUrl(repo.owner, repo.name, branch, docPath)), installName);
|
|
249
|
+
} catch (error) {
|
|
250
|
+
if (error instanceof RateLimitError) throw error;
|
|
251
|
+
// Keep the skill discoverable even if metadata fetch fails.
|
|
252
|
+
}
|
|
253
|
+
return {
|
|
254
|
+
key: `${repo.owner}/${repo.name}:${directory || repo.name}`,
|
|
255
|
+
name: metadata.name,
|
|
256
|
+
description: metadata.description,
|
|
257
|
+
directory: directory || repo.name,
|
|
258
|
+
readmeUrl: githubDocUrl(repo.owner, repo.name, branch, docPath),
|
|
259
|
+
repoOwner: repo.owner,
|
|
260
|
+
repoName: repo.name,
|
|
261
|
+
repoBranch: branch,
|
|
262
|
+
};
|
|
263
|
+
});
|
|
264
|
+
return skills.filter(Boolean);
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
function dedupeSkills(skills) {
|
|
268
|
+
const byKey = new Map();
|
|
269
|
+
for (const skill of skills) byKey.set(buildSkillKey(skill).toLowerCase(), skill);
|
|
270
|
+
return Array.from(byKey.values()).sort((a, b) => a.name.localeCompare(b.name));
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
function readDiscoverCache(fingerprint) {
|
|
274
|
+
const data = readJson(discoverCachePath(), null);
|
|
275
|
+
if (!data || typeof data !== "object" || !Array.isArray(data.skills)) return null;
|
|
276
|
+
if (data.fingerprint !== fingerprint) return null;
|
|
277
|
+
if (!Number.isFinite(data.generatedAt)) return null;
|
|
278
|
+
if (Date.now() - data.generatedAt > DISCOVER_CACHE_TTL_MS) return null;
|
|
279
|
+
return data;
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
function writeDiscoverCache(fingerprint, skills) {
|
|
283
|
+
writeJson(discoverCachePath(), { fingerprint, generatedAt: Date.now(), skills });
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
function invalidateDiscoverCache() {
|
|
287
|
+
try {
|
|
288
|
+
fs.rmSync(discoverCachePath(), { force: true });
|
|
289
|
+
} catch (_e) {
|
|
290
|
+
// ignore
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
async function discoverSkills({ force = false } = {}) {
|
|
295
|
+
const registry = readRegistry();
|
|
296
|
+
const enabled = registry.repos.map(normalizeRepo).filter((repo) => repo.enabled);
|
|
297
|
+
if (!enabled.length) return { skills: [], cached: false, generatedAt: Date.now() };
|
|
298
|
+
|
|
299
|
+
const fingerprint = enabled
|
|
300
|
+
.map((repo) => `${repo.owner}/${repo.name}@${repo.branch}`)
|
|
301
|
+
.sort()
|
|
302
|
+
.join("|");
|
|
303
|
+
|
|
304
|
+
if (!force) {
|
|
305
|
+
const cached = readDiscoverCache(fingerprint);
|
|
306
|
+
if (cached) return { skills: cached.skills, cached: true, generatedAt: cached.generatedAt };
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
const settled = await Promise.allSettled(enabled.map(discoverRepoSkills));
|
|
310
|
+
const merged = dedupeSkills(settled.flatMap((result) => (result.status === "fulfilled" ? result.value : [])));
|
|
311
|
+
if (!merged.length) {
|
|
312
|
+
const rateLimited = settled.find(
|
|
313
|
+
(result) => result.status === "rejected" && result.reason instanceof RateLimitError,
|
|
314
|
+
);
|
|
315
|
+
if (rateLimited) throw rateLimited.reason;
|
|
316
|
+
}
|
|
317
|
+
writeDiscoverCache(fingerprint, merged);
|
|
318
|
+
return { skills: merged, cached: false, generatedAt: Date.now() };
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
function removePath(targetPath) {
|
|
322
|
+
if (!fs.existsSync(targetPath) && !isSymlink(targetPath)) return;
|
|
323
|
+
fs.rmSync(targetPath, { recursive: true, force: true });
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
function isSymlink(targetPath) {
|
|
327
|
+
try {
|
|
328
|
+
return fs.lstatSync(targetPath).isSymbolicLink();
|
|
329
|
+
} catch (_e) {
|
|
330
|
+
return false;
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
function copyDir(source, dest) {
|
|
335
|
+
removePath(dest);
|
|
336
|
+
fs.cpSync(source, dest, { recursive: true, force: true });
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
function syncSkillToTarget(directory, targetId) {
|
|
340
|
+
const target = TARGETS[targetId];
|
|
341
|
+
if (!target) throw new Error(`Unsupported target: ${targetId}`);
|
|
342
|
+
const source = path.join(ssotDir(), directory);
|
|
343
|
+
const dest = path.join(target.dir(), directory);
|
|
344
|
+
if (!fs.existsSync(source)) throw new Error(`Managed skill not found: ${directory}`);
|
|
345
|
+
ensureDir(path.dirname(dest));
|
|
346
|
+
removePath(dest);
|
|
347
|
+
try {
|
|
348
|
+
fs.symlinkSync(source, dest, "dir");
|
|
349
|
+
} catch (_e) {
|
|
350
|
+
copyDir(source, dest);
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
function removeSkillFromTarget(directory, targetId) {
|
|
355
|
+
const target = TARGETS[targetId];
|
|
356
|
+
if (!target) return;
|
|
357
|
+
removePath(path.join(target.dir(), directory));
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
function scanTargetSkill(directory, targetId) {
|
|
361
|
+
const target = TARGETS[targetId];
|
|
362
|
+
if (!target) return false;
|
|
363
|
+
return fs.existsSync(path.join(target.dir(), directory)) || isSymlink(path.join(target.dir(), directory));
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
function listInstalledSkills() {
|
|
367
|
+
purgeExpiredTrash();
|
|
368
|
+
const registry = readRegistry();
|
|
369
|
+
const managed = registry.skills
|
|
370
|
+
.filter((skill) => !skill.trashedAt)
|
|
371
|
+
.map((skill) => {
|
|
372
|
+
const targets = Object.keys(TARGETS).filter((id) => scanTargetSkill(skill.directory, id));
|
|
373
|
+
return { ...skill, managed: true, targets };
|
|
374
|
+
});
|
|
375
|
+
|
|
376
|
+
const managedDirs = new Set(managed.map((skill) => skill.directory.toLowerCase()));
|
|
377
|
+
const unmanaged = new Map();
|
|
378
|
+
for (const target of Object.values(TARGETS)) {
|
|
379
|
+
const dir = target.dir();
|
|
380
|
+
let entries = [];
|
|
381
|
+
try {
|
|
382
|
+
entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
383
|
+
} catch (_e) {
|
|
384
|
+
continue;
|
|
385
|
+
}
|
|
386
|
+
for (const entry of entries) {
|
|
387
|
+
if (!entry.isDirectory() && !entry.isSymbolicLink()) continue;
|
|
388
|
+
const directory = entry.name;
|
|
389
|
+
if (!directory || directory.startsWith(".") || managedDirs.has(directory.toLowerCase())) continue;
|
|
390
|
+
const skillPath = path.join(dir, directory, "SKILL.md");
|
|
391
|
+
if (!fs.existsSync(skillPath)) continue;
|
|
392
|
+
const metadata = readSkillMetadata(fs.readFileSync(skillPath, "utf8"), directory);
|
|
393
|
+
const key = directory.toLowerCase();
|
|
394
|
+
if (!unmanaged.has(key)) {
|
|
395
|
+
unmanaged.set(key, {
|
|
396
|
+
id: `local:${directory}`,
|
|
397
|
+
key: `local:${directory}`,
|
|
398
|
+
name: metadata.name,
|
|
399
|
+
description: metadata.description,
|
|
400
|
+
directory,
|
|
401
|
+
readmeUrl: null,
|
|
402
|
+
repoOwner: null,
|
|
403
|
+
repoName: null,
|
|
404
|
+
repoBranch: null,
|
|
405
|
+
installedAt: null,
|
|
406
|
+
managed: false,
|
|
407
|
+
targets: [],
|
|
408
|
+
targetPaths: {},
|
|
409
|
+
});
|
|
410
|
+
}
|
|
411
|
+
const skill = unmanaged.get(key);
|
|
412
|
+
skill.targets.push(target.id);
|
|
413
|
+
skill.targetPaths[target.id] = path.join(dir, directory);
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
return [...managed, ...unmanaged.values()].sort((a, b) => a.name.localeCompare(b.name));
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
async function installSkill(skillInput, targetIds = ["claude", "codex"]) {
|
|
421
|
+
const skill = {
|
|
422
|
+
key: String(skillInput?.key || ""),
|
|
423
|
+
name: String(skillInput?.name || ""),
|
|
424
|
+
description: String(skillInput?.description || ""),
|
|
425
|
+
directory: String(skillInput?.directory || ""),
|
|
426
|
+
readmeUrl: skillInput?.readmeUrl || null,
|
|
427
|
+
repoOwner: String(skillInput?.repoOwner || ""),
|
|
428
|
+
repoName: String(skillInput?.repoName || ""),
|
|
429
|
+
repoBranch: String(skillInput?.repoBranch || "main") || "main",
|
|
430
|
+
};
|
|
431
|
+
if (!skill.repoOwner || !skill.repoName) throw new Error("Missing GitHub repository information");
|
|
432
|
+
const sourceDir = sanitizeRelativePath(skill.directory);
|
|
433
|
+
const installName = installNameFromDirectory(sourceDir);
|
|
434
|
+
if (!sourceDir || !installName) throw new Error("Invalid skill directory");
|
|
435
|
+
|
|
436
|
+
const registry = readRegistry();
|
|
437
|
+
const existingConflict = registry.skills.find(
|
|
438
|
+
(entry) =>
|
|
439
|
+
entry.directory.toLowerCase() === installName.toLowerCase() &&
|
|
440
|
+
`${entry.repoOwner}/${entry.repoName}`.toLowerCase() !== `${skill.repoOwner}/${skill.repoName}`.toLowerCase(),
|
|
441
|
+
);
|
|
442
|
+
if (existingConflict) {
|
|
443
|
+
throw new Error(
|
|
444
|
+
`Skill directory "${installName}" is already managed by ${existingConflict.repoOwner}/${existingConflict.repoName}`,
|
|
445
|
+
);
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
const { branch, tree } = await getRepoTree({
|
|
449
|
+
owner: skill.repoOwner,
|
|
450
|
+
name: skill.repoName,
|
|
451
|
+
branch: skill.repoBranch,
|
|
452
|
+
});
|
|
453
|
+
const files = tree.filter(
|
|
454
|
+
(entry) => entry?.type === "blob" && (entry.path === sourceDir || String(entry.path || "").startsWith(`${sourceDir}/`)),
|
|
455
|
+
);
|
|
456
|
+
if (!files.some((entry) => /(^|\/)SKILL\.md$/i.test(entry.path))) throw new Error("SKILL.md not found in selected directory");
|
|
457
|
+
|
|
458
|
+
const dest = path.join(ssotDir(), installName);
|
|
459
|
+
const temp = path.join(dataDir(), "tmp", `${installName}-${Date.now()}`);
|
|
460
|
+
removePath(temp);
|
|
461
|
+
ensureDir(temp);
|
|
462
|
+
try {
|
|
463
|
+
for (const entry of files) {
|
|
464
|
+
const relative = entry.path === sourceDir ? path.basename(entry.path) : entry.path.slice(sourceDir.length + 1);
|
|
465
|
+
const safeRelative = sanitizeRelativePath(relative);
|
|
466
|
+
if (!safeRelative) continue;
|
|
467
|
+
const out = path.join(temp, safeRelative);
|
|
468
|
+
ensureDir(path.dirname(out));
|
|
469
|
+
fs.writeFileSync(out, await fetchText(githubRawUrl(skill.repoOwner, skill.repoName, branch, entry.path)));
|
|
470
|
+
}
|
|
471
|
+
removePath(dest);
|
|
472
|
+
ensureDir(path.dirname(dest));
|
|
473
|
+
fs.renameSync(temp, dest);
|
|
474
|
+
} catch (error) {
|
|
475
|
+
removePath(temp);
|
|
476
|
+
throw error;
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
const skillMd = fs.readFileSync(path.join(dest, "SKILL.md"), "utf8");
|
|
480
|
+
const metadata = readSkillMetadata(skillMd, skill.name || installName);
|
|
481
|
+
const selectedTargets = targetIds.filter((id) => TARGETS[id]);
|
|
482
|
+
const installed = {
|
|
483
|
+
id: `${skill.repoOwner}/${skill.repoName}:${sourceDir}`,
|
|
484
|
+
key: `${skill.repoOwner}/${skill.repoName}:${sourceDir}`,
|
|
485
|
+
name: metadata.name,
|
|
486
|
+
description: metadata.description || skill.description,
|
|
487
|
+
directory: installName,
|
|
488
|
+
sourceDirectory: sourceDir,
|
|
489
|
+
readmeUrl: githubDocUrl(skill.repoOwner, skill.repoName, branch, `${sourceDir}/SKILL.md`),
|
|
490
|
+
repoOwner: skill.repoOwner,
|
|
491
|
+
repoName: skill.repoName,
|
|
492
|
+
repoBranch: branch,
|
|
493
|
+
installedAt: Date.now(),
|
|
494
|
+
targets: selectedTargets,
|
|
495
|
+
};
|
|
496
|
+
|
|
497
|
+
registry.skills = registry.skills.filter((entry) => entry.id !== installed.id && entry.directory.toLowerCase() !== installName.toLowerCase());
|
|
498
|
+
registry.skills.push(installed);
|
|
499
|
+
saveRegistry(registry);
|
|
500
|
+
|
|
501
|
+
for (const id of selectedTargets) syncSkillToTarget(installName, id);
|
|
502
|
+
return { ...installed, managed: true, targets: selectedTargets };
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
function uninstallSkill(id) {
|
|
506
|
+
const registry = readRegistry();
|
|
507
|
+
const skill = registry.skills.find((entry) => entry.id === id || entry.key === id);
|
|
508
|
+
if (!skill) throw new Error("Managed skill not found");
|
|
509
|
+
for (const targetId of Object.keys(TARGETS)) removeSkillFromTarget(skill.directory, targetId);
|
|
510
|
+
// Move SSOT copy into a trash bucket so it can be restored briefly. The
|
|
511
|
+
// registry entry is retained but flagged so restoreSkill can re-link it.
|
|
512
|
+
const ssotPath = path.join(ssotDir(), skill.directory);
|
|
513
|
+
if (fs.existsSync(ssotPath)) {
|
|
514
|
+
ensureDir(trashDir());
|
|
515
|
+
const stamp = Date.now();
|
|
516
|
+
const trashPath = path.join(trashDir(), `${skill.directory}-${stamp}`);
|
|
517
|
+
try {
|
|
518
|
+
fs.renameSync(ssotPath, trashPath);
|
|
519
|
+
skill.trashedAt = stamp;
|
|
520
|
+
skill.trashedDirectory = path.basename(trashPath);
|
|
521
|
+
skill.previousTargets = skill.targets || [];
|
|
522
|
+
skill.targets = [];
|
|
523
|
+
const others = registry.skills.filter((entry) => entry.id !== skill.id);
|
|
524
|
+
registry.skills = [...others, skill];
|
|
525
|
+
saveRegistry(registry);
|
|
526
|
+
purgeExpiredTrash();
|
|
527
|
+
return { ok: true, trashed: true, restoreId: skill.id, ttlMs: TRASH_TTL_MS };
|
|
528
|
+
} catch (_e) {
|
|
529
|
+
removePath(ssotPath);
|
|
530
|
+
}
|
|
531
|
+
}
|
|
532
|
+
registry.skills = registry.skills.filter((entry) => entry.id !== skill.id);
|
|
533
|
+
saveRegistry(registry);
|
|
534
|
+
return { ok: true, trashed: false };
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
function purgeExpiredTrash() {
|
|
538
|
+
try {
|
|
539
|
+
const registry = readRegistry();
|
|
540
|
+
const now = Date.now();
|
|
541
|
+
let dirty = false;
|
|
542
|
+
registry.skills = registry.skills.filter((skill) => {
|
|
543
|
+
if (!skill.trashedAt) return true;
|
|
544
|
+
if (now - skill.trashedAt < TRASH_TTL_MS) return true;
|
|
545
|
+
const trashPath = skill.trashedDirectory ? path.join(trashDir(), skill.trashedDirectory) : null;
|
|
546
|
+
if (trashPath) removePath(trashPath);
|
|
547
|
+
dirty = true;
|
|
548
|
+
return false;
|
|
549
|
+
});
|
|
550
|
+
if (dirty) saveRegistry(registry);
|
|
551
|
+
} catch (_e) {
|
|
552
|
+
// best-effort
|
|
553
|
+
}
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
function restoreSkill(id) {
|
|
557
|
+
const registry = readRegistry();
|
|
558
|
+
const skill = registry.skills.find((entry) => entry.id === id || entry.key === id);
|
|
559
|
+
if (!skill || !skill.trashedAt) throw new Error("Nothing to restore");
|
|
560
|
+
if (Date.now() - skill.trashedAt > TRASH_TTL_MS) {
|
|
561
|
+
throw new Error("Restore window expired");
|
|
562
|
+
}
|
|
563
|
+
const trashPath = path.join(trashDir(), skill.trashedDirectory || "");
|
|
564
|
+
const ssotPath = path.join(ssotDir(), skill.directory);
|
|
565
|
+
if (!fs.existsSync(trashPath)) throw new Error("Trashed copy is missing");
|
|
566
|
+
ensureDir(path.dirname(ssotPath));
|
|
567
|
+
removePath(ssotPath);
|
|
568
|
+
fs.renameSync(trashPath, ssotPath);
|
|
569
|
+
const targets = Array.isArray(skill.previousTargets) ? skill.previousTargets : [];
|
|
570
|
+
skill.targets = targets;
|
|
571
|
+
delete skill.trashedAt;
|
|
572
|
+
delete skill.trashedDirectory;
|
|
573
|
+
delete skill.previousTargets;
|
|
574
|
+
saveRegistry(registry);
|
|
575
|
+
for (const targetId of targets) syncSkillToTarget(skill.directory, targetId);
|
|
576
|
+
return { ...skill, managed: true, targets };
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
function setSkillTargets(id, targetIds) {
|
|
580
|
+
const registry = readRegistry();
|
|
581
|
+
const skill = registry.skills.find((entry) => entry.id === id || entry.key === id);
|
|
582
|
+
if (!skill) throw new Error("Managed skill not found");
|
|
583
|
+
const selectedTargets = targetIds.filter((targetId) => TARGETS[targetId]);
|
|
584
|
+
for (const targetId of Object.keys(TARGETS)) {
|
|
585
|
+
if (selectedTargets.includes(targetId)) syncSkillToTarget(skill.directory, targetId);
|
|
586
|
+
else removeSkillFromTarget(skill.directory, targetId);
|
|
587
|
+
}
|
|
588
|
+
skill.targets = selectedTargets;
|
|
589
|
+
saveRegistry(registry);
|
|
590
|
+
return { ...skill, managed: true, targets: selectedTargets };
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
function findLocalSkillSource(directory) {
|
|
594
|
+
const installName = sanitizePathSegment(directory);
|
|
595
|
+
if (!installName) return null;
|
|
596
|
+
for (const target of Object.values(TARGETS)) {
|
|
597
|
+
const skillPath = path.join(target.dir(), installName);
|
|
598
|
+
const docPath = path.join(skillPath, "SKILL.md");
|
|
599
|
+
if (fs.existsSync(docPath)) {
|
|
600
|
+
return { path: skillPath, targetId: target.id };
|
|
601
|
+
}
|
|
602
|
+
}
|
|
603
|
+
return null;
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
function importLocalSkill(directory, targetIds = []) {
|
|
607
|
+
const installName = sanitizePathSegment(directory);
|
|
608
|
+
if (!installName) throw new Error("Invalid skill directory");
|
|
609
|
+
const registry = readRegistry();
|
|
610
|
+
const existing = registry.skills.find((entry) => entry.directory.toLowerCase() === installName.toLowerCase());
|
|
611
|
+
if (existing) {
|
|
612
|
+
if (!targetIds || !targetIds.length) {
|
|
613
|
+
return { ...existing, managed: true, targets: existing.targets || [] };
|
|
614
|
+
}
|
|
615
|
+
return setSkillTargets(existing.id, targetIds);
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
const source = findLocalSkillSource(installName);
|
|
619
|
+
if (!source) throw new Error("Local skill not found");
|
|
620
|
+
|
|
621
|
+
const dest = path.join(ssotDir(), installName);
|
|
622
|
+
copyDir(source.path, dest);
|
|
623
|
+
const metadata = readSkillMetadata(fs.readFileSync(path.join(dest, "SKILL.md"), "utf8"), installName);
|
|
624
|
+
const discoveredTargets = Object.keys(TARGETS).filter((targetId) => scanTargetSkill(installName, targetId));
|
|
625
|
+
const selectedTargets = (targetIds.length ? targetIds : discoveredTargets).filter((targetId) => TARGETS[targetId]);
|
|
626
|
+
const skill = {
|
|
627
|
+
id: `local:${installName}`,
|
|
628
|
+
key: `local:${installName}`,
|
|
629
|
+
name: metadata.name,
|
|
630
|
+
description: metadata.description,
|
|
631
|
+
directory: installName,
|
|
632
|
+
sourceDirectory: installName,
|
|
633
|
+
readmeUrl: null,
|
|
634
|
+
repoOwner: null,
|
|
635
|
+
repoName: null,
|
|
636
|
+
repoBranch: null,
|
|
637
|
+
installedAt: Date.now(),
|
|
638
|
+
targets: selectedTargets,
|
|
639
|
+
};
|
|
640
|
+
|
|
641
|
+
registry.skills.push(skill);
|
|
642
|
+
saveRegistry(registry);
|
|
643
|
+
for (const targetId of Object.keys(TARGETS)) {
|
|
644
|
+
if (selectedTargets.includes(targetId)) syncSkillToTarget(installName, targetId);
|
|
645
|
+
else removeSkillFromTarget(installName, targetId);
|
|
646
|
+
}
|
|
647
|
+
return { ...skill, managed: true, targets: selectedTargets };
|
|
648
|
+
}
|
|
649
|
+
|
|
650
|
+
function deleteLocalSkill(directory, targetIds = []) {
|
|
651
|
+
const installName = sanitizePathSegment(directory);
|
|
652
|
+
if (!installName) throw new Error("Invalid skill directory");
|
|
653
|
+
const selectedTargets = targetIds.length ? targetIds : Object.keys(TARGETS);
|
|
654
|
+
for (const targetId of selectedTargets) removeSkillFromTarget(installName, targetId);
|
|
655
|
+
return { ok: true };
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
function listRepos() {
|
|
659
|
+
return readRegistry().repos.map(normalizeRepo);
|
|
660
|
+
}
|
|
661
|
+
|
|
662
|
+
function addRepo(repoInput) {
|
|
663
|
+
const repo = normalizeRepo(repoInput);
|
|
664
|
+
if (!repo.owner || !repo.name) throw new Error("Repository owner and name are required");
|
|
665
|
+
if (!OWNER_NAME_PATTERN.test(repo.owner) || !OWNER_NAME_PATTERN.test(repo.name)) {
|
|
666
|
+
throw new Error("Repository owner and name may only contain letters, digits, '.', '_', or '-'");
|
|
667
|
+
}
|
|
668
|
+
if (!OWNER_NAME_PATTERN.test(repo.branch)) {
|
|
669
|
+
throw new Error("Repository branch contains unsupported characters");
|
|
670
|
+
}
|
|
671
|
+
const registry = readRegistry();
|
|
672
|
+
registry.repos = registry.repos.filter(
|
|
673
|
+
(entry) => `${entry.owner}/${entry.name}`.toLowerCase() !== `${repo.owner}/${repo.name}`.toLowerCase(),
|
|
674
|
+
);
|
|
675
|
+
registry.repos.push(repo);
|
|
676
|
+
saveRegistry(registry);
|
|
677
|
+
invalidateDiscoverCache();
|
|
678
|
+
return repo;
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
function removeRepo(owner, name) {
|
|
682
|
+
const registry = readRegistry();
|
|
683
|
+
registry.repos = registry.repos.filter(
|
|
684
|
+
(entry) => `${entry.owner}/${entry.name}`.toLowerCase() !== `${owner}/${name}`.toLowerCase(),
|
|
685
|
+
);
|
|
686
|
+
saveRegistry(registry);
|
|
687
|
+
invalidateDiscoverCache();
|
|
688
|
+
return { ok: true };
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
async function searchSkillsSh(query, limit = 20, offset = 0) {
|
|
692
|
+
const q = String(query || "").trim();
|
|
693
|
+
if (q.length < 2) return { query: q, totalCount: 0, skills: [] };
|
|
694
|
+
const url = new URL("https://skills.sh/api/search");
|
|
695
|
+
url.searchParams.set("q", q);
|
|
696
|
+
url.searchParams.set("limit", String(Math.max(1, Math.min(50, Number(limit) || 20))));
|
|
697
|
+
url.searchParams.set("offset", String(Math.max(0, Number(offset) || 0)));
|
|
698
|
+
const data = await fetchJson(url.toString());
|
|
699
|
+
const skills = Array.isArray(data?.skills)
|
|
700
|
+
? data.skills
|
|
701
|
+
.map((entry) => {
|
|
702
|
+
const [owner, repoName] = String(entry?.source || "").split("/", 2);
|
|
703
|
+
if (!owner || !repoName || owner.includes(".") || repoName.includes(".")) return null;
|
|
704
|
+
return {
|
|
705
|
+
key: String(entry.id || `${owner}/${repoName}:${entry.skillId || entry.name}`),
|
|
706
|
+
name: String(entry.name || entry.skillId || "Skill"),
|
|
707
|
+
description: "",
|
|
708
|
+
directory: String(entry.skillId || entry.name || ""),
|
|
709
|
+
repoOwner: owner,
|
|
710
|
+
repoName,
|
|
711
|
+
repoBranch: "main",
|
|
712
|
+
readmeUrl: `https://github.com/${owner}/${repoName}`,
|
|
713
|
+
installs: Number(entry.installs || 0),
|
|
714
|
+
};
|
|
715
|
+
})
|
|
716
|
+
.filter(Boolean)
|
|
717
|
+
: [];
|
|
718
|
+
return {
|
|
719
|
+
query: String(data?.query || q),
|
|
720
|
+
totalCount: Number(data?.count || skills.length),
|
|
721
|
+
skills,
|
|
722
|
+
};
|
|
723
|
+
}
|
|
724
|
+
|
|
725
|
+
module.exports = {
|
|
726
|
+
addRepo,
|
|
727
|
+
discoverSkills,
|
|
728
|
+
deleteLocalSkill,
|
|
729
|
+
importLocalSkill,
|
|
730
|
+
installSkill,
|
|
731
|
+
listInstalledSkills,
|
|
732
|
+
listRepos,
|
|
733
|
+
removeRepo,
|
|
734
|
+
restoreSkill,
|
|
735
|
+
searchSkillsSh,
|
|
736
|
+
setSkillTargets,
|
|
737
|
+
targetList,
|
|
738
|
+
uninstallSkill,
|
|
739
|
+
};
|