#!/usr/bin/env bash
# cf — bash CLI for clawdforge
#
# Usage:
#   cf healthz
#   cf run "<prompt>"  [--model sonnet] [--system "..."] [--timeout 60] [--files token1,token2]
#   cf run -                                # read prompt from stdin (any size)
#   cf upload <path>   [--ttl 3600]
#   cf admin token-mint <name>  [--ip-cidrs cidr1,cidr2]
#   cf admin token-list
#   cf admin token-revoke <name>
#
# Sessions (v0.2 — multi-turn):
#   cf session new                          [--agent claude] [--meta '{"k":"v"}'] [--json]
#   cf session turn <id> "<prompt|->"       [--files tok1,tok2] [--timeout 120] [--json] [--trace path]
#   cf session get <id>                     [--json]
#   cf session list                         [--include-closed] [--json]
#   cf session close <id>                   [--json]
#
# Sessions don't autoclose — call `cf session close <id>` when you're done.
# Server-side TTL is 1h idle by default.
#
# Configuration (env or ~/.config/clawdforge/cf.env, env wins):
#   CLAWDFORGE_URL           — default http://192.168.0.5:8800
#   CLAWDFORGE_TOKEN         — required for /run + /files + /sessions
#   CLAWDFORGE_ADMIN_TOKEN   — required for admin/* subcommands
#
# The cf.env file MUST have permissions 0600 or 0400 and be owned by you,
# or cf will refuse to source it.
#
# Output:
#   - successful command → response JSON to stdout (pipe to jq freely)
#   - errors             → message to stderr, non-zero exit code
#
# Exit codes:
#   0  ok
#   1  generic error / curl failure
#   2  bad usage
#   3  missing token
#   4  HTTP 4xx (auth / not-found / bad-request)
#   5  HTTP 5xx (server / claude failure)
# --- end help ---

set -euo pipefail

# ---------- config loading -------------------------------------------------

_CFG_FILE="${XDG_CONFIG_HOME:-$HOME/.config}/clawdforge/cf.env"

# S5: Refuse to source cf.env unless perms are 0600/0400 and owner-matched.
_check_cfg_perms() {
  local f="$1" mode="" owner=""
  if stat -c '%a %U' "$f" >/dev/null 2>&1; then
    # GNU stat (Linux)
    read -r mode owner < <(stat -c '%a %U' "$f")
  elif stat -f '%Lp %Su' "$f" >/dev/null 2>&1; then
    # BSD stat (macOS)
    read -r mode owner < <(stat -f '%Lp %Su' "$f")
  else
    echo "cf: cannot stat $f to verify perms — refusing to source" >&2
    return 1
  fi
  if [[ "$mode" != "600" && "$mode" != "400" ]]; then
    echo "cf: refusing to source $f — perms are $mode (must be 0600 or 0400)" >&2
    echo "cf: fix with: chmod 600 $f" >&2
    return 1
  fi
  if [[ "$owner" != "$(id -un)" ]]; then
    echo "cf: refusing to source $f — owned by $owner, not $(id -un)" >&2
    return 1
  fi
  return 0
}

if [[ -f "$_CFG_FILE" ]]; then
  if _check_cfg_perms "$_CFG_FILE"; then
    set -a
    # shellcheck disable=SC1090
    . "$_CFG_FILE"
    set +a
  else
    exit 1
  fi
fi

CLAWDFORGE_URL="${CLAWDFORGE_URL:-http://192.168.0.5:8800}"
CLAWDFORGE_URL="${CLAWDFORGE_URL%/}"

# ---------- helpers --------------------------------------------------------

_die() { echo "cf: $*" >&2; exit "${2:-1}"; }

_need_token() {
  [[ -n "${CLAWDFORGE_TOKEN:-}" ]] || _die "CLAWDFORGE_TOKEN not set (env or ~/.config/clawdforge/cf.env)" 3
}

_need_admin_token() {
  [[ -n "${CLAWDFORGE_ADMIN_TOKEN:-}" ]] || _die "CLAWDFORGE_ADMIN_TOKEN not set" 3
}

