#!/bin/sh
# aidown: anonymous error reports from AI coding tools to https://aidown.dev
#
# What it sends (and nothing else): {"v":1,"tool","ver","model","event","n","id"}
#   tool  = claude-code | codex
#   event = rate_limit | overloaded | server_error | auth | billing | invalid_request | unknown | turns
#   id    = random install id from ~/.aidown/id (not linked to you)
# It never reads or sends prompts, code, file paths, emails or tokens.
# Hooks always exit 0 and send in the background, so they never slow a tool down.

AIDOWN_HOME="${AIDOWN_HOME:-$HOME/.aidown}"
AIDOWN_URL="${AIDOWN_URL:-https://aidown.dev}"
VERSION=1

# Keep only characters that can appear in a model name or version; cap the length.
clean() { printf '%s' "$1" | tr -cd 'A-Za-z0-9._:/-' | cut -c1-64; }

# Pull a top-level string field from one-line JSON without jq: "key":"value"
field() { printf '%s' "$2" | sed -n "s/.*\"$1\"[[:space:]]*:[[:space:]]*\"\([^\"]*\)\".*/\1/p" | head -n 1; }

install_id() { cat "$AIDOWN_HOME/id" 2>/dev/null; }

send() { # tool ver model event [n]
  id=$(install_id); [ -n "$id" ] || return 0
  [ -f "$AIDOWN_HOME/paused" ] && return 0
  body=$(printf '{"v":%s,"tool":"%s","ver":"%s","model":"%s","event":"%s","n":%s,"id":"%s"}' \
    "$VERSION" "$1" "$(clean "$2")" "$(clean "$3")" "$4" "${5:-1}" "$id")
  if [ -n "$AIDOWN_DEBUG" ]; then printf '%s\n' "$body" >>"$AIDOWN_HOME/debug.log"; fi
  # nohup + subshell: the send survives the tool exiting right after the hook returns.
  ( nohup curl -fsS -m 3 -X POST -H 'content-type: application/json' -d "$body" "$AIDOWN_URL/api/t" >/dev/null 2>&1 </dev/null & ) >/dev/null 2>&1
}

# Turns are counted locally and sent once per hour as one number (the error-rate denominator).
count_turns() { # tool ver model add
  f="$AIDOWN_HOME/turns.$1"; now=$(date +%s)
  set -- "$1" "$2" "$3" "$4" $(cat "$f" 2>/dev/null)
  n=$(( ${5:-0} + $4 )); since=${6:-$now}
  if [ $(( now - since )) -ge 3600 ] && [ "$n" -gt 0 ]; then
    [ "$n" -gt 500 ] && n=500
    send "$1" "$2" "$3" turns "$n"; printf '0 %s\n' "$now" >"$f"
  else
    printf '%s %s\n' "$n" "$since" >"$f"
  fi
}

claude_ver() { # cached for a day; refreshed in the background
  f="$AIDOWN_HOME/claude.version"
  if [ ! -f "$f" ] || [ -n "$(find "$f" -mmin +1440 2>/dev/null)" ]; then
    ( claude --version 2>/dev/null | cut -d' ' -f1 >"$f.tmp" && mv "$f.tmp" "$f" & ) >/dev/null 2>&1
  fi
  cat "$f" 2>/dev/null
}

# Session id is only used locally to remember the model of that session. It is never sent.
model_file() { printf '%s/s/%s' "$AIDOWN_HOME" "$(clean "$1")"; }

# Model of this session: remembered from SessionStart/PostModelSwitch, else the last "model" value
# in the local transcript (only that field is read; one-shot `claude -p` runs omit it at start).
claude_model() { # session_id transcript_path
  m=$(cat "$(model_file "$1")" 2>/dev/null)
  if [ -z "$m" ] && [ -f "$2" ]; then
    m=$(tail -n 200 "$2" | grep -o '"model":"claude-[A-Za-z0-9.-]*"' | tail -n 1 | sed 's/.*:"\(.*\)"/\1/')
  fi
  clean "$m"
}

