claudeup 4.41.0 → 4.42.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.
- package/package.json +4 -4
- package/src/__tests__/component-badge.test.ts +98 -0
- package/src/__tests__/component-icons.test.ts +94 -0
- package/src/__tests__/local-marketplace-scan.test.ts +174 -0
- package/src/__tests__/markdown-renderer.test.ts +143 -0
- package/src/__tests__/marketplaces.test.ts +4 -0
- package/src/__tests__/plugin-checkout.test.ts +66 -0
- package/src/__tests__/plugin-contents.test.ts +371 -0
- package/src/data/marketplaces.ts +22 -0
- package/src/services/local-marketplace.ts +110 -19
- package/src/services/marketplace-catalog.ts +34 -0
- package/src/services/plugin-checkout.ts +186 -0
- package/src/services/plugin-manager.ts +18 -0
- package/src/services/skills-manager.ts +531 -14
- package/src/ui/adapters/pluginsAdapter.ts +154 -17
- package/src/ui/components/primitives/KeyValueLine.tsx +8 -2
- package/src/ui/renderers/markdownRenderer.tsx +302 -0
- package/src/ui/renderers/pluginRenderers.tsx +415 -9
- package/src/ui/screens/PluginsScreen.tsx +181 -53
- package/src/ui/state/reducer.ts +56 -0
- package/src/ui/state/types.ts +42 -0
- package/src/ui/theme-mode.ts +67 -0
|
@@ -1,13 +1,21 @@
|
|
|
1
|
-
import fs from "fs-extra";
|
|
2
|
-
import path from "node:path";
|
|
3
1
|
import os from "node:os";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import fs from "fs-extra";
|
|
4
|
+
import {
|
|
5
|
+
type RecommendedSkill,
|
|
6
|
+
classifyStarReliability,
|
|
7
|
+
} from "../data/skill-repos.js";
|
|
4
8
|
import type {
|
|
5
|
-
SkillSource,
|
|
6
|
-
SkillInfo,
|
|
7
|
-
SkillFrontmatter,
|
|
8
9
|
GitTreeResponse,
|
|
10
|
+
SkillFrontmatter,
|
|
11
|
+
SkillInfo,
|
|
12
|
+
SkillSource,
|
|
9
13
|
} from "../types/index.js";
|
|
10
|
-
import {
|
|
14
|
+
import {
|
|
15
|
+
getMarketplaceClonePath,
|
|
16
|
+
scanMcpServers,
|
|
17
|
+
} from "./local-marketplace.js";
|
|
18
|
+
import { ensureCheckout } from "./plugin-checkout.js";
|
|
11
19
|
|
|
12
20
|
const SKILLS_API_BASE =
|
|
13
21
|
"https://us-central1-claudish-6da10.cloudfunctions.net/skills";
|
|
@@ -106,7 +114,10 @@ export async function fetchSkillSetSkills(
|
|
|
106
114
|
const projectInstalled = await getInstalledSkillNames("project", projectPath);
|
|
107
115
|
|
|
108
116
|
const slugify = (name: string) =>
|
|
109
|
-
name
|
|
117
|
+
name
|
|
118
|
+
.toLowerCase()
|
|
119
|
+
.replace(/[^a-z0-9]+/g, "-")
|
|
120
|
+
.replace(/^-|-$/g, "");
|
|
110
121
|
|
|
111
122
|
const source: SkillSource = {
|
|
112
123
|
label: repo,
|
|
@@ -145,6 +156,507 @@ export async function fetchSkillSetSkills(
|
|
|
145
156
|
});
|
|
146
157
|
}
|
|
147
158
|
|
|
159
|
+
// ─── Plugin contents listing ─────────────────────────────────────────────────
|
|
160
|
+
|
|
161
|
+
/** The four things a Claude Code plugin can ship. */
|
|
162
|
+
export type PluginComponentKind = "skill" | "command" | "agent" | "mcp";
|
|
163
|
+
|
|
164
|
+
/** One component found inside a plugin, for read-only display. */
|
|
165
|
+
export interface PluginComponent {
|
|
166
|
+
kind: PluginComponentKind;
|
|
167
|
+
/** Skill directory, command/agent file stem, or MCP server key. */
|
|
168
|
+
name: string;
|
|
169
|
+
/**
|
|
170
|
+
* Path within the repo. The SKILL.md / command .md / agent .md, or the
|
|
171
|
+
* `.mcp.json` that declares the server.
|
|
172
|
+
*/
|
|
173
|
+
repoPath: string;
|
|
174
|
+
/**
|
|
175
|
+
* Sub-directory between the component root and the component itself, when
|
|
176
|
+
* the plugin groups them. "engineering" for skills/engineering/code-review.
|
|
177
|
+
*/
|
|
178
|
+
group?: string;
|
|
179
|
+
/** MCP only: the command the server runs, for the detail pane. */
|
|
180
|
+
mcpCommand?: string;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* Strip a marketplace `source` string down to a repo-relative directory.
|
|
185
|
+
* "./" and "" both mean the repo root.
|
|
186
|
+
*/
|
|
187
|
+
function normalizePluginSource(source?: string): string {
|
|
188
|
+
if (!source) return "";
|
|
189
|
+
return source.replace(/^\.\//, "").replace(/\/+$/, "");
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/** Sort key: group the four kinds, then alphabetise within each. */
|
|
193
|
+
const KIND_ORDER: Record<PluginComponentKind, number> = {
|
|
194
|
+
skill: 0,
|
|
195
|
+
command: 1,
|
|
196
|
+
agent: 2,
|
|
197
|
+
mcp: 3,
|
|
198
|
+
};
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* Collect `<root>/**\/*.md` under one component directory.
|
|
202
|
+
*
|
|
203
|
+
* Commands and agents are flat `.md` files, sometimes one directory deep for
|
|
204
|
+
* grouping. README.md is excluded: several plugins document a directory from
|
|
205
|
+
* inside it, and listing that as a command invites the user to run it.
|
|
206
|
+
*/
|
|
207
|
+
function collectMarkdown(
|
|
208
|
+
paths: string[],
|
|
209
|
+
root: string,
|
|
210
|
+
kind: PluginComponentKind,
|
|
211
|
+
): PluginComponent[] {
|
|
212
|
+
const out: PluginComponent[] = [];
|
|
213
|
+
for (const path of paths) {
|
|
214
|
+
if (!path.startsWith(root) || !path.endsWith(".md")) continue;
|
|
215
|
+
const inner = path.slice(root.length, -".md".length).split("/");
|
|
216
|
+
const name = inner[inner.length - 1];
|
|
217
|
+
if (!name || name.toLowerCase() === "readme") continue;
|
|
218
|
+
out.push({
|
|
219
|
+
kind,
|
|
220
|
+
name,
|
|
221
|
+
repoPath: path,
|
|
222
|
+
group: inner.length > 1 ? inner.slice(0, -1).join("/") : undefined,
|
|
223
|
+
});
|
|
224
|
+
}
|
|
225
|
+
return out;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/**
|
|
229
|
+
* List everything a plugin ships, from the repo tree.
|
|
230
|
+
*
|
|
231
|
+
* Skills, commands and agents come from one tree read. MCP servers cost a
|
|
232
|
+
* second request, made only when the plugin actually carries a `.mcp.json` —
|
|
233
|
+
* the file has to be opened to learn the server *names*, and a plugin whose
|
|
234
|
+
* only content is an MCP server otherwise renders as empty. mnemex is exactly
|
|
235
|
+
* that shape: no skills, no commands, no agents, one server, and it appeared
|
|
236
|
+
* to ship nothing at all.
|
|
237
|
+
*
|
|
238
|
+
* Skill discovery is scoped deliberately to `<source>/skills/**` plus a
|
|
239
|
+
* `<source>/SKILL.md` for a plugin that IS one skill. Repos in the wild mirror
|
|
240
|
+
* their skills into every harness directory they support and into per-language
|
|
241
|
+
* docs trees: everything-claude-code carries 898 SKILL.md files but only 286
|
|
242
|
+
* distinct skills, the rest living under `.kiro/`, `.agents/`, `.cursor/` and
|
|
243
|
+
* `docs/<lang>/`. An unscoped walk lists all 898 and reads as the plugin being
|
|
244
|
+
* three times its real size. ponytail is the same shape at small scale: 6
|
|
245
|
+
* skills, mirrored into `.openclaw/skills/`.
|
|
246
|
+
*/
|
|
247
|
+
export async function fetchPluginContents(
|
|
248
|
+
repo: string,
|
|
249
|
+
pluginSource?: string,
|
|
250
|
+
marketplaceName?: string,
|
|
251
|
+
): Promise<PluginComponent[]> {
|
|
252
|
+
// Three sources, cheapest first.
|
|
253
|
+
//
|
|
254
|
+
// 1. Claude Code's marketplace clone. Free and complete — but only exists if
|
|
255
|
+
// the marketplace was actually cloned. "✓ Added" does not imply that: the
|
|
256
|
+
// badge means the catalog resolved, and a catalog resolves over HTTP.
|
|
257
|
+
// The short repo name is what a root-level SKILL.md is called, whichever
|
|
258
|
+
// source answers.
|
|
259
|
+
const rootSkillName = repo.split("/")[1] || repo;
|
|
260
|
+
|
|
261
|
+
if (marketplaceName) {
|
|
262
|
+
const local = await readLocalPluginContents(
|
|
263
|
+
getMarketplaceClonePath(marketplaceName),
|
|
264
|
+
pluginSource,
|
|
265
|
+
rootSkillName,
|
|
266
|
+
);
|
|
267
|
+
if (local) return local;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
// 2. Our own read-only checkout. Unlimited and offline after the first fetch,
|
|
271
|
+
// and explicitly not an install — see plugin-checkout.ts. This is what
|
|
272
|
+
// keeps browsing working once the API budget is gone.
|
|
273
|
+
const checkout = await ensureCheckout(repo);
|
|
274
|
+
if (checkout) {
|
|
275
|
+
const cloned = await readLocalPluginContents(
|
|
276
|
+
checkout,
|
|
277
|
+
pluginSource,
|
|
278
|
+
rootSkillName,
|
|
279
|
+
);
|
|
280
|
+
if (cloned) return cloned;
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
// 3. The GitHub Tree API. Capped at 60 requests an hour across every repo on
|
|
284
|
+
// screen combined, so it is the last resort rather than the first.
|
|
285
|
+
const tree = await fetchGitTree(repo);
|
|
286
|
+
const base = normalizePluginSource(pluginSource);
|
|
287
|
+
const prefix = base ? `${base}/` : "";
|
|
288
|
+
const skillsPrefix = `${prefix}skills/`;
|
|
289
|
+
|
|
290
|
+
const blobs = tree.tree
|
|
291
|
+
.filter((entry) => entry.type === "blob")
|
|
292
|
+
.map((entry) => entry.path);
|
|
293
|
+
|
|
294
|
+
const components: PluginComponent[] = [];
|
|
295
|
+
const seenSkills = new Set<string>();
|
|
296
|
+
|
|
297
|
+
for (const path of blobs) {
|
|
298
|
+
// A plugin that is itself a single skill (blader/humanizer): one
|
|
299
|
+
// SKILL.md at the plugin root and no skills/ directory at all.
|
|
300
|
+
if (path === `${prefix}SKILL.md`) {
|
|
301
|
+
const name = base ? base.split("/").pop() || repo : repo.split("/")[1];
|
|
302
|
+
if (!seenSkills.has(name)) {
|
|
303
|
+
seenSkills.add(name);
|
|
304
|
+
components.push({ kind: "skill", name, repoPath: path });
|
|
305
|
+
}
|
|
306
|
+
continue;
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
if (!path.startsWith(skillsPrefix) || !path.endsWith("/SKILL.md")) continue;
|
|
310
|
+
|
|
311
|
+
// "skills/engineering/code-review/SKILL.md" → ["engineering", "code-review"]
|
|
312
|
+
const inner = path
|
|
313
|
+
.slice(skillsPrefix.length, -"/SKILL.md".length)
|
|
314
|
+
.split("/");
|
|
315
|
+
const name = inner[inner.length - 1];
|
|
316
|
+
if (!name || seenSkills.has(name)) continue;
|
|
317
|
+
seenSkills.add(name);
|
|
318
|
+
|
|
319
|
+
components.push({
|
|
320
|
+
kind: "skill",
|
|
321
|
+
name,
|
|
322
|
+
repoPath: path,
|
|
323
|
+
group: inner.length > 1 ? inner.slice(0, -1).join("/") : undefined,
|
|
324
|
+
});
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
components.push(
|
|
328
|
+
...collectMarkdown(blobs, `${prefix}commands/`, "command"),
|
|
329
|
+
...collectMarkdown(blobs, `${prefix}agents/`, "agent"),
|
|
330
|
+
);
|
|
331
|
+
|
|
332
|
+
const mcpPath = `${prefix}.mcp.json`;
|
|
333
|
+
if (blobs.includes(mcpPath)) {
|
|
334
|
+
components.push(...(await fetchMcpServers(repo, mcpPath)));
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
components.sort((a, b) => {
|
|
338
|
+
if (a.kind !== b.kind) return KIND_ORDER[a.kind] - KIND_ORDER[b.kind];
|
|
339
|
+
const g = (a.group || "").localeCompare(b.group || "");
|
|
340
|
+
return g !== 0 ? g : a.name.localeCompare(b.name);
|
|
341
|
+
});
|
|
342
|
+
return components;
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
/**
|
|
346
|
+
* List a plugin's contents from the marketplace clone on disk.
|
|
347
|
+
*
|
|
348
|
+
* Returns null when there is no clone — the marketplace was never added, which
|
|
349
|
+
* is the normal state for a `featured` entry the user is only browsing. That
|
|
350
|
+
* case falls through to the tree API.
|
|
351
|
+
*
|
|
352
|
+
* The shape must match `fetchPluginContents`'s network path exactly, groups
|
|
353
|
+
* included, or a plugin would render differently depending on whether it
|
|
354
|
+
* happened to be installed.
|
|
355
|
+
*/
|
|
356
|
+
export async function readLocalPluginContents(
|
|
357
|
+
repoRoot: string,
|
|
358
|
+
pluginSource?: string,
|
|
359
|
+
/**
|
|
360
|
+
* Name for a plugin that IS one skill at its root. Passed in rather than
|
|
361
|
+
* derived from `repoRoot`, whose basename differs per source: a marketplace
|
|
362
|
+
* clone is `magus`, a checkout is `blader__humanizer`. Deriving it made the
|
|
363
|
+
* same plugin render under two different names depending on which source
|
|
364
|
+
* answered.
|
|
365
|
+
*/
|
|
366
|
+
rootSkillName?: string,
|
|
367
|
+
): Promise<PluginComponent[] | null> {
|
|
368
|
+
const base = normalizePluginSource(pluginSource);
|
|
369
|
+
const pluginDir = path.join(repoRoot, ...(base ? base.split("/") : []));
|
|
370
|
+
if (!(await fs.pathExists(pluginDir))) return null;
|
|
371
|
+
|
|
372
|
+
const fallbackName = rootSkillName || path.basename(repoRoot);
|
|
373
|
+
const components: PluginComponent[] = [];
|
|
374
|
+
const rel = (abs: string) => {
|
|
375
|
+
const suffix = path.relative(repoRoot, abs);
|
|
376
|
+
return suffix.split(path.sep).join("/");
|
|
377
|
+
};
|
|
378
|
+
|
|
379
|
+
// A plugin that is itself a single skill: one SKILL.md at its root.
|
|
380
|
+
const rootSkill = path.join(pluginDir, "SKILL.md");
|
|
381
|
+
if (await fs.pathExists(rootSkill)) {
|
|
382
|
+
components.push({
|
|
383
|
+
kind: "skill",
|
|
384
|
+
name: base ? base.split("/").pop() || fallbackName : fallbackName,
|
|
385
|
+
repoPath: rel(rootSkill),
|
|
386
|
+
});
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
for (const skill of await walkSkillDirs(path.join(pluginDir, "skills"))) {
|
|
390
|
+
components.push({
|
|
391
|
+
kind: "skill",
|
|
392
|
+
name: skill.name,
|
|
393
|
+
repoPath: rel(path.join(skill.dir, "SKILL.md")),
|
|
394
|
+
group: skill.group,
|
|
395
|
+
});
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
for (const [dir, kind] of [
|
|
399
|
+
["commands", "command"],
|
|
400
|
+
["agents", "agent"],
|
|
401
|
+
] as const) {
|
|
402
|
+
for (const file of await walkMarkdown(path.join(pluginDir, dir))) {
|
|
403
|
+
components.push({
|
|
404
|
+
kind,
|
|
405
|
+
name: file.name,
|
|
406
|
+
repoPath: rel(file.path),
|
|
407
|
+
group: file.group,
|
|
408
|
+
});
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
for (const name of await scanMcpServers(pluginDir)) {
|
|
413
|
+
components.push({
|
|
414
|
+
kind: "mcp",
|
|
415
|
+
name,
|
|
416
|
+
repoPath: rel(path.join(pluginDir, ".mcp.json")),
|
|
417
|
+
});
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
components.sort((a, b) => {
|
|
421
|
+
if (a.kind !== b.kind) return KIND_ORDER[a.kind] - KIND_ORDER[b.kind];
|
|
422
|
+
const g = (a.group || "").localeCompare(b.group || "");
|
|
423
|
+
return g !== 0 ? g : a.name.localeCompare(b.name);
|
|
424
|
+
});
|
|
425
|
+
return components;
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
/** Every directory under `root` that holds a SKILL.md, with its grouping path. */
|
|
429
|
+
export async function walkSkillDirs(
|
|
430
|
+
root: string,
|
|
431
|
+
group = "",
|
|
432
|
+
depth = 0,
|
|
433
|
+
): Promise<Array<{ name: string; dir: string; group?: string }>> {
|
|
434
|
+
if (depth > 3 || !(await fs.pathExists(root))) return [];
|
|
435
|
+
let entries: import("node:fs").Dirent[];
|
|
436
|
+
try {
|
|
437
|
+
entries = await fs.readdir(root, { withFileTypes: true });
|
|
438
|
+
} catch {
|
|
439
|
+
return [];
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
const found: Array<{ name: string; dir: string; group?: string }> = [];
|
|
443
|
+
for (const entry of entries) {
|
|
444
|
+
if (!entry.isDirectory()) continue;
|
|
445
|
+
const child = path.join(root, entry.name);
|
|
446
|
+
if (await fs.pathExists(path.join(child, "SKILL.md"))) {
|
|
447
|
+
found.push({ name: entry.name, dir: child, group: group || undefined });
|
|
448
|
+
} else {
|
|
449
|
+
found.push(
|
|
450
|
+
...(await walkSkillDirs(
|
|
451
|
+
child,
|
|
452
|
+
group ? `${group}/${entry.name}` : entry.name,
|
|
453
|
+
depth + 1,
|
|
454
|
+
)),
|
|
455
|
+
);
|
|
456
|
+
}
|
|
457
|
+
}
|
|
458
|
+
return found;
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
/** Markdown files under `root`, one level of grouping deep. README excluded. */
|
|
462
|
+
export async function walkMarkdown(
|
|
463
|
+
root: string,
|
|
464
|
+
group = "",
|
|
465
|
+
depth = 0,
|
|
466
|
+
): Promise<Array<{ name: string; path: string; group?: string }>> {
|
|
467
|
+
if (depth > 2 || !(await fs.pathExists(root))) return [];
|
|
468
|
+
let entries: import("node:fs").Dirent[];
|
|
469
|
+
try {
|
|
470
|
+
entries = await fs.readdir(root, { withFileTypes: true });
|
|
471
|
+
} catch {
|
|
472
|
+
return [];
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
const found: Array<{ name: string; path: string; group?: string }> = [];
|
|
476
|
+
for (const entry of entries) {
|
|
477
|
+
const child = path.join(root, entry.name);
|
|
478
|
+
if (entry.isDirectory()) {
|
|
479
|
+
found.push(
|
|
480
|
+
...(await walkMarkdown(
|
|
481
|
+
child,
|
|
482
|
+
group ? `${group}/${entry.name}` : entry.name,
|
|
483
|
+
depth + 1,
|
|
484
|
+
)),
|
|
485
|
+
);
|
|
486
|
+
continue;
|
|
487
|
+
}
|
|
488
|
+
if (!entry.name.endsWith(".md")) continue;
|
|
489
|
+
const name = entry.name.slice(0, -".md".length);
|
|
490
|
+
if (name.toLowerCase() === "readme") continue;
|
|
491
|
+
found.push({ name, path: child, group: group || undefined });
|
|
492
|
+
}
|
|
493
|
+
return found;
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
/**
|
|
497
|
+
* Read the server names out of a plugin's `.mcp.json`.
|
|
498
|
+
*
|
|
499
|
+
* Both shapes are in use: a bare `{ "<name>": {...} }` map, which mnemex
|
|
500
|
+
* writes, and the `{ "mcpServers": { "<name>": {...} } }` wrapper. A failure
|
|
501
|
+
* here degrades to a single unnamed entry rather than throwing — the plugin
|
|
502
|
+
* demonstrably has a server, and losing the whole listing over its name would
|
|
503
|
+
* be a worse answer than showing it without one.
|
|
504
|
+
*/
|
|
505
|
+
async function fetchMcpServers(
|
|
506
|
+
repo: string,
|
|
507
|
+
mcpPath: string,
|
|
508
|
+
): Promise<PluginComponent[]> {
|
|
509
|
+
try {
|
|
510
|
+
const headers: Record<string, string> = {
|
|
511
|
+
Accept: "application/vnd.github.raw",
|
|
512
|
+
"X-GitHub-Api-Version": "2022-11-28",
|
|
513
|
+
};
|
|
514
|
+
const token =
|
|
515
|
+
process.env.GITHUB_TOKEN || process.env.GITHUB_PERSONAL_ACCESS_TOKEN;
|
|
516
|
+
if (token) headers.Authorization = `Bearer ${token}`;
|
|
517
|
+
|
|
518
|
+
const response = await fetch(
|
|
519
|
+
`https://api.github.com/repos/${repo}/contents/${mcpPath}`,
|
|
520
|
+
{ headers, signal: AbortSignal.timeout(10000) },
|
|
521
|
+
);
|
|
522
|
+
if (!response.ok) throw new Error(String(response.status));
|
|
523
|
+
|
|
524
|
+
const raw = JSON.parse(await response.text()) as Record<string, unknown>;
|
|
525
|
+
const servers = (
|
|
526
|
+
raw.mcpServers && typeof raw.mcpServers === "object"
|
|
527
|
+
? raw.mcpServers
|
|
528
|
+
: raw
|
|
529
|
+
) as Record<string, { command?: string; args?: string[] }>;
|
|
530
|
+
|
|
531
|
+
const named = Object.entries(servers)
|
|
532
|
+
.filter(([, cfg]) => cfg && typeof cfg === "object")
|
|
533
|
+
.map(([name, cfg]) => ({
|
|
534
|
+
kind: "mcp" as const,
|
|
535
|
+
name,
|
|
536
|
+
repoPath: mcpPath,
|
|
537
|
+
mcpCommand: [cfg.command, ...(cfg.args ?? [])]
|
|
538
|
+
.filter(Boolean)
|
|
539
|
+
.join(" "),
|
|
540
|
+
}));
|
|
541
|
+
|
|
542
|
+
return named.length > 0
|
|
543
|
+
? named
|
|
544
|
+
: [{ kind: "mcp", name: "MCP server", repoPath: mcpPath }];
|
|
545
|
+
} catch {
|
|
546
|
+
return [{ kind: "mcp", name: "MCP server", repoPath: mcpPath }];
|
|
547
|
+
}
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
// ─── Plugin skill content ────────────────────────────────────────────────────
|
|
551
|
+
|
|
552
|
+
/** A skill's own SKILL.md, split into its frontmatter and its body. */
|
|
553
|
+
export interface PluginSkillDetail {
|
|
554
|
+
/** Every frontmatter key, in the order the file declares them. */
|
|
555
|
+
frontmatter: Array<{ key: string; value: string }>;
|
|
556
|
+
/** Markdown after the frontmatter block, trailing whitespace trimmed. */
|
|
557
|
+
body: string;
|
|
558
|
+
/** Total body lines, so a truncated render can say what it withheld. */
|
|
559
|
+
bodyLines: number;
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
/**
|
|
563
|
+
* Read one skill's SKILL.md.
|
|
564
|
+
*
|
|
565
|
+
* Served from the contents API rather than raw.githubusercontent.com, for two
|
|
566
|
+
* reasons: it is the host `fetchGitTree` already uses, so GITHUB_TOKEN raises
|
|
567
|
+
* the limit for both at once, and the raw host is the one a Tailscale MagicDNS
|
|
568
|
+
* search domain hijacks — which fails as an empty body rather than an error.
|
|
569
|
+
*/
|
|
570
|
+
export async function fetchPluginSkillDetail(
|
|
571
|
+
repo: string,
|
|
572
|
+
repoPath: string,
|
|
573
|
+
): Promise<PluginSkillDetail> {
|
|
574
|
+
const headers: Record<string, string> = {
|
|
575
|
+
Accept: "application/vnd.github.raw",
|
|
576
|
+
"X-GitHub-Api-Version": "2022-11-28",
|
|
577
|
+
};
|
|
578
|
+
const token =
|
|
579
|
+
process.env.GITHUB_TOKEN || process.env.GITHUB_PERSONAL_ACCESS_TOKEN;
|
|
580
|
+
if (token) headers.Authorization = `Bearer ${token}`;
|
|
581
|
+
|
|
582
|
+
const response = await fetch(
|
|
583
|
+
`https://api.github.com/repos/${repo}/contents/${repoPath}`,
|
|
584
|
+
{ headers, signal: AbortSignal.timeout(10000) },
|
|
585
|
+
);
|
|
586
|
+
|
|
587
|
+
if (response.status === 403 || response.status === 429) {
|
|
588
|
+
throw new Error(
|
|
589
|
+
"GitHub API rate limit exceeded. Set GITHUB_TOKEN to increase limits.",
|
|
590
|
+
);
|
|
591
|
+
}
|
|
592
|
+
if (!response.ok) {
|
|
593
|
+
throw new Error(
|
|
594
|
+
`GitHub API error: ${response.status} ${response.statusText}`,
|
|
595
|
+
);
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
return splitSkillDocument(await response.text());
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
/**
|
|
602
|
+
* Split a SKILL.md into ordered frontmatter pairs and its body.
|
|
603
|
+
*
|
|
604
|
+
* Ordered pairs rather than an object because the panel renders them as written
|
|
605
|
+
* — `name` and `description` first is the near-universal convention, and
|
|
606
|
+
* re-sorting them would bury the description under whatever keys sort earlier.
|
|
607
|
+
* List values are flattened to a comma-joined string for display.
|
|
608
|
+
*/
|
|
609
|
+
export function splitSkillDocument(content: string): PluginSkillDetail {
|
|
610
|
+
const match = content.match(/^---\s*\n([\s\S]*?)\n---\s*\n?/);
|
|
611
|
+
const frontmatter: Array<{ key: string; value: string }> = [];
|
|
612
|
+
|
|
613
|
+
if (match) {
|
|
614
|
+
let pendingKey: string | null = null;
|
|
615
|
+
for (const line of match[1].split("\n")) {
|
|
616
|
+
// A " - item" line continues the list opened by the previous key.
|
|
617
|
+
const listItem = line.match(/^\s*-\s+(.*)$/);
|
|
618
|
+
if (listItem && pendingKey) {
|
|
619
|
+
const last = frontmatter[frontmatter.length - 1];
|
|
620
|
+
last.value = last.value
|
|
621
|
+
? `${last.value}, ${stripQuotes(listItem[1])}`
|
|
622
|
+
: stripQuotes(listItem[1]);
|
|
623
|
+
continue;
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
const colonIdx = line.indexOf(":");
|
|
627
|
+
if (colonIdx === -1) continue;
|
|
628
|
+
const key = line.slice(0, colonIdx).trim();
|
|
629
|
+
if (!key || key.startsWith("#")) continue;
|
|
630
|
+
|
|
631
|
+
const raw = line.slice(colonIdx + 1).trim();
|
|
632
|
+
pendingKey = key;
|
|
633
|
+
frontmatter.push({
|
|
634
|
+
key,
|
|
635
|
+
value:
|
|
636
|
+
raw.startsWith("[") && raw.endsWith("]")
|
|
637
|
+
? raw
|
|
638
|
+
.slice(1, -1)
|
|
639
|
+
.split(",")
|
|
640
|
+
.map((s) => stripQuotes(s.trim()))
|
|
641
|
+
.filter(Boolean)
|
|
642
|
+
.join(", ")
|
|
643
|
+
: stripQuotes(raw),
|
|
644
|
+
});
|
|
645
|
+
}
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
const body = content.slice(match ? match[0].length : 0).trimEnd();
|
|
649
|
+
return {
|
|
650
|
+
frontmatter,
|
|
651
|
+
body,
|
|
652
|
+
bodyLines: body ? body.split("\n").length : 0,
|
|
653
|
+
};
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
function stripQuotes(value: string): string {
|
|
657
|
+
return value.replace(/^["']|["']$/g, "");
|
|
658
|
+
}
|
|
659
|
+
|
|
148
660
|
// ─── Frontmatter parser ───────────────────────────────────────────────────────
|
|
149
661
|
|
|
150
662
|
function parseYamlFrontmatter(content: string): Partial<SkillFrontmatter> {
|
|
@@ -217,9 +729,7 @@ export async function getInstalledSkillNames(
|
|
|
217
729
|
projectPath?: string,
|
|
218
730
|
): Promise<Set<string>> {
|
|
219
731
|
const dir =
|
|
220
|
-
scope === "user"
|
|
221
|
-
? getUserSkillsDir()
|
|
222
|
-
: getProjectSkillsDir(projectPath);
|
|
732
|
+
scope === "user" ? getUserSkillsDir() : getProjectSkillsDir(projectPath);
|
|
223
733
|
|
|
224
734
|
const installed = new Set<string>();
|
|
225
735
|
|
|
@@ -292,12 +802,17 @@ export async function fetchAvailableSkills(
|
|
|
292
802
|
const projectInstalled = await getInstalledSkillNames("project", projectPath);
|
|
293
803
|
|
|
294
804
|
const slugify = (name: string) =>
|
|
295
|
-
name
|
|
805
|
+
name
|
|
806
|
+
.toLowerCase()
|
|
807
|
+
.replace(/[^a-z0-9]+/g, "-")
|
|
808
|
+
.replace(/^-|-$/g, "");
|
|
296
809
|
|
|
297
810
|
const markInstalled = (skill: SkillInfo): SkillInfo => {
|
|
298
811
|
const slug = slugify(skill.name);
|
|
299
|
-
const isUserInstalled =
|
|
300
|
-
|
|
812
|
+
const isUserInstalled =
|
|
813
|
+
userInstalled.has(slug) || userInstalled.has(skill.name);
|
|
814
|
+
const isProjInstalled =
|
|
815
|
+
projectInstalled.has(slug) || projectInstalled.has(skill.name);
|
|
301
816
|
const installed = isUserInstalled || isProjInstalled;
|
|
302
817
|
const installedScope: "user" | "project" | null = isProjInstalled
|
|
303
818
|
? "project"
|
|
@@ -333,7 +848,9 @@ export async function fetchAvailableSkills(
|
|
|
333
848
|
|
|
334
849
|
// 2. Fetch popular skills from Firebase API
|
|
335
850
|
const popular = await fetchPopularSkills(30);
|
|
336
|
-
const popularSkills = popular.map((s) =>
|
|
851
|
+
const popularSkills = popular.map((s) =>
|
|
852
|
+
markInstalled({ ...s, isRecommended: false }),
|
|
853
|
+
);
|
|
337
854
|
|
|
338
855
|
// 3. Combine: recommended first, then popular (dedup by name)
|
|
339
856
|
const seen = new Set<string>(recommendedSkills.map((s) => s.name));
|