# JSON-escape a single string value (no surrounding quotes).
_json_escape() {
  local v="$1"
  v="${v//\\/\\\\}"
  v="${v//\"/\\\"}"
  v="${v//$'\n'/\\n}"
  v="${v//$'\r'/\\r}"
  v="${v//$'\t'/\\t}"
  printf '%s' "$v"
}

# Build a JSON array of strings from a comma-separated list. Values are
# JSON-escaped. Globbing is disabled around the comma-split loop.
# Args: comma-separated string
_json_string_array() {
  local raw="$1"
  if [[ -z "$raw" ]]; then printf '[]'; return 0; fi
  local out='['
  local first=1 item esc
  # B3: disable globbing on the split loop
  set -f
  local -a parts=()
  local IFS=','
  # shellcheck disable=SC2206
  parts=( $raw )
  set +f
  for item in "${parts[@]}"; do
    [[ "$first" == 0 ]] && out+=','
    first=0
    esc="$(_json_escape "$item")"
    out+="\"$esc\""
  done
  out+=']'
  printf '%s' "$out"
}

# URL-encode a string. Prefer jq; fall back to pure-bash.
_url_encode() {
  local v="$1"
  if command -v jq >/dev/null 2>&1; then
    printf '%s' "$v" | jq -sRr @uri
    return 0
  fi
  local i c out=""
  for (( i=0; i<${#v}; i++ )); do
    c="${v:i:1}"
    case "$c" in
      [a-zA-Z0-9._~-]) out+="$c" ;;
      *) out+="$(printf '%%%02X' "'$c")" ;;
    esac
  done
  printf '%s' "$out"
}

# Build a JSON object from --key value flags (each value is JSON-quoted as string).
# Numbers passed as $key=number are kept numeric. Booleans true/false kept bool.
# Special key syntax:
#   key=raw:...     — value is inserted verbatim (already-JSON, e.g. an array literal)
_json_obj_from_assoc() {
  local first=1 out='{'
  local kv k v
  for kv in "$@"; do
    k="${kv%%=*}"
    v="${kv#*=}"
    if [[ "$first" == 0 ]]; then out+=','; fi
    first=0
    local k_esc
    k_esc="$(_json_escape "$k")"
    out+="\"$k_esc\":"
    if [[ "$v" == raw:* ]]; then
      out+="${v#raw:}"
    elif [[ "$v" =~ ^-?[0-9]+(\.[0-9]+)?$ ]]; then
      out+="$v"
    elif [[ "$v" == "true" || "$v" == "false" ]]; then
      out+="$v"
    else
      local esc
      esc="$(_json_escape "$v")"
      out+="\"$esc\""
    fi
  done
  out+='}'
  printf '%s\n' "$out"
}

# Print the error body's "error" or "detail" field if it's JSON, else the whole body.
_extract_error() {
  local body="$1"
  if command -v jq >/dev/null 2>&1; then
    local parsed
    parsed="$(jq -r '.error // .detail // empty' <<<"$body" 2>/dev/null || true)"
    if [[ -n "$parsed" ]]; then
      printf '%s' "$parsed"
      return 0
    fi
  fi
  printf '%s' "$body"
}

# Holds the path of any tmp file created within the current shell process so
# the EXIT trap can clean it up even on _die-driven exits. (B5)
_CF_TMP=""
_cleanup_tmp() {
  if [[ -n "${_CF_TMP:-}" && -e "$_CF_TMP" ]]; then
    rm -f "$_CF_TMP"
  fi
}
trap _cleanup_tmp EXIT

# Fire a curl request, capture body + status, exit on HTTP error.
# Args: METHOD URL TOKEN [DATA_OR_- [CONTENT_TYPE]]
_request() {
  local method="$1" url="$2" token="$3" data="${4:-}" ctype="${5:-application/json}"
  local body status

  # S1: pass bearer via curl --config <(...) so the token never appears in argv.
  # B4: only mktemp the stdin tmp file when actually needed.
  local -a curl_args=(
    -sS -X "$method" "$url"
    -H "Accept: application/json"
    -w '\n__cf_status__=%{http_code}'
    --max-time 600
  )

  # Auth header file (chmod 600). Always created when token is non-empty so
  # the bearer never lands in argv via -H.
  local auth_file=""
  if [[ -n "$token" ]]; then
    auth_file="$(mktemp)"
    chmod 600 "$auth_file"
    # curl --config syntax: 'header = "Authorization: Bearer <tok>"'
    printf 'header = "Authorization: Bearer %s"\n' "$token" > "$auth_file"
    curl_args+=(--config "$auth_file")
  fi

  if [[ -n "$data" ]]; then
    if [[ "$data" == "-" ]]; then
      _CF_TMP="$(mktemp)"
      cat > "$_CF_TMP"
      curl_args+=(-H "Content-Type: $ctype" --data-binary "@$_CF_TMP")
    else
      curl_args+=(-H "Content-Type: $ctype" --data-binary "$data")
    fi
  fi

  local resp rc=0
  resp="$(curl "${curl_args[@]}" 2>&1)" || rc=$?
  if [[ -n "$auth_file" ]]; then rm -f "$auth_file"; fi
  if (( rc != 0 )); then _die "curl failed: $resp" 1; fi

  status="${resp##*__cf_status__=}"
  body="${resp%__cf_status__=*}"
  body="${body%$'\n'}"   # trim trailing newline before status marker

  if [[ "$status" -ge 200 && "$status" -lt 300 ]]; then
    printf '%s\n' "$body"
    return 0
  fi
  # B10: surface parsed .error/.detail messages instead of raw body.
  echo "cf: HTTP $status" >&2
  local msg
  msg="$(_extract_error "$body")"
  if [[ -n "$msg" ]]; then
    printf '%s\n' "$msg" >&2
  else
    printf '%s\n' "$body" >&2
  fi
  if [[ "$status" -ge 400 && "$status" -lt 500 ]]; then exit 4; fi
  exit 5
}

# ---------- subcommands ----------------------------------------------------

cmd_healthz() {
  # B8: drop Authorization entirely when no token is set.
  local tok="${CLAWDFORGE_TOKEN:-${CLAWDFORGE_ADMIN_TOKEN:-}}"
  _request GET "$CLAWDFORGE_URL/healthz" "$tok"
}

cmd_run() {
  _need_token
  local prompt="" model="" system="" timeout_secs="" files=""
  if [[ $# -lt 1 ]]; then _die "usage: cf run <prompt|->  [--model] [--system] [--timeout] [--files]" 2; fi
  prompt="$1"; shift
  if [[ "$prompt" == "-" ]]; then
    # B1: refuse to read from a TTY — would hang forever.
    if [[ -t 0 ]]; then
      _die "cf run -: stdin is a TTY, expected a pipe or redirect (e.g. 'echo hi | cf run -')" 2
    fi
    prompt="$(cat)"
  fi

  while [[ $# -gt 0 ]]; do
    case "$1" in
      --model)    model="$2"; shift 2;;
      --system)   system="$2"; shift 2;;
      --timeout)  timeout_secs="$2"; shift 2;;
      --files)    files="$2"; shift 2;;
      *) _die "unknown flag: $1" 2;;
    esac
  done

  # Build JSON safely. S2/B2: use the shared array composer + raw: pass-through
  # in _json_obj_from_assoc to avoid hand-splicing the body.
  local args=("prompt=$prompt")
  [[ -n "$model" ]]        && args+=("model=$model")
  [[ -n "$system" ]]       && args+=("system=$system")
  [[ -n "$timeout_secs" ]] && args+=("timeout_secs=$timeout_secs")

  if [[ -n "$files" ]]; then
    local farr
    farr="$(_json_string_array "$files")"
    args+=("files=raw:$farr")
  fi

  local body
  body="$(_json_obj_from_assoc "${args[@]}")"
  _request POST "$CLAWDFORGE_URL/run" "$CLAWDFORGE_TOKEN" "$body"
}

