dsh-claude-compat 0.2.0 → 0.6.2

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/mcp.js ADDED
@@ -0,0 +1,133 @@
1
+ // dsh-claude-compat: MCP servers from <projectRoot>/.mcp.json (Claude Code
2
+ // format). Read once at DSH startup from the launch workspace; each server is
3
+ // mounted as an @deepseek-ai/dsh-mcp-client plugin instance (stdio for
4
+ // `command` entries, streamable-http for `url`/`type:'http'|'sse'` entries).
5
+ //
6
+ // Failure handling is deliberately non-fatal: an unreadable/malformed .mcp.json
7
+ // or an absent dsh-mcp-client package logs a warning and skips, never crashing
8
+ // the harness. failOnStartupError only forwards to the mcp-client plugin.
9
+
10
+ import { readFileSync } from 'node:fs';
11
+ import { join } from 'node:path';
12
+ import {
13
+ findProjectRootSync,
14
+ pathExists,
15
+ } from './lib.js';
16
+
17
+ // Pure translation of Claude Code's .mcp.json body into dsh-mcp-client plugin
18
+ // configs, keyed by server name. Malformed entries are skipped and reported.
19
+ export function translateMcpServers(rawJson, { failOnStartupError = false } = {}) {
20
+ let parsed;
21
+ try { parsed = JSON.parse(rawJson); }
22
+ catch {
23
+ return { servers: [], warns: [`invalid-json: ${String(rawJson).slice(0, 100)}`] };
24
+ }
25
+ if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
26
+ return { servers: [], warns: ['invalid-json: root must be an object'] };
27
+ }
28
+ const servers = parsed.mcpServers;
29
+ if (typeof servers !== 'object' || servers === null || Array.isArray(servers)) {
30
+ return { servers: [], warns: ['mcpServers: missing or not an object'] };
31
+ }
32
+ const out = [];
33
+ const warns = [];
34
+ for (const [name, entry] of Object.entries(servers)) {
35
+ if (typeof entry !== 'object' || entry === null || Array.isArray(entry)) {
36
+ warns.push(`${name}: malformed entry, skipped`);
37
+ continue;
38
+ }
39
+ // serverName must match /^[A-Za-z0-9_-]{1,32}$/ (dsh-mcp-client contract).
40
+ const serverName = name.replace(/[^A-Za-z0-9_-]/g, '_').slice(0, 32);
41
+ if (serverName.length === 0) {
42
+ warns.push(`${name}: empty server name, skipped`);
43
+ continue;
44
+ }
45
+ const base = {
46
+ serverName,
47
+ toolCallTimeoutMs: 60_000,
48
+ failOnStartupError,
49
+ };
50
+ if (typeof entry.command === 'string' && entry.command.length > 0) {
51
+ out.push({
52
+ ...base,
53
+ transport: 'stdio',
54
+ command: entry.command,
55
+ args: Array.isArray(entry.args) ? entry.args.filter((a) => typeof a === 'string') : [],
56
+ env: stringDict(entry.env),
57
+ cwd: typeof entry.cwd === 'string' ? entry.cwd : '',
58
+ });
59
+ } else if (typeof entry.url === 'string' && entry.url.length > 0) {
60
+ out.push({
61
+ ...base,
62
+ transport: 'streamable-http',
63
+ url: entry.url,
64
+ headers: stringDict(entry.headers),
65
+ });
66
+ } else if (entry.type === 'http' || entry.type === 'sse') {
67
+ warns.push(`${name}: type '${entry.type}' without a url, skipped`);
68
+ } else {
69
+ warns.push(`${name}: neither command nor url, skipped`);
70
+ }
71
+ }
72
+ return { servers: out, warns };
73
+ }
74
+
75
+ function stringDict(v) {
76
+ if (typeof v !== 'object' || v === null || Array.isArray(v)) return {};
77
+ const out = {};
78
+ for (const [k, val] of Object.entries(v)) {
79
+ if (typeof val === 'string') out[k] = val;
80
+ else if (typeof val === 'number' || typeof val === 'boolean') out[k] = String(val);
81
+ }
82
+ return out;
83
+ }
84
+
85
+ // Mount one mcp-client per server listed in <projectRoot>/.mcp.json. Lifecycle:
86
+ // process-lifetime by design — the file is read once at DSH startup from the
87
+ // launch workspace via process.cwd()-based root discovery; deviating from the
88
+ // per-session cwd rules (which are session-scoped) is intended and documented.
89
+ export async function mountMcpServers(ctx, config) {
90
+ const projectRoot = findProjectRootSync(process.cwd(), config.projectRootMarkers);
91
+ if (projectRoot === undefined) return;
92
+ const mcpPath = join(projectRoot, '.mcp.json');
93
+ if (!(await pathExists(mcpPath))) return;
94
+ let raw;
95
+ try { raw = readFileSync(mcpPath, 'utf8'); } catch (error) {
96
+ console.warn(`dsh-claude-compat: .mcp.json unreadable: ${error?.message ?? error}`, '(skipping MCP)');
97
+ return;
98
+ }
99
+ const { servers, warns } = translateMcpServers(raw, {
100
+ failOnStartupError: config.mcpFailOnStartupError ?? false,
101
+ });
102
+ warns.forEach((w) => console.warn(`dsh-claude-compat: .mcp.json: ${w}`));
103
+ await mountMcpConfigs(ctx, servers);
104
+ }
105
+
106
+ // Mount pre-translated server configs (shared by .mcp.json and plugin MCP).
107
+ export async function mountMcpConfigs(ctx, servers) {
108
+ if (servers.length === 0) return;
109
+ let mcpClientModule;
110
+ try {
111
+ mcpClientModule = await import('@deepseek-ai/dsh-mcp-client');
112
+ } catch (error) {
113
+ console.warn('dsh-claude-compat: @deepseek-ai/dsh-mcp-client not installed', '(MCP disabled)', error?.message ?? error);
114
+ return;
115
+ }
116
+ const mcpClient = mcpClientModule.default ?? mcpClientModule;
117
+ const mounted = new Set();
118
+ for (const server of servers) {
119
+ if (mounted.has(server.serverName)) {
120
+ console.warn(`dsh-claude-compat: duplicate serverName "${server.serverName}" skipped`);
121
+ continue;
122
+ }
123
+ mounted.add(server.serverName);
124
+ // ctx.plugin returns a Fiber (PromiseLike) in real cordis; guard for
125
+ // non-promise returns in tests/embeddings.
126
+ const fiber = ctx.plugin(mcpClient, server);
127
+ if (fiber && typeof fiber.catch === 'function') {
128
+ fiber.catch((error) => {
129
+ console.warn(`dsh-claude-compat: server "${server.serverName}" failed to mount: ${error?.message ?? error}`);
130
+ });
131
+ }
132
+ }
133
+ }
@@ -0,0 +1,305 @@
1
+ // dsh-claude-compat: /plugin management skill (v0.6.0).
2
+ //
3
+ // Full Claude-Code-equivalent plugin management surfaced as a user-invocable
4
+ // DSH skill named "plugin". Execution engine: claude CLI first (`claude
5
+ // plugin ...`, present on PATH in most Claude Code setups), self-implemented
6
+ // JSON+git fallback when the CLI is unavailable. All state lives in the
7
+ // Claude-native locations (~/.claude/plugins/*, settings.json enabledPlugins)
8
+ // so both Claude Code and this plugin's discovery bridge read the same truth.
9
+ //
10
+ // Commands (mirroring `claude plugin` semantics):
11
+ // /plugin — list installed (status incl. enabled/disabled)
12
+ // /plugin <name>[@market] — one-shot install
13
+ // /plugin install <name>[@market]
14
+ // /plugin uninstall|remove <name>
15
+ // /plugin enable <name>
16
+ // /plugin disable <name>
17
+ // /plugin update [name] — one or all
18
+ // /plugin search <term> — scan known marketplaces
19
+ // /plugin marketplace list|add|remove|update [...]
20
+ //
21
+ // Safety: never mutates installed_plugins.json / known_marketplaces.json /
22
+ // settings.json directly without a timestamped backup beside the file; the
23
+ // claude-CLI path performs no direct mutation at all (CLI owns the writes).
24
+
25
+ import { execFile } from 'node:child_process';
26
+ import { copyFileSync, existsSync, readFileSync, readdirSync, writeFileSync, cpSync, mkdirSync } from 'node:fs';
27
+ import { join } from 'node:path';
28
+ import { homedir } from 'node:os';
29
+ import { promisify } from 'node:util';
30
+
31
+ const execFileAsync = promisify(execFile);
32
+
33
+ const HELP = `# Claude Code Plugin Manager (dsh-claude-compat)
34
+
35
+ Manage Claude Code plugins and marketplaces. State is stored in Claude-native
36
+ locations (~/.claude/plugins, ~/.claude/settings.json enabledPlugins), so
37
+ Claude Code and DSH see the same plugins. Newly installed plugin content
38
+ appears in the DSH skill catalog on next reconcile (MCP servers need a DSH restart).
39
+
40
+ ## Commands (invoked as /cc-plugin <args>)
41
+ - \`/plugin\` — list installed plugins with enable/disable status
42
+ - \`/plugin <name>[@marketplace]\` — one-shot install (e.g. \`/plugin ralph@omc\`)
43
+ - \`/plugin install <name>[@marketplace]\`
44
+ - \`/plugin uninstall <name>\` (alias: remove)
45
+ - \`/plugin enable <name>\` / \`/plugin disable <name>\`
46
+ - \`/plugin update [name]\` — update one plugin or all
47
+ - \`/plugin search <term>\` — search all known marketplaces
48
+ - \`/plugin marketplace list\`
49
+ - \`/plugin marketplace add <github-repo-or-url>\`
50
+ - \`/plugin marketplace remove <name>\`
51
+ - \`/plugin marketplace update [name]\`
52
+
53
+ ## Execution
54
+ Prefers the \`claude plugin\` CLI when available; falls back to direct
55
+ JSON/git manipulation (with automatic timestamped backups of every file it
56
+ touches). Report which engine ran in the result.`;
57
+
58
+ /** Run `claude plugin ...` args; resolve {ok, stdout} or {ok:false, error}. */
59
+ async function claudePlugin(args, timeoutMs = 120_000) {
60
+ return execFileAsync('claude', ['plugin', ...args], { timeout: timeoutMs, encoding: 'utf8' })
61
+ .then(({ stdout }) => ({ ok: true, stdout: stdout ?? '' }))
62
+ .catch((error) => ({ ok: false, error: error?.message ?? String(error) }));
63
+ }
64
+
65
+ function pluginsRoot() {
66
+ return join(homedir(), '.claude', 'plugins');
67
+ }
68
+
69
+ function backupFile(path) {
70
+ if (!existsSync(path)) return undefined;
71
+ const backup = `${path}.bak-${new Date().toISOString().replace(/[:.]/g, '-')}`;
72
+ copyFileSync(path, backup);
73
+ return backup;
74
+ }
75
+
76
+ function readJson(path, fallback) {
77
+ try { return JSON.parse(readFileSync(path, 'utf8')); }
78
+ catch { return fallback; }
79
+ }
80
+
81
+ /** Pure: parse `claude plugin list` output into [{name, marketplace, version, scope, enabled}]. */
82
+ export function parsePluginList(output) {
83
+ const plugins = [];
84
+ const lines = String(output ?? '').split('\n');
85
+ let current;
86
+ for (const line of lines) {
87
+ const entry = line.match(/^\s*❯\s*(\S+)@(\S+)\s*$/);
88
+ if (entry) {
89
+ if (current) plugins.push(current);
90
+ current = { name: entry[1], marketplace: entry[2] };
91
+ continue;
92
+ }
93
+ if (!current) continue;
94
+ const version = line.match(/^\s*Version:\s*(\S+)/);
95
+ if (version) current.version = version[1];
96
+ const scope = line.match(/^\s*Scope:\s*(\S+)/);
97
+ if (scope) current.scope = scope[1];
98
+ const status = line.match(/^\s*Status:\s*(✔|✘)\s*(\S+)/);
99
+ if (status) current.enabled = status[2] === 'enabled';
100
+ }
101
+ if (current) plugins.push(current);
102
+ return plugins;
103
+ }
104
+
105
+ /** Pure: list plugin entries from installed_plugins.json + enabledPlugins map. */
106
+ export function listFromDisk(pluginsDir = pluginsRoot(), settingsPath = join(homedir(), '.claude', 'settings.json')) {
107
+ const installed = readJson(join(pluginsDir, 'installed_plugins.json'), { version: 2, plugins: {} });
108
+ const settings = readJson(settingsPath, {});
109
+ const enabledMap = settings.enabledPlugins ?? {};
110
+ const out = [];
111
+ for (const [key, entries] of Object.entries(installed.plugins ?? {})) {
112
+ const last = Array.isArray(entries) ? entries[entries.length - 1] : entries;
113
+ if (!last) continue;
114
+ const [name, marketplace] = key.split('@');
115
+ const installedEnabled = enabledMap[key];
116
+ out.push({
117
+ name,
118
+ marketplace,
119
+ version: last.version,
120
+ scope: last.scope,
121
+ // enabledPlugins is authoritative when the key exists; default enabled.
122
+ enabled: installedEnabled === undefined ? true : Boolean(installedEnabled),
123
+ });
124
+ }
125
+ return out;
126
+ }
127
+
128
+ /** Pure: search marketplace.json catalogs under marketplaces/ for a term. */
129
+ export function searchMarketplaces(term, pluginsDir = pluginsRoot()) {
130
+ const marketRoot = join(pluginsDir, 'marketplaces');
131
+ const needle = String(term ?? '').toLowerCase();
132
+ const hits = [];
133
+ let dirs = [];
134
+ try { dirs = readdirSync(marketRoot, { withFileTypes: true }); } catch { return hits; }
135
+ for (const dir of dirs) {
136
+ if (!dir.isDirectory()) continue;
137
+ const manifest = join(marketRoot, dir.name, '.claude-plugin', 'marketplace.json');
138
+ if (!existsSync(manifest)) continue;
139
+ const json = readJson(manifest, null);
140
+ for (const plugin of json?.plugins ?? []) {
141
+ const haystack = `${plugin.name} ${plugin.description ?? ''} ${(plugin.tags ?? []).join(' ')}`.toLowerCase();
142
+ if (needle === '' || haystack.includes(needle)) {
143
+ hits.push({
144
+ name: plugin.name,
145
+ marketplace: dir.name,
146
+ description: plugin.description ?? '',
147
+ version: plugin.version,
148
+ installId: `${plugin.name}@${dir.name}`,
149
+ });
150
+ }
151
+ }
152
+ }
153
+ return hits;
154
+ }
155
+
156
+ /**
157
+ * Fallback install for no-CLI environments: clone the marketplace repo is
158
+ * already present under marketplaces/<name>; copy plugin source into cache and
159
+ * register in installed_plugins.json + enabledPlugins.
160
+ */
161
+ export async function fallbackInstall(installId, pluginsDir = pluginsRoot(), settingsPath = join(homedir(), '.claude', 'settings.json')) {
162
+ const [pluginName, marketName] = String(installId).split('@');
163
+ if (!marketName) return { ok: false, error: 'fallback install needs name@marketplace (marketplace repo must already be cloned)' };
164
+ const marketJson = join(pluginsDir, 'marketplaces', marketName, '.claude-plugin', 'marketplace.json');
165
+ const catalog = readJson(marketJson, null);
166
+ const entry = (catalog?.plugins ?? []).find((p) => p.name === pluginName);
167
+ if (!entry) return { ok: false, error: `plugin "${pluginName}" not found in marketplace "${marketName}"` };
168
+ const version = entry.version ?? 'unknown';
169
+ const sourceRel = String(entry.source ?? './').replace(/^\.\//, '');
170
+ const from = join(pluginsDir, 'marketplaces', marketName, sourceRel);
171
+ if (!existsSync(from)) return { ok: false, error: `plugin source missing in marketplace clone: ${sourceRel}` };
172
+ const dest = join(pluginsDir, 'cache', marketName, pluginName, version);
173
+ mkdirSync(dest, { recursive: true });
174
+ cpSync(from, dest, { recursive: true });
175
+ const installedPath = join(pluginsDir, 'installed_plugins.json');
176
+ backupFile(installedPath);
177
+ const installed = readJson(installedPath, { version: 2, plugins: {} });
178
+ const key = `${pluginName}@${marketName}`;
179
+ installed.plugins[key] = installed.plugins[key] ?? [];
180
+ installed.plugins[key].push({
181
+ scope: 'user',
182
+ installPath: dest,
183
+ version,
184
+ installedAt: new Date().toISOString(),
185
+ lastUpdated: new Date().toISOString(),
186
+ });
187
+ writeFileSync(installedPath, JSON.stringify(installed, null, 2) + '\n');
188
+ backupFile(settingsPath);
189
+ const settings = readJson(settingsPath, {});
190
+ settings.enabledPlugins = settings.enabledPlugins ?? {};
191
+ settings.enabledPlugins[key] = true;
192
+ writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + '\n');
193
+ return { ok: true, engine: 'fallback', installPath: dest, version };
194
+ }
195
+
196
+ /** Fallback enable/disable: flip enabledPlugins in settings.json (backed up). */
197
+ export function fallbackToggle(pluginRef, enable, pluginsDir = pluginsRoot(), settingsPath = join(homedir(), '.claude', 'settings.json')) {
198
+ const installed = readJson(join(pluginsDir, 'installed_plugins.json'), { plugins: {} });
199
+ const keys = Object.keys(installed.plugins ?? {});
200
+ const key = keys.find((k) => k === pluginRef || k.startsWith(`${pluginRef}@`));
201
+ if (!key) return { ok: false, error: `plugin not found: ${pluginRef}` };
202
+ backupFile(settingsPath);
203
+ const settings = readJson(settingsPath, {});
204
+ settings.enabledPlugins = settings.enabledPlugins ?? {};
205
+ settings.enabledPlugins[key] = Boolean(enable);
206
+ writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + '\n');
207
+ return { ok: true, engine: 'fallback', key, enabled: Boolean(enable) };
208
+ }
209
+
210
+ /**
211
+ * Main dispatcher. Input: the raw argument string after "/plugin".
212
+ * Returns markdown text for the model to present.
213
+ */
214
+ export async function runPluginCommand(input, deps = {}) {
215
+ const claudeFn = deps.claude ?? claudePlugin;
216
+ const args = String(input ?? '').trim().split(/\s+/).filter(Boolean);
217
+ if (args.length === 0 || args[0] === 'help') return HELP;
218
+
219
+ const [cmd, ...rest] = args;
220
+
221
+ // `/plugin <name>[@market]` one-shot install.
222
+ const isOneShot = !['install', 'uninstall', 'remove', 'enable', 'disable',
223
+ 'update', 'search', 'marketplace', 'list'].includes(cmd);
224
+ if (isOneShot) return doInstall(cmd, claudeFn);
225
+
226
+ if (cmd === 'list') {
227
+ const res = await claudeFn(['list']);
228
+ if (res.ok) {
229
+ const parsed = parsePluginList(res.stdout);
230
+ if (parsed.length === 0) return 'No plugins installed.';
231
+ return ['Installed plugins:', '',
232
+ ...parsed.map((p) => `- **${p.name}@${p.marketplace}** v${p.version} [${p.scope}] ${p.enabled ? '✔ enabled' : '✘ disabled'}`),
233
+ '', 'Enable/disable with `/plugin enable|disable <name>`; install with `/plugin <name>@<marketplace>`.'].join('\n');
234
+ }
235
+ const parsed = listFromDisk();
236
+ if (parsed.length === 0) return 'No plugins installed (CLI unavailable, disk scan found none).';
237
+ return ['Installed plugins (disk scan; claude CLI unavailable):', '',
238
+ ...parsed.map((p) => `- **${p.name}@${p.marketplace}** v${p.version} [${p.scope}] ${p.enabled ? '✔ enabled' : '✘ disabled'}`)].join('\n');
239
+ }
240
+
241
+ if (cmd === 'search') {
242
+ if (rest.length === 0) return 'Usage: `/plugin search <term>`';
243
+ const hits = searchMarketplaces(rest.join(' '));
244
+ if (hits.length === 0) return `No marketplace plugin matches "${rest.join(' ')}".`;
245
+ return [`Marketplace matches for "${rest.join(' ')}":`, '',
246
+ ...hits.slice(0, 30).map((h) => `- **${h.installId}** v${h.version ?? '?'} — ${h.description.slice(0, 100)}`),
247
+ '', 'Install with `/plugin <name>@<marketplace>`.'].join('\n');
248
+ }
249
+
250
+ if (cmd === 'marketplace') {
251
+ const [sub, ...mrest] = rest;
252
+ const subArgs = { list: ['marketplace', 'list'], add: ['marketplace', 'add', ...mrest], remove: ['marketplace', 'remove', ...mrest], rm: ['marketplace', 'remove', ...mrest], update: ['marketplace', 'update', ...mrest] }[sub];
253
+ if (!subArgs) return 'Usage: `/plugin marketplace list|add|remove|update [...]`';
254
+ const res = await claudeFn(subArgs, 300_000);
255
+ if (res.ok) return `Marketplace ${sub} OK (engine: claude CLI):\n\n${res.stdout.trim()}`;
256
+ return `Marketplace ${sub} failed. Engine: claude CLI (unavailable or errored).\n\n${res.error}\n\nFallback for marketplace mutation is not implemented (manual: git clone into ~/.claude/plugins/marketplaces/<name> and register in known_marketplaces.json).`;
257
+ }
258
+
259
+ const passThrough = {
260
+ install: ['install', ...rest],
261
+ uninstall: ['uninstall', ...rest],
262
+ remove: ['uninstall', ...rest],
263
+ enable: ['enable', ...rest],
264
+ disable: ['disable', ...rest],
265
+ }[cmd];
266
+
267
+ if (cmd === 'update') {
268
+ const res = await claudeFn(['update', ...rest], 300_000);
269
+ if (res.ok) return `Update OK (engine: claude CLI):\n\n${res.stdout.trim()}\n\nRestart DSH to apply MCP changes; skills refresh automatically.`;
270
+ return `Update failed (engine: claude CLI):\n\n${res.error}`;
271
+ }
272
+
273
+ if (passThrough) {
274
+ if (passThrough[0] === 'install') return doInstall(rest.join(' '), claudeFn);
275
+ const res = await claudeFn(passThrough, 300_000);
276
+ if (res.ok) return `\`${cmd} ${rest.join(' ')}\` OK (engine: claude CLI):\n\n${res.stdout.trim()}`;
277
+ if ((cmd === 'enable' || cmd === 'disable') && /ENOENT|not found|spawn/i.test(res.error)) {
278
+ const fb = fallbackToggle(rest[0], cmd === 'enable');
279
+ if (fb.ok) return `\`${cmd} ${rest[0]}\` OK (engine: fallback, settings.json backed up): ${fb.key} → ${fb.enabled ? 'enabled' : 'disabled'}`;
280
+ return `Both engines failed:\n- claude CLI: ${res.error}\n- fallback: ${fb.error}`;
281
+ }
282
+ return `\`${cmd} ${rest.join(' ')}\` failed (engine: claude CLI):\n\n${res.error}`;
283
+ }
284
+
285
+ return HELP;
286
+ }
287
+
288
+ async function doInstall(installId, claudeFn) {
289
+ if (!installId) return 'Usage: `/plugin <name>[@marketplace]`';
290
+ const res = await claudeFn(['install', installId], 300_000);
291
+ if (res.ok) {
292
+ return [`Installed **${installId}** (engine: claude CLI).`, '', res.stdout.trim(),
293
+ '', 'Skills/commands appear in the catalog on next reconcile; restart DSH if the plugin ships MCP servers.'].join('\n');
294
+ }
295
+ const fb = await fallbackInstall(installId);
296
+ if (fb.ok) {
297
+ return [`Installed **${installId}** (engine: fallback; installed_plugins.json + settings.json backed up).`,
298
+ '', `installPath: ${fb.installPath}`, '',
299
+ 'Skills/commands appear in the catalog on next reconcile; restart DSH if the plugin ships MCP servers.'].join('\n');
300
+ }
301
+ return [`Install failed for **${installId}**:`, '', `- claude CLI: ${res.error}`, `- fallback: ${fb.error}`].join('\n');
302
+ }
303
+
304
+ export const PLUGIN_SKILL_NAME = 'plugin';
305
+ export const PLUGIN_SKILL_BODY = HELP;
package/src/plugins.js ADDED
@@ -0,0 +1,170 @@
1
+ // dsh-claude-compat: installed Claude Code plugins (~/.claude/plugins).
2
+ //
3
+ // Claude Code's plugin-marketplace installs plugins into
4
+ // <pluginsRoot>/cache/<marketplace>/<name>/<version>/ and records them in
5
+ // <pluginsRoot>/installed_plugins.json. Each install root carries a
6
+ // .claude-plugin/plugin.json manifest listing skills dirs / commands dir /
7
+ // agents dir / an .mcp.json. We surface skills/commands/agents as DSH skill
8
+ // candidates at pluginSkillRank (default 750 — the long tail, below
9
+ // user-claude 700 and bundled 600), keeping original names; existing dedupe
10
+ // resolves collisions. Plugin MCP servers mount only when enablePluginMcp is
11
+ // opted in (mounting third-party MCP servers is a bigger trust step than
12
+ // listing skills).
13
+
14
+ import { readFileSync as rfs } from 'node:fs';
15
+ import { join, resolve } from 'node:path';
16
+ import {
17
+ pathExists,
18
+ readTextSafe,
19
+ resolveUserClaudeDir,
20
+ parseFrontmatter,
21
+ stringField,
22
+ } from './lib.js';
23
+ import { translateMcpServers } from './mcp.js';
24
+
25
+ // Parse installed_plugins.json → [{ key, name, marketplace, installPath,
26
+ // version }] for scope==='user' entries. Dedupe per key, keeping the last
27
+ // entry (Claude Code appends reinstall records; last is freshest).
28
+ export function readInstalledClaudePlugins(pluginsRoot) {
29
+ const root = resolveUserClaudeDir(pluginsRoot === undefined ? '~/.claude/plugins' : pluginsRoot);
30
+ const manifestPath = join(root, 'installed_plugins.json');
31
+ let parsed;
32
+ try { parsed = JSON.parse(claReadSync(manifestPath)); }
33
+ catch { return []; }
34
+ if (typeof parsed !== 'object' || parsed === null) return [];
35
+ const plugins = parsed.plugins;
36
+ if (typeof plugins !== 'object' || plugins === null || Array.isArray(plugins)) return [];
37
+ const byKey = new Map();
38
+ for (const [key, entries] of Object.entries(plugins)) {
39
+ if (!Array.isArray(entries)) continue;
40
+ const record = entries.findLast((e) => e?.scope === 'user');
41
+ if (record === undefined || typeof record.installPath !== 'string') continue;
42
+ const at = key.lastIndexOf('@');
43
+ byKey.set(key, {
44
+ key,
45
+ name: at > 0 ? key.slice(0, at) : key,
46
+ marketplace: at > 0 ? key.slice(at + 1) : '',
47
+ installPath: record.installPath,
48
+ version: typeof record.version === 'string' ? record.version : 'unknown',
49
+ });
50
+ }
51
+ return [...byKey.values()];
52
+ }
53
+
54
+ // readFileSync kept local so readInstalledClaudePlugins stays sync-simple.
55
+ function claReadSync(p) { return rfs(p, 'utf8'); }
56
+
57
+ // Discover skill/command/agent candidates from all installed plugins. When a
58
+ // plugin has a .claude-plugin/plugin.json manifest, its listed dirs are used;
59
+ // otherwise fall back to scanning <installPath>/skills and <installPath>/commands.
60
+ // Claude Code's discoverCommands drops commands whose only description is the
61
+ // empty string; plugin marketplaces ship many such stubs, so this wrapper
62
+ // re-surfaces them with the filename stem as the description.
63
+ export async function discoverPluginContent(pluginsRoot, providerName, source, rank, { enableAgents } = {}) {
64
+ const out = [];
65
+ const plugins = readInstalledClaudePlugins(pluginsRoot);
66
+ const { discoverSkills, discoverCommands } = await import('./index.js');
67
+ const { discoverAgents } = await import('./agents.js');
68
+ const patchedCommands = async (dir) => {
69
+ const batch = await discoverCommands(dir, providerName, source, rank);
70
+ if (batch.length > 0) return batch;
71
+ const kept = new Set(batch.map((c) => c.path));
72
+ const extra = await discoverCommandsAllowEmpty(dir, providerName, source, rank);
73
+ return [...batch, ...extra.filter((c) => !kept.has(c.path))];
74
+ };
75
+ for (const plugin of plugins) {
76
+ const { installPath } = plugin;
77
+ const manifest = await readPluginManifest(installPath);
78
+ // Dedupe within each KIND only: omc-style plugins ship both skills/ask and
79
+ // commands/ask.md — same-name cross-kind pairs are surfaced separately and
80
+ // resolved by registry dedupe (skills are emitted first, so the skill wins,
81
+ // mirroring Claude Code's own behavior).
82
+ const seenByKind = { skills: new Set(), commands: new Set(), agents: new Set() };
83
+ const push = (kind) => (batch) => {
84
+ for (const c of batch) {
85
+ if (seenByKind[kind].has(c.name)) continue;
86
+ seenByKind[kind].add(c.name);
87
+ out.push(c);
88
+ }
89
+ };
90
+ if (manifest !== undefined) {
91
+ const skillDirs = manifestDirs(manifest, 'skills', installPath);
92
+ for (const dir of skillDirs) push('skills')(await discoverSkills(dir, providerName, source, rank));
93
+ const commandsDir = typeof manifest.commands === 'string' ? resolve(installPath, manifest.commands) : undefined;
94
+ if (commandsDir !== undefined) push('commands')(await patchedCommands(commandsDir));
95
+ } else {
96
+ push('skills')(await discoverSkills(join(installPath, 'skills'), providerName, source, rank));
97
+ push('commands')(await patchedCommands(join(installPath, 'commands')));
98
+ }
99
+ if (enableAgents !== false) {
100
+ const agentsDir = join(installPath, 'agents');
101
+ if (await pathExists(agentsDir)) push('agents')(await discoverAgents(agentsDir, providerName, source, rank));
102
+ }
103
+ }
104
+ return out;
105
+ }
106
+
107
+ // Like discoverCommands but keeps empty-description stubs (stem as description).
108
+ async function discoverCommandsAllowEmpty(rootDir, providerName, source, rank) {
109
+ const { readdir } = await import('node:fs/promises');
110
+ const { isSkillName } = await import('@deepseek-ai/dsh-skill');
111
+ const out = []; if (!(await pathExists(rootDir))) return out;
112
+ let entries;
113
+ try { entries = await readdir(rootDir, { withFileTypes: true, encoding: 'utf8' }); }
114
+ catch { return out; }
115
+ for (const entry of entries) {
116
+ if (!entry.isFile() || !entry.name.endsWith('.md')) continue;
117
+ const stem = entry.name.slice(0, -3);
118
+ if (!isSkillName(stem)) continue;
119
+ const raw = await readTextSafe(join(rootDir, entry.name));
120
+ if (raw === undefined) continue;
121
+ const parsed = parseFrontmatter(raw);
122
+ if (parsed === undefined) continue; // no frontmatter at all → skip (index.js variant covers body-only)
123
+ const description = stringField(parsed.data, 'description');
124
+ if (description !== undefined) continue; // non-empty handled by index.js variant
125
+ out.push({
126
+ name: stem,
127
+ description: stem,
128
+ invocation: { modelInvocable: true, userInvocable: true },
129
+ provider: providerName,
130
+ source,
131
+ rank,
132
+ locator: { path: join(rootDir, entry.name), directory: rootDir },
133
+ resourceBase: { kind: 'directory', path: rootDir },
134
+ path: join(rootDir, entry.name),
135
+ });
136
+ }
137
+ return out;
138
+ }
139
+
140
+ async function readPluginManifest(installPath) {
141
+ const raw = await readTextSafe(join(installPath, '.claude-plugin', 'plugin.json'));
142
+ if (raw === undefined) return undefined;
143
+ try {
144
+ const parsed = JSON.parse(raw);
145
+ return typeof parsed === 'object' && parsed !== null ? parsed : undefined;
146
+ } catch { return undefined; }
147
+ }
148
+
149
+ function manifestDirs(manifest, field, installPath) {
150
+ const v = manifest[field];
151
+ if (typeof v === 'string') return [resolve(installPath, v)];
152
+ if (Array.isArray(v)) {
153
+ return v.filter((d) => typeof d === 'string').map((d) => resolve(installPath, d));
154
+ }
155
+ return [];
156
+ }
157
+
158
+ // Translate a plugin's MCP config into dsh-mcp-client server configs. Returns
159
+ // { servers, warns } (empty when no/invalid mcpServers field). Substitutes the
160
+ // ${CLAUDE_PLUGIN_ROOT} placeholder Claude Code defines for plugin processes.
161
+ export function translatePluginMcp(manifest, installPath, opts = {}) {
162
+ const ref = manifest?.mcpServers;
163
+ if (typeof ref !== 'string') return { servers: [], warns: [] };
164
+ const raw = readTextSafeSync(resolve(installPath, ref));
165
+ if (raw === undefined) return { servers: [], warns: [`mcpServers file not readable: ${ref}`] };
166
+ const substituted = raw.replaceAll('${CLAUDE_PLUGIN_ROOT}', installPath);
167
+ return translateMcpServers(substituted, opts);
168
+ }
169
+
170
+ function readTextSafeSync(p) { try { return rfs(p, 'utf8'); } catch { return undefined; } }