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.
@@ -0,0 +1,371 @@
1
+ import { afterEach, describe, expect, it } from "bun:test";
2
+ import type { PluginInfo } from "../services/plugin-manager.js";
3
+ import {
4
+ fetchPluginContents,
5
+ splitSkillDocument,
6
+ } from "../services/skills-manager.js";
7
+ import type { Marketplace } from "../types/index.js";
8
+ import {
9
+ buildPluginBrowserItems,
10
+ pluginContentsRepo,
11
+ } from "../ui/adapters/pluginsAdapter.js";
12
+ import type { PluginContentsEntry } from "../ui/state/types.js";
13
+
14
+ // ─── fetchPluginContents ─────────────────────────────────────────────────────
15
+
16
+ const realFetch = globalThis.fetch;
17
+
18
+ /**
19
+ * Serve one canned git-tree response. `fetchGitTree` caches per repo for the
20
+ * life of the process, so every test must use a repo name of its own.
21
+ */
22
+ function stubTree(paths: string[]): void {
23
+ globalThis.fetch = (async () =>
24
+ new Response(
25
+ JSON.stringify({
26
+ sha: "deadbeef",
27
+ truncated: false,
28
+ tree: paths.map((path) => ({
29
+ path,
30
+ mode: "100644",
31
+ type: "blob",
32
+ sha: `sha-${path}`,
33
+ })),
34
+ }),
35
+ { status: 200, headers: { ETag: '"x"' } },
36
+ )) as unknown as typeof fetch;
37
+ }
38
+
39
+ afterEach(() => {
40
+ globalThis.fetch = realFetch;
41
+ });
42
+
43
+ describe("fetchPluginContents", () => {
44
+ it("counts a grouped skills/ tree once per skill, not once per group", async () => {
45
+ stubTree([
46
+ "README.md",
47
+ "skills/engineering/code-review/SKILL.md",
48
+ "skills/engineering/tdd/SKILL.md",
49
+ "skills/writing/prd/SKILL.md",
50
+ ]);
51
+
52
+ const skills = await fetchPluginContents("fake/grouped", "./");
53
+
54
+ expect(skills.map((s) => s.name)).toEqual(["code-review", "tdd", "prd"]);
55
+ expect(skills.every((s) => s.kind === "skill")).toBe(true);
56
+ expect(skills[0].group).toBe("engineering");
57
+ expect(skills[2].group).toBe("writing");
58
+ });
59
+
60
+ it("ignores harness mirrors and translated docs trees", async () => {
61
+ // everything-claude-code's real shape: 286 skills under skills/, mirrored
62
+ // into four harness directories and six docs/<lang>/ trees for 898 files.
63
+ stubTree([
64
+ "skills/api-design/SKILL.md",
65
+ "skills/agent-sort/SKILL.md",
66
+ ".agents/skills/api-design/SKILL.md",
67
+ ".kiro/skills/api-design/SKILL.md",
68
+ ".cursor/skills/api-design/SKILL.md",
69
+ "docs/ja-JP/skills/api-design/SKILL.md",
70
+ "docs/zh-CN/skills/api-design/SKILL.md",
71
+ ]);
72
+
73
+ const skills = await fetchPluginContents("fake/mirrored", "./");
74
+
75
+ expect(skills.map((s) => s.name)).toEqual(["agent-sort", "api-design"]);
76
+ });
77
+
78
+ it("finds a plugin that is one skill at its root", async () => {
79
+ // blader/humanizer: a root SKILL.md and no skills/ directory at all.
80
+ stubTree([".claude-plugin/plugin.json", "SKILL.md", "README.md"]);
81
+
82
+ const skills = await fetchPluginContents("blader/humanizer", "./");
83
+
84
+ expect(skills).toHaveLength(1);
85
+ expect(skills[0].name).toBe("humanizer");
86
+ expect(skills[0].repoPath).toBe("SKILL.md");
87
+ });
88
+
89
+ it("scopes to one plugin's subtree in a multi-plugin marketplace", async () => {
90
+ stubTree([
91
+ "plugins/dev/skills/frontend/design-system/SKILL.md",
92
+ "plugins/dev/skills/backend/db-branching/SKILL.md",
93
+ "plugins/seo/skills/audit/SKILL.md",
94
+ ]);
95
+
96
+ const skills = await fetchPluginContents("fake/multi", "./plugins/dev");
97
+
98
+ expect(skills.map((s) => s.name)).toEqual([
99
+ "db-branching",
100
+ "design-system",
101
+ ]);
102
+ });
103
+
104
+ it("still lists commands and agents for a plugin with no skills", async () => {
105
+ stubTree(["agents/reviewer.md", "commands/go.md"]);
106
+
107
+ const found = await fetchPluginContents("fake/no-skills", "./");
108
+
109
+ expect(found.filter((c) => c.kind === "skill")).toEqual([]);
110
+ expect(found.map((c) => `${c.kind}:${c.name}`)).toEqual([
111
+ "command:go",
112
+ "agent:reviewer",
113
+ ]);
114
+ });
115
+ });
116
+
117
+ // ─── splitSkillDocument ──────────────────────────────────────────────────────
118
+
119
+ describe("splitSkillDocument", () => {
120
+ it("keeps frontmatter in file order so description stays near the top", () => {
121
+ const { frontmatter } = splitSkillDocument(
122
+ [
123
+ "---",
124
+ "name: code-review",
125
+ "description: Reviews a diff for correctness",
126
+ "allowed-tools: Read, Grep",
127
+ "---",
128
+ "",
129
+ "# Code review",
130
+ ].join("\n"),
131
+ );
132
+
133
+ expect(frontmatter.map((f) => f.key)).toEqual([
134
+ "name",
135
+ "description",
136
+ "allowed-tools",
137
+ ]);
138
+ expect(frontmatter[1].value).toBe("Reviews a diff for correctness");
139
+ });
140
+
141
+ it("flattens both list shapes to one display string", () => {
142
+ const { frontmatter } = splitSkillDocument(
143
+ [
144
+ "---",
145
+ 'tags: ["tdd", "testing"]',
146
+ "allowed-tools:",
147
+ " - Read",
148
+ " - Bash",
149
+ "---",
150
+ "body",
151
+ ].join("\n"),
152
+ );
153
+
154
+ expect(frontmatter[0]).toEqual({ key: "tags", value: "tdd, testing" });
155
+ expect(frontmatter[1]).toEqual({
156
+ key: "allowed-tools",
157
+ value: "Read, Bash",
158
+ });
159
+ });
160
+
161
+ it("separates the body and counts its lines", () => {
162
+ const doc = splitSkillDocument(
163
+ ["---", "name: x", "---", "", "# Title", "", "Body text.", ""].join("\n"),
164
+ );
165
+
166
+ expect(doc.body).toBe("# Title\n\nBody text.");
167
+ expect(doc.bodyLines).toBe(3);
168
+ });
169
+
170
+ it("treats a file with no frontmatter as all body", () => {
171
+ const doc = splitSkillDocument("# Just markdown\n\nNo frontmatter here.");
172
+
173
+ expect(doc.frontmatter).toEqual([]);
174
+ expect(doc.body).toBe("# Just markdown\n\nNo frontmatter here.");
175
+ });
176
+
177
+ it("reports an empty body rather than a phantom line", () => {
178
+ const doc = splitSkillDocument("---\nname: x\ndescription: y\n---\n");
179
+
180
+ expect(doc.body).toBe("");
181
+ expect(doc.bodyLines).toBe(0);
182
+ });
183
+ });
184
+
185
+ // ─── buildPluginBrowserItems ─────────────────────────────────────────────────
186
+
187
+ const marketplace: Marketplace = {
188
+ name: "mattpocock",
189
+ displayName: "Matt Pocock",
190
+ source: { source: "github", repo: "mattpocock/skills" },
191
+ description: "Agent skills for real engineering",
192
+ featured: true,
193
+ };
194
+
195
+ function makePlugin(overrides: Partial<PluginInfo> = {}): PluginInfo {
196
+ return {
197
+ id: "mattpocock-skills@mattpocock",
198
+ name: "mattpocock-skills",
199
+ version: "1.0.0",
200
+ description: "Matt Pocock's agent skills",
201
+ marketplace: "mattpocock",
202
+ marketplaceDisplay: "Matt Pocock",
203
+ enabled: false,
204
+ source: "./",
205
+ ...overrides,
206
+ };
207
+ }
208
+
209
+ function build(
210
+ plugin: PluginInfo,
211
+ expandedPlugins: Set<string>,
212
+ pluginContents: Map<string, PluginContentsEntry> = new Map(),
213
+ ) {
214
+ return buildPluginBrowserItems({
215
+ marketplaces: [marketplace],
216
+ plugins: [plugin],
217
+ collapsedMarketplaces: new Set(),
218
+ expandedPlugins,
219
+ pluginContents,
220
+ });
221
+ }
222
+
223
+ describe("buildPluginBrowserItems component rows", () => {
224
+ it("marks a plugin expandable and emits no component rows while collapsed", () => {
225
+ const items = build(makePlugin(), new Set());
226
+
227
+ expect(items.map((i) => i.kind)).toEqual(["category", "plugin"]);
228
+ const row = items[1];
229
+ expect(row.kind === "plugin" && row.isExpandable).toBe(true);
230
+ expect(row.kind === "plugin" && row.isExpanded).toBe(false);
231
+ });
232
+
233
+ it("emits one row per component when expanded, of every kind", () => {
234
+ const items = build(
235
+ makePlugin(),
236
+ new Set(["mattpocock-skills@mattpocock"]),
237
+ new Map([
238
+ [
239
+ "mattpocock-skills@mattpocock",
240
+ {
241
+ status: "success",
242
+ components: [
243
+ {
244
+ kind: "skill",
245
+ name: "code-review",
246
+ repoPath: "skills/engineering/code-review/SKILL.md",
247
+ group: "engineering",
248
+ },
249
+ { kind: "command", name: "go", repoPath: "commands/go.md" },
250
+ { kind: "agent", name: "critic", repoPath: "agents/critic.md" },
251
+ { kind: "mcp", name: "mnemex", repoPath: ".mcp.json" },
252
+ ],
253
+ } satisfies PluginContentsEntry,
254
+ ],
255
+ ]),
256
+ );
257
+
258
+ expect(items.map((i) => i.kind)).toEqual([
259
+ "category",
260
+ "plugin",
261
+ "component",
262
+ "component",
263
+ "component",
264
+ "component",
265
+ ]);
266
+ expect(items.slice(2).map((i) => i.label)).toEqual([
267
+ "code-review",
268
+ "go",
269
+ "critic",
270
+ "mnemex",
271
+ ]);
272
+ });
273
+
274
+ it("gives an MCP-only plugin a row instead of rendering it as empty", () => {
275
+ // mnemex: no skills, no commands, no agents, one server. It used to show
276
+ // nothing at all, which read as a plugin that ships nothing.
277
+ const items = build(
278
+ makePlugin({ id: "mnemex@magus", name: "mnemex" }),
279
+ new Set(["mnemex@magus"]),
280
+ new Map([
281
+ [
282
+ "mnemex@magus",
283
+ {
284
+ status: "success",
285
+ components: [
286
+ {
287
+ kind: "mcp",
288
+ name: "mnemex",
289
+ repoPath: ".mcp.json",
290
+ mcpCommand: "mnemex --mcp",
291
+ },
292
+ ],
293
+ } satisfies PluginContentsEntry,
294
+ ],
295
+ ]),
296
+ );
297
+
298
+ expect(items[2].kind).toBe("component");
299
+ expect(items[2].label).toBe("mnemex");
300
+ });
301
+
302
+ it("shows a loading row before the listing arrives", () => {
303
+ const items = build(
304
+ makePlugin(),
305
+ new Set(["mattpocock-skills@mattpocock"]),
306
+ );
307
+
308
+ expect(items[2].kind).toBe("contents-status");
309
+ expect(items[2].kind === "contents-status" && items[2].status).toBe(
310
+ "loading",
311
+ );
312
+ });
313
+
314
+ it("surfaces a failed listing instead of rendering an empty expansion", () => {
315
+ const items = build(
316
+ makePlugin(),
317
+ new Set(["mattpocock-skills@mattpocock"]),
318
+ new Map([
319
+ [
320
+ "mattpocock-skills@mattpocock",
321
+ {
322
+ status: "error",
323
+ error: "GitHub API rate limit exceeded",
324
+ } satisfies PluginContentsEntry,
325
+ ],
326
+ ]),
327
+ );
328
+
329
+ expect(items[2].kind === "contents-status" && items[2].status).toBe(
330
+ "error",
331
+ );
332
+ expect(items[2].kind === "contents-status" && items[2].error).toBe(
333
+ "GitHub API rate limit exceeded",
334
+ );
335
+ });
336
+
337
+ it("does not offer expansion for an orphaned plugin", () => {
338
+ const items = build(makePlugin({ isOrphaned: true }), new Set());
339
+
340
+ expect(items[1].kind === "plugin" && items[1].isExpandable).toBe(false);
341
+ });
342
+
343
+ it("does not offer expansion when the marketplace has no repo to read", () => {
344
+ const items = buildPluginBrowserItems({
345
+ marketplaces: [
346
+ { ...marketplace, source: { source: "directory", repo: undefined } },
347
+ ],
348
+ plugins: [makePlugin()],
349
+ collapsedMarketplaces: new Set(),
350
+ expandedPlugins: new Set(),
351
+ pluginContents: new Map(),
352
+ });
353
+
354
+ expect(items[1].kind === "plugin" && items[1].isExpandable).toBe(false);
355
+ });
356
+ });
357
+
358
+ describe("pluginContentsRepo", () => {
359
+ it("prefers a plugin's own repo over its marketplace's", () => {
360
+ const plugin = makePlugin({ sourceRepo: "addyosmani/agent-skills" });
361
+ expect(pluginContentsRepo(plugin, marketplace)).toBe(
362
+ "addyosmani/agent-skills",
363
+ );
364
+ });
365
+
366
+ it("falls back to the marketplace repo", () => {
367
+ expect(pluginContentsRepo(makePlugin(), marketplace)).toBe(
368
+ "mattpocock/skills",
369
+ );
370
+ });
371
+ });
@@ -161,6 +161,28 @@ export const defaultMarketplaces: Marketplace[] = [
161
161
  "Designs and redesigns frontend UI and marketing graphics, routing to specialist skills per artifact type",
162
162
  featured: true,
163
163
  },