cmd_upload() {
  _need_token
  local path="${1:-}" ttl=""
  [[ -n "$path" && -f "$path" ]] || _die "usage: cf upload <path> [--ttl 3600]" 2
  shift
  while [[ $# -gt 0 ]]; do
    case "$1" in
      --ttl) ttl="$2"; shift 2;;
      *) _die "unknown flag: $1" 2;;
    esac
  done

  # S6: reject ';' in upload paths — curl's -F @ syntax interprets
  # ;type=...;filename=... suffixes, allowing a self-attacker to override
  # the uploaded filename or content-type.
  if [[ "$path" == *';'* ]]; then
    _die "upload path contains ';' which curl @ syntax interprets specially — rename the file" 2
  fi

  # S1: bearer via --config tmpfile, not -H argv.
  local auth_file
  auth_file="$(mktemp)"
  chmod 600 "$auth_file"
  printf 'header = "Authorization: Bearer %s"\n' "$CLAWDFORGE_TOKEN" > "$auth_file"

  local -a curl_args=(
    -sS -X POST "$CLAWDFORGE_URL/files"
    --config "$auth_file"
    -H "Accept: application/json"
    -w '\n__cf_status__=%{http_code}'
    --max-time 600
    -F "file=@$path"
  )
  [[ -n "$ttl" ]] && curl_args+=(-F "ttl_secs=$ttl")

  # B7: capture stderr like _request does so failures are useful.
  local resp rc=0
  resp="$(curl "${curl_args[@]}" 2>&1)" || rc=$?
  rm -f "$auth_file"
  if (( rc != 0 )); then _die "curl failed: $resp" 1; fi

  local status="${resp##*__cf_status__=}"
  local body="${resp%__cf_status__=*}"; body="${body%$'\n'}"
  if [[ "$status" -ge 200 && "$status" -lt 300 ]]; then
    printf '%s\n' "$body"
  else
    echo "cf: HTTP $status" >&2
    local msg
    msg="$(_extract_error "$body")"
    if [[ -n "$msg" ]]; then
      printf '%s\n' "$msg" >&2
    else
      printf '%s\n' "$body" >&2
    fi
    [[ "$status" -lt 500 ]] && exit 4 || exit 5
  fi
}

