feat: initial commit for omp-hooks plugin
This commit is contained in:
commit
a0d4230537
6 files changed
+624
No files matched your search
@@ -0,0 +1,53 @@
|
|||||||
|
# @local/omp-hooks
|
||||||
|
|
||||||
|
Project-level and session-level lifecycle hooks for DeepSeek Harness (DSH), with full compatibility for Claude Code `.claude/settings.json`, `.claude/hooks.json`, and project-root `hooks.json`.
|
||||||
|
|
||||||
|
## Supported Hook Events
|
||||||
|
|
||||||
|
| Event | Firing Moment | Capabilities |
|
||||||
|
|---|---|---|
|
||||||
|
| `SessionStart` | On agent creation / session init | Injects context before the first turn |
|
||||||
|
| `UserPromptSubmit` | When user prompt / messages are submitted | Can block prompt (exit 2) or attach extra context |
|
||||||
|
| `PreToolUse` | Before any tool executes | Can block execution (`deny`), ask confirmation (`ask`), or pass |
|
||||||
|
| `PostToolUse` | After a tool finishes execution | Can block result with feedback, or append context |
|
||||||
|
| `Stop` | When agent turn is stopping | Can steer agent into continuing with a reason |
|
||||||
|
|
||||||
|
## Configuration Locations (per-project discovery)
|
||||||
|
|
||||||
|
In any project workspace directory, hooks are automatically discovered from:
|
||||||
|
1. `.claude/settings.local.json`
|
||||||
|
2. `.claude/settings.json`
|
||||||
|
3. `.claude/hooks.json`
|
||||||
|
4. `hooks.json`
|
||||||
|
|
||||||
|
## Example `.claude/settings.json`
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"hooks": {
|
||||||
|
"PreToolUse": [
|
||||||
|
{
|
||||||
|
"matcher": "bash|write",
|
||||||
|
"hooks": [
|
||||||
|
{
|
||||||
|
"type": "command",
|
||||||
|
"command": "python3 $CLAUDE_PROJECT_DIR/.claude/hooks/guard.py",
|
||||||
|
"timeout": 10
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"PostToolUse": [
|
||||||
|
{
|
||||||
|
"matcher": "edit|write",
|
||||||
|
"hooks": [
|
||||||
|
{
|
||||||
|
"type": "command",
|
||||||
|
"command": "npx prettier --write $CLAUDE_PROJECT_DIR/src"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
- insert:
|
||||||
|
- id: omp-hooks
|
||||||
|
name: '@local/omp-hooks'
|
||||||
|
config:
|
||||||
|
enabled: true
|
||||||
@@ -0,0 +1,423 @@
|
|||||||
|
/**
|
||||||
|
* @local/omp-hooks — Project-level & session-level lifecycle hooks for DSH.
|
||||||
|
* Full compatibility with Claude Code .claude/settings.json, .claude/hooks.json,
|
||||||
|
* and project-level hooks.
|
||||||
|
*
|
||||||
|
* Interception seams:
|
||||||
|
* - SessionStart -> agent/created (injects initial prompt context)
|
||||||
|
* - UserPromptSubmit -> agent/pre-step (can block prompt or attach context)
|
||||||
|
* - PreToolUse -> tools/pre-execute (can deny or ask approval before execution)
|
||||||
|
* - PostToolUse -> tools/post-execute (can block result or attach context)
|
||||||
|
* - Stop -> agent/turn-stopping (can steer agent to force continuation)
|
||||||
|
*/
|
||||||
|
import fs from 'node:fs'
|
||||||
|
import path from 'node:path'
|
||||||
|
import { spawn } from 'node:child_process'
|
||||||
|
import { createUserMessage } from './lib/message.js'
|
||||||
|
|
||||||
|
export const name = 'omp-hooks'
|
||||||
|
export const inject = ['tools']
|
||||||
|
|
||||||
|
const CLAUDE_EVENTS = [
|
||||||
|
'SessionStart',
|
||||||
|
'UserPromptSubmit',
|
||||||
|
'PreToolUse',
|
||||||
|
'PostToolUse',
|
||||||
|
'Stop',
|
||||||
|
'SubagentStart',
|
||||||
|
'SubagentStop',
|
||||||
|
]
|
||||||
|
|
||||||
|
const CONTEXT_SOURCE = { kind: 'omp-hooks' }
|
||||||
|
|
||||||
|
function asObject(val) {
|
||||||
|
return typeof val === 'object' && val !== null && !Array.isArray(val) ? val : undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
export function substituteCommand(command, vars = {}) {
|
||||||
|
let out = command
|
||||||
|
if (vars.pluginRoot !== undefined) out = out.split('${CLAUDE_PLUGIN_ROOT}').join(vars.pluginRoot)
|
||||||
|
if (vars.projectDir !== undefined) out = out.split('${CLAUDE_PROJECT_DIR}').join(vars.projectDir)
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseClaudeConfig(raw, vars = {}) {
|
||||||
|
const config = {}
|
||||||
|
const root = asObject(raw)
|
||||||
|
const hooksMap = root ? asObject(root.hooks) ?? root : undefined
|
||||||
|
if (!hooksMap) return config
|
||||||
|
|
||||||
|
for (const event of CLAUDE_EVENTS) {
|
||||||
|
const rawGroups = hooksMap[event]
|
||||||
|
if (!Array.isArray(rawGroups)) continue
|
||||||
|
const groups = []
|
||||||
|
for (const rawGroup of rawGroups) {
|
||||||
|
const group = asObject(rawGroup)
|
||||||
|
if (!group || !Array.isArray(group.hooks)) continue
|
||||||
|
const commands = []
|
||||||
|
for (const rawHook of group.hooks) {
|
||||||
|
const hook = asObject(rawHook)
|
||||||
|
if (!hook) continue
|
||||||
|
const type = typeof hook.type === 'string' ? hook.type : 'command'
|
||||||
|
if (type !== 'command') continue
|
||||||
|
if (typeof hook.command !== 'string') continue
|
||||||
|
commands.push({
|
||||||
|
command: substituteCommand(hook.command, vars),
|
||||||
|
timeoutSec: typeof hook.timeout === 'number' ? hook.timeout : 600,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if (commands.length === 0) continue
|
||||||
|
const matcher = (event === 'UserPromptSubmit' || event === 'Stop')
|
||||||
|
? undefined
|
||||||
|
: (typeof group.matcher === 'string' ? group.matcher : undefined)
|
||||||
|
groups.push({
|
||||||
|
matcher,
|
||||||
|
hooks: commands,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if (groups.length > 0) config[event] = groups
|
||||||
|
}
|
||||||
|
return config
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Discovers hook configuration in order of precedence:
|
||||||
|
* 1. .claude/settings.local.json
|
||||||
|
* 2. .claude/settings.json
|
||||||
|
* 3. .claude/hooks.json
|
||||||
|
* 4. hooks.json
|
||||||
|
*/
|
||||||
|
export function findProjectHookConfig(projectDir) {
|
||||||
|
if (!projectDir || typeof projectDir !== 'string') return null
|
||||||
|
const candidates = [
|
||||||
|
path.join(projectDir, '.claude', 'settings.local.json'),
|
||||||
|
path.join(projectDir, '.claude', 'settings.json'),
|
||||||
|
path.join(projectDir, '.claude', 'hooks.json'),
|
||||||
|
path.join(projectDir, 'hooks.json'),
|
||||||
|
]
|
||||||
|
|
||||||
|
for (const candidate of candidates) {
|
||||||
|
try {
|
||||||
|
if (fs.existsSync(candidate)) {
|
||||||
|
const text = fs.readFileSync(candidate, 'utf8')
|
||||||
|
const raw = JSON.parse(text)
|
||||||
|
return parseClaudeConfig(raw, { projectDir })
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
export function matchesPattern(pattern, subject) {
|
||||||
|
if (!pattern || pattern === '*' || pattern === '') return true
|
||||||
|
if (pattern === subject) return true
|
||||||
|
try {
|
||||||
|
const rx = new RegExp(`^(${pattern})$`)
|
||||||
|
return rx.test(subject)
|
||||||
|
} catch {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Execute a hook command with JSON payload on stdin
|
||||||
|
*/
|
||||||
|
export async function executeHookCommand(cmdStr, payload, { cwd, timeoutSec = 600, signal }) {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
let resolved = false
|
||||||
|
const done = (res) => {
|
||||||
|
if (resolved) return
|
||||||
|
resolved = true
|
||||||
|
resolve(res)
|
||||||
|
}
|
||||||
|
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
done({ exitCode: 124, stdout: '', stderr: 'Hook timed out' })
|
||||||
|
try { proc.kill('SIGTERM') } catch {}
|
||||||
|
}, timeoutSec * 1000)
|
||||||
|
|
||||||
|
const env = {
|
||||||
|
...process.env,
|
||||||
|
CLAUDE_PROJECT_DIR: cwd || process.cwd(),
|
||||||
|
}
|
||||||
|
|
||||||
|
const proc = spawn('bash', ['-c', cmdStr], {
|
||||||
|
cwd: cwd || process.cwd(),
|
||||||
|
env,
|
||||||
|
stdio: ['pipe', 'pipe', 'pipe'],
|
||||||
|
})
|
||||||
|
|
||||||
|
if (signal) {
|
||||||
|
signal.addEventListener('abort', () => {
|
||||||
|
clearTimeout(timer)
|
||||||
|
try { proc.kill('SIGKILL') } catch {}
|
||||||
|
done({ exitCode: 130, stdout: '', stderr: 'Operation cancelled' })
|
||||||
|
}, { once: true })
|
||||||
|
}
|
||||||
|
|
||||||
|
let stdout = ''
|
||||||
|
let stderr = ''
|
||||||
|
proc.stdout.on('data', (d) => { stdout += d.toString('utf8') })
|
||||||
|
proc.stderr.on('data', (d) => { stderr += d.toString('utf8') })
|
||||||
|
|
||||||
|
proc.on('error', (err) => {
|
||||||
|
clearTimeout(timer)
|
||||||
|
done({ exitCode: 1, stdout: '', stderr: String(err?.message ?? err) })
|
||||||
|
})
|
||||||
|
|
||||||
|
proc.on('close', (code) => {
|
||||||
|
clearTimeout(timer)
|
||||||
|
done({ exitCode: code ?? 0, stdout, stderr })
|
||||||
|
})
|
||||||
|
|
||||||
|
try {
|
||||||
|
proc.stdin.write(JSON.stringify(payload) + '\n')
|
||||||
|
proc.stdin.end()
|
||||||
|
} catch {}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseHookStdout(stdout, exitCode, stderr) {
|
||||||
|
let decision = 'allow' // allow | deny | ask
|
||||||
|
let reason = undefined
|
||||||
|
let additionalContext = []
|
||||||
|
|
||||||
|
// Check exit code conventions:
|
||||||
|
// Exit 2 = Deny / Block
|
||||||
|
// Exit 0 = Allow
|
||||||
|
if (exitCode === 2) {
|
||||||
|
decision = 'deny'
|
||||||
|
reason = stderr.trim() || 'Blocked by lifecycle hook (exit code 2)'
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse stdout JSON if available
|
||||||
|
const trimmed = stdout.trim()
|
||||||
|
if (trimmed) {
|
||||||
|
try {
|
||||||
|
const data = JSON.parse(trimmed)
|
||||||
|
if (data && typeof data === 'object') {
|
||||||
|
if (data.decision === 'deny' || data.blocked === true) {
|
||||||
|
decision = 'deny'
|
||||||
|
reason = data.reason || reason || 'Blocked by lifecycle hook'
|
||||||
|
} else if (data.decision === 'ask') {
|
||||||
|
decision = 'ask'
|
||||||
|
reason = data.reason
|
||||||
|
}
|
||||||
|
if (typeof data.reason === 'string' && decision === 'deny') {
|
||||||
|
reason = data.reason
|
||||||
|
}
|
||||||
|
if (typeof data.context === 'string') {
|
||||||
|
additionalContext.push(data.context)
|
||||||
|
} else if (Array.isArray(data.context)) {
|
||||||
|
additionalContext.push(...data.context.filter((c) => typeof c === 'string'))
|
||||||
|
}
|
||||||
|
if (typeof data.additionalContext === 'string') {
|
||||||
|
additionalContext.push(data.additionalContext)
|
||||||
|
} else if (Array.isArray(data.additionalContext)) {
|
||||||
|
additionalContext.push(...data.additionalContext.filter((c) => typeof c === 'string'))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// stdout was non-JSON text; if exitCode is 0 and it's non-empty, could be context
|
||||||
|
if (exitCode === 0 && trimmed.length > 0 && !trimmed.startsWith('{')) {
|
||||||
|
additionalContext.push(trimmed)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { decision, reason, additionalContext }
|
||||||
|
}
|
||||||
|
|
||||||
|
export function apply(ctx, config = {}) {
|
||||||
|
const cfg = {
|
||||||
|
enabled: true,
|
||||||
|
...config,
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!cfg.enabled) return
|
||||||
|
|
||||||
|
// Cache configs per cwd with 5s expiry
|
||||||
|
const configCache = new Map()
|
||||||
|
function getConfigForDir(dir) {
|
||||||
|
const cwd = dir || process.cwd()
|
||||||
|
const now = Date.now()
|
||||||
|
const hit = configCache.get(cwd)
|
||||||
|
if (hit && now - hit.time < 5000) return hit.config
|
||||||
|
|
||||||
|
const conf = findProjectHookConfig(cwd)
|
||||||
|
configCache.set(cwd, { time: now, config: conf })
|
||||||
|
return conf
|
||||||
|
}
|
||||||
|
|
||||||
|
function cwdOf(entity) {
|
||||||
|
return entity?.session?.header?.cwd || entity?.cwd || process.cwd()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runHooks(point, matchQuery, payload, cwd, signal) {
|
||||||
|
const conf = getConfigForDir(cwd)
|
||||||
|
if (!conf) return { decision: 'allow', additionalContext: [] }
|
||||||
|
|
||||||
|
const groups = conf[point] ?? []
|
||||||
|
let finalDecision = 'allow'
|
||||||
|
let finalReason = undefined
|
||||||
|
const allContext = []
|
||||||
|
|
||||||
|
for (const group of groups) {
|
||||||
|
if (!matchesPattern(group.matcher, matchQuery)) continue
|
||||||
|
for (const hook of group.hooks) {
|
||||||
|
const res = await executeHookCommand(hook.command, payload, {
|
||||||
|
cwd,
|
||||||
|
timeoutSec: hook.timeoutSec,
|
||||||
|
signal,
|
||||||
|
})
|
||||||
|
const parsed = parseHookStdout(res.stdout, res.exitCode, res.stderr)
|
||||||
|
if (parsed.decision === 'deny') {
|
||||||
|
finalDecision = 'deny'
|
||||||
|
finalReason = parsed.reason || res.stderr.trim() || 'Blocked by hook'
|
||||||
|
} else if (parsed.decision === 'ask' && finalDecision !== 'deny') {
|
||||||
|
finalDecision = 'ask'
|
||||||
|
finalReason = parsed.reason
|
||||||
|
}
|
||||||
|
if (parsed.additionalContext.length > 0) {
|
||||||
|
allContext.push(...parsed.additionalContext)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { decision: finalDecision, reason: finalReason, additionalContext: allContext }
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1. SessionStart
|
||||||
|
ctx.on('agent/created', async ({ agent, source, signal }) => {
|
||||||
|
const cwd = cwdOf(agent)
|
||||||
|
const payload = {
|
||||||
|
session_id: agent?.id,
|
||||||
|
source: source || 'user',
|
||||||
|
cwd,
|
||||||
|
}
|
||||||
|
const { additionalContext } = await runHooks('SessionStart', source || '', payload, cwd, signal)
|
||||||
|
if (additionalContext.length > 0 && agent?.inject) {
|
||||||
|
agent.inject(createUserMessage({
|
||||||
|
content: additionalContext.map((text) => ({ type: 'text', text })),
|
||||||
|
source: CONTEXT_SOURCE,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// 2. UserPromptSubmit
|
||||||
|
ctx.on('agent/pre-step', async ({ agent, messages, signal }, next) => {
|
||||||
|
if (!messages || messages.length === 0) return next()
|
||||||
|
const cwd = cwdOf(agent)
|
||||||
|
const promptText = messages.map((m) => {
|
||||||
|
if (typeof m.content === 'string') return m.content
|
||||||
|
if (Array.isArray(m.content)) {
|
||||||
|
return m.content.map((c) => c.text || '').join('\n')
|
||||||
|
}
|
||||||
|
return ''
|
||||||
|
}).join('\n')
|
||||||
|
|
||||||
|
const payload = {
|
||||||
|
session_id: agent?.id,
|
||||||
|
prompt: promptText,
|
||||||
|
}
|
||||||
|
|
||||||
|
const { decision, reason, additionalContext } = await runHooks('UserPromptSubmit', '', payload, cwd, signal)
|
||||||
|
if (decision === 'deny') {
|
||||||
|
return { kind: 'deny', reason: reason || 'Prompt rejected by UserPromptSubmit hook' }
|
||||||
|
}
|
||||||
|
|
||||||
|
const downstream = await next()
|
||||||
|
if (additionalContext.length > 0 && downstream?.kind === 'enter') {
|
||||||
|
const extraMsg = createUserMessage({
|
||||||
|
content: additionalContext.map((text) => ({ type: 'text', text })),
|
||||||
|
source: CONTEXT_SOURCE,
|
||||||
|
})
|
||||||
|
return {
|
||||||
|
...downstream,
|
||||||
|
messages: [...(downstream.messages || []), extraMsg],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return downstream
|
||||||
|
})
|
||||||
|
|
||||||
|
// 3. PreToolUse
|
||||||
|
ctx.on('tools/pre-execute', async (exec, next) => {
|
||||||
|
const cwd = exec.agent ? cwdOf(exec.agent) : process.cwd()
|
||||||
|
const payload = {
|
||||||
|
session_id: exec.agent?.id,
|
||||||
|
tool_name: exec.name,
|
||||||
|
tool_input: exec.arguments ?? {},
|
||||||
|
}
|
||||||
|
|
||||||
|
const { decision, reason } = await runHooks('PreToolUse', exec.name, payload, cwd, exec.signal)
|
||||||
|
if (decision === 'deny') {
|
||||||
|
return {
|
||||||
|
kind: 'deny',
|
||||||
|
reason: reason || `Blocked by PreToolUse hook for ${exec.name}`,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (decision === 'ask') {
|
||||||
|
return {
|
||||||
|
kind: 'ask',
|
||||||
|
reason: reason || `Approval requested by PreToolUse hook for ${exec.name}`,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return next()
|
||||||
|
})
|
||||||
|
|
||||||
|
// 4. PostToolUse
|
||||||
|
ctx.on('tools/post-execute', async (exec, result, next) => {
|
||||||
|
const cwd = exec.agent ? cwdOf(exec.agent) : process.cwd()
|
||||||
|
const payload = {
|
||||||
|
session_id: exec.agent?.id,
|
||||||
|
tool_name: exec.name,
|
||||||
|
tool_input: exec.arguments ?? {},
|
||||||
|
tool_result: result?.content ?? result ?? null,
|
||||||
|
is_error: !!result?.isError,
|
||||||
|
}
|
||||||
|
|
||||||
|
const { decision, reason, additionalContext } = await runHooks('PostToolUse', exec.name, payload, cwd, exec.signal)
|
||||||
|
if (decision === 'deny') {
|
||||||
|
return {
|
||||||
|
kind: 'block',
|
||||||
|
feedback: [{ type: 'text', text: reason || `Blocked by PostToolUse hook for ${exec.name}` }],
|
||||||
|
...(additionalContext.length > 0 ? {
|
||||||
|
additionalContexts: [createUserMessage({
|
||||||
|
content: additionalContext.map((t) => ({ type: 'text', text: t })),
|
||||||
|
source: CONTEXT_SOURCE,
|
||||||
|
})],
|
||||||
|
} : {}),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const downstream = await next()
|
||||||
|
if (additionalContext.length > 0) {
|
||||||
|
const extra = createUserMessage({
|
||||||
|
content: additionalContext.map((t) => ({ type: 'text', text: t })),
|
||||||
|
source: CONTEXT_SOURCE,
|
||||||
|
})
|
||||||
|
return {
|
||||||
|
...downstream,
|
||||||
|
additionalContexts: [extra, ...(downstream?.additionalContexts || [])],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return downstream
|
||||||
|
})
|
||||||
|
|
||||||
|
// 5. Stop
|
||||||
|
ctx.on('agent/turn-stopping', async ({ agent, turn, signal }) => {
|
||||||
|
const cwd = cwdOf(agent)
|
||||||
|
const payload = {
|
||||||
|
session_id: agent?.id,
|
||||||
|
turn,
|
||||||
|
}
|
||||||
|
const { decision, reason } = await runHooks('Stop', '', payload, cwd, signal)
|
||||||
|
if (decision === 'deny') {
|
||||||
|
const text = reason || 'Continue: blocked by Stop hook'
|
||||||
|
agent?.steer?.(createUserMessage({
|
||||||
|
content: [{ type: 'text', text }],
|
||||||
|
source: CONTEXT_SOURCE,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import crypto from 'node:crypto'
|
||||||
|
|
||||||
|
export function createUserMessage(input) {
|
||||||
|
return {
|
||||||
|
id: crypto.randomUUID(),
|
||||||
|
role: 'user',
|
||||||
|
content: input.content,
|
||||||
|
source: input.source,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
{
|
||||||
|
"name": "@local/omp-hooks",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"description": "Project-level and session-level lifecycle hooks for DSH with full .claude/settings.json and hooks.json compatibility",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"license": "MIT",
|
||||||
|
"exports": {
|
||||||
|
".": "./index.js",
|
||||||
|
"./package.json": "./package.json"
|
||||||
|
},
|
||||||
|
"files": [
|
||||||
|
"index.js",
|
||||||
|
"lib/*.js",
|
||||||
|
"cordis.patch.yml"
|
||||||
|
],
|
||||||
|
"scripts": {
|
||||||
|
"test": "node --test test/*.test.mjs"
|
||||||
|
},
|
||||||
|
"dsh": {
|
||||||
|
"bundle": {
|
||||||
|
"patch": "./cordis.patch.yml"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,108 @@
|
|||||||
|
import test from 'node:test'
|
||||||
|
import assert from 'node:assert/strict'
|
||||||
|
import fs from 'node:fs'
|
||||||
|
import path from 'node:path'
|
||||||
|
import os from 'node:os'
|
||||||
|
import {
|
||||||
|
parseClaudeConfig,
|
||||||
|
matchesPattern,
|
||||||
|
substituteCommand,
|
||||||
|
parseHookStdout,
|
||||||
|
executeHookCommand,
|
||||||
|
findProjectHookConfig,
|
||||||
|
} from '../index.js'
|
||||||
|
|
||||||
|
test('substituteCommand replaces CLAUDE_PROJECT_DIR and CLAUDE_PLUGIN_ROOT', () => {
|
||||||
|
const cmd = 'python3 ${CLAUDE_PROJECT_DIR}/check.py --root ${CLAUDE_PLUGIN_ROOT}'
|
||||||
|
const res = substituteCommand(cmd, {
|
||||||
|
projectDir: '/my/proj',
|
||||||
|
pluginRoot: '/my/plugin',
|
||||||
|
})
|
||||||
|
assert.equal(res, 'python3 /my/proj/check.py --root /my/plugin')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('parseClaudeConfig handles settings.json format with matchers and timeouts', () => {
|
||||||
|
const raw = {
|
||||||
|
hooks: {
|
||||||
|
PreToolUse: [
|
||||||
|
{
|
||||||
|
matcher: 'bash|write',
|
||||||
|
hooks: [
|
||||||
|
{ type: 'command', command: 'echo "check"', timeout: 5 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
UserPromptSubmit: [
|
||||||
|
{
|
||||||
|
hooks: [
|
||||||
|
{ type: 'command', command: 'echo "prompt"' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
const parsed = parseClaudeConfig(raw, { projectDir: '/app' })
|
||||||
|
assert.ok(parsed.PreToolUse)
|
||||||
|
assert.equal(parsed.PreToolUse[0].matcher, 'bash|write')
|
||||||
|
assert.equal(parsed.PreToolUse[0].hooks[0].timeoutSec, 5)
|
||||||
|
assert.ok(parsed.UserPromptSubmit)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('matchesPattern tests exact, regex, wildcard, and empty', () => {
|
||||||
|
assert.equal(matchesPattern('*', 'bash'), true)
|
||||||
|
assert.equal(matchesPattern('', 'bash'), true)
|
||||||
|
assert.equal(matchesPattern(undefined, 'bash'), true)
|
||||||
|
assert.equal(matchesPattern('bash', 'bash'), true)
|
||||||
|
assert.equal(matchesPattern('bash', 'read'), false)
|
||||||
|
assert.equal(matchesPattern('Edit|Write', 'Edit'), true)
|
||||||
|
assert.equal(matchesPattern('Edit|Write', 'Write'), true)
|
||||||
|
assert.equal(matchesPattern('Edit|Write', 'bash'), false)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('parseHookStdout handles exit code 2 as deny', () => {
|
||||||
|
const res = parseHookStdout('', 2, 'Sensitive file blocked')
|
||||||
|
assert.equal(res.decision, 'deny')
|
||||||
|
assert.equal(res.reason, 'Sensitive file blocked')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('parseHookStdout handles JSON decision and context', () => {
|
||||||
|
const jsonOut = JSON.stringify({
|
||||||
|
decision: 'deny',
|
||||||
|
reason: 'Security violation',
|
||||||
|
additionalContext: 'Remember security rule 101',
|
||||||
|
})
|
||||||
|
const res = parseHookStdout(jsonOut, 0, '')
|
||||||
|
assert.equal(res.decision, 'deny')
|
||||||
|
assert.equal(res.reason, 'Security violation')
|
||||||
|
assert.deepEqual(res.additionalContext, ['Remember security rule 101'])
|
||||||
|
})
|
||||||
|
|
||||||
|
test('executeHookCommand runs shell command and passes stdin payload', async () => {
|
||||||
|
const res = await executeHookCommand(
|
||||||
|
'read line; echo "Received: $line"',
|
||||||
|
{ tool: 'test_tool' },
|
||||||
|
{ timeoutSec: 5 }
|
||||||
|
)
|
||||||
|
assert.equal(res.exitCode, 0)
|
||||||
|
assert.ok(res.stdout.includes('Received: {"tool":"test_tool"}'))
|
||||||
|
})
|
||||||
|
|
||||||
|
test('findProjectHookConfig discovers .claude/settings.json', () => {
|
||||||
|
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'hooks-test-'))
|
||||||
|
const claudeDir = path.join(tmp, '.claude')
|
||||||
|
fs.mkdirSync(claudeDir)
|
||||||
|
fs.writeFileSync(path.join(claudeDir, 'settings.json'), JSON.stringify({
|
||||||
|
hooks: {
|
||||||
|
PreToolUse: [
|
||||||
|
{ matcher: 'bash', hooks: [{ type: 'command', command: 'echo 1' }] },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
|
||||||
|
const conf = findProjectHookConfig(tmp)
|
||||||
|
assert.ok(conf)
|
||||||
|
assert.ok(conf.PreToolUse)
|
||||||
|
|
||||||
|
fs.rmSync(tmp, { recursive: true, force: true })
|
||||||
|
})
|
||||||
Reference in new issue
Block a user