hook_claude() { # event name from Claude Code; hook JSON on stdin
  input=$(cat | tr -d '\n')
  sid=$(field session_id "$input")
  mkdir -p "$AIDOWN_HOME/s"
  case "$1" in
    start)
      m=$(field model "$input"); [ -n "$m" ] && [ -n "$sid" ] && clean "$m" >"$(model_file "$sid")"
      find "$AIDOWN_HOME/s" -type f -mtime +2 -delete 2>/dev/null
      claude_ver >/dev/null ;;
    switch)
      m=$(field to_model "$input"); [ -n "$m" ] && [ -n "$sid" ] && clean "$m" >"$(model_file "$sid")" ;;
    stop)
      count_turns claude-code "$(claude_ver)" "$(claude_model "$sid" "$(field transcript_path "$input")")" 1 ;;
    fail)
      case "$(field error "$input")" in
        rate_limit) ev=rate_limit ;;
        overloaded) ev=overloaded ;;
        server_error) ev=server_error ;;
        authentication_failed|oauth_org_not_allowed|account_on_hold|cloud_credential_error) ev=auth ;;
        billing_error) ev=billing ;;
        invalid_request|model_not_found) ev=invalid_request ;;
        max_output_tokens) return 0 ;; # a long answer, not an outage
        *) ev=unknown ;;
      esac
      m=$(claude_model "$sid" "$(field transcript_path "$input")")
      send claude-code "$(claude_ver)" "$m" "$ev"
      count_turns claude-code "$(claude_ver)" "$m" 1 ;;
  esac
}

codex_event() { # one task_complete line → event name (empty = not an outage signal)
  info=$(printf '%s' "$1" | sed -n 's/.*"codex_error_info"[[:space:]]*:[[:space:]]*"\([a-z_]*\)".*/\1/p')
  case "$info" in
    usage_limit_exceeded) echo rate_limit; return ;;
    server_overloaded) echo overloaded; return ;;
    internal_server_error|http_connection_failed|response_stream_connection_failed|response_stream_disconnected|response_too_many_failed_attempts) echo server_error; return ;;
    unauthorized) echo auth; return ;;
    bad_request) echo invalid_request; return ;;
    context_window_exceeded|sandbox_error) return ;;
  esac
  st=$(printf '%s' "$1" | sed -n 's/.*\\"status\\":\([0-9][0-9][0-9]\).*/\1/p' | head -n 1)
  case "$st" in
    429) echo rate_limit ;; 529|503) echo overloaded ;; 5??) echo server_error ;;
    401|403) echo auth ;; 402) echo billing ;; 4??) echo invalid_request ;; *) echo unknown ;;
  esac
}

hook_codex() { # Stop / SessionEnd from Codex; reads only new task_complete lines of the local session file
  input=$(cat | tr -d '\n')
  tp=$(field transcript_path "$input"); model=$(field model "$input")
  [ -f "$tp" ] || return 0
  mkdir -p "$AIDOWN_HOME/codex"
  key="$AIDOWN_HOME/codex/$(basename "$tp" | tr -cd 'A-Za-z0-9.-')"
  done_lines=$(cat "$key" 2>/dev/null || echo 0)
  total=$(wc -l <"$tp" | tr -d ' ')
  [ "$total" -gt "$done_lines" ] || return 0
  ver=$(sed -n '1,5s/.*"cli_version"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' "$tp" | head -n 1)
  turns=0
  tail -n +"$((done_lines + 1))" "$tp" | grep '"type":"task_complete"' >"$key.new" 2>/dev/null
  while IFS= read -r line; do
    turns=$((turns + 1))
    case "$line" in
      *'"error":{'*) ev=$(codex_event "$line"); [ -n "$ev" ] && send codex "$ver" "$model" "$ev" ;;
    esac
  done <"$key.new"
  rm -f "$key.new"; echo "$total" >"$key"
  find "$AIDOWN_HOME/codex" -type f -mtime +7 -delete 2>/dev/null
  [ "$turns" -gt 0 ] && count_turns codex "$ver" "$model" "$turns"
}

