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
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# dsh-restart.sh — restart the DSH Web GUI safely, with an optional
|
|
3
|
+
# terminal-bash prompt patch.
|
|
4
|
+
#
|
|
5
|
+
# What it does:
|
|
6
|
+
# 1. (optional, --no-patch to skip) Patch the dsh-terminal-bash plugin's
|
|
7
|
+
# hardcoded "dsh> " prompt so DSH's own placeholder prompt is honored.
|
|
8
|
+
# Idempotent: skips when already patched. Re-run after every npx/npm
|
|
9
|
+
# refresh of DSH (the cache overwrite reverts the patch).
|
|
10
|
+
# 2. Stop any running `dsh web` (graceful wait, up to 10s).
|
|
11
|
+
# 3. Start `dsh web` in the background (nohup), log to a temp file.
|
|
12
|
+
# 4. Wait until http://127.0.0.1:3080 responds (up to 30s); on timeout
|
|
13
|
+
# print the last 20 log lines and exit 1.
|
|
14
|
+
#
|
|
15
|
+
# Requires: bash, curl. Works with a global `dsh`, an nvm-installed `dsh`,
|
|
16
|
+
# or the npx cache layout (`~/.npm/_npx/*/node_modules/.bin/dsh`).
|
|
17
|
+
set -euo pipefail
|
|
18
|
+
|
|
19
|
+
PORT="${DSH_RESTART_PORT:-3080}"
|
|
20
|
+
PATCH=1
|
|
21
|
+
for arg in "$@"; do
|
|
22
|
+
case "$arg" in
|
|
23
|
+
--no-patch) PATCH=0 ;;
|
|
24
|
+
-h|--help) sed -n '2,16p' "$0"; exit 0 ;;
|
|
25
|
+
*) echo "unknown arg: $arg (see --help)" >&2; exit 2 ;;
|
|
26
|
+
esac
|
|
27
|
+
done
|
|
28
|
+
|
|
29
|
+
# ── 1. terminal-bash prompt patch (idempotent) ───────────────────────────────
|
|
30
|
+
if [ "$PATCH" = 1 ]; then
|
|
31
|
+
OLD_PROMPT='const CONTROLLED_PROMPT = "dsh> ";'
|
|
32
|
+
NEW_PROMPT='const CONTROLLED_PROMPT = "__DSH_PERSISTENT_BASH_PROMPT__ ";'
|
|
33
|
+
OLD_LEN='Math.max(0, 6 - this.promptTail.length)'
|
|
34
|
+
NEW_LEN='Math.max(0, CONTROLLED_PROMPT.length + 1 - this.promptTail.length)'
|
|
35
|
+
|
|
36
|
+
# Find every installed copy of dsh-terminal-bash: npx cache + any global
|
|
37
|
+
# node_modules reachable from `dsh`'s real location.
|
|
38
|
+
CANDIDATES="$(ls "$HOME"/.npm/_npx/*/node_modules/@deepseek-ai/dsh-terminal-bash/lib/index.js 2>/dev/null || true)"
|
|
39
|
+
DSH_ON_PATH="$(command -v dsh || true)"
|
|
40
|
+
if [ -n "$DSH_ON_PATH" ]; then
|
|
41
|
+
REAL="$(readlink -f "$DSH_ON_PATH" 2>/dev/null || echo "$DSH_ON_PATH")"
|
|
42
|
+
ROOT="$(dirname "$(dirname "$(dirname "$(dirname "$REAL")")")")"
|
|
43
|
+
[ -f "$ROOT/@deepseek-ai/dsh-terminal-bash/lib/index.js" ] && CANDIDATES="$CANDIDATES
|
|
44
|
+
$ROOT/@deepseek-ai/dsh-terminal-bash/lib/index.js"
|
|
45
|
+
fi
|
|
46
|
+
|
|
47
|
+
if [ -z "$CANDIDATES" ]; then
|
|
48
|
+
echo "[fix] no dsh-terminal-bash installation found; skipping patch"
|
|
49
|
+
else
|
|
50
|
+
for FILE in $CANDIDATES; do
|
|
51
|
+
if grep -qF "$NEW_PROMPT" "$FILE" && grep -qF "$NEW_LEN" "$FILE"; then
|
|
52
|
+
echo "[fix] already patched: $FILE"
|
|
53
|
+
continue
|
|
54
|
+
fi
|
|
55
|
+
echo "[fix] patching: $FILE"
|
|
56
|
+
if sed -i.bak -e "s|$OLD_PROMPT|$NEW_PROMPT|" -e "s|$OLD_LEN|$NEW_LEN|" "$FILE" 2>/dev/null \
|
|
57
|
+
|| sed -i '' -e "s|$OLD_PROMPT|$NEW_PROMPT|" -e "s|$OLD_LEN|$NEW_LEN|" "$FILE"; then
|
|
58
|
+
rm -f "$FILE.bak"
|
|
59
|
+
grep -qF "$NEW_PROMPT" "$FILE" && grep -qF "$NEW_LEN" "$FILE" \
|
|
60
|
+
|| { echo "[fix] patch failed on $FILE"; exit 1; }
|
|
61
|
+
else
|
|
62
|
+
echo "[fix] cannot write $FILE (permissions?); skipping"; continue
|
|
63
|
+
fi
|
|
64
|
+
done
|
|
65
|
+
fi
|
|
66
|
+
fi
|
|
67
|
+
|
|
68
|
+
# ── 2. stop running dsh web ──────────────────────────────────────────────────
|
|
69
|
+
echo "[restart] stopping dsh web"
|
|
70
|
+
pkill -f "dsh web" 2>/dev/null || true
|
|
71
|
+
for _ in $(seq 1 20); do pgrep -f "dsh web" >/dev/null || break; sleep 0.5; done
|
|
72
|
+
if pgrep -f "dsh web" >/dev/null; then
|
|
73
|
+
echo "[restart] dsh web did not stop; last resort SIGKILL"
|
|
74
|
+
pkill -9 -f "dsh web" 2>/dev/null || true
|
|
75
|
+
sleep 1
|
|
76
|
+
pgrep -f "dsh web" >/dev/null && { echo "[restart] cannot stop dsh web"; exit 1; }
|
|
77
|
+
fi
|
|
78
|
+
|
|
79
|
+
# ── 3. locate the dsh binary ─────────────────────────────────────────────────
|
|
80
|
+
DSH_BIN="$(command -v dsh || true)"
|
|
81
|
+
if [ -z "$DSH_BIN" ]; then
|
|
82
|
+
# newest npx cache entry first (npm exec resolves to the same one)
|
|
83
|
+
DSH_BIN="$(ls -t "$HOME"/.npm/_npx/*/node_modules/.bin/dsh 2>/dev/null | head -1 || true)"
|
|
84
|
+
fi
|
|
85
|
+
if [ -z "$DSH_BIN" ]; then
|
|
86
|
+
echo "[restart] no dsh binary found (PATH or npx cache). Install with: npm install -g @deepseek-ai/dsh" >&2
|
|
87
|
+
exit 1
|
|
88
|
+
fi
|
|
89
|
+
echo "[restart] using: $DSH_BIN"
|
|
90
|
+
|
|
91
|
+
# ── 4. start + health-check ──────────────────────────────────────────────────
|
|
92
|
+
LOG="$(mktemp -t dsh-web-restart)"
|
|
93
|
+
echo "[restart] starting dsh web on :$PORT"
|
|
94
|
+
nohup "$DSH_BIN" web >"$LOG" 2>&1 &
|
|
95
|
+
PID=$!
|
|
96
|
+
echo "[restart] pid=$PID log=$LOG"
|
|
97
|
+
for _ in $(seq 1 30); do
|
|
98
|
+
if curl -sf -o /dev/null "http://127.0.0.1:$PORT"; then
|
|
99
|
+
echo "[restart] http://127.0.0.1:$PORT is up ✅"
|
|
100
|
+
exit 0
|
|
101
|
+
fi
|
|
102
|
+
kill -0 "$PID" 2>/dev/null || break
|
|
103
|
+
sleep 1
|
|
104
|
+
done
|
|
105
|
+
echo "[restart] failed; last log lines:"
|
|
106
|
+
tail -20 "$LOG"
|
|
107
|
+
exit 1
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
// zstd-compat.mjs — compress strings with the `zstd` binary via stdin.
|
|
2
|
+
// (macOS: `brew install zstd`; most Linux CI images and the DSH runtime
|
|
3
|
+
// environment already ship it — DSH itself shell-outs to zstd for its own
|
|
4
|
+
// session logs.)
|
|
5
|
+
import { execFileSync } from 'node:child_process';
|
|
6
|
+
|
|
7
|
+
export function zstdSync(text) {
|
|
8
|
+
return execFileSync('zstd', ['-3', '-T0', '-q', '-'], {
|
|
9
|
+
input: text,
|
|
10
|
+
maxBuffer: 1 << 28,
|
|
11
|
+
stdio: ['pipe', 'pipe', 'inherit'],
|
|
12
|
+
});
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
// DSH session logs require the first zstd frame to hold exactly the header
|
|
16
|
+
// line (assertZstdHeaderFrame); events must be in separate frames.
|
|
17
|
+
export function zstdFramesSync(...texts) {
|
|
18
|
+
return Buffer.concat(texts.map((t) => zstdSync(t)));
|
|
19
|
+
}
|
package/src/agents.js
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
// dsh-claude-compat: .claude/agents/*.md compatibility. DSH has no markdown
|
|
2
|
+
// subagent-definition format, so each agent file is surfaced as a DSH *skill*
|
|
3
|
+
// candidate: the frontmatter name (or filename stem) becomes the skill name
|
|
4
|
+
// (model- and user-invocable, so `/agent-name` works), the description doubles
|
|
5
|
+
// as whenToUse, and get() prefixes the body with an explicit subagent-delegation
|
|
6
|
+
// instruction. Project .claude/agents wins over ~/.claude/agents by name via
|
|
7
|
+
// the provider's rank (50 < 700) plus the shared dedupe flow.
|
|
8
|
+
|
|
9
|
+
import { join } from 'node:path';
|
|
10
|
+
import { isSkillName } from '@deepseek-ai/dsh-skill';
|
|
11
|
+
import {
|
|
12
|
+
readTextSafe,
|
|
13
|
+
stringField,
|
|
14
|
+
parseFrontmatter,
|
|
15
|
+
pathExists,
|
|
16
|
+
} from './lib.js';
|
|
17
|
+
|
|
18
|
+
const AGENT_HEADER = 'Agent delegation: use the subagent/background-agent capability with this persona and instructions.';
|
|
19
|
+
|
|
20
|
+
// Discover agent candidates from one agents dir (flat: *.md). Returns DSH skill
|
|
21
|
+
// candidates in the same shape as discoverSkills/discoverCommands so the
|
|
22
|
+
// registry's dedupe treats them uniformly.
|
|
23
|
+
export async function discoverAgents(rootDir, providerName, source, rank) {
|
|
24
|
+
const out = [];
|
|
25
|
+
if (!(await pathExists(rootDir))) return out;
|
|
26
|
+
let entries;
|
|
27
|
+
try {
|
|
28
|
+
const { readdir } = await import('node:fs/promises');
|
|
29
|
+
entries = await readdir(rootDir, { withFileTypes: true, encoding: 'utf8' });
|
|
30
|
+
} catch { return out; }
|
|
31
|
+
for (const entry of entries) {
|
|
32
|
+
if (!entry.isFile() || !entry.name.endsWith('.md')) continue;
|
|
33
|
+
const path = join(rootDir, entry.name);
|
|
34
|
+
const raw = await readTextSafe(path);
|
|
35
|
+
if (raw === undefined) continue;
|
|
36
|
+
const parsed = parseFrontmatter(raw);
|
|
37
|
+
const data = parsed?.data ?? {};
|
|
38
|
+
const stem = entry.name.slice(0, -3);
|
|
39
|
+
let name = stringField(data, 'name');
|
|
40
|
+
if (name === undefined || !isSkillName(name)) name = stem;
|
|
41
|
+
if (!isSkillName(name)) continue; // non-kebab names rejected by registry anyway
|
|
42
|
+
const description = stringField(data, 'description')
|
|
43
|
+
?? firstLine(parsed?.body ?? raw, 100);
|
|
44
|
+
const tools = stringField(data, 'tools');
|
|
45
|
+
out.push({
|
|
46
|
+
name,
|
|
47
|
+
description,
|
|
48
|
+
whenToUse: description,
|
|
49
|
+
invocation: { modelInvocable: true, userInvocable: true },
|
|
50
|
+
provider: providerName,
|
|
51
|
+
source,
|
|
52
|
+
rank,
|
|
53
|
+
locator: { path, directory: rootDir },
|
|
54
|
+
resourceBase: { kind: 'directory', path: rootDir },
|
|
55
|
+
path,
|
|
56
|
+
agentTools: tools,
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
return out;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function firstLine(body, max) {
|
|
63
|
+
if (typeof body !== 'string') return undefined;
|
|
64
|
+
const line = body.split('\n').find((l) => l.trim() !== '') ?? '';
|
|
65
|
+
const trimmed = line.trim();
|
|
66
|
+
if (trimmed === '') return undefined;
|
|
67
|
+
return trimmed.length > max ? `${trimmed.slice(0, max)}…` : trimmed;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// Render the content the skill tool returns for an agent-backed candidate.
|
|
71
|
+
export function renderAgentContent(candidate, body) {
|
|
72
|
+
const lines = [AGENT_HEADER];
|
|
73
|
+
if (candidate.agentTools !== undefined) lines.push(`Tools: ${candidate.agentTools}`);
|
|
74
|
+
lines.push('');
|
|
75
|
+
lines.push(body);
|
|
76
|
+
return lines.join('\n');
|
|
77
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
// Skill body for /cc-resume — instructs the model to drive scripts/cc-resume.mjs.
|
|
2
|
+
export const CC_RESUME_SKILL_BODY = `# Claude Code Session Resume
|
|
3
|
+
|
|
4
|
+
List and import Claude Code conversation sessions into DSH. Imported sessions
|
|
5
|
+
appear in the DSH session list (refresh the GUI) with full user/assistant/tool
|
|
6
|
+
history and can be resumed like any native session.
|
|
7
|
+
|
|
8
|
+
## Commands
|
|
9
|
+
Run from the session's working directory (or pass \`--cwd <dir>\`):
|
|
10
|
+
|
|
11
|
+
1. **List** Claude sessions for the current project:
|
|
12
|
+
\`\`\`bash
|
|
13
|
+
node <plugin-root>/scripts/cc-resume.mjs list --limit 20
|
|
14
|
+
\`\`\`
|
|
15
|
+
Output: one line per session — imported marker (\`*\`), id, date, message
|
|
16
|
+
count, first-user-text preview. Pick the \`sessionId\` the user wants.
|
|
17
|
+
|
|
18
|
+
2. **Import** a session:
|
|
19
|
+
\`\`\`bash
|
|
20
|
+
node <plugin-root>/scripts/cc-resume.mjs import <sessionId>
|
|
21
|
+
\`\`\`
|
|
22
|
+
Options: \`--limit-turns N\` imports only the last N turns (useful for huge
|
|
23
|
+
sessions), \`--cwd <dir>\` overrides the project directory.
|
|
24
|
+
|
|
25
|
+
3. Tell the user: refresh the DSH GUI session list and open the session titled
|
|
26
|
+
\`cc: <preview>\`, or the imported id can be resumed directly.
|
|
27
|
+
|
|
28
|
+
## Behavior notes
|
|
29
|
+
- Imports are main-thread only (Claude sidechain/subagent messages skipped).
|
|
30
|
+
- Thinking blocks are dropped (DSH reasoning is stream-based; replaying
|
|
31
|
+
reconstructed reasoning would corrupt the log).
|
|
32
|
+
- Re-importing the same session is idempotent (same target, events rewritten).
|
|
33
|
+
- The imported conversation is static history — continuing it starts a fresh
|
|
34
|
+
DSH turn flow on top of that context.
|
|
35
|
+
- Requires the \`zstd\` binary on PATH (macOS: \`brew install zstd\`).`;
|
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
|
+
}
|