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/README.md +75 -10
- package/README.zh-CN.md +70 -10
- package/package.json +20 -8
- package/scripts/cc-resume.mjs +278 -0
- package/scripts/dsh-restart.sh +107 -0
- package/scripts/zstd-compat.mjs +19 -0
- package/src/agents.js +77 -0
- package/src/cc-resume-skill.mjs +35 -0
- package/src/hooks.js +275 -0
- package/src/index.js +166 -120
- package/src/lib.js +123 -0
- package/src/mcp.js +133 -0
- package/src/plugin-manager.js +305 -0
- package/src/plugins.js +170 -0
package/src/index.js
CHANGED
|
@@ -1,13 +1,15 @@
|
|
|
1
1
|
// dsh-claude-compat — bridge Claude Code's .claude/ into DSH.
|
|
2
2
|
//
|
|
3
|
-
//
|
|
4
|
-
// 1. Skill provider on ctx.skills — scans <projectRoot>/.claude/skills/**/SKILL.md
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
// the `/skill-name` slash trigger, the `skill` tool, and the
|
|
8
|
-
// catalog pick them up natively. Only name+description load
|
|
9
|
-
// the body loads on demand — same contract as the shipped
|
|
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 {
|
|
40
|
-
import {
|
|
41
|
-
import {
|
|
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
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
+
}
|