dsh-claude-compat 0.3.0 → 0.7.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/index.js CHANGED
@@ -1,13 +1,15 @@
1
1
  // dsh-claude-compat — bridge Claude Code's .claude/ into DSH.
2
2
  //
3
- // Two contributions to the live runtime:
4
- // 1. Skill provider on ctx.skills — scans <projectRoot>/.claude/skills/**/SKILL.md
5
- // and <projectRoot>/.claude/commands/*.md (plus the user-level
6
- // ~/.claude/skills and ~/.claude/commands), surfaces them as DSH skills so
7
- // the `/skill-name` slash trigger, the `skill` tool, and the model-visible
8
- // catalog pick them up natively. Only name+description load at discovery;
9
- // the body loads on demand — same contract as the shipped filesystem
10
- // provider.
3
+ // Contributions to the live runtime:
4
+ // 1. Skill provider on ctx.skills — scans <projectRoot>/.claude/skills/**/SKILL.md,
5
+ // <projectRoot>/.claude/commands/*.md and <projectRoot>/.claude/agents/*.md
6
+ // (plus the user-level ~/.claude equivalents), surfaces them as DSH skills
7
+ // so the `/skill-name` slash trigger, the `skill` tool, and the
8
+ // model-visible catalog pick them up natively. Only name+description load
9
+ // at discovery; the body loads on demand — same contract as the shipped
10
+ // filesystem provider. Agents are a delegation shim: DSH has no markdown
11
+ // subagent format, so each agent file becomes a skill whose body leads
12
+ // with an explicit "delegate with this persona" instruction.
11
13
  // 2. Rules message-stream injection — injects <projectRoot>/.claude/rules/*.md
12
14
  // and ~/.claude/rules/*.md as ONE user-role <system-reminder> message
13
15
  // prepended at the front of the message array, once per session
@@ -16,6 +18,11 @@
16
18
  // project .claude winning over ~/.claude.
17
19
  // CLAUDE.md / AGENTS.md are already handled by dsh-agent-instructions,
18
20
  // so we do NOT re-inject them here.
21
+ // 3. Hooks (Claude Code subset PreToolUse/PostToolUse/UserPromptSubmit) from
22
+ // .claude/settings.json — bridged onto tools/pre-execute,
23
+ // tools/post-execute and agent/pre-step (see src/hooks.js).
24
+ // 4. MCP servers from <projectRoot>/.mcp.json — mounted as dsh-mcp-client
25
+ // plugin instances at startup from the launch workspace (see src/mcp.js).
19
26
  //
20
27
  // Skill name flattening: .claude/skills/gitnexus/gitnexus-guide/SKILL.md →
21
28
  // "gitnexus-gitnexus-guide" (DSH skill names must be kebab-case; the shipped
@@ -36,14 +43,25 @@
36
43
  // launched from anywhere). Editing a rule mid-session takes effect in the
37
44
  // next session.
38
45
 
39
- import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs';
40
- import { readFile, readdir, stat } from 'node:fs/promises';
41
- import { join, dirname, resolve } from 'node:path';
42
- import { homedir } from 'node:os';
46
+ import { readFileSync } from 'node:fs';
47
+ import { stat } from 'node:fs/promises';
48
+ import { dirname, join } from 'node:path';
43
49
  import z from '@deepseek-ai/schemastery';
44
- import { parse } from 'yaml';
45
50
  import { isSkillName } from '@deepseek-ai/dsh-skill';
46
51
  import { createUserMessage } from '@deepseek-ai/dsh-llm';
52
+ import { discoverAgents, renderAgentContent } from './agents.js';
53
+ import { registerHooks } from './hooks.js';
54
+ import { mountMcpServers, mountMcpConfigs } from './mcp.js';
55
+ import { discoverPluginContent, readInstalledClaudePlugins, translatePluginMcp } from './plugins.js';
56
+ import {
57
+ findProjectRoot,
58
+ findProjectRootSync,
59
+ listMdFilesSync,
60
+ parseFrontmatter,
61
+ readTextSafe,
62
+ resolveUserClaudeDir,
63
+ stringField,
64
+ } from './lib.js';
47
65
 