status() {
  { curl -fsS -m 8 "$AIDOWN_URL/api/status.json" 2>/dev/null; echo; } | tr '{' '\n' | while IFS= read -r l; do
    name=$(field name "$l"); [ -n "$name" ] || continue
    v=$(field verdict "$l")
    case "$v" in ok) mark='●  ok      ' ;; problems) mark='▲  problems' ;; down) mark='✖  DOWN    ' ;; *) mark='?  unknown ' ;; esac
    printf '%s  %s\n' "$mark" "$name"
  done
  echo "Details: $AIDOWN_URL"
}

# Remove our hook entries from a JSON settings file (anything whose command runs ~/.aidown/bin/aidown).
unhook_json() {
  f="$1"; [ -f "$f" ] || return 0
  grep -q '.aidown/bin/aidown' "$f" || return 0
  cp "$f" "$f.aidown-bak-$(date +%Y%m%d%H%M%S)"
  if [ "${AIDOWN_JSON_TOOL:-jq}" = jq ] && command -v jq >/dev/null 2>&1; then
    jq 'if .hooks == null then . else
          .hooks |= (with_entries(.value |= (map(.hooks |= map(select((.command // "") | contains(".aidown/bin/aidown") | not)))
                                             | map(select(.hooks | length > 0))))
                     | with_entries(select(.value | length > 0)))
          | if .hooks == {} then del(.hooks) else . end
        end' "$f" >"$f.tmp" && mv "$f.tmp" "$f" || { rm -f "$f.tmp"; return 1; }
  elif command -v node >/dev/null 2>&1; then
    node -e '
      const fs=require("fs"),f=process.argv[1],j=JSON.parse(fs.readFileSync(f,"utf8"));
      for (const ev of Object.keys(j.hooks||{})) {
        j.hooks[ev]=j.hooks[ev].map(g=>({...g,hooks:(g.hooks||[]).filter(h=>!String(h.command||"").includes(".aidown/bin/aidown"))})).filter(g=>g.hooks.length);
        if(!j.hooks[ev].length) delete j.hooks[ev];
      }
      if (j.hooks && !Object.keys(j.hooks).length) delete j.hooks;
      fs.writeFileSync(f, JSON.stringify(j,null,2)+"\n");' "$f" || return 1
  else
    echo "aidown: remove the aidown entries from $f by hand (backup saved)." >&2
    return 1
  fi
  ! grep -q '.aidown/bin/aidown' "$f"
}

uninstall() {
  for f in "$HOME/.claude/settings.json" "$HOME/.codex/hooks.json"; do
    unhook_json "$f" || { echo "aidown: could not remove the hooks from $f. Nothing deleted." >&2; return 1; }
  done
  if [ -n "$(install_id)" ]; then
    curl -fsS -m 8 -X POST -H 'content-type: application/json' -d "{\"id\":\"$(install_id)\"}" "$AIDOWN_URL/api/t/forget" >/dev/null 2>&1 \
      && echo "aidown: deleted your reports on the server."
  fi
  rm -rf "$AIDOWN_HOME"
  echo "aidown: removed. Backups of edited settings files end in .aidown-bak-*"
}

case "$1" in
  hook)
    case "$2" in claude-*) hook_claude "${2#claude-}" ;; codex) hook_codex ;; esac
    exit 0 ;;
  status) status ;;
  pause) touch "$AIDOWN_HOME/paused"; echo "aidown: paused. Resume with: aidown resume" ;;
  resume) rm -f "$AIDOWN_HOME/paused"; echo "aidown: sending again." ;;
  uninstall) uninstall ;;
  forget) [ -n "$(install_id)" ] && curl -fsS -m 8 -X POST -H 'content-type: application/json' -d "{\"id\":\"$(install_id)\"}" "$AIDOWN_URL/api/t/forget"; echo ;;
  *) cat <<EOF
aidown $VERSION: anonymous error reports from Claude Code and Codex to $AIDOWN_URL

  aidown status      live status of AI tools
  aidown pause       stop sending (resume: aidown resume)
  aidown forget      delete your reports on the server
  aidown uninstall   remove hooks, delete server data, delete ~/.aidown
EOF
  ;;
esac
exit 0
