#!/bin/bash
# shunt — optional PreToolUse hook for Read.
#
# Blocks full-file reads above a line threshold and points Claude at the
# bulk_read MCP tool instead. OFF by default: it only does anything once it is
# registered in settings.json (see README). Run the tool by hand for a while
# first; turn this on only if you want the enforcement.
#
# Adapted from spotify/portal-ai-plugins (Apache-2.0), whose routing rules —
# let targeted reads through, let small files through, let missing files fall
# to Read's own error — are the part worth keeping.

MIN_LINES="${TOKENSAVE_MIN_LINES:-350}"
case "$MIN_LINES" in ''|*[!0-9]*) MIN_LINES=350 ;; esac

input=$(cat)

allow() { echo '{"decision": "allow"}'; exit 0; }

command -v jq >/dev/null 2>&1 || allow   # no jq: never block, just get out of the way

# Malformed input is not our problem to report — stay silent and allow.
printf '%s' "$input" | jq -e . >/dev/null 2>&1 || allow

file_path=$(printf '%s' "$input" | jq -r '.tool_input.file_path // empty' 2>/dev/null)
offset=$(printf '%s' "$input" | jq -r '.tool_input.offset // empty' 2>/dev/null)
limit=$(printf '%s' "$input" | jq -r '.tool_input.limit // empty' 2>/dev/null)

# A targeted read means Claude already knows what it wants — and is usually
# about to edit, which needs exact content. Never block those.
[ -n "$offset" ] || [ -n "$limit" ] && allow

# Empty path, missing file, or an unreadable one: let Read report it.
[ -z "$file_path" ] && allow
[ -f "$file_path" ] || allow
[ -r "$file_path" ] || allow

# Never block a binary: bulk_read would be useless on it anyway.
if command -v file >/dev/null 2>&1; then
  case "$(file -b --mime-encoding "$file_path" 2>/dev/null)" in
    binary) allow ;;
  esac
fi

lines=$(wc -l < "$file_path" 2>/dev/null | tr -d ' ')
case "$lines" in ''|*[!0-9]*) allow ;; esac    # unreadable count: don't block

# Byte size matters as much as line count: a minified bundle or a one-line JSON
# blob is 1 line and megabytes of context. Block on either dimension.
bytes=$(wc -c < "$file_path" 2>/dev/null | tr -d ' ')
case "$bytes" in ''|*[!0-9]*) bytes=0 ;; esac
MAX_BYTES="${TOKENSAVE_HOOK_MAX_BYTES:-100000}"
case "$MAX_BYTES" in ''|*[!0-9]*) MAX_BYTES=100000 ;; esac

if [ "$lines" -le "$MIN_LINES" ] && [ "$bytes" -le "$MAX_BYTES" ]; then
  allow
fi

# Mode: "block" refuses the read outright; "warn" lets it through but tells the
# agent what it just cost. Blocking is the point of the tool, but it is also the
# fastest way to wreck someone's workflow — so the gentler mode is one env var
# away, and the message is identical either way.
MODE="${TOKENSAVE_HOOK_MODE:-block}"
case "$MODE" in block|warn) ;; *) MODE=block ;; esac

# Build the JSON with jq, never by interpolating into a heredoc: a file path may
# legally contain a quote, a backslash or a newline, any of which would produce
# malformed JSON. Claude Code cannot parse that, so the decision would silently
# be dropped — the hook would fail open exactly on the weirdest paths.
if [ "$MODE" = "warn" ]; then
  jq -n --arg path "$file_path" --arg lines "$lines" '
    {
      decision: "allow",
      reason: (
        "token-save: reading \($lines) lines into context. bulk_read would " +
        "have delegated this to a worker instead. (warn mode — set " +
        "TOKENSAVE_HOOK_MODE=block to enforce.)"
      )
    }'
  exit 0
fi

jq -n --arg path "$file_path" --arg lines "$lines" --arg min "$MIN_LINES" '
  {
    decision: "block",
    reason: (
      "This file is \($lines) lines (threshold: \($min)). Use the bulk_read " +
      "MCP tool to delegate the read to a worker model instead of spending " +
      "that context: bulk_read(question=\"<what you need to know>\", " +
      "paths=[\"\($path)\"]). If you need exact content in order to EDIT this " +
      "file, re-read it with an offset/limit for just the section you are " +
      "changing — that is allowed through."
    )
  }'
