#!/usr/bin/env bash # Scribe plugin — SessionStart push channel (two tiers). # # Tier 1 (STATIC, always fires, no auth, no network): injects a bundled # behavioral mandate (scribe_static_context.md) so a fresh session knows to # reach for Scribe — record work, recall before acting — even when the instance # is unreachable OR the API token never reached this hook. The latter is a known # Claude Code gap: sensitive userConfig values aren't always exported to the # hook subprocess, so the dynamic tier can silently get nothing. The static tier # is the load-bearing floor that does not depend on the key or the network. # # Tier 2 (DYNAMIC, best-effort enrichment): curls the operator's Scribe instance # for always-on rules + active-project context and appends it. Config comes from # the plugin's userConfig, exported to hooks as: # CLAUDE_PLUGIN_OPTION_api_endpoint base URL, no trailing slash # CLAUDE_PLUGIN_OPTION_api_token fmcp_ API key (sensitive) # The active project is resolved server-side from the working repo's git remote # (see services/repo_bindings); bind each repo once with the bind_repo MCP tool. # # IMPORTANT: do NOT pass config via `${user_config.*}` substitution in # hooks.json — sensitive values are kept in the keychain and never spliced into # a hook command line, so the placeholder arrives unexpanded. The harness env # vars above are the supported channel; SCRIBE_URL / SCRIBE_TOKEN override for # the settings.json dogfooding path. # # FAIL-OPEN: the dynamic tier injects nothing on any error; the static tier # still fires. A session is never blocked by Scribe. set -uo pipefail command -v jq >/dev/null 2>&1 || exit 0 # needed to emit the JSON envelope safely here=$(CDPATH= cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd) || exit 0 # --- Tier 1: static behavioral mandate (always, keyless, networkless) --- out="" [ -f "$here/scribe_static_context.md" ] && out=$(cat "$here/scribe_static_context.md") # --- Tier 2: dynamic rules + active-project context (best-effort) --- url=${SCRIBE_URL:-${CLAUDE_PLUGIN_OPTION_api_endpoint:-}} token=${SCRIBE_TOKEN:-${CLAUDE_PLUGIN_OPTION_api_token:-}} # Guard against an unexpanded `${...}` placeholder reaching us as a literal — it # would otherwise be sent as a garbage Bearer token and 401. Treat as unset. case "$url" in *'${'*) url="" ;; esac case "$token" in *'${'*) token="" ;; esac if command -v curl >/dev/null 2>&1 && [ -n "$url" ] && [ -n "$token" ]; then # Resolve the working repo's remote so the server can map it to a project. repo_dir=${CLAUDE_PROJECT_DIR:-$PWD} repo=$(git -C "$repo_dir" remote get-url origin 2>/dev/null || true) q="" if [ -n "$repo" ]; then enc=$(printf '%s' "$repo" | jq -rR '@uri' 2>/dev/null) || enc="" [ -n "$enc" ] && q="?repo=${enc}" fi body=$(curl -fsS --max-time 8 \ -H "Authorization: Bearer ${token}" \ "${url%/}/api/plugin/context${q}" 2>/dev/null) || body="" if [ -n "$body" ]; then dyn=$(printf '%s' "$body" | jq -r '.context // empty' 2>/dev/null) || dyn="" if [ -n "$dyn" ]; then if [ -n "$out" ]; then out="${out}"$'\n\n---\n\n'"${dyn}" else out="$dyn" fi fi fi fi # Nothing to inject (no static file AND no dynamic context) → stay silent. [ -n "$out" ] || exit 0 jq -n --arg c "$out" \ '{hookSpecificOutput: {hookEventName: "SessionStart", additionalContext: $c}}' exit 0