48
66
  export const name = 'claude-compat';
49
67
  export const inject = ['skills'];
@@ -63,6 +81,20 @@ export const Config = z.object({
63
81
  userRulesMaxBytes: z.number().default(65536),
64
82
  enableRules: z.boolean().default(true),
65
83
  enableSkills: z.boolean().default(true),
84
+ enableMcp: z.boolean().default(true),
85
+ mcpFailOnStartupError: z.boolean().default(false),
86
+ enableHooks: z.boolean().default(true),
87
+ hooksTimeoutMs: z.number().default(60000),
88
+ enableAgents: z.boolean().default(true),
89
+ // Claude Code plugin-marketplace installs (~/.claude/plugins). Skills,
90
+ // commands and agents surface at pluginSkillRank (750: the long tail —
91
+ // everything else wins collisions). Plugin MCP servers are opt-in via
92
+ // enablePluginMcp (mounting third-party MCP is a bigger trust step).
93
+ enablePlugins: z.boolean().default(true),
94
+ pluginSkillRank: z.number().default(750),
95
+ pluginSkillSource: z.string().default('claude-plugin'),
96
+ pluginsRoot: z.string().default('~/.claude/plugins'),
97
+ enablePluginMcp: z.boolean().default(false),
66
98
  });
67
99
 
68
100
  export function apply(ctx, config = {}) {
@@ -73,6 +105,36 @@ export function apply(ctx, config = {}) {
73
105
  if (config.enableRules !== false) {
74
106
  registerRulesSection(ctx, config);
75
107
  }
108
+ if (config.enableHooks !== false) {
109
+ registerHooks(ctx, config);
110
+ }
111
+ if (config.enableMcp !== false) {
112
+ // Process-lifetime mount — deliberately NOT session-scoped (see src/mcp.js).
113
+ mountMcpServers(ctx, config).catch((error) => {
114
+ console.warn('dsh-claude-compat: MCP mount failed:', error?.message ?? error);
115
+ });
116
+ if (config.enablePlugins !== false && config.enablePluginMcp === true) {
117
+ mountPluginMcp(ctx, config).catch((error) => {
118
+ console.warn('dsh-claude-compat: plugin MCP mount failed:', error?.message ?? error);
119
+ });
120
+ }
121
+ }
122
+ }
123
+
124
+ // Opt-in: translate every installed plugin's MCP config and mount alongside
125
+ // the project .mcp.json servers (see src/mcp.js mountMcpConfigs).
126
+ async function mountPluginMcp(ctx, config) {
127
+ for (const plugin of readInstalledClaudePlugins(config.pluginsRoot)) {
128
+ const raw = await readTextSafe(join(plugin.installPath, '.claude-plugin', 'plugin.json'));
129
+ if (raw === undefined) continue;
130
+ let manifest;
131
+ try { manifest = JSON.parse(raw); } catch { continue; }
132
+ const { servers, warns } = translatePluginMcp(manifest, plugin.installPath, {
133
+ failOnStartupError: config.mcpFailOnStartupError ?? false,
134
+ });
135
+ warns.forEach((w) => console.warn(`dsh-claude-compat: plugin ${plugin.key}: ${w}`));
136
+ if (servers.length > 0) await mountMcpConfigs(ctx, servers);
137
+ }
76
138
  }
77
139
 
78
140
  // ─── skill provider ──────────────────────────────────────────────────────────
@@ -87,6 +149,7 @@ class ClaudeCompatSkillProvider {
87
149
  this.userSkillRank = config.userSkillRank ?? 700;
88
150
  this.userSkillSource = config.userSkillSource ?? 'user-claude';
89
151
  this.userClaudeDir = resolveUserClaudeDir(config.userClaudeDir);
152
+ this.control = control;
90
153
  control.signal.addEventListener('abort', () => {}, { once: true });
91
154
  }
92
155
 
@@ -103,17 +166,86 @@ class ClaudeCompatSkillProvider {
103
166
  }
104
167
  }
105
168
  await this.addRootCandidates(candidates, this.userClaudeDir, this.userSkillSource, this.userSkillRank);
