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.
@@ -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 { classifyStarReliability, type RecommendedSkill } from "../data/skill-repos.js";
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.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "");
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.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "");
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 = userInstalled.has(slug) || userInstalled.has(skill.name);
300
- const isProjInstalled = projectInstalled.has(slug) || projectInstalled.has(skill.name);
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) => markInstalled({ ...s, isRecommended: false }));
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));