164
+ // A community port, deliberately, because the original cannot be installed.
165
+ // pstack is published by Cursor in cursor/plugins, whose manifests all live
166
+ // under `.cursor-plugin/` — measured over the whole tree: 61 `.cursor-plugin`
167
+ // files, 0 `.claude-plugin` files. Claude Code reads
168
+ // `.claude-plugin/marketplace.json` only, so `claude plugin marketplace add
169
+ // cursor/plugins` cannot resolve it. Same reason github/spec-kit is absent.
170
+ //
171
+ // Of the ports that relocate the manifest, this is the most starred: 95
172
+ // against pstack-claude's 94. backnotprop/pstack has more (140) but carries
173
+ // no Claude manifest either, and peadar/pstack (261) is an unrelated C
174
+ // stack-trace tool.
175
+ {
176
+ name: "open-pstack",
177
+ displayName: "pstack",
178
+ source: {
179
+ source: "github",
180
+ repo: "ericlitman/open-pstack",
181
+ },
182
+ description:
183
+ "Rigorous, parallelizable agent workflows that favour going deep before going fast. Community port of Cursor's pstack",
184
+ featured: true,
185
+ },
164
186
  {
165
187
  name: "claude-code-plugins",
166
188
  displayName: "Anthropic Deprecated",
@@ -1,7 +1,7 @@
1
- import fs from "fs-extra";
2
- import path from "node:path";
3
- import os from "node:os";
4
1
  import { execSync } from "node:child_process";
2
+ import os from "node:os";
3
+ import path from "node:path";
4
+ import fs from "fs-extra";
5
5
  import type { PluginRelease } from "../types/index.js";
6
6
  import { normalizeReleases } from "./plugin-releases.js";
7
7
 
@@ -43,6 +43,18 @@ const KNOWN_MARKETPLACES_FILE = path.join(
43
43
  "known_marketplaces.json",
44
44
  );
45
45
 
46
+ /**
47
+ * Where Claude Code keeps a marketplace's clone, whether or not it exists.
48
+ *
49
+ * Exported so a reader can prefer the clone over the network. Anything already
50
+ * added is on disk in full, and reading it costs no GitHub request — which
51
+ * matters because an unauthenticated client gets 60 an hour for every repo on
52
+ * screen combined.
53
+ */
54
+ export function getMarketplaceClonePath(marketplaceName: string): string {
55
+ return path.join(CLAUDE_PLUGINS_DIR, marketplaceName);
56
+ }
57
+
46
58
  interface KnownMarketplaceEntry {
47
59
  source: { source: string; url?: string; repo?: string };
48
60
  installLocation: string;
@@ -97,6 +109,99 @@ function getGitRemote(marketplacePath: string): string | undefined {
97
109
  /**
98
110
  * Scan a single marketplace directory and return marketplace info
99
111
  */
112
+ /**
113
+ * Names of the skills under `skillsDir`, recursing through grouping directories.
114
+ *
115
+ * A skill is a directory holding a SKILL.md, and it may sit one or more levels
116
+ * down: dev groups its 43 skills under `skills/frontend/`, `skills/backend/`
117
+ * and so on, and mattpocock groups its 37 under `skills/engineering/`. Reading
118
+ * only the top level counted the *groups* — dev reported 9 skills against 43 on
119
+ * disk, mattpocock 5 against 37 — and that count is what the plugin detail
120
+ * panel prints.
121
+ *
122
+ * Depth is capped because this runs over every plugin of every cloned
123
+ * marketplace on each list load, and a skill nested four levels below `skills/`
124
+ * does not exist in practice.
125
+ */
126
+ export async function scanSkillDirs(
127
+ skillsDir: string,
128
+ depth = 0,
129
+ ): Promise<string[]> {
130
+ if (depth > 3) return [];
131
+ if (!(await fs.pathExists(skillsDir))) return [];
132
+
133
+ let entries: import("node:fs").Dirent[];
134
+ try {
135
+ entries = await fs.readdir(skillsDir, { withFileTypes: true });
136
+ } catch {
137
+ return [];
138
+ }
139
+
140
+ const found: string[] = [];
141
+ for (const entry of entries) {
142
+ if (!entry.isDirectory()) continue;
143
+ const child = path.join(skillsDir, entry.name);
144
+ if (await fs.pathExists(path.join(child, "SKILL.md"))) {
145
+ found.push(entry.name);
146
+ } else {
147
+ found.push(...(await scanSkillDirs(child, depth + 1)));
148
+ }
149
+ }
150
+ return found;
151
+ }
152
+
153
+ /**
154
+ * Names of the MCP servers a plugin declares.
155
+ *
156
+ * Two locations are in use, and only one of them used to be read. An
157
+ * `mcp-servers/` directory of one JSON per server was the only thing scanned;
158
+ * a root `.mcp.json`, which is the form `plugin.json` points at with
159
+ * `"mcpServers": "./.mcp.json"`, was invisible. In this marketplace that is 5
160
+ * plugins of 7, mnemex among them — and mnemex ships *nothing else*, so it
161
+ * reported zero components of every kind and read as an empty plugin.
162
+ *
163
+ * Inside `.mcp.json`, both a bare `{ "<name>": {...} }` map and the
164
+ * `{ "mcpServers": {...} }` wrapper appear in the wild.
165
+ */
166
+ export async function scanMcpServers(pluginPath: string): Promise<string[]> {
167
+ const found: string[] = [];
168
+
169
+ const mcpDir = path.join(pluginPath, "mcp-servers");
170
+ if (await fs.pathExists(mcpDir)) {
171
+ try {
172
+ const files = await fs.readdir(mcpDir);
173
+ found.push(
174
+ ...files
175
+ .filter((f) => f.endsWith(".json"))
176
+ .map((f) => f.replace(".json", "")),
177
+ );
178
+ } catch {
179
+ // Ignore scan errors
180
+ }
181
+ }
182
+
183
+ const mcpFile = path.join(pluginPath, ".mcp.json");
184
+ if (await fs.pathExists(mcpFile)) {
185
+ try {
186
+ const raw = (await fs.readJson(mcpFile)) as Record<string, unknown>;
187
+ const servers =
188
+ raw.mcpServers && typeof raw.mcpServers === "object"
189
+ ? (raw.mcpServers as Record<string, unknown>)
190
+ : raw;
191
+ for (const name of Object.keys(servers)) {
192
+ if (!found.includes(name)) found.push(name);
193
+ }
194
+ } catch {
195
+ // Unreadable or malformed. The file exists, so the plugin does declare a
196
+ // server — report one rather than dropping to zero, which is the exact
197
+ // "ships nothing" misreport this function exists to fix.
198
+ if (found.length === 0) found.push("mcp");
199
+ }
200
+ }
201
+
202
+ return found;
203
+ }
204
+
100
205
  async function scanSingleMarketplace(
101
206
  marketplacePath: string,
102
207
  marketplaceName: string,
@@ -159,23 +264,9 @@ async function scanSingleMarketplace(
159
264
  .map((f) => f.replace(".md", ""));
160
265
  }
161
266
  // Scan for skills
162
- const skillsDir = path.join(pluginPath, "skills");
163
- if (await fs.pathExists(skillsDir)) {
164
- const skillFiles = await fs.readdir(skillsDir);
165
- skills = skillFiles.filter(
166
- (f) =>
167
- f.endsWith(".md") ||
168
- fs.statSync(path.join(skillsDir, f)).isDirectory(),
169
- );
170
- }
267
+ skills = await scanSkillDirs(path.join(pluginPath, "skills"));
171
268
  // Scan for MCP servers
172
- const mcpDir = path.join(pluginPath, "mcp-servers");
173
- if (await fs.pathExists(mcpDir)) {
174
- const mcpFiles = await fs.readdir(mcpDir);
175
- mcpServers = mcpFiles
176
- .filter((f) => f.endsWith(".json"))
177
- .map((f) => f.replace(".json", ""));
178
- }
269
+ mcpServers = await scanMcpServers(pluginPath);
179
270
  } catch {
180
271
  // Ignore scan errors
181
272
  }
@@ -26,6 +26,17 @@ export interface MarketplacePlugin {
26
26
  homepage?: string;
27
27
  tags?: string[];
28
28
  releases?: PluginRelease[];
29
+ /**
30
+ * The plugin's directory inside the marketplace repo, as declared. "" means
31
+ * the repo root, which is how every single-plugin marketplace publishes
32
+ * ("source": "./").
33
+ */
34
+ source?: string;
35
+ /**
36
+ * Set only when the plugin declares an object source naming a repo other
37
+ * than the marketplace's own. Its files live there, not in the marketplace.
38
+ */
39
+ sourceRepo?: string;
29
40
  }
30
41
 
31
42
  // Session-level cache for each marketplace's declared version (from
@@ -58,6 +69,28 @@ interface RawPlugin {
58
69
  homepage?: string;
59
70
  tags?: string[];
60
71
  releases?: unknown;
72
+ source?: string | { source?: string; repo?: string };
73
+ }
74
+
75
+ /**
76
+ * Split a manifest `source` into a repo-relative directory and, when the plugin
77
+ * points at a different repo than the marketplace, that repo.
78
+ *
79
+ * Both forms are in use: a string path ("./", "./plugins/dev") and an object
80
+ * ({"source":"github","repo":"owner/name"}), which addyosmani/agent-skills
81
+ * publishes. The object form has no path — the plugin is the whole repo.
82
+ */
83
+ function splitSource(source: RawPlugin["source"]): {
84
+ source?: string;
85
+ sourceRepo?: string;
86
+ } {
87
+ if (typeof source === "string") {
88
+ return { source: source.replace(/^\.\//, "").replace(/\/+$/, "") };
89
+ }
90
+ if (source && typeof source === "object" && typeof source.repo === "string") {
91
+ return { source: "", sourceRepo: source.repo };
92
+ }
93
+ return {};
61
94
  }
62
95
 
63
96
  /** Parse a `marketplace.json` payload into plugin entries. */
@@ -87,6 +120,7 @@ export function normalizeCatalogJson(
87
120
  homepage: plugin.homepage,
88
121
  tags: plugin.tags,
89
122
  releases: normalizeReleases(plugin.releases),
123
+ ...splitSource(plugin.source),
90
124
  });
91
125
  }
92
126
  }