#!/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. * * v0.7 additions (T-hooks-installer): * H1 PostToolUse added to FF_EVENTS (A4 timeline completeness, AC-A4.5) * L5 --max-time computed from PERM_TIMEOUT_MS env (not hardcoded 350, SEC-M4) * H3 ntfy/Pushover bridge: only installed when WEBTERM_NTFY_URL+TOPIC set; * token always via $WEBTERM_NTFY_TOKEN env — never a literal (SEC-C6) * B2 statusLine handler: scripts/statusline.mjs installed idempotently; * --remove cleans the entry; user's own statusLine is not overwritten * * 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' import { fileURLToPath } from 'node:url' // ── Constants ──────────────────────────────────────────────────────────────── /** Appears in every web-terminal hook command — used by cleanup to identify our entries. */ export const MARKER = 'WEBTERM_HOOK_URL' /** Appears in our statusLine command — used to detect and remove our entry idempotently. */ export const STATUSLINE_MARKER = 'statusline.mjs' /** Safety buffer added to PERM_TIMEOUT_MS/1000 for --max-time (L5/SEC-M4). */ const CURL_BUFFER_SEC = 5 /** Default value for PERM_TIMEOUT_MS when env is unset or invalid (5 minutes in ms). */ const DEFAULT_PERM_TIMEOUT_MS = 300_000 // ── Pure helpers (exported for unit tests) ─────────────────────────────────── /** * Compute the --max-time value (seconds) for the held PermissionRequest curl. * Result is always > permTimeoutMs/1000 so curl never races the server (L5/SEC-M4). * Falls back to DEFAULT_PERM_TIMEOUT_MS when the input is missing, zero, or negative. * * @param {number} [permTimeoutMs] * @returns {number} */ export function computeMaxTimeSec(permTimeoutMs = DEFAULT_PERM_TIMEOUT_MS) { const safeMs = Number.isFinite(permTimeoutMs) && permTimeoutMs > 0 ? permTimeoutMs : DEFAULT_PERM_TIMEOUT_MS return Math.ceil(safeMs / 1000) + CURL_BUFFER_SEC } /** * Build the held PermissionRequest curl command string. * --max-time is supplied by the caller (from computeMaxTimeSec) — never hardcoded (L5). * * @param {number} maxTimeSec * @returns {string} */ export function buildPermCommand(maxTimeSec) { return ( 'curl -s --max-time ' + maxTimeSec + ' -X POST "${WEBTERM_HOOK_URL}/permission"' + ' -H "X-Webterm-Session: $WEBTERM_SESSION" -H "Content-Type: application/json" --data-binary @-' ) } /** * Build a fire-and-forget ntfy notification curl command (H3). * * Security guarantees (SEC-C6): * - $WEBTERM_NTFY_TOKEN is a shell variable reference, NEVER a literal value. * - The guard `[ -n "$WEBTERM_HOOK_URL" ]` makes this a no-op outside web-terminal * (WEBTERM_HOOK_URL is injected by the server spawn, not available globally). * - Because the command contains MARKER (WEBTERM_HOOK_URL), the existing cleanup * loop removes it correctly on --remove / re-install. * * @param {'high' | 'low'} priority - ntfy Priority header value * @returns {string} */ export function buildNtfyCommand(priority) { return ( '[ -n "$WEBTERM_HOOK_URL" ] && ' + 'curl -s -X POST "${WEBTERM_NTFY_URL}/${WEBTERM_NTFY_TOPIC}"' + ' -H "Authorization: Bearer $WEBTERM_NTFY_TOKEN"' + ' -H "Priority: ' + priority + '"' + ' >/dev/null 2>&1 || true' ) } /** * Build the statusLine command Claude Code runs on each status update (B2). * Uses an absolute path so it works regardless of the shell's CWD. * * @param {string} scriptDir - directory that contains statusline.mjs * @returns {string} */ export function buildStatusLineCommand(scriptDir) { return 'node ' + path.join(scriptDir, 'statusline.mjs') } // ── Hook event lists ───────────────────────────────────────────────────────── /** * Fire-and-forget hook events. * H1: PostToolUse added so the A4 timeline captures "task complete" events * (AC-A4.5, A4-FR1). These all POST asynchronously; Claude doesn't wait. */ export const FF_EVENTS = [ 'UserPromptSubmit', 'PreToolUse', 'PostToolUse', // H1 — added for A4 timeline completeness 'Notification', 'Stop', 'SessionEnd', ] /** Fire-and-forget: POST hook body to web-terminal, discard response. */ export 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' /** Held PermissionRequest command — computed at module load from PERM_TIMEOUT_MS env. */ export const PERM_COMMAND = buildPermCommand( computeMaxTimeSec( (() => { const raw = process.env['PERM_TIMEOUT_MS'] if (raw === undefined) return DEFAULT_PERM_TIMEOUT_MS const n = Number(raw) return Number.isFinite(n) && n > 0 ? n : DEFAULT_PERM_TIMEOUT_MS })(), ), ) // ── Core install/remove logic (exported for tests) ─────────────────────────── /** * Apply (install or remove) web-terminal hooks to a settings.json file. * This is the testable core — the main script entry calls it with real env values. * * @param {Object} opts * @param {string} opts.settingsPath - absolute path to settings.json * @param {boolean} opts.remove - true = remove our entries, false = install * @param {boolean} opts.hasNtfy - true = add ntfy bridge hook commands (H3) * @param {number} opts.permTimeoutMs - used to compute --max-time (L5/SEC-M4) * @param {string} opts.scriptDir - directory of statusline.mjs (for absolute path) * @returns {{ ok: boolean; error?: string; settings?: object }} */ export function applyHooks({ settingsPath, remove, hasNtfy, permTimeoutMs, scriptDir }) { const maxTimeSec = computeMaxTimeSec(permTimeoutMs) const permCommand = buildPermCommand(maxTimeSec) const statusLineCommand = buildStatusLineCommand(scriptDir) /** @type {Record} */ let settings = {} if (existsSync(settingsPath)) { try { settings = JSON.parse(readFileSync(settingsPath, 'utf8')) } catch { return { ok: false, error: `${settingsPath} is not valid JSON — aborting` } } copyFileSync(settingsPath, `${settingsPath}.webterm-bak`) } if (typeof settings['hooks'] !== 'object' || settings['hooks'] === null) { settings['hooks'] = {} } /** @type {Record} */ const hooks = /** @type {Record} */ (settings['hooks']) // ── Strip existing web-terminal hook groups (idempotent + --remove) ───────── // Any hook group whose command contains MARKER is ours (FF_COMMAND, PERM_COMMAND, // and ntfy commands all contain WEBTERM_HOOK_URL, so one cleanup pass covers all). for (const ev of Object.keys(hooks)) { const groups = hooks[ev] if (!Array.isArray(groups)) continue hooks[ev] = groups.filter( (g) => !( g != null && typeof g === 'object' && Array.isArray(/** @type {Record}*/ (g)['hooks']) && /** @type {Array>} */ ( /** @type {Record} */ (g)['hooks'] ).some( (h) => typeof h['command'] === 'string' && h['command'].includes(MARKER), ) ), ) if (hooks[ev].length === 0) delete hooks[ev] } // ── Strip statusLine if it's ours (idempotent + --remove) ─────────────────── if ( typeof settings['statusLine'] === 'string' && settings['statusLine'].includes(STATUSLINE_MARKER) ) { delete settings['statusLine'] } // ── Install (skip when --remove) ──────────────────────────────────────────── if (!remove) { // Fire-and-forget events for (const ev of FF_EVENTS) { if (!Array.isArray(hooks[ev])) hooks[ev] = [] hooks[ev].push({ hooks: [{ type: 'command', command: FF_COMMAND }] }) } // Held PermissionRequest (waits for server decision JSON) if (!Array.isArray(hooks['PermissionRequest'])) hooks['PermissionRequest'] = [] hooks['PermissionRequest'].push({ hooks: [{ type: 'command', command: permCommand }] }) // H3: ntfy bridge — only when URL + TOPIC are configured at install time. // The token ($WEBTERM_NTFY_TOKEN) is NEVER a literal here (SEC-C6). if (hasNtfy) { // NEEDS-INPUT signal: PermissionRequest → high priority hooks['PermissionRequest'].push({ hooks: [{ type: 'command', command: buildNtfyCommand('high') }], }) // DONE signal: Stop + SessionEnd → low priority for (const ev of ['Stop', 'SessionEnd']) { hooks[ev].push({ hooks: [{ type: 'command', command: buildNtfyCommand('low') }] }) } } // B2: statusLine handler — only when the key is currently absent, so we never // overwrite a statusLine the user configured independently. if (!settings['statusLine']) { settings['statusLine'] = statusLineCommand } } mkdirSync(path.dirname(settingsPath), { recursive: true }) writeFileSync(settingsPath, `${JSON.stringify(settings, null, 2)}\n`) return { ok: true, settings } } // ── Main script entry (runs only when executed directly, not when imported) ── if (process.argv[1] === fileURLToPath(import.meta.url)) { const SETTINGS = path.join(homedir(), '.claude', 'settings.json') const SCRIPT_DIR = path.dirname(fileURLToPath(import.meta.url)) const remove = process.argv.includes('--remove') // H3: ntfy bridge is installed only when WEBTERM_NTFY_URL + TOPIC are in env const ntfyUrl = process.env['WEBTERM_NTFY_URL'] const ntfyTopic = process.env['WEBTERM_NTFY_TOPIC'] const hasNtfy = Boolean(ntfyUrl && ntfyTopic) // L5/SEC-M4: --max-time computed from PERM_TIMEOUT_MS (never a bare literal) const permTimeoutMs = (() => { const raw = process.env['PERM_TIMEOUT_MS'] if (raw === undefined) return DEFAULT_PERM_TIMEOUT_MS const n = Number(raw) return Number.isFinite(n) && n > 0 ? n : DEFAULT_PERM_TIMEOUT_MS })() const result = applyHooks({ settingsPath: SETTINGS, remove, hasNtfy, permTimeoutMs, scriptDir: SCRIPT_DIR, }) if (!result.ok) { console.error(result.error) process.exit(1) } const allEvents = [...FF_EVENTS, 'PermissionRequest'] console.log( remove ? `Removed web-terminal hooks from ${SETTINGS}` : `Installed web-terminal hooks into ${SETTINGS} (events: ${allEvents.join(', ')})`, ) if (!remove) { console.log(`Installed statusLine handler: ${buildStatusLineCommand(SCRIPT_DIR)}`) if (hasNtfy) { console.log(`Installed ntfy bridge (NEEDS-INPUT=high, DONE=low) → ${ntfyUrl}/${ntfyTopic}`) } } if (existsSync(`${SETTINGS}.webterm-bak`)) console.log(`Backup: ${SETTINGS}.webterm-bak`) console.log('Restart any running `claude` sessions for the hooks to take effect.') }