169
+ if (this.config.enablePluginManager !== false) {
170
+ candidates.push(this.managerSkillCandidate('cc-plugin',
171
+ 'Manage Claude Code plugins and marketplaces: list, install, uninstall, enable, disable, update, search.',
172
+ 'User wants to install, list, enable/disable, update, or uninstall Claude Code plugins, or manage plugin marketplaces. Usage: /cc-plugin, /cc-plugin <name>[@marketplace], /cc-plugin install|uninstall|enable|disable|update|search ..., /cc-plugin marketplace list|add|remove|update.',
173
+ (await import('./plugin-manager.js')).PLUGIN_SKILL_BODY));
174
+ candidates.push(this.reloadSkillDef('reload-cc-plugins',
175
+ 'Hot-reload the skill catalog: pick up newly installed/removed Claude Code plugin skills without a new session.'));
176
+ candidates.push(this.reloadSkillDef('reload-skills',
177
+ 'Hot-reload the skill catalog (alias of /reload-cc-plugins).'));
178
+ candidates.push(this.managerSkillCandidate('cc-resume',
179
+ 'List and import Claude Code sessions into DSH: browse ~/.claude/projects history and resume any conversation.',
180
+ 'User wants to resume, list, or import a Claude Code conversation/session into DSH. Usage: /cc-resume list, /cc-resume import <sessionId>.',
181
+ (await import('./cc-resume-skill.mjs')).CC_RESUME_SKILL_BODY));
182
+ }
183
+ if (this.config.enablePlugins !== false) {
184
+ const pluginCandidates = await discoverPluginContent(
185
+ this.config.pluginsRoot, this.name, this.config.pluginSkillSource ?? 'claude-plugin',
186
+ this.config.pluginSkillRank ?? 750, { enableAgents: this.config.enableAgents });
187
+ candidates.push(...pluginCandidates);
188
+ }
106
189
  return candidates;
107
190
  }
108
191
 
