dsh-agent-plugins-market 0.5.2 → 0.6.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.
Files changed (86) hide show
  1. package/README.md +29 -3
  2. package/README.zh.md +29 -3
  3. package/client/client.js +1388 -204
  4. package/client/client.js.map +1 -1
  5. package/lib/application/catalog.js +537 -71
  6. package/lib/application/details.js +39 -12
  7. package/lib/catalog/archive.js +341 -0
  8. package/lib/catalog/git.js +85 -12
  9. package/lib/catalog/legacy-root-migration.js +51 -0
  10. package/lib/catalog/lsp-spec.js +87 -0
  11. package/lib/catalog/manifests.js +84 -32
  12. package/lib/catalog/paths.js +65 -7
  13. package/lib/catalog/scan-pipeline.js +50 -0
  14. package/lib/catalog/scan-resolvers.js +402 -0
  15. package/lib/catalog/source-catalog.js +22 -8
  16. package/lib/catalog/suite-scanner.js +5 -176
  17. package/lib/catalog/surfaces.js +80 -30
  18. package/lib/contracts/lsp-status.js +2 -0
  19. package/lib/contracts/market.js +8 -1
  20. package/lib/index.js +118 -13
  21. package/lib/model/state.js +14 -1
  22. package/lib/model/types.js +21 -2
  23. package/lib/routes.js +88 -6
  24. package/lib/runtime/commands-mounts.js +6 -2
  25. package/lib/runtime/hooks-mounts.js +9 -6
  26. package/lib/runtime/lsp-direct-config.js +44 -0
  27. package/lib/runtime/lsp-mounts.js +236 -0
  28. package/lib/runtime/lsp-status.js +96 -0
  29. package/lib/runtime/mcp-backend.js +60 -0
  30. package/lib/runtime/mcp-client/bridge.js +126 -0
  31. package/lib/runtime/mcp-client/config.js +42 -0
  32. package/lib/runtime/mcp-client/connection.js +380 -0
  33. package/lib/runtime/mcp-client/host-seams.js +88 -0
  34. package/lib/runtime/mcp-client/json-schema-subset.js +181 -0
  35. package/lib/runtime/mcp-client/oauth.js +379 -0
  36. package/lib/runtime/mcp-client/tools.js +432 -0
  37. package/lib/runtime/mcp-client/transport.js +103 -0
  38. package/lib/runtime/mcp-config.js +196 -54
  39. package/lib/runtime/mcp-credentials.js +20 -0
  40. package/lib/runtime/mcp-mounts.js +237 -27
  41. package/lib/runtime/mcp-overrides.js +64 -6
  42. package/lib/runtime/mcp-redaction.js +92 -0
  43. package/lib/runtime/mcp-status.js +76 -32
  44. package/lib/runtime/reconciler.js +35 -1
  45. package/lib/runtime/regions.js +56 -0
  46. package/lib/types/application/catalog.d.ts +191 -14
  47. package/lib/types/application/details.d.ts +1 -6
  48. package/lib/types/application/queries.d.ts +28 -1
  49. package/lib/types/catalog/archive.d.ts +48 -0
  50. package/lib/types/catalog/git.d.ts +27 -3
  51. package/lib/types/catalog/legacy-root-migration.d.ts +14 -0
  52. package/lib/types/catalog/lsp-spec.d.ts +14 -0
  53. package/lib/types/catalog/manifests.d.ts +16 -1
  54. package/lib/types/catalog/paths.d.ts +23 -2
  55. package/lib/types/catalog/scan-pipeline.d.ts +76 -0
  56. package/lib/types/catalog/scan-resolvers.d.ts +81 -0
  57. package/lib/types/catalog/source-catalog.d.ts +5 -0
  58. package/lib/types/catalog/suite-scanner.d.ts +14 -7
  59. package/lib/types/catalog/surfaces.d.ts +3 -3
  60. package/lib/types/contracts/lsp-status.d.ts +53 -0
  61. package/lib/types/contracts/market.d.ts +55 -1
  62. package/lib/types/contracts/mcp-status.d.ts +14 -2
  63. package/lib/types/index.d.ts +24 -8
  64. package/lib/types/model/types.d.ts +52 -3
  65. package/lib/types/runtime/lsp-direct-config.d.ts +12 -0
  66. package/lib/types/runtime/lsp-mounts.d.ts +89 -0
  67. package/lib/types/runtime/lsp-status.d.ts +30 -0
  68. package/lib/types/runtime/mcp-backend.d.ts +49 -0
  69. package/lib/types/runtime/mcp-client/bridge.d.ts +37 -0
  70. package/lib/types/runtime/mcp-client/config.d.ts +123 -0
  71. package/lib/types/runtime/mcp-client/connection.d.ts +82 -0
  72. package/lib/types/runtime/mcp-client/host-seams.d.ts +89 -0
  73. package/lib/types/runtime/mcp-client/json-schema-subset.d.ts +58 -0
  74. package/lib/types/runtime/mcp-client/oauth.d.ts +121 -0
  75. package/lib/types/runtime/mcp-client/tools.d.ts +136 -0
  76. package/lib/types/runtime/mcp-client/transport.d.ts +36 -0
  77. package/lib/types/runtime/mcp-config.d.ts +29 -12
  78. package/lib/types/runtime/mcp-credentials.d.ts +9 -0
  79. package/lib/types/runtime/mcp-mounts.d.ts +56 -6
  80. package/lib/types/runtime/mcp-overrides.d.ts +23 -4
  81. package/lib/types/runtime/mcp-redaction.d.ts +7 -0
  82. package/lib/types/runtime/mcp-status.d.ts +4 -2
  83. package/lib/types/runtime/reconciler.d.ts +22 -4
  84. package/lib/types/runtime/regions.d.ts +37 -0
  85. package/package.json +15 -11
  86. package/schemas/1.0.0/mcp.schema.json +22 -0
