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/README.md +28 -0
- package/package.json +30 -0
- package/src/agents.js +296 -0
- package/src/classify.js +465 -0
- package/src/index.js +41 -0
- package/src/load.js +174 -0
- package/src/lsp.js +84 -0
- package/src/map-tools.js +122 -0
- package/src/mcp.js +218 -0
- package/src/parse-rule.js +98 -0
- package/src/patterns.js +121 -0
- package/src/plugin.js +750 -0
- package/src/settings.js +123 -0
- package/src/skills.js +250 -0
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
|
+
}
|