cmd_admin() {
  local sub="${1:-}"; shift || true
  # B9: validate the subcommand BEFORE requiring the admin token so typos
  # don't get swallowed by a "missing token" error.
  case "$sub" in
    token-mint|token-list|token-revoke) ;;
    "") _die "usage: cf admin <token-mint|token-list|token-revoke>" 2;;
    *) _die "unknown admin subcommand: $sub (token-mint|token-list|token-revoke)" 2;;
  esac
  _need_admin_token
  case "$sub" in
    token-mint)
      local name="${1:-}"; shift || true
      [[ -n "$name" ]] || _die "usage: cf admin token-mint <name> [--ip-cidrs cidr1,cidr2]" 2
      local cidrs=""
      while [[ $# -gt 0 ]]; do
        case "$1" in
          --ip-cidrs) cidrs="$2"; shift 2;;
          *) _die "unknown flag: $1" 2;;
        esac
      done
      # S3: route via the shared escape pipeline.
      local -a args=("name=$name")
      local arr
      arr="$(_json_string_array "$cidrs")"
      args+=("ip_cidrs=raw:$arr")
      local body
      body="$(_json_obj_from_assoc "${args[@]}")"
      _request POST "$CLAWDFORGE_URL/admin/tokens" "$CLAWDFORGE_ADMIN_TOKEN" "$body"
      ;;
    token-list)
      _request GET "$CLAWDFORGE_URL/admin/tokens" "$CLAWDFORGE_ADMIN_TOKEN"
      ;;
    token-revoke)
      local name="${1:-}"
      [[ -n "$name" ]] || _die "usage: cf admin token-revoke <name>" 2
      # S4: URL-encode the name so '/', '?', '#', etc. can't smuggle path/query.
      local enc
      enc="$(_url_encode "$name")"
      _request DELETE "$CLAWDFORGE_URL/admin/tokens/$enc" "$CLAWDFORGE_ADMIN_TOKEN"
      ;;
  esac
}