@@ -0,0 +1,402 @@
1
+ /**
2
+ * Scan strategies and the marketplace-entry handler chain.
3
+ *
4
+ * Three filters answer the source scan question, most specific first
5
+ * (classloader-style parent-first delegation):
6
+ *
7
+ * 1. `MarketplaceStrategy` — a Claude Code (`.claude-plugin/`) or Codex
8
+ * (`.agents/plugins/`) marketplace manifest. Dialects are tried in
9
+ * precedence order and each must prove productive (≥1 suite) before it
10
+ * wins, so a shadowed dialect can still carry the scan when the
11
+ * higher-precedence one yields nothing. Marketplace entries resolve
12
+ * through a per-entry handler chain: local paths, self-references
13
+ * (the entry points back at this very source), and remote references
14
+ * (URL or GitHub `repo` shorthand). Unclaimed entries are diagnosed,
15
+ * never silently dropped.
16
+ * 2. `RootedStrategy` — nested plugin roots carrying a suite manifest or
17
+ * skill files, up to four levels deep.
18
+ * 3. `FlatCollectionsStrategy` — the terminal fallback: manifest-less
19
+ * `<root>/<name>/SKILL.md` collections.
20
+ */
21
+ import { readdir, stat } from 'node:fs/promises';
22
+ import { join, resolve } from 'node:path';
23
+ import { isDirectory, sanitizeId } from './paths.js';
24
+ import { declaredSkillsPath, detectManifest, readManifest, readMarketplaces, syntheticManifestName, declaredLspServers } from './manifests.js';
25
+ import { countSurfaces, discoverMcp, discoverSkills } from './surfaces.js';
26
+ import { parseLspServers } from './lsp-spec.js';
27
+ import { runScanChain } from './scan-pipeline.js';
28
+ export { repoName } from './manifests.js';
29
+ export { listMdFiles, discoverLspEntries } from './surfaces.js';
30
+ const CONTAINER_DIRS = ['plugins', 'external_plugins', 'skills'];
31
+ const DOT_DIRS = new Set(['.git', '.github', '.claude', '.cursor', '.kimi', '.plugin', '.sources', 'node_modules']);
32
+ /**
33
+ * Canonical `https://host/owner/repo` form of a git URL, for equality
34
+ * comparisons between a source's configured URL and an entry's remote.
35
+ */
36
+ export function canonicalGitUrl(url) {
37
+ let value = url.trim();
38
+ // scp-like ssh shorthand: git@github.com:owner/repo(.git)
39
+ const scp = /^git@([^:/]+):(.+)$/.exec(value);
40
+ if (scp !== null)
41
+ value = `https://${scp[1]}/${scp[2]}`;
42
+ value = value.replace(/^ssh:\/\//, 'https://').replace(/^git:\/\//, 'https://');
43
+ try {
44
+ const parsed = new URL(value);
45
+ const path = decodeURIComponent(parsed.pathname)
46
+ .replace(/\/+$/, '')
47
+ .replace(/\.git$/, '');
48
+ return `${parsed.protocol}//${parsed.host.toLowerCase()}${path}`;
49
+ }
50
+ catch {
51
+ // Not an absolute URL; normalize the trailing bits only.
52
+ return value.replace(/\/+$/, '').replace(/\.git$/, '');
53
+ }
54
+ }
55
+ /** GitHub shorthand `{ source: 'github', repo: 'owner/repo' }` → web URL. */
56
+ function githubRepoUrl(record) {
57
+ if (record['source'] !== 'github' && record['source'] !== 'github-release')
58
+ return undefined;
59
+ if (typeof record['repo'] !== 'string' || record['repo'] === '')
60
+ return undefined;
61
+ return `https://github.com/${record['repo']}`;
62
+ }
63
+ /** The remote URL an entry points at, across every dialect's shape. */
64
+ export function entryRemoteUrl(entry) {
65
+ if (typeof entry.source === 'string')
66
+ return undefined;
67
+ if (entry.source === null || typeof entry.source !== 'object')
68
+ return undefined;
69
+ const record = entry.source;
70
+ if (typeof record['url'] === 'string' && record['url'] !== '')
71
+ return record['url'];
72
+ return githubRepoUrl(record);
73
+ }
74
+ function claimLocal(checkout, declaredPath) {
75
+ const dir = resolve(checkout, declaredPath);
76
+ // Containment guard: a relative entry must stay inside the checkout.
77
+ if (dir !== checkout && !dir.startsWith(`${checkout}/`)) {
78
+ // `rejected`: the handler positively identified a local path, but the
79
+ // path escapes the checkout. Its verdict is authoritative — later
80
+ // handlers must not reinterpret it as something else.
81
+ return { kind: 'rejected', reason: `path "${declaredPath}" escapes the checkout` };
82
+ }
83
+ return { kind: 'local', dir };
84
+ }
85
+ function unclaimed(reason) {
86
+ return { kind: 'unclaimed', reason };
87
+ }
88
+ /** String source: a checkout-relative path (Claude Code `"./skills/x"` form). */
89
+ const STRING_PATH_HANDLER = {
90
+ name: 'local-string-path',
91
+ handle(context) {
92
+ const source = context.entry.source;
93
+ if (typeof source !== 'string')
94
+ return unclaimed('source is not a path string');
95
+ return claimLocal(context.checkout, source);
96
+ }
97
+ };
98
+ /** Object source with `path`: Codex `{ source: 'local', path }` and Claude `{ path }`. */
99
+ const OBJECT_PATH_HANDLER = {
100
+ name: 'local-object-path',
101
+ handle(context) {
102
+ const source = context.entry.source;
103
+ if (source === null || typeof source !== 'object')
104
+ return unclaimed('source is not an object');
105
+ const record = source;
106
+ if (typeof record['path'] !== 'string' || record['path'] === '')
107
+ return unclaimed('object source carries no path');
108
+ return claimLocal(context.checkout, record['path']);
109
+ }
110
+ };
111
+ /** Remote entries: `{ url }` (Claude/Codex) and `{ source: 'github', repo }` shorthand. */
112
+ const REMOTE_URL_HANDLER = {
113
+ name: 'remote-url',
114
+ handle(context) {
115
+ const url = entryRemoteUrl(context.entry);
116
+ if (url === undefined)
117
+ return unclaimed('source carries no resolvable remote url or repo');
118
+ return { kind: 'remote', url };
119
+ }
120
+ };
121
+ const ENTRY_HANDLERS = [STRING_PATH_HANDLER, OBJECT_PATH_HANDLER, REMOTE_URL_HANDLER];
122
+ /**
123
+ * Resolve one marketplace entry's source against its checkout. Self-
124
+ * references (an entry pointing back at this very source, as Claude Code
125
+ * ships for single-repo marketplaces) resolve to the local checkout so the
126
+ * repo's own manifest and surfaces are scanned directly.
127
+ */
128
+ export async function resolveMarketplaceEntry(checkout, entry, sourceUrl) {
129
+ const remoteUrl = entryRemoteUrl(entry);
130
+ if (sourceUrl !== undefined && remoteUrl !== undefined && canonicalGitUrl(remoteUrl) === canonicalGitUrl(sourceUrl) && (await isDirectory(checkout))) {
131
+ return { kind: 'local', dir: checkout };
132
+ }
133
+ let lastReason = 'no handler claimed the entry';
134
+ for (const handler of ENTRY_HANDLERS) {
135
+ const resolution = await handler.handle({ checkout, entry }, { next: () => unclaimed(lastReason) });
136
+ if (resolution.kind === 'rejected')
137
+ return resolution;
138
+ if (resolution.kind !== 'unclaimed')
139
+ return resolution;
140
+ lastReason = resolution.reason;
141
+ }
142
+ return { kind: 'unclaimed', reason: lastReason };
143
+ }
144
+ // ---------------------------------------------------------------------------
145
+ // Marketplace strategy
146
+ // ---------------------------------------------------------------------------
147
+ /** Read one checkout through marketplace manifests, dialect by dialect. */
148
+ export class MarketplaceStrategy {
149
+ name = 'marketplace';
150
+ async doScan(context, chain) {
151
+ const marketplaces = await readMarketplaces(context.checkout);
152
+ for (const marketplace of marketplaces) {
153
+ const roots = await this.marketplaceRoots(context, marketplace.entries);
154
+ if (roots.length === 0) {
155
+ context.notes.push(`marketplace ${marketplace.path}: no entry resolved (all ${marketplace.entries.length} dropped or remote)`);
156
+ continue;
157
+ }
158
+ const suites = await Promise.all(roots.map(async (root) => root.dir === undefined ? remoteSuite(context.sourceId, context.dimension, root) : readSuite(root.dir, context.sourceId, context.dimension, root.hint)));
159
+ const resolved = suites.filter((suite) => suite !== undefined);
160
+ if (resolved.length === 0) {
161
+ context.notes.push(`marketplace ${marketplace.path}: every resolved entry failed to parse a manifest`);
162
+ continue;
163
+ }
164
+ // A productive dialect wins; supplement with containers it did not list.
165
+ for (const container of CONTAINER_DIRS) {
166
+ const containerDir = join(context.checkout, container);
167
+ if (!(await isDirectory(containerDir)))
168
+ continue;
169
+ for (const child of await listChildDirs(containerDir)) {
170
+ if (roots.some(root => root.dir === child) || !(await hasSuiteManifest(child)))
171
+ continue;
172
+ const extra = await readSuite(child, context.sourceId, context.dimension, undefined);
173
+ if (extra !== undefined)
174
+ resolved.push(extra);
175
+ }
176
+ }
177
+ return { kind: 'resolved', suites: resolved };
178
+ }
179
+ if (marketplaces.length > 0) {
180
+ context.notes.push('no marketplace dialect produced suites; falling through to rooted discovery');
181
+ }
182
+ return chain.next(context);
183
+ }
184
+ async marketplaceRoots(context, entries) {
185
+ const roots = [];
186
+ const seen = new Set();
187
+ for (const entry of entries) {
188
+ const hint = {
189
+ name: entry.name,
190
+ version: entry.version,
191
+ description: entry.description,
192
+ // Claude Code declares LSP servers inline on the marketplace entry
193
+ // (typescript-lsp & co. ship no manifest at all).
194
+ ...(entry.lspServers !== undefined ? { lspServers: entry.lspServers } : {})
195
+ };
196
+ const resolution = await resolveMarketplaceEntry(context.checkout, entry, context.sourceUrl);
197
+ if (resolution.kind === 'unclaimed' || resolution.kind === 'rejected') {
198
+ context.notes.push(`marketplace entry "${String(entry.name ?? '<unnamed>')}": ${resolution.reason}`);
199
+ continue;
200
+ }
201
+ if (resolution.kind === 'remote') {
202
+ const entryName = typeof entry.name === 'string' && entry.name !== '' ? entry.name : resolution.url;
203
+ // Dedupe by entry name, not URL: one remote repo can host several
204
+ // plugins that each appear as their own marketplace entry.
205
+ if (entryName !== undefined && !seen.has(entryName)) {
206
+ roots.push({ hint, remoteUrl: resolution.url });
207
+ seen.add(entryName);
208
+ }
209
+ continue;
210
+ }
211
+ roots.push(...(await collectRoots(resolution.dir, hint, seen)));
212
+ }
213
+ return roots;
214
+ }
215
+ }
216
+ // ---------------------------------------------------------------------------
217
+ // Rooted and flat-collection strategies
218
+ // ---------------------------------------------------------------------------
219
+ /** Read nested plugin roots up to four levels deep. */
220
+ export class RootedStrategy {
221
+ name = 'rooted';
222
+ async doScan(context, chain) {
223
+ if (await hasSuiteManifest(context.checkout)) {
224
+ const suite = await readSuite(context.checkout, context.sourceId, context.dimension, undefined);
225
+ return suite === undefined ? chain.next(context) : { kind: 'resolved', suites: [suite] };
226
+ }
227
+ const found = await collectRoots(context.checkout, undefined, new Set());
228
+ if (found.length === 0)
229
+ return chain.next(context);
230
+ const suites = await Promise.all(found.map(async (root) => root.dir === undefined ? remoteSuite(context.sourceId, context.dimension, root) : readSuite(root.dir, context.sourceId, context.dimension, root.hint)));
231
+ const resolved = suites.filter((suite) => suite !== undefined);
232
+ return resolved.length === 0 ? chain.next(context) : { kind: 'resolved', suites: resolved };
233
+ }
234
+ }
235
+ /** Terminal fallback: manifest-less flat `<child>/SKILL.md` collections. */
236
+ export class FlatCollectionsStrategy {
237
+ name = 'flat-collections';
238
+ async doScan(context) {
239
+ const collection = [];
240
+ for (const child of await listChildDirs(context.checkout)) {
241
+ if (await hasSkillFiles(child))
242
+ collection.push({ dir: child });
243
+ }
244
+ if (collection.length === 0) {
245
+ context.notes.push('checkout declares no recognizable plugin shape');
246
+ return { kind: 'resolved', suites: [] };
247
+ }
248
+ const suites = await Promise.all(collection.map(async (root) => root.dir === undefined ? remoteSuite(context.sourceId, context.dimension, root) : readSuite(root.dir, context.sourceId, context.dimension, root.hint)));
249
+ return { kind: 'resolved', suites: suites.filter((suite) => suite !== undefined) };
250
+ }
251
+ }
252
+ /** The default chain, most specific first with a terminal fallback. */
253
+ export function defaultScanFilters() {
254
+ return [new MarketplaceStrategy(), new RootedStrategy(), new FlatCollectionsStrategy()];
255
+ }
256
+ // ---------------------------------------------------------------------------
257
+ // Public entry points
258
+ // ---------------------------------------------------------------------------
259
+ /** Discover every suite under one source checkout. */
260
+ export async function discoverSuitesInSource(checkoutDir, sourceId, dimension, sourceUrl) {
261
+ const result = await scanSource(checkoutDir, sourceId, dimension, sourceUrl);
262
+ return result.suites;
263
+ }
264
+ /** Scan one checkout through the filter chain with full diagnostics. */
265
+ export async function scanSource(checkoutDir, sourceId, dimension, sourceUrl) {
266
+ return runScanChain(defaultScanFilters(), { checkout: checkoutDir, sourceId, dimension, ...(sourceUrl !== undefined ? { sourceUrl } : {}), notes: [] });
267
+ }
268
+ // ---------------------------------------------------------------------------
269
+ // Shared root/manifest plumbing
270
+ // ---------------------------------------------------------------------------
271
+ /**
272
+ * Collect nested plugin roots up to four levels deep. Sibling subtrees are
273
+ * traversed concurrently (readdir/stat are I/O-bound); results concat in
274
+ * deterministic lexicographic order because each level maps children in
275
+ * order before flattening.
276
+ */
277
+ async function collectRoots(dir, hint, seen, depth = 0) {
278
+ if (depth > 4 || seen.has(dir))
279
+ return [];
280
+ // An entry carrying inline LSP declarations is a suite by declaration alone
281
+ // (official CC lsp plugins ship only a README), but only at the marketplace
282
+ // entry root — never deeper, so containers cannot self-declare.
283
+ if ((await hasSuiteManifest(dir)) || (await hasSkillFiles(dir)) || (hint?.lspServers !== undefined && depth === 0)) {
284
+ seen.add(dir);
285
+ return [{ dir, hint }];
286
+ }
287
+ const children = await listChildDirs(dir);
288
+ const nested = await Promise.all(children.map(child => collectRoots(child, hint, seen, depth + 1)));
289
+ return nested.flat();
290
+ }
291
+ /** Whether a directory carries any known suite manifest. */
292
+ export async function hasSuiteManifest(dir) {
293
+ return (await detectManifest(dir)) !== undefined;
294
+ }
295
+ /** Whether a directory carries skill files in the flat or bundled shape. */
296
+ export async function hasSkillFiles(dir) {
297
+ if (await isFile(join(dir, 'SKILL.md')))
298
+ return true;
299
+ const skillsDir = join(dir, 'skills');
300
+ if (!(await isDirectory(skillsDir)))
301
+ return false;
302
+ for (const child of await listChildDirs(skillsDir)) {
303
+ if (await isFile(join(child, 'SKILL.md')))
304
+ return true;
305
+ }
306
+ return false;
307
+ }
308
+ async function listChildDirs(dir) {
309
+ let entries;
310
+ try {
311
+ entries = await readdir(dir, { withFileTypes: true });
312
+ }
313
+ catch {
314
+ return [];
315
+ }
316
+ return entries.filter(entry => entry.isDirectory() && !DOT_DIRS.has(entry.name) && !entry.name.startsWith('.')).map(entry => join(dir, entry.name));
317
+ }
318
+ /** Read one suite root into the normalized shape, or undefined when no manifest parses. */
319
+ export async function readSuite(root, sourceId, dimension, hint) {
320
+ const errors = [];
321
+ let manifest = (await readManifest(root, errors, hint)) ?? (await syntheticManifest(root));
322
+ // A declaration-only suite (official CC lsp plugins ship just a README):
323
+ // the marketplace entry's inline lspServers are its manifest.
324
+ if (manifest === undefined && hint?.lspServers !== undefined) {
325
+ const name = hint.name ?? syntheticManifestName(root);
326
+ manifest = {
327
+ layout: 'claude-code',
328
+ path: '',
329
+ id: sanitizeId(name),
330
+ name,
331
+ ...(hint.version === undefined ? {} : { version: hint.version }),
332
+ ...(hint.description === undefined ? {} : { description: hint.description })
333
+ };
334
+ }
335
+ if (manifest === undefined)
336
+ return undefined;
337
+ const declared = await declaredSkillsPath(root);
338
+ const skills = await discoverSkills(root, errors, declared);
339
+ const mcp = await discoverMcp(root, errors);
340
+ // Inline LSP declarations: the marketplace entry wins over the manifest
341
+ // (Claude Code ships lspServers on the entry), and either is optional.
342
+ const lspRaw = hint?.lspServers ?? (await declaredLspServers(root));
343
+ const lspServers = parseLspServers(lspRaw, errors);
344
+ const lsp = Object.keys(lspServers).length > 0 ? { servers: lspServers } : undefined;
345
+ const surfaces = await countSurfaces(root, skills, mcp, lsp);
346
+ return {
347
+ sourceId,
348
+ id: manifest.id,
349
+ root,
350
+ manifest,
351
+ skills,
352
+ ...(mcp === undefined ? {} : { mcp }),
353
+ ...(lsp === undefined ? {} : { lsp }),
354
+ surfaces,
355
+ dimension,
356
+ enabled: false,
357
+ errors
358
+ };
359
+ }
360
+ /** Metadata-only suite for a marketplace entry whose content is remote. */
361
+ function remoteSuite(sourceId, dimension, root) {
362
+ const name = root.hint?.name ?? 'remote-plugin';
363
+ return {
364
+ sourceId,
365
+ id: sanitizeId(name),
366
+ root: '',
367
+ manifest: {
368
+ layout: 'remote',
369
+ path: '',
370
+ id: sanitizeId(name),
371
+ name,
372
+ version: root.hint?.version,
373
+ description: root.hint?.description
374
+ },
375
+ skills: [],
376
+ surfaces: { skills: 0, mcp: 0, hooks: 0, commands: 0, agents: 0, lsp: 0 },
377
+ dimension,
378
+ enabled: false,
379
+ remote: { url: root.remoteUrl ?? '' },
380
+ errors: []
381
+ };
382
+ }
383
+ /** Manifest-less directories still produce a synthetic suite identity. */
384
+ async function syntheticManifest(root) {
385
+ if (!(await hasSkillFiles(root)))
386
+ return undefined;
387
+ const name = syntheticManifestName(root);
388
+ return {
389
+ layout: 'skill-collection',
390
+ path: join(root, 'SKILL.md'),
391
+ id: sanitizeId(name),
392
+ name
393
+ };
394
+ }
395
+ async function isFile(path) {
396
+ try {
397
+ return (await stat(path)).isFile();
398
+ }
399
+ catch {
400
+ return false;
401
+ }
402
+ }
@@ -10,38 +10,52 @@
10
10
  import { readdir } from 'node:fs/promises';
11
11
  import { dirname, join } from 'node:path';
12
12
  import { expandHome, isDirectory, sourcesDir } from './paths.js';
13
- import { discoverSuitesInSource } from './suite-scanner.js';
13
+ import { scanSource as scanSourceWithNotes } from './suite-scanner.js';
14
14
  import { discoverNativeProjectSuites } from './native-project.js';
15
15
  /** Discover suites from the selected configured or project checkouts. */
16
16
  export async function discoverSourceList(sources, dimension, dimensionRoot) {
17
+ return (await discoverSourceListWithNotes(sources, dimension, dimensionRoot)).suites;
18
+ }
19
+ /** Discover suites plus per-source scan diagnostics for one dimension. */
20
+ export async function discoverSourceListWithNotes(sources, dimension, dimensionRoot) {
17
21
  const checkoutRoot = sourcesDir(dimensionRoot);
18
22
  const listed = new Set(sources.map(source => source.id));
19
23
  const checkouts = sources.map(source => ({
20
24
  sourceId: source.id,
21
- checkout: source.local === true ? expandHome(source.url) : join(checkoutRoot, source.id)
25
+ checkout: source.local === true ? expandHome(source.url) : join(checkoutRoot, source.id),
26
+ // The configured URL drives marketplace-entry self-reference resolution:
27
+ // an entry pointing back at this very source (Claude Code ships
28
+ // `{ source: 'github', repo: <own repo> }`) scans the local checkout.
29
+ sourceUrl: source.url
22
30
  }));
23
31
  if (dimension === 'project') {
24
32
  try {
25
33
  for (const entry of await readdir(checkoutRoot, { withFileTypes: true })) {
26
34
  if (!entry.isDirectory() || entry.name.startsWith('.') || listed.has(entry.name))
27
35
  continue;
28
- checkouts.push({ sourceId: entry.name, checkout: join(checkoutRoot, entry.name) });
36
+ checkouts.push({ sourceId: entry.name, checkout: join(checkoutRoot, entry.name), sourceUrl: undefined });
29
37
  }
30
38
  }
31
39
  catch {
32
40
  // A missing project checkout root has no unmanaged project sources.
33
41
  }
34
42
  }
35
- const discovered = await Promise.all(checkouts.map(async ({ sourceId, checkout }) => {
43
+ const discovered = await Promise.all(checkouts.map(async ({ sourceId, checkout, sourceUrl }) => {
36
44
  if (!(await isDirectory(checkout)))
37
- return [];
38
- return discoverSuitesInSource(checkout, sourceId, dimension);
45
+ return { sourceId, suites: [], notes: [] };
46
+ const result = await scanSourceWithNotes(checkout, sourceId, dimension, sourceUrl);
47
+ return { sourceId, suites: result.suites, notes: result.notes };
39
48
  }));
40
- const suites = discovered.flat();
49
+ const suites = discovered.flatMap(entry => entry.suites);
50
+ const scanNotes = {};
51
+ for (const entry of discovered) {
52
+ if (entry.notes.length > 0)
53
+ scanNotes[entry.sourceId] = entry.notes;
54
+ }
41
55
  if (dimension === 'project') {
42
56
  // Native project directories live two levels above the dimension root
43
57
  // (`<projectRoot>/.dsh/agent-plugins`); read them in place.
44
58
  suites.push(...(await discoverNativeProjectSuites(dirname(dirname(dimensionRoot)), dimension)));
45
59
  }
46
- return suites;
60
+ return { suites, scanNotes };
47
61
  }
@@ -1,178 +1,7 @@
1
- /**
2
- * Scan one source checkout into normalized suites.
3
- *
4
- * This module owns root selection inside one checkout and delegates manifest and
5
- * surface parsing to the established discovery adapters. Source-list policy
6
- * (configured, local, and project checkout selection) belongs to source-catalog.
7
- */
8
- import { readdir, stat } from 'node:fs/promises';
9
- import { isAbsolute, join } from 'node:path';
10
- import { isDirectory, sanitizeId } from './paths.js';
11
- import { declaredSkillsPath, detectManifest, marketplaceEntryDir, readManifest, readMarketplace, repoName, syntheticManifestName } from './manifests.js';
12
- import { countSurfaces, discoverLspEntries, discoverMcp, discoverSkills, listMdFiles } from './surfaces.js';
13
- export { repoName, listMdFiles, discoverLspEntries };
14
- const CONTAINER_DIRS = ['plugins', 'external_plugins', 'skills'];
15
- const DOT_DIRS = new Set(['.git', '.github', '.claude', '.cursor', '.kimi', '.plugin', '.sources', 'node_modules']);
1
+ import { scanSource } from './scan-resolvers.js';
2
+ export { repoName, listMdFiles, discoverLspEntries, scanSource, canonicalGitUrl, resolveMarketplaceEntry, defaultScanFilters, MarketplaceStrategy, RootedStrategy, FlatCollectionsStrategy } from './scan-resolvers.js';
3
+ export { runScanChain } from './scan-pipeline.js';
16
4
  /** Discover every suite under one source checkout. */
17
- export async function discoverSuitesInSource(checkoutDir, sourceId, dimension) {
18
- const roots = await suiteRoots(checkoutDir);
19
- const suites = await Promise.all(roots.map(async (root) => (root.dir === undefined ? remoteSuite(sourceId, dimension, root) : readSuite(root.dir, sourceId, dimension, root.hint))));
20
- return suites.filter((suite) => suite !== undefined);
21
- }
22
- /** Resolve all suite roots selected by a marketplace or checkout layout. */
23
- async function suiteRoots(checkoutDir) {
24
- const marketplace = await readMarketplace(checkoutDir);
25
- if (marketplace !== undefined && marketplace.entries.length > 0) {
26
- const roots = [];
27
- const seen = new Set();
28
- for (const entry of marketplace.entries) {
29
- const hint = { name: entry.name, version: entry.version, description: entry.description };
30
- const dir = marketplaceEntryDir(checkoutDir, entry);
31
- if (dir === undefined) {
32
- const remoteUrl = typeof entry.source === 'object' ? entry.source?.url : undefined;
33
- // Dedupe by entry name, not URL: one remote repo can host several
34
- // plugins that each appear as their own marketplace entry.
35
- const entryName = typeof entry.name === 'string' && entry.name !== '' ? entry.name : remoteUrl;
36
- if (remoteUrl !== undefined && entryName !== undefined && !seen.has(entryName)) {
37
- roots.push({ hint, remoteUrl });
38
- seen.add(entryName);
39
- }
40
- continue;
41
- }
42
- await collectRoot(dir, hint, roots, seen);
43
- }
44
- for (const container of CONTAINER_DIRS) {
45
- const containerDir = join(checkoutDir, container);
46
- if (!(await isDirectory(containerDir)))
47
- continue;
48
- for (const child of await listChildDirs(containerDir)) {
49
- if (!seen.has(child) && (await hasSuiteManifest(child)))
50
- roots.push({ dir: child });
51
- }
52
- }
53
- return roots;
54
- }
55
- if (await hasSuiteManifest(checkoutDir))
56
- return [{ dir: checkoutDir }];
57
- const found = [];
58
- await collectRoot(checkoutDir, undefined, found, new Set());
59
- if (found.length > 0)
60
- return found;
61
- // Manifest-less skill collection (the flat `<root>/<name>/SKILL.md` shape).
62
- const collection = [];
63
- for (const child of await listChildDirs(checkoutDir)) {
64
- if (await hasSkillFiles(child))
65
- collection.push({ dir: child });
66
- }
67
- return collection;
68
- }
69
- /** Collect nested plugin roots up to four levels deep. */
70
- async function collectRoot(dir, hint, out, seen, depth = 0) {
71
- if (depth > 4 || seen.has(dir))
72
- return;
73
- if ((await hasSuiteManifest(dir)) || (await hasSkillFiles(dir))) {
74
- out.push({ dir, hint });
75
- seen.add(dir);
76
- return;
77
- }
78
- for (const child of await listChildDirs(dir)) {
79
- await collectRoot(child, hint, out, seen, depth + 1);
80
- }
81
- }
82
- /** Whether a directory carries any known suite manifest. */
83
- async function hasSuiteManifest(dir) {
84
- return (await detectManifest(dir)) !== undefined;
85
- }
86
- /** Whether a directory carries skill files in the flat or bundled shape. */
87
- async function hasSkillFiles(dir) {
88
- if (await isFile(join(dir, 'SKILL.md')))
89
- return true;
90
- const skillsDir = join(dir, 'skills');
91
- if (!(await isDirectory(skillsDir)))
92
- return false;
93
- for (const child of await listChildDirs(skillsDir)) {
94
- if (await isFile(join(child, 'SKILL.md')))
95
- return true;
96
- }
97
- return false;
98
- }
99
- async function listChildDirs(dir) {
100
- let entries;
101
- try {
102
- entries = await readdir(dir, { withFileTypes: true });
103
- }
104
- catch {
105
- return [];
106
- }
107
- return entries.filter(entry => entry.isDirectory() && !DOT_DIRS.has(entry.name) && !entry.name.startsWith('.')).map(entry => join(dir, entry.name));
108
- }
109
- /** Read one suite root into the normalized shape, or undefined when no manifest parses. */
110
- async function readSuite(root, sourceId, dimension, hint) {
111
- const errors = [];
112
- const manifest = (await readManifest(root, errors, hint)) ?? (await syntheticManifest(root));
113
- if (manifest === undefined)
114
- return undefined;
115
- const declared = await declaredSkillsPath(root);
116
- const skills = await discoverSkills(root, errors, declared);
117
- const mcp = await discoverMcp(root, errors);
118
- const surfaces = await countSurfaces(root, skills, mcp);
119
- return {
120
- sourceId,
121
- id: manifest.id,
122
- root,
123
- manifest,
124
- skills,
125
- ...(mcp === undefined ? {} : { mcp }),
126
- surfaces,
127
- dimension,
128
- enabled: false,
129
- errors
130
- };
131
- }
132
- /** Metadata-only suite for a marketplace entry whose content is remote. */
133
- function remoteSuite(sourceId, dimension, root) {
134
- const name = root.hint?.name ?? 'remote-plugin';
135
- return {
136
- sourceId,
137
- id: sanitizeId(name),
138
- root: '',
139
- manifest: {
140
- layout: 'remote',
141
- path: '',
142
- id: sanitizeId(name),
143
- name,
144
- version: root.hint?.version,
145
- description: root.hint?.description
146
- },
147
- skills: [],
148
- surfaces: { skills: 0, mcp: 0, hooks: 0, commands: 0, agents: 0, lsp: 0 },
149
- dimension,
150
- enabled: false,
151
- remote: { url: root.remoteUrl ?? '' },
152
- errors: []
153
- };
154
- }
155
- /** Manifest-less directories still produce a synthetic suite identity. */
156
- async function syntheticManifest(root) {
157
- if (!(await hasSkillFiles(root)))
158
- return undefined;
159
- const name = syntheticManifestName(root);
160
- return {
161
- layout: 'skill-collection',
162
- path: join(root, 'SKILL.md'),
163
- id: sanitizeId(name),
164
- name
165
- };
166
- }
167
- async function isFile(path) {
168
- try {
169
- return (await stat(path)).isFile();
170
- }
171
- catch {
172
- return false;
173
- }
174
- }
175
- /** Whether a suite root path lies outside the checkout. */
176
- export function isOutside(root, candidate) {
177
- return isAbsolute(candidate) ? !candidate.startsWith(root) : false;
5
+ export async function discoverSuitesInSource(checkoutDir, sourceId, dimension, sourceUrl) {
6
+ return scanSource(checkoutDir, sourceId, dimension, sourceUrl).then(result => result.suites);
178
7
  }