dsh-claude-compat 0.2.0 → 0.6.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/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
+ }