#!/usr/bin/env bash # PreToolUse hook: sugiere funciones del registry cuando un comando Bash # inline probablemente reinventa una funcion existente (issue 0087). # # Llama a `./fn match ""` con timeout 200ms. Si encaja con alta # confianza, imprime un a stderr para que Claude Code # lo lea como recordatorio. NUNCA bloquea la tool — exit 0 siempre. set -euo pipefail # ---- Always exit 0, no matter what ---- trap 'exit 0' ERR # ---- Resolve registry root (walks up from cwd) ---- resolve_root() { local d="${PWD}" while [ "$d" != "/" ]; do if [ -f "$d/registry.db" ]; then printf '%s' "$d" return 0 fi d=$(dirname "$d") done return 1 } ROOT=$(resolve_root) || exit 0 FN_BIN="$ROOT/fn" [ -x "$FN_BIN" ] || exit 0 # ---- Read stdin JSON ---- command -v jq >/dev/null 2>&1 || exit 0 INPUT=$(cat) [ -z "$INPUT" ] && exit 0 TOOL_NAME=$(printf '%s' "$INPUT" | jq -r '.tool_name // ""' 2>/dev/null || echo "") [ "$TOOL_NAME" = "Bash" ] || exit 0 CMD=$(printf '%s' "$INPUT" | jq -r '.tool_input.command // ""' 2>/dev/null || echo "") [ -z "$CMD" ] && exit 0 # Single-line for matching against denylist patterns CMD_FLAT=$(printf '%s' "$CMD" | tr '\n' ' ') # ---- Denylist (skip antes de llamar fn match para ahorrar el invoke) ---- # Comandos demasiado cortos -> trivial CMD_LEN=${#CMD_FLAT} [ "$CMD_LEN" -lt 20 ] && exit 0 # Trivial single-utility commands case "$CMD_FLAT" in "ls"|"ls "*|"cd"|"cd "*|"pwd"|"pwd "*|"cat"|"cat "*|"echo"|"echo "*) exit 0 ;; "grep"|"grep "*|"head"|"head "*|"tail"|"tail "*|"wc"|"wc "*) exit 0 ;; "mkdir"|"mkdir "*|"rm"|"rm "*|"mv"|"mv "*|"cp"|"cp "*) exit 0 ;; "git"|"git "*) exit 0 ;; "go"|"go "*) # go build / go test corrientes — el agente ya los maneja exit 0 ;; esac # Comandos que ya usan el registry: ./fn ..., fn run ..., mcp__registry__* if printf '%s' "$CMD_FLAT" | grep -qE '(^|[[:space:]])\./fn([[:space:]]|$)'; then exit 0 fi if printf '%s' "$CMD_FLAT" | grep -qE '(^|[[:space:]])fn[[:space:]]+(run|search|show|code|uses|doctor|index|match|list|add|proposal|sync|ops|check)'; then exit 0 fi # Pure-cd (movement only, no logic) if printf '%s' "$CMD_FLAT" | grep -qE '^[[:space:]]*cd[[:space:]]+[^&|;]+$'; then exit 0 fi # ---- Llamar fn match con timeout 200ms ---- command -v timeout >/dev/null 2>&1 || exit 0 # Truncar el comando a algo razonable para fn match (evitar args huge) CMD_TRUNC=$(printf '%s' "$CMD_FLAT" | head -c 500) MATCH_JSON=$(timeout 0.2 "$FN_BIN" match "$CMD_TRUNC" --format json --top 3 2>/dev/null) || exit 0 [ -z "$MATCH_JSON" ] && exit 0 # ---- Parsear JSON ---- HIGH_CONF=$(printf '%s' "$MATCH_JSON" | jq -r '.high_confidence // false' 2>/dev/null || echo "false") TOP_ID=$(printf '%s' "$MATCH_JSON" | jq -r '.top[0].id // ""' 2>/dev/null || echo "") TOP_SCORE=$(printf '%s' "$MATCH_JSON" | jq -r '.top[0].score // 0' 2>/dev/null || echo "0") TOP_SIG=$(printf '%s' "$MATCH_JSON" | jq -r '.top[0].signature // ""' 2>/dev/null || echo "") TOP_SNIP=$(printf '%s' "$MATCH_JSON" | jq -r '.top[0].snippet // ""' 2>/dev/null || echo "") [ -z "$TOP_ID" ] && exit 0 # Trigger condition: (high_confidence==true OR score>=0.85) AND score>=0.6 # - high_confidence requires top1/top2 gap > 1.5 (set por fn match) # - score>=0.85 cubre matches muy fuertes donde el gap es modesto SCORE_HI=$(awk -v s="$TOP_SCORE" 'BEGIN{ print (s+0 >= 0.85) ? "1" : "0" }') SCORE_MIN=$(awk -v s="$TOP_SCORE" 'BEGIN{ print (s+0 >= 0.6) ? "1" : "0" }') [ "$SCORE_MIN" = "1" ] || exit 0 if [ "$HIGH_CONF" != "true" ] && [ "$SCORE_HI" != "1" ]; then exit 0 fi # Truncar snippet a 100 chars y limpiar saltos de linea SNIP_SHORT=$(printf '%s' "$TOP_SNIP" | tr '\n' ' ' | head -c 100) # Formatear score con 2 decimales SCORE_FMT=$(awk -v s="$TOP_SCORE" 'BEGIN{ printf "%.2f", s+0 }') # ---- Emitir a stderr ---- cat >&2 <FUZZY-MATCH (issue 0087): your Bash command may already be a function. USE: ./fn run $TOP_ID -> $TOP_SIG SNIPPET: $SNIP_SHORT Confidence: $SCORE_FMT. If you proceed inline, the violation will be logged. EOF exit 0 # Test manual: # echo '{"tool_name":"Bash","tool_input":{"command":"taskkill.exe /IM registry_dashboard.exe /F"},"session_id":"test"}' \ # | bash .claude/scripts/hook_fn_match.sh # # Casos silenciosos: # echo '{"tool_name":"Bash","tool_input":{"command":"ls -la"},"session_id":"test"}' \ # | bash .claude/scripts/hook_fn_match.sh # echo '{"tool_name":"Bash","tool_input":{"command":"./fn run filter_slice_go_core 1 2 3"},"session_id":"test"}' \ # | bash .claude/scripts/hook_fn_match.sh