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
|
@@ -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
|
+
});
|
package/src/data/marketplaces.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
}
|