- types/protocol: ClientMessage approve/reject; ServerMessage status.pending
- server: POST /hook/permission HELD until the client decides; approve/reject
ws messages resolve it with {hookSpecificOutput.decision.behavior allow|deny};
5-min timeout + release-on-disconnect fall back to Claude's own prompt
- manager.handleHookEvent threads pending flag
- setup-hooks: PermissionRequest uses the held curl (writes decision to stdout);
status events stay fire-and-forget
- FE: terminal-session approve()/reject() + pending state; tabs approval banner
(Approve/Reject) for the active tab's held request
- Tests: protocol approve/reject; integration ⑧ held→approve→allow. 207 green.
- Browser-verified: banner 'Claude wants to use Bash' → Approve → resolves allow.
88 lines
3.6 KiB
JavaScript
88 lines
3.6 KiB
JavaScript
#!/usr/bin/env node
|
|
/**
|
|
* scripts/setup-hooks.mjs — install/remove web-terminal's Claude Code hooks (H2).
|
|
*
|
|
* Adds `command` hooks to ~/.claude/settings.json that POST each hook event to
|
|
* the web-terminal server so it can show live Claude status per tab. The command
|
|
* is an inline curl that uses two env vars the server injects into each spawned
|
|
* shell: $WEBTERM_HOOK_URL (the server's /hook URL, with the right port) and
|
|
* $WEBTERM_SESSION (which tab). Outside web-terminal those vars are unset, so
|
|
* curl no-ops (|| true) — the hooks are harmless in normal terminals.
|
|
*
|
|
* Usage:
|
|
* node scripts/setup-hooks.mjs # install (idempotent)
|
|
* node scripts/setup-hooks.mjs --remove # uninstall
|
|
*
|
|
* Backs up settings.json to settings.json.webterm-bak before writing.
|
|
*/
|
|
|
|
import { readFileSync, writeFileSync, existsSync, copyFileSync, mkdirSync } from 'node:fs'
|
|
import { homedir } from 'node:os'
|
|
import path from 'node:path'
|
|
|
|
const SETTINGS = path.join(homedir(), '.claude', 'settings.json')
|
|
const MARKER = 'WEBTERM_HOOK_URL' // identifies our hook entries
|
|
|
|
// Fire-and-forget status events: POST and discard the response.
|
|
const FF_COMMAND =
|
|
'curl -s -X POST "$WEBTERM_HOOK_URL" -H "X-Webterm-Session: $WEBTERM_SESSION"' +
|
|
' -H "Content-Type: application/json" --data-binary @- >/dev/null 2>&1 || true'
|
|
const FF_EVENTS = ['UserPromptSubmit', 'PreToolUse', 'Notification', 'Stop', 'SessionEnd']
|
|
|
|
// PermissionRequest is HELD: curl waits for the server's response (the decision
|
|
// JSON, produced when you tap Approve/Reject) and writes it to stdout for Claude.
|
|
// Empty/failed (outside web-terminal, or timeout) → Claude shows its own prompt.
|
|
const PERM_COMMAND =
|
|
'curl -s --max-time 350 -X POST "${WEBTERM_HOOK_URL}/permission"' +
|
|
' -H "X-Webterm-Session: $WEBTERM_SESSION" -H "Content-Type: application/json" --data-binary @-'
|
|
|
|
const remove = process.argv.includes('--remove')
|
|
|
|
let settings = {}
|
|
if (existsSync(SETTINGS)) {
|
|
try {
|
|
settings = JSON.parse(readFileSync(SETTINGS, 'utf8'))
|
|
} catch {
|
|
console.error(`${SETTINGS} is not valid JSON — aborting (fix it first).`)
|
|
process.exit(1)
|
|
}
|
|
copyFileSync(SETTINGS, `${SETTINGS}.webterm-bak`)
|
|
}
|
|
|
|
if (typeof settings.hooks !== 'object' || settings.hooks === null) settings.hooks = {}
|
|
|
|
// Strip any existing web-terminal hook groups (makes install idempotent + powers --remove).
|
|
for (const ev of Object.keys(settings.hooks)) {
|
|
const groups = settings.hooks[ev]
|
|
if (!Array.isArray(groups)) continue
|
|
settings.hooks[ev] = groups.filter(
|
|
(g) =>
|
|
!(
|
|
g &&
|
|
Array.isArray(g.hooks) &&
|
|
g.hooks.some((h) => typeof h?.command === 'string' && h.command.includes(MARKER))
|
|
),
|
|
)
|
|
if (settings.hooks[ev].length === 0) delete settings.hooks[ev]
|
|
}
|
|
|
|
if (!remove) {
|
|
for (const ev of FF_EVENTS) {
|
|
if (!Array.isArray(settings.hooks[ev])) settings.hooks[ev] = []
|
|
settings.hooks[ev].push({ hooks: [{ type: 'command', command: FF_COMMAND }] })
|
|
}
|
|
if (!Array.isArray(settings.hooks['PermissionRequest'])) settings.hooks['PermissionRequest'] = []
|
|
settings.hooks['PermissionRequest'].push({ hooks: [{ type: 'command', command: PERM_COMMAND }] })
|
|
}
|
|
|
|
mkdirSync(path.dirname(SETTINGS), { recursive: true })
|
|
writeFileSync(SETTINGS, `${JSON.stringify(settings, null, 2)}\n`)
|
|
|
|
console.log(
|
|
remove
|
|
? `Removed web-terminal hooks from ${SETTINGS}`
|
|
: `Installed web-terminal hooks into ${SETTINGS} (events: ${[...FF_EVENTS, 'PermissionRequest'].join(', ')})`,
|
|
)
|
|
if (existsSync(`${SETTINGS}.webterm-bak`)) console.log(`Backup: ${SETTINGS}.webterm-bak`)
|
|
console.log('Restart any running `claude` sessions for the hooks to take effect.')
|