# ---------- session subcommands (v0.2) -------------------------------------
#
# Per-app multi-turn sessions backed by ACPX. Server endpoints:
#   POST   /sessions                  → create
#   POST   /sessions/{id}/turn        → send a turn
#   GET    /sessions/{id}             → state
#   DELETE /sessions/{id}             → soft-close (idempotent)
#   GET    /sessions                  → list (per-token)
#
# Session subcommands inherit the same v0.1 hygiene:
#   - bearer never lands in argv (S1) — same _request helper
#   - JSON injection via prompt/agent/files is impossible (S2/S3) — every
#     value goes through _json_escape or _json_string_array
#   - --json flag mirrors v0.1 convention; default cmd_session_turn output
#     is the concatenated text-event stream (matches `cf run` ergonomics)

# Validate a session id looks like a server-assigned identifier. Permissive
# but blocks the obvious smuggling vectors (path traversal, query, frag,
# whitespace, control chars). Server canonicalizes UUIDs but we don't pin
# the shape here in case format evolves.
_validate_session_id() {
  local id="$1"
  [[ -n "$id" ]] || return 1
  # Reject path / query / frag / whitespace / NUL. NOTE: shell case patterns
  # treat unquoted ? and * as glob meta — match each char one at a time via
  # a substring search instead of a glob class so '?' (literal) and friends
  # are caught reliably.
  local ch
  for (( i=0; i<${#id}; i++ )); do
    ch="${id:i:1}"
    case "$ch" in
      '/'|'?'|'#'|' '|$'\t'|$'\n'|$'\r'|$'\0') return 1 ;;
    esac
  done
  # Length sanity — UUIDs are 36; allow up to 128 for future-proofing.
  if (( ${#id} < 1 || ${#id} > 128 )); then return 1; fi
  return 0
}

cmd_session_new() {
  _need_token
  local agent="" meta_raw="" json_out=0
  while [[ $# -gt 0 ]]; do
    case "$1" in
      --agent)  agent="$2"; shift 2;;
      --meta)   meta_raw="$2"; shift 2;;
      --json)   json_out=1; shift;;
      *) _die "unknown flag: $1" 2;;
    esac
  done

  # Default agent server-side is "claude"; only send the field if user set it.
  local -a args=()
  [[ -n "$agent" ]] && args+=("agent=$agent")

  if [[ -n "$meta_raw" ]]; then
    # --meta accepts a JSON object literal. Validate via jq when available
    # so we don't ship a malformed body. Pass through raw: so it isn't
    # double-quoted as a string.
    if command -v jq >/dev/null 2>&1; then
      if ! printf '%s' "$meta_raw" | jq -e 'type == "object"' >/dev/null 2>&1; then
        _die "--meta must be a JSON object literal (e.g. '{\"k\":\"v\"}')" 2
      fi
    fi
    args+=("meta=raw:$meta_raw")
  fi

  local body
  if [[ ${#args[@]} -eq 0 ]]; then
    body='{}'
  else
    body="$(_json_obj_from_assoc "${args[@]}")"
  fi

  local resp
  resp="$(_request POST "$CLAWDFORGE_URL/sessions" "$CLAWDFORGE_TOKEN" "$body")"

  if (( json_out )); then
    printf '%s\n' "$resp"
    return 0
  fi
  # Default: machine-pipeable — just the UUID on stdout.
  if command -v jq >/dev/null 2>&1; then
    local sid
    sid="$(jq -r '.session_id // empty' <<<"$resp")"
    [[ -n "$sid" ]] || _die "session_id missing from server response" 1
    printf '%s\n' "$sid"
  else
    # Best-effort fallback parser when jq isn't installed.
    local sid
    sid="$(printf '%s' "$resp" | grep -o '"session_id"[[:space:]]*:[[:space:]]*"[^"]*"' \
              | head -n1 | sed -E 's/.*"session_id"[[:space:]]*:[[:space:]]*"([^"]*)".*/\1/')"
    [[ -n "$sid" ]] || _die "session_id missing from server response" 1
    printf '%s\n' "$sid"
  fi
}

cmd_session_turn() {
  _need_token
  local sid="${1:-}" prompt="${2:-}"
  if [[ -z "$sid" || -z "$prompt" ]]; then
    _die "usage: cf session turn <id> <prompt|->  [--files] [--timeout] [--json] [--trace path]" 2
  fi
  _validate_session_id "$sid" || _die "invalid session id: $sid" 2
  shift 2

  if [[ "$prompt" == "-" ]]; then
    if [[ -t 0 ]]; then
      _die "cf session turn -: stdin is a TTY, expected a pipe or redirect" 2
    fi
    prompt="$(cat)"
  fi

  local files="" timeout_secs="" json_out=0 trace_path=""
  while [[ $# -gt 0 ]]; do
    case "$1" in
      --files)    files="$2"; shift 2;;
      --timeout)  timeout_secs="$2"; shift 2;;
      --json)     json_out=1; shift;;
      --trace)    trace_path="$2"; shift 2;;
      --trace=*)  trace_path="${1#--trace=}"; shift;;
      *) _die "unknown flag: $1" 2;;
    esac
  done

  # Build body via the shared escape pipeline (S2 hygiene applies).
  local -a args=("prompt=$prompt")
  [[ -n "$timeout_secs" ]] && args+=("timeout_secs=$timeout_secs")

  if [[ -n "$files" ]]; then
    local farr
    farr="$(_json_string_array "$files")"
    args+=("files=raw:$farr")
  fi

  local body
  body="$(_json_obj_from_assoc "${args[@]}")"

  # URL-encode the session id so any future non-UUID format can't smuggle
  # path/query parts.
  local enc
  enc="$(_url_encode "$sid")"

  local resp
  resp="$(_request POST "$CLAWDFORGE_URL/sessions/$enc/turn" "$CLAWDFORGE_TOKEN" "$body")"

  # Emit the trace if requested. Trace = full JSON response, regardless of
  # --json. Non-fatal write errors surface to stderr but don't change exit.
  if [[ -n "$trace_path" ]]; then
    if ! printf '%s\n' "$resp" > "$trace_path" 2>/dev/null; then
      echo "cf: warning: failed to write trace to $trace_path" >&2
    fi
  fi

  if (( json_out )); then
    printf '%s\n' "$resp"
    return 0
  fi
  # Default: concatenated text events to stdout (matches `cf run` style).
  if command -v jq >/dev/null 2>&1; then
    # Print every text event's content concatenated, then a trailing newline
    # so terminal output is well-formed. Strip embedded NULs defensively.
    jq -r '.events[]? | select(.type == "text") | .content // empty' <<<"$resp" | tr -d '\0'
  else
    # No jq → print the whole body. Better than silent loss.
    printf '%s\n' "$resp"
  fi
}

cmd_session_get() {
  _need_token
  local sid="${1:-}"
  [[ -n "$sid" ]] || _die "usage: cf session get <id> [--json]" 2
  _validate_session_id "$sid" || _die "invalid session id: $sid" 2
  shift
  # Accept --json silently — get always returns JSON (no other rendering).
  while [[ $# -gt 0 ]]; do
    case "$1" in
      --json) shift;;
      *) _die "unknown flag: $1" 2;;
    esac
  done
  local enc
  enc="$(_url_encode "$sid")"
  _request GET "$CLAWDFORGE_URL/sessions/$enc" "$CLAWDFORGE_TOKEN"
}

cmd_session_list() {
  _need_token
  local json_out=0 include_closed=""
  while [[ $# -gt 0 ]]; do
    case "$1" in
      --json)             json_out=1; shift;;
      --include-closed)   include_closed=1; shift;;
      --no-include-closed) include_closed=0; shift;;
      *) _die "unknown flag: $1" 2;;
    esac
  done
  local url="$CLAWDFORGE_URL/sessions"
  if [[ "$include_closed" == "0" ]]; then
    url="$url?include_closed=false"
  elif [[ "$include_closed" == "1" ]]; then
    url="$url?include_closed=true"
  fi

  local resp
  resp="$(_request GET "$url" "$CLAWDFORGE_TOKEN")"

  if (( json_out )); then
    printf '%s\n' "$resp"
    return 0
  fi

  # Default: human table. Falls back to JSON when jq is unavailable.
  if command -v jq >/dev/null 2>&1; then
    # Header + one row per session. Tab-separated; pipe to column -t for
    # alignment if you like.
    printf 'SESSION_ID\tAGENT\tTURNS\tCREATED_AT\tCLOSED_AT\n'
    jq -r '.sessions[]? |
      [(.session_id // ""),
       (.agent // ""),
       ((.turn_count // 0) | tostring),
       ((.created_at // 0) | tostring),
       (if .closed_at == null then "-" else (.closed_at | tostring) end)
      ] | @tsv' <<<"$resp"
  else
    printf '%s\n' "$resp"
  fi
}

cmd_session_close() {
  _need_token
  local sid="${1:-}"
  [[ -n "$sid" ]] || _die "usage: cf session close <id> [--json]" 2
  _validate_session_id "$sid" || _die "invalid session id: $sid" 2
  shift
  local json_out=0
  while [[ $# -gt 0 ]]; do
    case "$1" in
      --json) json_out=1; shift;;
      *) _die "unknown flag: $1" 2;;
    esac
  done

  local enc
  enc="$(_url_encode "$sid")"

  # Idempotency: server returns 200 with already_closed:true on a second
  # call. Transport / 4xx / 5xx still propagate non-zero via _request.
  local resp
  resp="$(_request DELETE "$CLAWDFORGE_URL/sessions/$enc" "$CLAWDFORGE_TOKEN")"

  if (( json_out )); then
    printf '%s\n' "$resp"
    return 0
  fi

  local already=""
  if command -v jq >/dev/null 2>&1; then
    already="$(jq -r '.already_closed // false' <<<"$resp" 2>/dev/null || echo false)"
  else
    case "$resp" in
      *'"already_closed"'*'true'*) already="true" ;;
      *) already="false" ;;
    esac
  fi

  if [[ "$already" == "true" ]]; then
    echo "already-closed"
  else
    echo "closed"
  fi
}

cmd_session() {
  local sub="${1:-}"; shift || true
  case "$sub" in
    new|create)  cmd_session_new "$@" ;;
    turn)        cmd_session_turn "$@" ;;
    get)         cmd_session_get "$@" ;;
    list|ls)     cmd_session_list "$@" ;;
    close|delete) cmd_session_close "$@" ;;
    "") _die "usage: cf session <new|turn|get|list|close>" 2 ;;
    *) _die "unknown session subcommand: $sub (new|turn|get|list|close)" 2 ;;
  esac
}

cmd_help() {
  # U3: dedicated sentinel comment so help-text extraction survives reorgs.
  sed -n '2,/^# --- end help ---/{/^# --- end help ---/q;p}' "${BASH_SOURCE[0]}" | sed 's/^# \?//'
}

# ---------- dispatch -------------------------------------------------------

cmd="${1:-help}"
case "$cmd" in
  -h|--help|help)  cmd_help;;
  healthz)         shift; cmd_healthz "$@";;
  run)             shift; cmd_run "$@";;
  upload)          shift; cmd_upload "$@";;
  admin)           shift; cmd_admin "$@";;
  session)         shift; cmd_session "$@";;
  *) _die "unknown command: $cmd (healthz|run|upload|admin|session|help)" 2;;
esac