192
+ // Invalidate fires when the definition is actually loaded (get()), not at
193
+ // list() time — loading the reload skill IS the reload.
194
+ reloadSkillDef(name, description) {
195
+ return {
196
+ name,
197
+ description,
198
+ invocation: { modelInvocable: true, userInvocable: true },
199
+ provider: 'claude-compat',
200
+ source: 'claude-compat-manager',
201
+ rank: this.config.pluginManagerRank ?? 40,
202
+ locator: { kind: 'inline', path: import.meta.url },
203
+ get: async () => {
204
+ try { this.control?.invalidate?.(); } catch { /* best-effort */ }
205
+ return {
206
+ name,
207
+ description,
208
+ body: `# Catalog reloaded ✅
209
+
210
+ The DSH skill catalog cache was just dropped and observers notified.
211
+ Freshly installed/removed skills are now visible in this session (the skill
212
+ picker refreshes automatically on the next catalog read).
213
+
214
+ Notes:
215
+ - New skills from \`/plugin install\` or edits under \`.claude\` are live now.
216
+ - MCP servers shipped by plugins still require a DSH restart (process-lifetime mount).
217
+ - \`/reload-cc-plugins\` and \`/reload-skills\` are aliases.`,
218
+ };
219
+ },
220
+ };
221
+ }
222
+
223
+ managerSkillCandidate(name, description, whenToUse, body) {
224
+ return {
225
+ name,
226
+ description,
227
+ whenToUse,
228
+ invocation: { modelInvocable: true, userInvocable: true },
229
+ provider: 'claude-compat',
230
+ source: 'claude-compat-manager',
231
+ rank: this.config.pluginManagerRank ?? 40,
232
+ locator: { kind: 'inline', path: import.meta.url },
233
+ get: async () => ({ name, description, body }),
234
+ };
235
+ }
236
+
109
237
  async addRootCandidates(candidates, claudeDir, source, rank) {
110
- if (!(await pathExists(claudeDir))) return;
111
- for (const c of await discoverSkills(join(claudeDir, 'skills'), this.name, source, rank)) {
112
- candidates.push(c);
113
- }
114
- for (const c of await discoverCommands(join(claudeDir, 'commands'), this.name, source, rank)) {
115
- candidates.push(c);
238
+ const provider = this.name;
239
+ const tasks = [discoverSkills(join(claudeDir, 'skills'), provider, source, rank)];
240
+ for (const [dir, fn] of [
241
+ ['commands', discoverCommands],
242
+ ['agents', discoverAgents],
243
+ ]) {
244
+ if (dir === 'agents' && this.config.enableAgents === false) continue;
245
+ tasks.push(fn(join(claudeDir, dir), provider, source, rank));
116
246
  }
247
+ const batches = await Promise.all(tasks);
248
+ for (const batch of batches) candidates.push(...batch);
117
249
  }
118
250
 
119
251
  async get(candidate) {
@@ -121,7 +253,13 @@ class ClaudeCompatSkillProvider {
121
253
  const raw = await readTextSafe(locator.path);
122
254
  if (raw === undefined) return undefined;
123
255
  const parsed = parseFrontmatter(raw);
124
- const content = parsed === undefined ? raw.trim() : parsed.body.trim();
256
+ let content;
257
+ if (candidate.agentTools !== undefined) {
258
+ // Agent-backed candidate: lead the body with the delegation instruction.
259
+ content = renderAgentContent(candidate, (parsed?.body ?? raw).trim());
260
+ } else {
261
+ content = parsed === undefined ? raw.trim() : parsed.body.trim();
262
+ }
125
263
  return {
126
264
  name: candidate.name,
127
265
  description: candidate.description,
@@ -138,9 +276,10 @@ class ClaudeCompatSkillProvider {
138
276
 
139
277
  // ─── discovery: <root>/.claude/skills (recursive, ≤3 levels) ─────────────────
140
278
 
141
- async function discoverSkills(rootDir, providerName, source, rank) {
279
+ export async function discoverSkills(rootDir, providerName, source, rank) {
280
+ const { readdir } = await import('node:fs/promises');
142
281
  const out = [];
143
- if (!(await pathExists(rootDir))) return out;
282
+ if (!(await pathExistsSafe(rootDir))) return out;
144
283
  await walk(rootDir, '', 0);
145
284
  return out;
146
285
 
@@ -167,9 +306,10 @@ async function discoverSkills(rootDir, providerName, source, rank) {
167
306
 
168
307
  // ─── discovery: <root>/.claude/commands (flat) ───────────────────────────────
169
308
 
170
- async function discoverCommands(rootDir, providerName, source, rank) {
309
+ export async function discoverCommands(rootDir, providerName, source, rank) {
310
+ const { readdir } = await import('node:fs/promises');
171
311
  const out = [];
172
- if (!(await pathExists(rootDir))) return out;
312
+ if (!(await pathExistsSafe(rootDir))) return out;
173
313
  let entries;
174
314
  try {
175
315
  entries = await readdir(rootDir, { withFileTypes: true, encoding: 'utf8' });
@@ -307,106 +447,12 @@ ${parts.join('\n')}
307
447
  </system-reminder>`;
308
448
  }
309
449
 
310
- function findProjectRootSync(cwd, markers = ['.git']) {
311
- let current = resolve(cwd);
312
- while (true) {
313
- for (const marker of markers) {
314
- if (existsSync(join(current, marker))) return current;
315
- }
316
- const parent = dirname(current);
317
- if (parent === current) return undefined;
318
- current = parent;
319
- }
320
- }
321
-
322
- function listMdFilesSync(dir) {
323
- let entries;
324
- try { entries = readdirSync(dir); } catch { return []; }
325
- const files = [];
326
- for (const name of entries) {
327
- if (!name.endsWith('.md')) continue;
328
- const p = join(dir, name);
329
- try { if (statSync(p).isFile()) files.push(p); } catch {}
330
- }
331
- files.sort();
332
- return files;
333
- }
334
-
335
450
  // ─── helpers ─────────────────────────────────────────────────────────────────
336
451
 
337
- function resolveUserClaudeDir(dir) {
338
- if (dir === undefined || dir === null || dir === '') return join(homedir(), '.claude');
339
- if (dir === '~') return homedir();
340
- if (dir.startsWith('~/')) return join(homedir(), dir.slice(2));
341
- return resolve(dir);
342
- }
343
-
344
- async function findProjectRoot(cwd, markers = ['.git']) {
345
- let current = resolve(cwd);
346
- while (true) {
347
- for (const marker of markers) {
348
- if (await pathExists(join(current, marker))) return current;
349
- }
350
- const parent = dirname(current);
351
- if (parent === current) return undefined;
352
- current = parent;
353
- }
354
- }
355
-
356
- async function pathExists(path) {
452
+ async function pathExistsSafe(path) {
357
453
  try { await stat(path); return true; } catch { return false; }
358
454
  }
359
455
 
360
- async function readTextSafe(path) {
361
- try { return await readFile(path, { encoding: 'utf8' }); }
362
- catch { return undefined; }
363
- }
364
-
365
- async function listMdFiles(dir) {
366
- let entries;
367
- try { entries = await readdir(dir, { withFileTypes: true, encoding: 'utf8' }); }
368
- catch { return []; }
369
- const files = [];
370
- for (const e of entries) {
371
- if (e.isFile() && e.name.endsWith('.md')) files.push(join(dir, e.name));
372
- }
373
- files.sort();
374
- return files;
375
- }
376
-
377
- function parseFrontmatter(raw) {
378
- const firstLineEnd = raw.indexOf('\n');
379
- if (firstLineEnd < 0) return undefined;
380
- if (raw.slice(0, firstLineEnd).replace(/\r$/, '') !== '---') return undefined;
381
- const start = firstLineEnd + 1;
382
- const closing = findClosingFrontmatter(raw, start);
383
- if (closing === undefined) return undefined;
384
- let parsed;
385
- try { parsed = parse(raw.slice(start, closing.start)); }
386
- catch { return undefined; }
387
- if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) return undefined;
388
- return { data: parsed, body: raw.slice(closing.bodyStart) };
389
- }
390
-
391
- function findClosingFrontmatter(raw, start) {
392
- let lineStart = start;
393
- while (lineStart <= raw.length) {
394
- const nextNewline = raw.indexOf('\n', lineStart);
395
- const lineEnd = nextNewline < 0 ? raw.length : nextNewline;
396
- if (raw.slice(lineStart, lineEnd).replace(/\r$/, '') === '---') {
397
- return { start: lineStart, bodyStart: nextNewline < 0 ? raw.length : nextNewline + 1 };
398
- }
399
- if (nextNewline < 0) return undefined;
400
- lineStart = nextNewline + 1;
401
- }
402
- return undefined;
403
- }
404
-
405
- function stringField(data, key) {
406
- const v = data[key];
407
- return typeof v === 'string' && v.length > 0 ? v : undefined;
408
- }
409
-
410
456
  function parseInvocationPolicy(data) {
411
457
  const miv = data['disable-model-invocation'];
412
458
  const uiv = data['user-invocable'];
@@ -424,4 +470,4 @@ function truthy(v) {
424
470
  }
425
471
  if (typeof v === 'number') return v !== 0;
426
472
  return false;
427
- }
473
+ }
package/src/lib.js ADDED
@@ -0,0 +1,123 @@
1
+ // Shared helpers for dsh-claude-compat (path resolution, sync/async root
2
+ // discovery, reader utilities, frontmatter parsing). Extracted from index.js
3
+ // so mcp.js / hooks.js / agents.js can reuse them without circular imports.
4
+
5
+ import { existsSync, readdirSync, statSync } from 'node:fs';
6
+ import { readFile, readdir, stat } from 'node:fs/promises';
7
+ import { dirname, join, resolve } from 'node:path';
8
+ import { homedir } from 'node:os';
9
+ import { parse } from 'yaml';
10
+
11
+ export function resolveUserClaudeDir(dir) {
12
+ if (dir === undefined || dir === null || dir === '') return join(homedir(), '.claude');
13
+ if (dir === '~') return homedir();
14
+ if (dir.startsWith('~/')) return join(homedir(), dir.slice(2));
15
+ return resolve(dir);
16
+ }
17
+
18
+ export async function findProjectRoot(cwd, markers = ['.git']) {
19
+ let current = resolve(cwd);
20
+ while (true) {
21
+ for (const marker of markers) {
22
+ if (await pathExists(join(current, marker))) return current;
23
+ }
24
+ const parent = dirname(current);
25
+ if (parent === current) return undefined;
26
+ current = parent;
27
+ }
28
+ }
29
+
30
+ export function findProjectRootSync(cwd, markers = ['.git']) {
31
+ let current = resolve(cwd);
32
+ while (true) {
33
+ for (const marker of markers) {
34
+ if (existsSync(join(current, marker))) return current;
35
+ }
36
+ const parent = dirname(current);
37
+ if (parent === current) return undefined;
38
+ current = parent;
39
+ }
40
+ }
41
+
42
+ export async function pathExists(path) {
43
+ try { await stat(path); return true; } catch { return false; }
44
+ }
45
+
46
+ export async function readTextSafe(path) {
47
+ try { return await readFile(path, { encoding: 'utf8' }); }
48
+ catch { return undefined; }
49
+ }
50
+
51
+ export function readTextSafeSync(path) {
52
+ try { return readFileSync(path, 'utf8'); } catch { return undefined; }
53
+ }
54
+
55
+ export async function listMdFiles(dir) {
56
+ let entries;
57
+ try { entries = await readdir(dir, { withFileTypes: true, encoding: 'utf8' }); }
58
+ catch { return []; }
59
+ const files = [];
60
+ for (const e of entries) {
61
+ if (e.isFile() && e.name.endsWith('.md')) files.push(join(dir, e.name));
62
+ }
63
+ files.sort();
64
+ return files;
65
+ }
66
+
67
+ export function listMdFilesSync(dir) {
68
+ let entries;
69
+ try { entries = readdirSync(dir); } catch { return []; }
70
+ const files = [];
71
+ for (const name of entries) {
72
+ if (!name.endsWith('.md')) continue;
73
+ const p = join(dir, name);
74
+ try { if (statSync(p).isFile()) files.push(p); } catch {}
75
+ }
76
+ files.sort();
77
+ return files;
78
+ }
79
+
80
+ // Frontmatter: `---` line, YAML block, closing `---` line, body after. Returns
81
+ // { data, body } on success, undefined on anything malformed.
82
+ export function parseFrontmatter(raw) {
83
+ const firstLineEnd = raw.indexOf('\n');
84
+ if (firstLineEnd < 0) return undefined;
85
+ if (raw.slice(0, firstLineEnd).replace(/\r$/, '') !== '---') return undefined;
86
+ const start = firstLineEnd + 1;
87
+ const closing = findClosingFrontmatter(raw, start);
88
+ if (closing === undefined) return undefined;
89
+ let parsed;
90
+ try { parsed = parse(raw.slice(start, closing.start)); }
91
+ catch { return undefined; }
92
+ if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) return undefined;
93
+ return { data: parsed, body: raw.slice(closing.bodyStart) };
94
+ }
95
+
96
+ export function stringField(data, key) {
97
+ const v = data[key];
98
+ return typeof v === 'string' && v.length > 0 ? v : undefined;
99
+ }
100
+
101
+ export function truthy(v) {
102
+ if (typeof v === 'boolean') return v;
103
+ if (typeof v === 'string') {
104
+ const s = v.toLowerCase();
105
+ return s === 'true' || s === 'yes' || s === 'on' || s === '1';
106
+ }
107
+ if (typeof v === 'number') return v !== 0;
108
+ return false;
109
+ }
110
+
111
+ function findClosingFrontmatter(raw, start) {
112
+ let lineStart = start;
113
+ while (lineStart <= raw.length) {
114
+ const nextNewline = raw.indexOf('\n', lineStart);
115
+ const lineEnd = nextNewline < 0 ? raw.length : nextNewline;
116
+ if (raw.slice(lineStart, lineEnd).replace(/\r$/, '') === '---') {
117
+ return { start: lineStart, bodyStart: nextNewline < 0 ? raw.length : nextNewline + 1 };
118
+ }
119
+ if (nextNewline < 0) return undefined;
120
+ lineStart = nextNewline + 1;
121
+ }
122
+ return undefined;
123
+ }
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
+ }