dsh-claude-compat 0.3.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/README.md +58 -10
- package/README.zh-CN.md +58 -10
- package/package.json +20 -8
- package/scripts/dsh-restart.sh +107 -0
- package/src/agents.js +77 -0
- package/src/hooks.js +275 -0
- package/src/index.js +162 -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/hooks.js
ADDED
|
@@ -0,0 +1,275 @@
|
|
|
1
|
+
// dsh-claude-compat: Claude Code hooks subset (PreToolUse / PostToolUse /
|
|
2
|
+
// UserPromptSubmit) bridged onto DSH's tools/pre-execute, tools/post-execute,
|
|
3
|
+
// and agent/pre-step waterfalls.
|
|
4
|
+
//
|
|
5
|
+
// Hooks are read from <projectRoot>/.claude/settings.json (and ~/.claude/
|
|
6
|
+
// settings.json; project wins on duplicate command strings). Commands run via
|
|
7
|
+
// /bin/sh -c with a Claude-style JSON payload on stdin. Decisions follow Claude
|
|
8
|
+
// Code semantics (exit code 2 = deny/block, hookSpecificOutput override,
|
|
9
|
+
// timeouts allowed-through with a warning).
|
|
10
|
+
|
|
11
|
+
import { execFile } from 'node:child_process';
|
|
12
|
+
import { readFileSync } from 'node:fs';
|
|
13
|
+
import { join } from 'node:path';
|
|
14
|
+
import { createUserMessage } from '@deepseek-ai/dsh-llm';
|
|
15
|
+
import {
|
|
16
|
+
findProjectRootSync,
|
|
17
|
+
resolveUserClaudeDir,
|
|
18
|
+
} from './lib.js';
|
|
19
|
+
|
|
20
|
+
// Compile a Claude Code hook matcher ("Bash", "Bash|Edit", ".*") into a
|
|
21
|
+
// predicate. Exact names match exactly; `*` / `.*` are wildcards; `|`
|
|
22
|
+
// alternates.
|
|
23
|
+
export function compileMatcher(matcher) {
|
|
24
|
+
if (typeof matcher !== 'string') return () => false;
|
|
25
|
+
const trimmed = matcher.trim();
|
|
26
|
+
if (trimmed === '' || trimmed === '*' || trimmed === '.*') return () => true;
|
|
27
|
+
try {
|
|
28
|
+
const pattern = trimmed
|
|
29
|
+
.split('|')
|
|
30
|
+
.map((part) => part.trim().split('*').join('.*'))
|
|
31
|
+
.join('|');
|
|
32
|
+
const re = new RegExp(`^(?:${pattern})$`);
|
|
33
|
+
return (name) => re.test(name);
|
|
34
|
+
} catch {
|
|
35
|
+
return () => false;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// ----- payloads ---------------------------------------------------------------
|
|
40
|
+
|
|
41
|
+
function preToolPayload(exec) {
|
|
42
|
+
return JSON.stringify({
|
|
43
|
+
session_id: String(exec.callId),
|
|
44
|
+
tool_name: exec.name,
|
|
45
|
+
tool_input: exec.arguments ?? {},
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function postToolPayload(exec, result) {
|
|
50
|
+
return JSON.stringify({
|
|
51
|
+
tool_name: exec.name,
|
|
52
|
+
tool_input: exec.arguments ?? {},
|
|
53
|
+
tool_response: result,
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function promptPayload(agent) {
|
|
58
|
+
let prompt = '';
|
|
59
|
+
const events = agent.session?.events ?? {};
|
|
60
|
+
for (const event of Object.values(events)) {
|
|
61
|
+
const data = event?.data;
|
|
62
|
+
if (event?.type?.endsWith('user/message') && typeof data?.content === 'string') {
|
|
63
|
+
prompt = data.content;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
return JSON.stringify({ prompt });
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// ----- decision mapping (pure, exported for tests) -----------------------------
|
|
70
|
+
|
|
71
|
+
// PreToolUse: exit 0 → allow; exit 2 → deny (stdout reason); other codes →
|
|
72
|
+
// allow with warning. stdout JSON {"hookSpecificOutput": {...permissionDecision}}
|
|
73
|
+
// overrides: "deny" → deny, "ask" → ask (best effort).
|
|
74
|
+
export function mapPreHookOutput(exitCode, stdout, { warn = console.warn } = {}) {
|
|
75
|
+
if (exitCode === 0) {
|
|
76
|
+
const specific = tryHookJson(stdout)?.hookSpecificOutput;
|
|
77
|
+
if (specific?.permissionDecision === 'deny') {
|
|
78
|
+
return { kind: 'deny', reason: String(specific.permissionDecisionReason ?? fallbackReason(stdout)) };
|
|
79
|
+
}
|
|
80
|
+
if (specific?.permissionDecision === 'ask') {
|
|
81
|
+
return { kind: 'ask', reason: String(specific.permissionDecisionReason ?? 'hook requests approval') };
|
|
82
|
+
}
|
|
83
|
+
return { kind: 'allow' };
|
|
84
|
+
}
|
|
85
|
+
if (exitCode === 2) {
|
|
86
|
+
return { kind: 'deny', reason: fallbackReason(stdout) };
|
|
87
|
+
}
|
|
88
|
+
warn(`dsh-claude-compat: PreToolUse hook exited with code ${exitCode}, allowing`);
|
|
89
|
+
return { kind: 'allow' };
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// PostToolUse: exit 2 → block (stdout feedback); exit 0 with JSON stdout
|
|
93
|
+
// carrying an "additionalContext" string → accept with that context as an
|
|
94
|
+
// additional user message; anything else → accept, stdout log-only.
|
|
95
|
+
export function mapPostHookOutput(exitCode, stdout, { warn = console.warn } = {}) {
|
|
96
|
+
if (exitCode === 2) {
|
|
97
|
+
return { kind: 'block', feedback: [{ type: 'text', text: fallbackReason(stdout) }] };
|
|
98
|
+
}
|
|
99
|
+
if (exitCode !== 0) {
|
|
100
|
+
warn(`dsh-claude-compat: PostToolUse hook exited with code ${exitCode}, accepting`);
|
|
101
|
+
return { kind: 'accept' };
|
|
102
|
+
}
|
|
103
|
+
const trimmed = stdout.trim();
|
|
104
|
+
if (trimmed === '') return { kind: 'accept' };
|
|
105
|
+
const additional = tryHookJson(stdout)?.additionalContext;
|
|
106
|
+
if (typeof additional === 'string' && additional.length > 0) {
|
|
107
|
+
return {
|
|
108
|
+
kind: 'accept',
|
|
109
|
+
additionalContexts: [createUserMessage({
|
|
110
|
+
content: [{ type: 'text', text: additional }],
|
|
111
|
+
source: { kind: 'claude-compat', form: 'hook-context' },
|
|
112
|
+
})],
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
console.log(`dsh-claude-compat: PostToolUse hook stdout (logged, not injected): ${trimmed}`);
|
|
116
|
+
return { kind: 'accept' };
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function fallbackReason(stdout) {
|
|
120
|
+
const trimmed = stdout.trim();
|
|
121
|
+
return trimmed !== '' ? trimmed : 'blocked by hook';
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function tryHookJson(stdout) {
|
|
125
|
+
const s = stdout.trim();
|
|
126
|
+
if (s === '') return undefined;
|
|
127
|
+
try { return JSON.parse(s); } catch { return undefined; }
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// ----- runner ---------------------------------------------------------------
|
|
131
|
+
|
|
132
|
+
function runHookCommand(command, stdin, { timeoutMs = 60_000 } = {}) {
|
|
133
|
+
return new Promise((resolve) => {
|
|
134
|
+
let stdout = '';
|
|
135
|
+
let settled = false;
|
|
136
|
+
const child = execFile('/bin/sh', ['-c', command], {
|
|
137
|
+
maxBuffer: 4 * 1024 * 1024,
|
|
138
|
+
});
|
|
139
|
+
const timer = setTimeout(() => {
|
|
140
|
+
if (settled) return;
|
|
141
|
+
settled = true;
|
|
142
|
+
child.kill('SIGKILL');
|
|
143
|
+
console.warn(`dsh-claude-compat: hook timed out after ${timeoutMs}ms, killed: ${command}`);
|
|
144
|
+
resolve({ exitCode: 1, stdout }); // treat as non-2, allow-through
|
|
145
|
+
}, timeoutMs);
|
|
146
|
+
child.stdout.on('data', (chunk) => { stdout += chunk; });
|
|
147
|
+
child.stderr.on('data', (chunk) => { stdout += chunk; }); // surface stderr in feedback
|
|
148
|
+
child.on('error', () => {
|
|
149
|
+
if (settled) return;
|
|
150
|
+
settled = true;
|
|
151
|
+
clearTimeout(timer);
|
|
152
|
+
console.warn(`dsh-claude-compat: hook command failed to start: ${command}`);
|
|
153
|
+
resolve({ exitCode: 1, stdout });
|
|
154
|
+
});
|
|
155
|
+
child.on('close', (code) => {
|
|
156
|
+
if (settled) return;
|
|
157
|
+
settled = true;
|
|
158
|
+
clearTimeout(timer);
|
|
159
|
+
resolve({ exitCode: code ?? 1, stdout });
|
|
160
|
+
});
|
|
161
|
+
child.stdin.on('error', () => {});
|
|
162
|
+
child.stdin.end(stdin);
|
|
163
|
+
});
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// ----- settings loading ------------------------------------------------------
|
|
167
|
+
|
|
168
|
+
const EVENTS = { PreToolUse: 'pre-tool-use', PostToolUse: 'post-tool-use', UserPromptSubmit: 'user-prompt-submit' };
|
|
169
|
+
|
|
170
|
+
// Combined hooks map. Project .claude/settings.json is applied LAST so it wins
|
|
171
|
+
// same (event, matcher, command) keys against ~/.claude/settings.json.
|
|
172
|
+
export function loadHookSettings(config) {
|
|
173
|
+
const combined = {};
|
|
174
|
+
applySettingsFile(combined, join(resolveUserClaudeDir(config.userClaudeDir), 'settings.json'));
|
|
175
|
+
const projectRoot = findProjectRootSync(process.cwd(), config.projectRootMarkers);
|
|
176
|
+
if (projectRoot !== undefined) {
|
|
177
|
+
applySettingsFile(combined, join(projectRoot, '.claude', 'settings.json'));
|
|
178
|
+
}
|
|
179
|
+
return combined;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
function applySettingsFile(combined, path) {
|
|
183
|
+
let raw;
|
|
184
|
+
try { raw = readFileSync(path, 'utf8'); } catch { return; }
|
|
185
|
+
let parsed;
|
|
186
|
+
try { parsed = JSON.parse(raw); } catch {
|
|
187
|
+
console.warn(`dsh-claude-compat: unparseable settings.json: ${path}`);
|
|
188
|
+
return;
|
|
189
|
+
}
|
|
190
|
+
const hooks = parsed?.hooks;
|
|
191
|
+
if (typeof hooks !== 'object' || hooks === null || Array.isArray(hooks)) return;
|
|
192
|
+
for (const [eventName, entries] of Object.entries(hooks)) {
|
|
193
|
+
const event = EVENTS[eventName];
|
|
194
|
+
if (event === undefined || !Array.isArray(entries)) continue;
|
|
195
|
+
for (const entry of entries) {
|
|
196
|
+
if (typeof entry !== 'object' || entry === null) continue;
|
|
197
|
+
const matcher = typeof entry.matcher === 'string' ? entry.matcher : '.*';
|
|
198
|
+
const commands = Array.isArray(entry.hooks)
|
|
199
|
+
? entry.hooks
|
|
200
|
+
.filter((h) => typeof h === 'object' && h !== null && typeof h.command === 'string')
|
|
201
|
+
.map((h) => h.command)
|
|
202
|
+
: [];
|
|
203
|
+
for (const command of commands) {
|
|
204
|
+
combined[`${event}\u0000${matcher}\u0000${command}`] = { event, matcher, command };
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
// ----- listeners -------------------------------------------------------------
|
|
211
|
+
|
|
212
|
+
export function registerHooks(ctx, config) {
|
|
213
|
+
const settings = loadHookSettings(config);
|
|
214
|
+
const matching = (hooks, name) =>
|
|
215
|
+
hooks.filter((h) => compileMatcher(h.matcher)(name));
|
|
216
|
+
|
|
217
|
+
const pre = Object.values(settings).filter((h) => h.event === 'pre-tool-use');
|
|
218
|
+
if (pre.length > 0) {
|
|
219
|
+
ctx.on('tools/pre-execute', async (exec) => {
|
|
220
|
+
for (const hook of matching(pre, exec.name)) {
|
|
221
|
+
const payload = preToolPayload(exec);
|
|
222
|
+
const run = await runHookCommand(hook.command, payload, { timeoutMs: config.hooksTimeoutMs ?? 60_000 });
|
|
223
|
+
const decision = mapPreHookOutput(run.exitCode, run.stdout);
|
|
224
|
+
if (decision.kind !== 'allow') return decision;
|
|
225
|
+
}
|
|
226
|
+
return { kind: 'allow' };
|
|
227
|
+
});
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
const post = Object.values(settings).filter((h) => h.event === 'post-tool-use');
|
|
231
|
+
if (post.length > 0) {
|
|
232
|
+
ctx.on('tools/post-execute', async (exec, result) => {
|
|
233
|
+
let accept;
|
|
234
|
+
for (const hook of matching(post, exec.name)) {
|
|
235
|
+
const payload = postToolPayload(exec, result);
|
|
236
|
+
const run = await runHookCommand(hook.command, payload, { timeoutMs: config.hooksTimeoutMs ?? 60_000 });
|
|
237
|
+
const decision = mapPostHookOutput(run.exitCode, run.stdout);
|
|
238
|
+
if (decision.kind === 'block') return decision;
|
|
239
|
+
if (decision.additionalContexts !== undefined) accept = decision;
|
|
240
|
+
}
|
|
241
|
+
return accept ?? { kind: 'accept' };
|
|
242
|
+
});
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
const prompt = Object.values(settings).filter((h) => h.event === 'user-prompt-submit');
|
|
246
|
+
if (prompt.length > 0) {
|
|
247
|
+
ctx.on('agent/pre-step', async ({ agent, messages }, next) => {
|
|
248
|
+
const decision = await next();
|
|
249
|
+
if (decision.kind !== 'enter') return decision;
|
|
250
|
+
// First enter of the turn: the DSH loop hands the step the user claims,
|
|
251
|
+
// which is where Claude Code would have fired UserPromptSubmit. Hook
|
|
252
|
+
// output becomes an extra user message appended after the claimed ones.
|
|
253
|
+
const extra = await runPromptHooks(prompt, agent, config);
|
|
254
|
+
if (extra === undefined) return decision;
|
|
255
|
+
return { kind: 'enter', messages: decision.messages.concat(extra) };
|
|
256
|
+
});
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
async function runPromptHooks(hooks, agent, config) {
|
|
261
|
+
const timeoutMs = Math.min(config.hooksTimeoutMs ?? 60_000, 10_000); // never block the loop longer than 10s
|
|
262
|
+
const payload = promptPayload(agent);
|
|
263
|
+
const extra = [];
|
|
264
|
+
for (const hook of hooks) {
|
|
265
|
+
const run = await runHookCommand(hook.command, payload, { timeoutMs });
|
|
266
|
+
if (run.exitCode !== 0) continue; // non-zero: skip, never block a prompt
|
|
267
|
+
const text = run.stdout.trim();
|
|
268
|
+
if (text === '') continue;
|
|
269
|
+
extra.push(createUserMessage({
|
|
270
|
+
content: [{ type: 'text', text }],
|
|
271
|
+
source: { kind: 'claude-compat', form: 'hook-context' },
|
|
272
|
+
}));
|
|
273
|
+
}
|
|
274
|
+
return extra.length > 0 ? extra : undefined;
|
|
275
|
+
}
|
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,82 @@ 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
|
+
}
|
|
179
|
+
if (this.config.enablePlugins !== false) {
|
|
180
|
+
const pluginCandidates = await discoverPluginContent(
|
|
181
|
+
this.config.pluginsRoot, this.name, this.config.pluginSkillSource ?? 'claude-plugin',
|
|
182
|
+
this.config.pluginSkillRank ?? 750, { enableAgents: this.config.enableAgents });
|
|
183
|
+
candidates.push(...pluginCandidates);
|
|
184
|
+
}
|
|
106
185
|
return candidates;
|
|
107
186
|
}
|
|
108
187
|
|
|
188
|
+
// Invalidate fires when the definition is actually loaded (get()), not at
|
|
189
|
+
// list() time — loading the reload skill IS the reload.
|
|
190
|
+
reloadSkillDef(name, description) {
|
|
191
|
+
return {
|
|
192
|
+
name,
|
|
193
|
+
description,
|
|
194
|
+
invocation: { modelInvocable: true, userInvocable: true },
|
|
195
|
+
provider: 'claude-compat',
|
|
196
|
+
source: 'claude-compat-manager',
|
|
197
|
+
rank: this.config.pluginManagerRank ?? 40,
|
|
198
|
+
locator: { kind: 'inline', path: import.meta.url },
|
|
199
|
+
get: async () => {
|
|
200
|
+
try { this.control?.invalidate?.(); } catch { /* best-effort */ }
|
|
201
|
+
return {
|
|
202
|
+
name,
|
|
203
|
+
description,
|
|
204
|
+
body: `# Catalog reloaded ✅
|
|
205
|
+
|
|
206
|
+
The DSH skill catalog cache was just dropped and observers notified.
|
|
207
|
+
Freshly installed/removed skills are now visible in this session (the skill
|
|
208
|
+
picker refreshes automatically on the next catalog read).
|
|
209
|
+
|
|
210
|
+
Notes:
|
|
211
|
+
- New skills from \`/plugin install\` or edits under \`.claude\` are live now.
|
|
212
|
+
- MCP servers shipped by plugins still require a DSH restart (process-lifetime mount).
|
|
213
|
+
- \`/reload-cc-plugins\` and \`/reload-skills\` are aliases.`,
|
|
214
|
+
};
|
|
215
|
+
},
|
|
216
|
+
};
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
managerSkillCandidate(name, description, whenToUse, body) {
|
|
220
|
+
return {
|
|
221
|
+
name,
|
|
222
|
+
description,
|
|
223
|
+
whenToUse,
|
|
224
|
+
invocation: { modelInvocable: true, userInvocable: true },
|
|
225
|
+
provider: 'claude-compat',
|
|
226
|
+
source: 'claude-compat-manager',
|
|
227
|
+
rank: this.config.pluginManagerRank ?? 40,
|
|
228
|
+
locator: { kind: 'inline', path: import.meta.url },
|
|
229
|
+
get: async () => ({ name, description, body }),
|
|
230
|
+
};
|
|
231
|
+
}
|
|
232
|
+
|
|
109
233
|
async addRootCandidates(candidates, claudeDir, source, rank) {
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
234
|
+
const provider = this.name;
|
|
235
|
+
const tasks = [discoverSkills(join(claudeDir, 'skills'), provider, source, rank)];
|
|
236
|
+
for (const [dir, fn] of [
|
|
237
|
+
['commands', discoverCommands],
|
|
238
|
+
['agents', discoverAgents],
|
|
239
|
+
]) {
|
|
240
|
+
if (dir === 'agents' && this.config.enableAgents === false) continue;
|
|
241
|
+
tasks.push(fn(join(claudeDir, dir), provider, source, rank));
|
|
116
242
|
}
|
|
243
|
+
const batches = await Promise.all(tasks);
|
|
244
|
+
for (const batch of batches) candidates.push(...batch);
|
|
117
245
|
}
|
|
118
246
|
|
|
119
247
|
async get(candidate) {
|
|
@@ -121,7 +249,13 @@ class ClaudeCompatSkillProvider {
|
|
|
121
249
|
const raw = await readTextSafe(locator.path);
|
|
122
250
|
if (raw === undefined) return undefined;
|
|
123
251
|
const parsed = parseFrontmatter(raw);
|
|
124
|
-
|
|
252
|
+
let content;
|
|
253
|
+
if (candidate.agentTools !== undefined) {
|
|
254
|
+
// Agent-backed candidate: lead the body with the delegation instruction.
|
|
255
|
+
content = renderAgentContent(candidate, (parsed?.body ?? raw).trim());
|
|
256
|
+
} else {
|
|
257
|
+
content = parsed === undefined ? raw.trim() : parsed.body.trim();
|
|
258
|
+
}
|
|
125
259
|
return {
|
|
126
260
|
name: candidate.name,
|
|
127
261
|
description: candidate.description,
|
|
@@ -138,9 +272,10 @@ class ClaudeCompatSkillProvider {
|
|
|
138
272
|
|
|
139
273
|
// ─── discovery: <root>/.claude/skills (recursive, ≤3 levels) ─────────────────
|
|
140
274
|
|
|
141
|
-
async function discoverSkills(rootDir, providerName, source, rank) {
|
|
275
|
+
export async function discoverSkills(rootDir, providerName, source, rank) {
|
|
276
|
+
const { readdir } = await import('node:fs/promises');
|
|
142
277
|
const out = [];
|
|
143
|
-
if (!(await
|
|
278
|
+
if (!(await pathExistsSafe(rootDir))) return out;
|
|
144
279
|
await walk(rootDir, '', 0);
|
|
145
280
|
return out;
|
|
146
281
|
|
|
@@ -167,9 +302,10 @@ async function discoverSkills(rootDir, providerName, source, rank) {
|
|
|
167
302
|
|
|
168
303
|
// ─── discovery: <root>/.claude/commands (flat) ───────────────────────────────
|
|
169
304
|
|
|
170
|
-
async function discoverCommands(rootDir, providerName, source, rank) {
|
|
305
|
+
export async function discoverCommands(rootDir, providerName, source, rank) {
|
|
306
|
+
const { readdir } = await import('node:fs/promises');
|
|
171
307
|
const out = [];
|
|
172
|
-
if (!(await
|
|
308
|
+
if (!(await pathExistsSafe(rootDir))) return out;
|
|
173
309
|
let entries;
|
|
174
310
|
try {
|
|
175
311
|
entries = await readdir(rootDir, { withFileTypes: true, encoding: 'utf8' });
|
|
@@ -307,106 +443,12 @@ ${parts.join('\n')}
|
|
|
307
443
|
</system-reminder>`;
|
|
308
444
|
}
|
|
309
445
|
|
|
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
446
|
// ─── helpers ─────────────────────────────────────────────────────────────────
|
|
336
447
|
|
|
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) {
|
|
448
|
+
async function pathExistsSafe(path) {
|
|
357
449
|
try { await stat(path); return true; } catch { return false; }
|
|
358
450
|
}
|
|
359
451
|
|
|
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
452
|
function parseInvocationPolicy(data) {
|
|
411
453
|
const miv = data['disable-model-invocation'];
|
|
412
454
|
const uiv = data['user-invocable'];
|
|
@@ -424,4 +466,4 @@ function truthy(v) {
|
|
|
424
466
|
}
|
|
425
467
|
if (typeof v === 'number') return v !== 0;
|
|
426
468
|
return false;
|
|
427
|
-
}
|
|
469
|
+
}
|