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/README.md +71 -21
- package/README.zh-CN.md +71 -21
- package/package.json +24 -8
- package/scripts/dsh-restart.sh +107 -0
- package/src/agents.js +77 -0
- package/src/hooks.js +275 -0
- package/src/index.js +228 -139
- 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
|
+
}
|