dsh-cc-loader 0.1.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/src/plugin.js ADDED
@@ -0,0 +1,750 @@
1
+ // dsh-cc-loader — plugin manifest + marketplace discovery (.claude-plugin/).
2
+ //
3
+ // Claude Code plugins are self-contained directories with an optional
4
+ // `.claude-plugin/plugin.json` manifest; a marketplace adds
5
+ // `.claude-plugin/marketplace.json` listing distributable plugins.
6
+ //
7
+ // This module parses both files into IR and discovers a plugin root's
8
+ // components by reusing the existing skills/commands/agents/mcp/lsp scanners,
9
+ // so one entry point (discoverPluginRoot) inventories a whole plugin and the
10
+ // manifest's component-path fields are honored per the official path
11
+ // behavior rules (verified against code.claude.com/docs/en/plugins-reference):
12
+ // - skills ADD to the default skills/ scan (root SKILL.md single-skill
13
+ // case included: no skills/ dir, no manifest skills field, SKILL.md at
14
+ // the plugin root → the root itself is one skill bundle)
15
+ // - commands / agents / workflows / outputStyles / themes / monitors
16
+ // REPLACE their default directories when the manifest names them
17
+ // - hooks / mcpServers / lspServers have their own merge rules
18
+ //
19
+ // Classification vocabulary (same as the rest of the loader):
20
+ // DIRECT — metadata + component path fields DSH can inventory now
21
+ // ADAPTED — userConfig / dependencies (reported, not executed)
22
+ // UNSUPPORTED — workflows/output-styles/themes/monitors (M5 misc),
23
+ // channels (needs an MCP channel bridge), remote marketplace
24
+ // sources (github/url/npm/… fetch not implemented in DSH)
25
+ // INVALID — shape errors (skipped with a warning, never fatal)
26
+
27
+ import { readFile, stat } from 'node:fs/promises'
28
+ import { join, dirname } from 'node:path'
29
+ import { pathExists, readTextSafe, discoverSkills, discoverCommands, parseFrontmatter, isSkillName } from './skills.js'
30
+ import { discoverAgents, buildAgentEntry } from './agents.js'
31
+ import { parseMcpText, serverEntries, discoverMcpConfig } from './mcp.js'
32
+ import { parseLspText, discoverLspConfig } from './lsp.js'
33
+ import { STATUS } from './classify.js'
34
+
35
+ /** CC plugin-name grammar: kebab-case, no spaces. */
36
+ export const PLUGIN_NAME_RE = /^[a-z0-9]+(?:-[a-z0-9]+)*$/
37
+
38
+ /**
39
+ * Plugin name from a root directory name (used when no manifest exists, or as
40
+ * a fallback). Mirrors CC: name derives from the directory name when the
41
+ * manifest is absent; discoverPluginRoot prefers manifest `name` when present.
42
+ */
43
+ export function pluginNameOf(root) {
44
+ const base = root.split(/[\\/]/).filter(Boolean).pop() ?? 'plugin'
45
+ return base.replace(/[^A-Za-z0-9_-]/g, '_').slice(0, 64) || 'plugin'
46
+ }
47
+
48
+ /**
49
+ * Namespace a plugin component name for DSH exposure.
50
+ *
51
+ * CC uses `<plugin>:<component>` (e.g. `/superpowers:brainstorming`), but DSH
52
+ * skill/command names are strict kebab-case (no colons, no double hyphens —
53
+ * host grammar is /^[a-z0-9]+(?:-[a-z0-9]+)*$/), and the host is NOT modified
54
+ * by the ecosystem. The agreed mapping is a literal `plugin-` prefix followed
55
+ * by the plugin name and the component name, all single-hyphen kebab:
56
+ * plugin-superpowers-brainstorming
57
+ *
58
+ * @param {string} pluginName - plugin name (manifest name or dir fallback).
59
+ * @param {string} componentName - the component's own kebab-case name.
60
+ * @returns {string} DSH-safe namespaced name.
61
+ */
62
+ export function pluginComponentName(pluginName, componentName) {
63
+ const clean = String(pluginName ?? '')
64
+ .toLowerCase()
65
+ .replace(/[^a-z0-9]+/g, '-')
66
+ .replace(/^-+|-+$/g, '')
67
+ if (clean.length === 0) return `plugin-${componentName}`
68
+ return `plugin-${clean}-${componentName}`
69
+ }
70
+
71
+ /** Marketplace names Claude reserves for official Anthropic use. */
72
+ export const RESERVED_MARKETPLACE_NAMES = new Set([
73
+ 'claude-code-marketplace', 'claude-code-plugins', 'claude-plugins-official',
74
+ 'claude-plugins-community', 'claude-community', 'anthropic-marketplace',
75
+ 'anthropic-plugins', 'agent-skills', 'anthropic-agent-skills',
76
+ 'knowledge-work-plugins', 'life-sciences', 'claude-for-legal',
77
+ 'claude-for-financial-services', 'financial-services-plugins',
78
+ 'first-party-plugins', 'healthcare',
79
+ ])
80
+
81
+ /** Manifest fields that are pure metadata (classified DIRECT). */
82
+ const METADATA_FIELDS = new Set([
83
+ '$schema', 'name', 'displayName', 'version', 'description', 'author',
84
+ 'homepage', 'repository', 'license', 'keywords', 'metadata', 'defaultEnabled',
85
+ ])
86
+
87
+ /**
88
+ * Component path fields → how they interact with the default directory.
89
+ * 'add' — default dir always scanned, manifest paths scanned alongside.
90
+ * 'replace' — manifest paths replace the default dir when present.
91
+ * 'merge' — own merge rules (extra files / inline objects).
92
+ */
93
+ const COMPONENT_FIELDS = {
94
+ skills: 'add', commands: 'replace', agents: 'replace',
95
+ hooks: 'merge', mcpServers: 'merge', lspServers: 'merge',
96
+ workflows: 'replace', outputStyles: 'replace',
97
+ }
98
+
99
+ /** Component path fields DSH cannot honor yet (classified UNSUPPORTED, M5). */
100
+ const UNSUPPORTED_PATH_FIELDS = new Set(['workflows', 'outputStyles'])
101
+
102
+ const RECOGNIZED_TOP_LEVEL = new Set([
103
+ ...METADATA_FIELDS, ...Object.keys(COMPONENT_FIELDS),
104
+ 'experimental', 'userConfig', 'channels', 'dependencies',
105
+ ])
106
+
107
+ /** Manifest field → { status, reason? }. */
108
+ function classifyManifestField(field) {
109
+ if (METADATA_FIELDS.has(field)) return { status: STATUS.DIRECT }
110
+ if (COMPONENT_FIELDS[field] !== undefined) {
111
+ if (UNSUPPORTED_PATH_FIELDS.has(field)) {
112
+ return { status: STATUS.UNSUPPORTED, reason: `${field}: not bridged yet (M5 misc inventory)` }
113
+ }
114
+ return { status: STATUS.DIRECT }
115
+ }
116
+ if (field === 'experimental') {
117
+ return { status: STATUS.UNSUPPORTED, reason: 'experimental.* components (themes/monitors): not bridged yet (M5 misc inventory)' }
118
+ }
119
+ if (field === 'userConfig') {
120
+ return { status: STATUS.ADAPTED, reason: 'userConfig reported; ${user_config.*} substitution not implemented (report-only)' }
121
+ }
122
+ if (field === 'channels') {
123
+ return { status: STATUS.UNSUPPORTED, reason: 'channels need an MCP message-injection bridge — not implemented' }
124
+ }
125
+ if (field === 'dependencies') {
126
+ return { status: STATUS.ADAPTED, reason: 'plugin dependencies reported; enable/disable graph not implemented (report-only)' }
127
+ }
128
+ return undefined
129
+ }
130
+
131
+ /** Normalize a string|array manifest value into a string array (or undefined). */
132
+ function stringListField(value) {
133
+ if (value === undefined) return undefined
134
+ if (typeof value === 'string') return [value]
135
+ if (Array.isArray(value) && value.every((v) => typeof v === 'string')) return value
136
+ return undefined // wrong type → warning at call site
137
+ }
138
+
139
+ /**
140
+ * Normalize hooks/mcpServers/lspServers manifest values: a string path, an
141
+ * array of strings and/or inline config objects, or one inline object.
142
+ */
143
+ function mixedConfigField(value) {
144
+ if (value === undefined) return undefined
145
+ if (typeof value === 'string') return [value]
146
+ if (Array.isArray(value)) {
147
+ if (value.every((v) => typeof v === 'string')) return value
148
+ if (value.every((v) => typeof v === 'string' || (v !== null && typeof v === 'object' && !Array.isArray(v)))) {
149
+ return value
150
+ }
151
+ return undefined
152
+ }
153
+ if (value !== null && typeof value === 'object' && !Array.isArray(value)) return [value]
154
+ return undefined
155
+ }
156
+
157
+ /** Normalize an author object: { name, email?, url? } → string → undefined. */
158
+ function authorField(value) {
159
+ if (typeof value === 'string') return { name: value }
160
+ if (value === null || typeof value !== 'object' || Array.isArray(value)) return undefined
161
+ const out = {}
162
+ if (typeof value.name === 'string') out.name = value.name
163
+ if (typeof value.email === 'string') out.email = value.email
164
+ if (typeof value.url === 'string') out.url = value.url
165
+ return Object.keys(out).length > 0 ? out : undefined
166
+ }
167
+
168
+ /**
169
+ * Parse `.claude-plugin/plugin.json` text into a structured manifest IR.
170
+ * Pure parse; shape errors are reported, never thrown.
171
+ * @param {string} text - raw plugin.json text.
172
+ * @param {object} [opts] - { warn? }
173
+ * @returns {object} {
174
+ * manifest: { name, displayName?, version?, description?, author?, homepage?,
175
+ * repository?, license?, keywords?, metadata?, defaultEnabled? },
176
+ * paths: { skills?: string[], commands?, agents?, workflows?, outputStyles?,
177
+ * hooks?: (string|object)[], mcpServers?: (string|object)[],
178
+ * lspServers?: (string|object)[], themes?, monitors? },
179
+ * classification: [{ field, status, reason? }],
180
+ * unrecognized: string[],
181
+ * warnings: string[],
182
+ * }
183
+ * `manifest` is undefined when the JSON is invalid or not an object.
184
+ */
185
+ export function parsePluginManifest(text, opts = {}) {
186
+ const warn = opts.warn ?? (() => {})
187
+ const warnings = []
188
+ const localWarn = (m) => { warnings.push(m); warn(m) }
189
+
190
+ let parsed
191
+ try {
192
+ parsed = JSON.parse(text)
193
+ } catch (error) {
194
+ localWarn(`plugin.json: invalid JSON: ${error.message}`)
195
+ return { manifest: undefined, paths: {}, classification: [], unrecognized: [], warnings }
196
+ }
197
+ if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) {
198
+ localWarn('plugin.json: manifest must be a JSON object')
199
+ return { manifest: undefined, paths: {}, classification: [], unrecognized: [], warnings }
200
+ }
201
+
202
+ const classification = []
203
+ const unrecognized = []
204
+ const paths = {}
205
+ const manifest = {}
206
+
207
+ for (const [key, value] of Object.entries(parsed)) {
208
+ const cls = classifyManifestField(key)
209
+ if (cls === undefined) {
210
+ unrecognized.push(key)
211
+ localWarn(`plugin.json: unrecognized field "${key}" (Claude Code ignores it; validate would warn)`)
212
+ continue
213
+ }
214
+ classification.push({ field: key, ...cls })
215
+
216
+ if (key === 'name') {
217
+ if (typeof value !== 'string' || value.length === 0) {
218
+ localWarn('plugin.json: "name" must be a non-empty string')
219
+ continue
220
+ }
221
+ manifest.name = value
222
+ if (!PLUGIN_NAME_RE.test(value)) {
223
+ localWarn(`plugin.json: name "${value}" is not kebab-case — components may not namespace correctly`)
224
+ }
225
+ continue
226
+ }
227
+ if (key === 'displayName' || key === 'version' || key === 'description'
228
+ || key === 'homepage' || key === 'repository' || key === 'license') {
229
+ if (typeof value === 'string') manifest[key] = value
230
+ else localWarn(`plugin.json: "${key}" must be a string`)
231
+ continue
232
+ }
233
+ if (key === 'author') {
234
+ const a = authorField(value)
235
+ if (a === undefined) localWarn('plugin.json: "author" must be an object {name, email?, url?} or string')
236
+ else manifest.author = a
237
+ continue
238
+ }
239
+ if (key === 'keywords') {
240
+ if (Array.isArray(value) && value.every((v) => typeof v === 'string')) manifest.keywords = value
241
+ else localWarn('plugin.json: "keywords" must be an array of strings')
242
+ continue
243
+ }
244
+ if (key === 'metadata') {
245
+ if (value !== null && typeof value === 'object' && !Array.isArray(value)) manifest.metadata = value
246
+ else localWarn('plugin.json: "metadata" must be an object (Claude Code ignores a non-object)')
247
+ continue
248
+ }
249
+ if (key === 'defaultEnabled') {
250
+ if (typeof value === 'boolean') manifest.defaultEnabled = value
251
+ else localWarn('plugin.json: "defaultEnabled" must be a boolean')
252
+ continue
253
+ }
254
+ if (key === '$schema') continue // ignored at load time by CC as well
255
+
256
+ // Component path fields.
257
+ if (key === 'skills' || key === 'commands' || key === 'agents'
258
+ || key === 'workflows' || key === 'outputStyles' || key === 'themes') {
259
+ const list = stringListField(value)
260
+ if (list === undefined) localWarn(`plugin.json: "${key}" must be a string or array of strings`)
261
+ else paths[key] = list
262
+ continue
263
+ }
264
+ if (key === 'hooks' || key === 'mcpServers' || key === 'lspServers') {
265
+ const list = mixedConfigField(value)
266
+ if (list === undefined) localWarn(`plugin.json: "${key}" must be a string, array of strings/objects, or inline object`)
267
+ else paths[key] = list
268
+ continue
269
+ }
270
+ if (key === 'experimental') {
271
+ if (value === null || typeof value !== 'object' || Array.isArray(value)) {
272
+ localWarn('plugin.json: "experimental" must be an object (Claude Code ignores a non-object)')
273
+ continue
274
+ }
275
+ for (const [sub, v] of Object.entries(value)) {
276
+ const list = stringListField(v)
277
+ if (list === undefined) localWarn(`plugin.json: "experimental.${sub}" must be a string or array of strings`)
278
+ else paths[sub] = list // themes / monitors
279
+ }
280
+ continue
281
+ }
282
+ if (key === 'userConfig' || key === 'channels' || key === 'dependencies') {
283
+ // Reported only — kept verbatim for the IR inventory.
284
+ manifest[key] = value
285
+ }
286
+ }
287
+
288
+ if (manifest.name === undefined) {
289
+ localWarn('plugin.json: "name" is required (only required field)')
290
+ }
291
+ return { manifest: Object.keys(manifest).length > 0 ? manifest : undefined, paths, classification, unrecognized, warnings }
292
+ }
293
+
294
+ /**
295
+ * Normalize one marketplace plugin entry's `source` into a classified shape.
296
+ * @returns {{ kind: string, raw: unknown, fields: object }} kind is one of
297
+ * 'local' | 'github' | 'url' | 'git-subdir' | 'npm' | 'archive' | 'command' | 'invalid'
298
+ */
299
+ export function normalizePluginSource(source) {
300
+ if (typeof source === 'string') {
301
+ if (source.startsWith('./') || source.startsWith('.\\')) return { kind: 'local', raw: source, fields: { path: source } }
302
+ if (/^[A-Za-z]:[\\/]/.test(source) || source.startsWith('/')) return { kind: 'local', raw: source, fields: { path: source } }
303
+ return { kind: 'invalid', raw: source, fields: { reason: 'relative source must start with "./"' } }
304
+ }
305
+ if (source !== null && typeof source === 'object' && !Array.isArray(source) && typeof source.source === 'string') {
306
+ const kind = source.source
307
+ const known = new Set(['github', 'url', 'git-subdir', 'npm', 'archive', 'command'])
308
+ if (!known.has(kind)) return { kind: 'invalid', raw: source, fields: { reason: `unknown source type "${kind}"` } }
309
+ return { kind, raw: source, fields: { ...source } }
310
+ }
311
+ return { kind: 'invalid', raw: source, fields: { reason: 'source must be a relative path string or an object {source, …}' } }
312
+ }
313
+
314
+ /**
315
+ * Parse `.claude-plugin/marketplace.json` text into a marketplace IR.
316
+ * @param {string} text - raw marketplace.json text.
317
+ * @param {object} [opts] - { warn? }
318
+ * @returns {object} {
319
+ * marketplace: { name, owner?, description?, version?, metadata?, pluginRoot?,
320
+ * allowCrossMarketplaceDependenciesOn?, renames? },
321
+ * plugins: [{ name, source: normalized, status, reason?, ...manifestFields }],
322
+ * classification: [{ name, sourceKind, status, reason? }],
323
+ * warnings: string[],
324
+ * }
325
+ */
326
+ export function parseMarketplace(text, opts = {}) {
327
+ const warn = opts.warn ?? (() => {})
328
+ const warnings = []
329
+ const localWarn = (m) => { warnings.push(m); warn(m) }
330
+
331
+ let parsed
332
+ try {
333
+ parsed = JSON.parse(text)
334
+ } catch (error) {
335
+ localWarn(`marketplace.json: invalid JSON: ${error.message}`)
336
+ return { marketplace: undefined, plugins: [], classification: [], warnings }
337
+ }
338
+ if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) {
339
+ localWarn('marketplace.json: must be a JSON object')
340
+ return { marketplace: undefined, plugins: [], classification: [], warnings }
341
+ }
342
+
343
+ if (typeof parsed.name !== 'string' || parsed.name.length === 0) {
344
+ localWarn('marketplace.json: "name" is required')
345
+ return { marketplace: undefined, plugins: [], classification: [], warnings }
346
+ }
347
+ if (RESERVED_MARKETPLACE_NAMES.has(parsed.name)) {
348
+ localWarn(`marketplace.json: name "${parsed.name}" is reserved for official Anthropic use`)
349
+ }
350
+
351
+ const marketplace = { name: parsed.name }
352
+ const owner = authorField(parsed.owner)
353
+ if (owner !== undefined) marketplace.owner = owner
354
+ if (typeof parsed.description === 'string') marketplace.description = parsed.description
355
+ if (typeof parsed.version === 'string') marketplace.version = parsed.version
356
+ if (parsed.metadata !== null && typeof parsed.metadata === 'object' && !Array.isArray(parsed.metadata)) {
357
+ marketplace.metadata = parsed.metadata
358
+ if (typeof parsed.metadata.pluginRoot === 'string') marketplace.pluginRoot = parsed.metadata.pluginRoot
359
+ if (marketplace.description === undefined && typeof parsed.metadata.description === 'string') {
360
+ marketplace.description = parsed.metadata.description
361
+ }
362
+ if (marketplace.version === undefined && typeof parsed.metadata.version === 'string') {
363
+ marketplace.version = parsed.metadata.version
364
+ }
365
+ }
366
+ if (Array.isArray(parsed.allowCrossMarketplaceDependenciesOn)) {
367
+ marketplace.allowCrossMarketplaceDependenciesOn = parsed.allowCrossMarketplaceDependenciesOn
368
+ }
369
+ if (parsed.renames !== null && typeof parsed.renames === 'object' && !Array.isArray(parsed.renames)) {
370
+ marketplace.renames = parsed.renames
371
+ }
372
+
373
+ const plugins = []
374
+ const classification = []
375
+ if (!Array.isArray(parsed.plugins)) {
376
+ localWarn('marketplace.json: "plugins" must be an array')
377
+ return { marketplace, plugins, classification, warnings }
378
+ }
379
+ for (const entry of parsed.plugins) {
380
+ if (entry === null || typeof entry !== 'object' || Array.isArray(entry) || typeof entry.name !== 'string') {
381
+ localWarn('marketplace.json: each plugin entry needs a string "name"')
382
+ continue
383
+ }
384
+ const src = normalizePluginSource(entry.source)
385
+ const status = src.kind === 'local' ? STATUS.DIRECT : STATUS.UNSUPPORTED
386
+ const reason = src.kind === 'local' ? undefined
387
+ : `marketplace plugin source "${src.kind}" requires a remote fetch DSH does not implement (local "./path" sources work)`
388
+ if (src.kind === 'invalid') {
389
+ localWarn(`marketplace.json: plugin "${entry.name}": ${src.fields.reason ?? 'invalid source'}`)
390
+ }
391
+ const manifestFields = {}
392
+ for (const [k, v] of Object.entries(entry)) {
393
+ if (k === 'name' || k === 'source') continue
394
+ manifestFields[k] = v
395
+ }
396
+ plugins.push({ name: entry.name, source: src, status, reason, ...manifestFields })
397
+ classification.push({ name: entry.name, sourceKind: src.kind, status, reason })
398
+ }
399
+ return { marketplace, plugins, classification, warnings }
400
+ }
401
+
402
+ /** Default component directories at a plugin root (relative to root). */
403
+ const DEFAULT_DIRS = {
404
+ skills: 'skills', commands: 'commands', agents: 'agents',
405
+ workflows: 'workflows', outputStyles: 'output-styles',
406
+ themes: 'themes', monitors: 'monitors',
407
+ }
408
+
409
+ /** Resolve a manifest-relative path (`./x`) against the plugin root. */
410
+ function resolveManifestPath(root, p) {
411
+ if (typeof p !== 'string' || p.length === 0) return undefined
412
+ const cleaned = p.replace(/^\.\//, '').replace(/^[\\/]+/, '')
413
+ return join(root, cleaned)
414
+ }
415
+
416
+ /** Mirror of skills.js stringField (frontmatter string value). */
417
+ function strField(data, key) {
418
+ const v = data[key]
419
+ return typeof v === 'string' && v.length > 0 ? v : undefined
420
+ }
421
+
422
+ /** Mirror of skills.js stringList (frontmatter string/array-of-strings). */
423
+ function strList(data, key) {
424
+ const v = data[key]
425
+ if (v === undefined) return []
426
+ if (typeof v === 'string') return [v]
427
+ if (Array.isArray(v)) return v.filter((x) => typeof x === 'string' && x.length > 0)
428
+ return []
429
+ }
430
+
431
+ /** True when the path exists and is a regular file (not a directory). */
432
+ async function isFile(path) {
433
+ try {
434
+ const s = await stat(path)
435
+ return s.isFile()
436
+ } catch {
437
+ return false
438
+ }
439
+ }
440
+
441
+ /**
442
+ * Parse one flat command `.md` file (manifest `commands` may name individual
443
+ * files, not just directories). Mirrors discoverCommands' per-file shape.
444
+ */
445
+ async function discoverCommandFile(filePath, source, rank, warnings = []) {
446
+ const raw = await readTextSafe(filePath)
447
+ if (raw === undefined) return undefined
448
+ const parsed = parseFrontmatter(raw)
449
+ const stem = filePath.split(/[\\/]/).pop().replace(/\.md$/, '')
450
+ if (!isSkillName(stem)) {
451
+ warnings.push(`command "${filePath}" skipped: name not kebab-case`)
452
+ return undefined
453
+ }
454
+ const description = parsed === undefined ? stem : (strField(parsed.data, 'description') ?? stem)
455
+ const allowedTools = parsed === undefined ? [] : strList(parsed.data, 'allowed-tools')
456
+ const disallowedTools = parsed === undefined ? [] : strList(parsed.data, 'disallowed-tools')
457
+ return {
458
+ kind: 'command',
459
+ name: stem,
460
+ description,
461
+ whenToUse: undefined,
462
+ invocation: { modelInvocable: true, userInvocable: true },
463
+ source,
464
+ rank,
465
+ locator: { path: filePath, directory: dirname(filePath) },
466
+ resourceBase: { kind: 'directory', path: dirname(filePath) },
467
+ frontmatter: parsed?.data ?? null,
468
+ allowedTools,
469
+ disallowedTools,
470
+ status: 'DIRECT',
471
+ }
472
+ }
473
+
474
+ /**
475
+ * Parse one agent `.md` file (manifest `agents` may name individual files).
476
+ * Mirrors discoverAgents' validation, reusing buildAgentEntry for the IR.
477
+ */
478
+ async function discoverAgentFile(filePath, scope, rank, warnings = []) {
479
+ const raw = await readTextSafe(filePath)
480
+ if (raw === undefined) return undefined
481
+ const parsed = parseFrontmatter(raw)
482
+ if (parsed === undefined) {
483
+ warnings.push(`agent "${filePath}" skipped: no frontmatter`)
484
+ return undefined
485
+ }
486
+ const fm = parsed.data
487
+ const stem = filePath.split(/[\\/]/).pop().replace(/\.md$/, '')
488
+ const name = strField(fm, 'name') ?? stem
489
+ if (!isSkillName(name)) {
490
+ warnings.push(`agent "${filePath}" skipped: name "${name}" not kebab-case`)
491
+ return undefined
492
+ }
493
+ const description = strField(fm, 'description')
494
+ if (description === undefined) {
495
+ warnings.push(`agent "${filePath}" skipped: no description`)
496
+ return undefined
497
+ }
498
+ const body = parsed.body.trim()
499
+ if (body.length === 0) {
500
+ warnings.push(`agent "${filePath}" skipped: empty system prompt body`)
501
+ return undefined
502
+ }
503
+ return buildAgentEntry({ path: filePath, directory: dirname(filePath), name, description, body, fm, scope, rank, warnings })
504
+ }
505
+
506
+ /**
507
+ * Discover one plugin root into an IR plugin block.
508
+ * Reuses the shared scanners; manifest component-path fields are honored per
509
+ * the official path behavior rules (see header). Never throws.
510
+ * @param {string} root - plugin directory.
511
+ * @param {object} [opts] - {
512
+ * warn?, skillRank? (=160), agentScope? ('plugin'), readJsonText? (test seam)
513
+ * }
514
+ * @returns {Promise<object>} {
515
+ * root, name, pluginJsonPath?, manifest?, paths: resolved absolute dirs,
516
+ * components: { skills, commands, agents, mcp: {servers, sources},
517
+ * lsp: {servers, sources}, hooks: {paths, inline},
518
+ * unsupported: [{kind, name, reason}] },
519
+ * classification, warnings,
520
+ * }
521
+ */
522
+ export async function discoverPluginRoot(root, opts = {}) {
523
+ const warn = opts.warn ?? (() => {})
524
+ const warnings = []
525
+ const localWarn = (m) => { warnings.push(m); warn(m) }
526
+ const skillRank = opts.skillRank ?? 160
527
+
528
+ // 1. Manifest (optional). Name = manifest name, else directory basename.
529
+ const pluginJsonPath = join(root, '.claude-plugin', 'plugin.json')
530
+ let manifest
531
+ let manifestPaths = {}
532
+ let manifestClassification = []
533
+ if (await pathExists(pluginJsonPath)) {
534
+ const text = await readTextSafe(pluginJsonPath)
535
+ if (text !== undefined) {
536
+ // parsePluginManifest already forwards every warning through localWarn.
537
+ const parsed = parsePluginManifest(text, { warn: localWarn })
538
+ manifest = parsed.manifest
539
+ manifestPaths = parsed.paths
540
+ manifestClassification = parsed.classification
541
+ }
542
+ }
543
+ const fallbackName = pluginNameOf(root)
544
+ const name = manifest?.name ?? fallbackName
545
+
546
+ // 2. Component path resolution.
547
+ const mpaths = manifestPaths
548
+ const paths = {
549
+ skills: [],
550
+ commands: [],
551
+ agents: [],
552
+ workflows: [],
553
+ outputStyles: [],
554
+ themes: [],
555
+ monitors: [],
556
+ }
557
+ for (const [field, dirName] of Object.entries(DEFAULT_DIRS)) {
558
+ const manifestList = mpaths[field]
559
+ if (field === 'skills') {
560
+ // skills always add the default dir, unless the single-root-SKILL.md case.
561
+ paths.skills.push(join(root, dirName))
562
+ if (Array.isArray(manifestList)) {
563
+ for (const p of manifestList) {
564
+ const abs = resolveManifestPath(root, p)
565
+ if (abs !== undefined) paths.skills.push(abs)
566
+ }
567
+ }
568
+ } else {
569
+ // replace semantics: manifest paths replace the default dir.
570
+ if (Array.isArray(manifestList) && manifestList.length > 0) {
571
+ for (const p of manifestList) {
572
+ const abs = resolveManifestPath(root, p)
573
+ if (abs !== undefined) paths[field].push(abs)
574
+ }
575
+ } else {
576
+ paths[field].push(join(root, dirName))
577
+ }
578
+ }
579
+ }
580
+
581
+ // 3. Discover each component with the shared scanners.
582
+ const components = { skills: [], commands: [], agents: [], mcp: { servers: [], sources: [] }, lsp: { servers: [], sources: [] }, hooks: { paths: [], inline: null }, unsupported: [] }
583
+
584
+ const markPlugin = (entry) => ({ ...entry, plugin: name })
585
+ const skillDirs = paths.skills
586
+ const hasSkillsDir = await pathExists(join(root, 'skills'))
587
+ const hasManifestSkills = Array.isArray(mpaths.skills) && mpaths.skills.length > 0
588
+ const rootSkillExists = await pathExists(join(root, 'SKILL.md'))
589
+ if (!hasSkillsDir && !hasManifestSkills && rootSkillExists) {
590
+ // Single-skill plugin: the root itself is a skill bundle.
591
+ components.skills.push(...(await discoverSkills(root, 'plugin', skillRank, warnings)).map(markPlugin))
592
+ } else {
593
+ for (const dir of skillDirs) {
594
+ components.skills.push(...(await discoverSkills(dir, 'plugin', skillRank, warnings)).map(markPlugin))
595
+ }
596
+ }
597
+
598
+ for (const dir of paths.commands) {
599
+ // Manifest `commands` may name single .md files or directories.
600
+ if (await isFile(dir)) {
601
+ const entry = await discoverCommandFile(dir, 'plugin', skillRank, warnings)
602
+ if (entry !== undefined) components.commands.push(markPlugin(entry))
603
+ } else {
604
+ components.commands.push(...(await discoverCommands(dir, 'plugin', skillRank, warnings)).map(markPlugin))
605
+ }
606
+ }
607
+ for (const dir of paths.agents) {
608
+ // Manifest `agents` may name single .md files or directories.
609
+ if (await isFile(dir)) {
610
+ const entry = await discoverAgentFile(dir, 'plugin', skillRank, warnings)
611
+ if (entry !== undefined) components.agents.push(markPlugin(entry))
612
+ } else {
613
+ components.agents.push(...(await discoverAgents(dir, 'plugin', skillRank, warnings)).map(markPlugin))
614
+ }
615
+ }
616
+
617
+ // MCP: root .mcp.json + plugin.json inline (shared scanner) + manifest paths/inline.
618
+ const mcpFound = await discoverMcpConfig(root, { pluginName: name, warn: localWarn })
619
+ components.mcp.servers.push(...mcpFound.servers)
620
+ components.mcp.sources.push(...mcpFound.sources)
621
+ if (Array.isArray(mpaths.mcpServers)) {
622
+ for (const cfg of mpaths.mcpServers) {
623
+ if (typeof cfg === 'string') {
624
+ const abs = resolveManifestPath(root, cfg)
625
+ if (abs === undefined) continue
626
+ const text = await readTextSafe(abs)
627
+ if (text === undefined) { localWarn(`mcpServers path "${cfg}" not readable — skipped`); continue }
628
+ try {
629
+ const map = parseMcpText(text)
630
+ components.mcp.servers.push(...serverEntries(map, { pluginName: name, warn: localWarn }))
631
+ components.mcp.sources.push(abs)
632
+ } catch (error) {
633
+ localWarn(`${abs}: ${error.message}`)
634
+ }
635
+ } else if (cfg !== null && typeof cfg === 'object' && !Array.isArray(cfg)) {
636
+ // Inline mcpServers map (bare or wrapped — parseMcpText detects).
637
+ try {
638
+ const map = parseMcpText(JSON.stringify(cfg))
639
+ components.mcp.servers.push(...serverEntries(map, { pluginName: name, warn: localWarn }))
640
+ components.mcp.sources.push(`${root}/plugin.json (inline mcpServers)`)
641
+ } catch (error) {
642
+ localWarn(`inline mcpServers: ${error.message}`)
643
+ }
644
+ }
645
+ }
646
+ }
647
+
648
+ // LSP: root .lsp.json (shared scanner) + manifest paths/inline.
649
+ const lspFound = await discoverLspConfig(root, { warn: localWarn })
650
+ components.lsp.servers.push(...lspFound.servers)
651
+ components.lsp.sources.push(...lspFound.sources)
652
+ if (Array.isArray(mpaths.lspServers)) {
653
+ for (const cfg of mpaths.lspServers) {
654
+ if (typeof cfg === 'string') {
655
+ const abs = resolveManifestPath(root, cfg)
656
+ if (abs === undefined) continue
657
+ const text = await readTextSafe(abs)
658
+ if (text === undefined) { localWarn(`lspServers path "${cfg}" not readable — skipped`); continue }
659
+ components.lsp.servers.push(...parseLspText(text, abs, localWarn))
660
+ components.lsp.sources.push(abs)
661
+ } else if (cfg !== null && typeof cfg === 'object' && !Array.isArray(cfg)) {
662
+ components.lsp.servers.push(...parseLspText(JSON.stringify(cfg), `${root}/plugin.json (inline lspServers)`, localWarn))
663
+ }
664
+ }
665
+ }
666
+
667
+ // Hooks: manifest paths / inline object, else default hooks/hooks.json.
668
+ const hookPaths = Array.isArray(mpaths.hooks) ? mpaths.hooks : []
669
+ let inlineHooks = null
670
+ for (const h of hookPaths) {
671
+ if (typeof h === 'string') {
672
+ const abs = resolveManifestPath(root, h)
673
+ if (abs !== undefined) components.hooks.paths.push(abs)
674
+ } else if (h !== null && typeof h === 'object' && !Array.isArray(h)) {
675
+ inlineHooks = h
676
+ }
677
+ }
678
+ if (components.hooks.paths.length === 0 && inlineHooks === null) {
679
+ const def = join(root, 'hooks', 'hooks.json')
680
+ if (await pathExists(def)) components.hooks.paths.push(def)
681
+ }
682
+ components.hooks.inline = inlineHooks
683
+
684
+ // Unsupported component paths (M5 misc) — report-only inventory.
685
+ for (const field of ['workflows', 'outputStyles', 'themes', 'monitors']) {
686
+ for (const dir of paths[field]) {
687
+ if (await pathExists(dir)) {
688
+ components.unsupported.push({
689
+ kind: field, name: field, status: STATUS.UNSUPPORTED,
690
+ reason: `${field}: not bridged yet (M5 misc inventory)`,
691
+ })
692
+ }
693
+ }
694
+ }
695
+
696
+ return {
697
+ root, name,
698
+ pluginJsonPath: manifest !== undefined ? pluginJsonPath : undefined,
699
+ manifest, paths, components,
700
+ classification: manifestClassification,
701
+ warnings,
702
+ }
703
+ }
704
+
705
+ /**
706
+ * Discover a marketplace root: read `.claude-plugin/marketplace.json`, resolve
707
+ * local plugin sources against the marketplace root (metadata.pluginRoot
708
+ * prepended when present). Never throws.
709
+ * @param {string} root - marketplace directory (contains .claude-plugin/).
710
+ * @param {object} [opts] - { warn? }
711
+ * @returns {Promise<object>} {
712
+ * root, marketplaceJsonPath?, marketplace?, plugins: [{ name, source, dir?,
713
+ * status, reason?, ...manifestFields }], classification, warnings
714
+ * }
715
+ */
716
+ export async function discoverMarketplace(root, opts = {}) {
717
+ const warn = opts.warn ?? (() => {})
718
+ const warnings = []
719
+ const localWarn = (m) => { warnings.push(m); warn(m) }
720
+
721
+ const marketplaceJsonPath = join(root, '.claude-plugin', 'marketplace.json')
722
+ if (!(await pathExists(marketplaceJsonPath))) {
723
+ return { root, marketplaceJsonPath: undefined, marketplace: undefined, plugins: [], classification: [], warnings }
724
+ }
725
+ const text = await readTextSafe(marketplaceJsonPath)
726
+ if (text === undefined) {
727
+ localWarn(`cannot read ${marketplaceJsonPath}`)
728
+ return { root, marketplaceJsonPath, marketplace: undefined, plugins: [], classification: [], warnings }
729
+ }
730
+
731
+ const parsed = parseMarketplace(text, { warn: localWarn })
732
+ const marketplace = parsed.marketplace
733
+ if (marketplace === undefined) {
734
+ return { root, marketplaceJsonPath, marketplace, plugins: [], classification: parsed.classification, warnings }
735
+ }
736
+
737
+ // Resolve local sources: relative to marketplace root, with metadata.pluginRoot prepended.
738
+ const base = marketplace.pluginRoot !== undefined
739
+ ? join(root, marketplace.pluginRoot.replace(/^\.\//, '')) : root
740
+ const plugins = parsed.plugins.map((entry) => {
741
+ if (entry.source.kind === 'local') {
742
+ const p = typeof entry.source.fields.path === 'string'
743
+ ? entry.source.fields.path.replace(/^\.\//, '').replace(/^[\\/]+/, '') : ''
744
+ const dir = p.length > 0 ? join(base, p) : root
745
+ return { ...entry, dir }
746
+ }
747
+ return { ...entry, dir: undefined }
748
+ })
749
+ return { root, marketplaceJsonPath, marketplace, plugins, classification: parsed.classification, warnings }
750
+ }