#!/bin/sh
# byteask — ByteAsk AI coding agent CLI.
# Thin wrapper over the engine so the client surface is fully ByteAsk-branded.
set -eu

VERSION="0.1.9"
DEFAULT_GATEWAY="https://code.byteask.ai"
export CODEX_HOME="${BYTEASK_HOME:-$HOME/.byteask}"     # engine's config/home dir
export CODEX_BRAND="${BYTEASK_BRAND:-ByteAsk}"          # in-app banner brand
export BYTEASK_CLIENT_VERSION="$VERSION"                # engine displays THIS (not its crate ver)
SELF_DIR="$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)"
ENGINE="$SELF_DIR/byteask-engine"

# minimal JSON string-field extractor (no jq dependency on clients)
_json() { grep -o "\"$1\"[[:space:]]*:[[:space:]]*\"[^\"]*\"" | head -1 | sed -E "s/.*:[[:space:]]*\"([^\"]*)\"/\1/"; }

resolve_gateway() {
  if [ -n "${BYTEASK_GATEWAY:-}" ]; then echo "$BYTEASK_GATEWAY"
  elif [ -f "$CODEX_HOME/gateway" ]; then cat "$CODEX_HOME/gateway"
  else echo "$DEFAULT_GATEWAY"; fi
}

# ---- auto-update check: cached ~hourly, fail-open, never blocks launch ----
# State file holds:  last_check=<epoch>  latest=<ver>
# No "dismissed" memory: a declined update is re-offered on every launch, so a
# freshly published version keeps nudging until the user takes it.
UPDATE_STATE="$CODEX_HOME/update-check"

# version_gt A B  -> returns 0 if A > B (numeric, dot-separated), else 1.
# POSIX component compare (macOS `sort` has no -V, so we cannot use it).
version_gt() {
  _a="$1"; _b="$2"
  if [ "$_a" = "$_b" ]; then return 1; fi
  while [ -n "$_a" ] || [ -n "$_b" ]; do
    _ia=${_a%%.*}; _ib=${_b%%.*}
    case "$_a" in *.*) _a=${_a#*.};; *) _a="";; esac
    case "$_b" in *.*) _b=${_b#*.};; *) _b="";; esac
    case "$_ia" in ''|*[!0-9]*) _ia=0;; esac
    case "$_ib" in ''|*[!0-9]*) _ib=0;; esac
    if [ "$_ia" -gt "$_ib" ]; then return 0; fi
    if [ "$_ia" -lt "$_ib" ]; then return 1; fi
  done
  return 1
}

# Check the server for a newer version and (on a TTY) offer to update. Entirely
# best-effort: any failure (offline, no /version, bad data) is swallowed so the
# engine always launches. Opt out with BYTEASK_NO_UPDATE_CHECK=1.
check_for_update() {
  if [ -n "${BYTEASK_NO_UPDATE_CHECK:-}" ]; then return 0; fi
  _uc_latest=""; _uc_last=0
  if [ -f "$UPDATE_STATE" ]; then
    while IFS='=' read -r _k _v; do
      case "$_k" in
        last_check) _uc_last=$_v;;
        latest) _uc_latest=$_v;;
      esac
    done < "$UPDATE_STATE"
  fi
  case "$_uc_last" in ''|*[!0-9]*) _uc_last=0;; esac
  _uc_now=$(date +%s 2>/dev/null || echo 0)
  # Hit the network at most once per hour; cache the result. Fail-open. The
  # short TTL means a new release is noticed within the hour on any launch.
  if [ -z "$_uc_latest" ] || [ $(( _uc_now - _uc_last )) -ge 3600 ]; then
    _uc_gw="$(resolve_gateway)"; _uc_gw="${_uc_gw%/}"
    _uc_fetched=$(curl -fsS --max-time 2 "$_uc_gw/version" 2>/dev/null | head -n1 | tr -d '[:space:]') || _uc_fetched=""
    if printf '%s' "$_uc_fetched" | grep -qE '^[0-9]+(\.[0-9]+)+$'; then
      _uc_latest="$_uc_fetched"; _uc_last="$_uc_now"
      mkdir -p "$CODEX_HOME" 2>/dev/null || true
      printf 'last_check=%s\nlatest=%s\n' "$_uc_last" "$_uc_latest" > "$UPDATE_STATE" 2>/dev/null || true
    fi
  fi
  if [ -z "$_uc_latest" ]; then return 0; fi
  if ! version_gt "$_uc_latest" "$VERSION"; then return 0; fi   # not newer than installed
  if [ -t 0 ] && [ -t 1 ]; then
    printf 'ByteAsk %s is available (you have %s). Update now? [Y/n] ' "$_uc_latest" "$VERSION" >&2
    read -r _uc_ans </dev/tty 2>/dev/null || _uc_ans=""
    case "$_uc_ans" in
      ""|[yY]*)   # Enter (default) or y -> update
        _uc_gw="$(resolve_gateway)"; _uc_gw="${_uc_gw%/}"
        printf 'Updating ByteAsk to %s ...\n' "$_uc_latest" >&2
        # Install in place (over the running wrapper), then relaunch the fresh
        # wrapper with the original args. The relaunch carries a no-recheck guard
        # so a same-session update can never loop, even if the install no-ops.
        if curl -fsSL "$_uc_gw/install.sh" | PREFIX="$SELF_DIR" sh; then
          exec env BYTEASK_NO_UPDATE_CHECK=1 "$SELF_DIR/byteask" "$@"
        fi
        printf 'Update failed; continuing on %s.\n' "$VERSION" >&2
        ;;
      *) : ;;   # declined: continue now, re-offer next launch (no dismiss memory)
    esac
  else
    printf 'ByteAsk %s is available (you have %s). Run: byteask --update\n' "$_uc_latest" "$VERSION" >&2
  fi
  return 0
}

# ============================ BYOK (bring your own key) =====================
# A user's own OpenAI/Anthropic/Gemini key (or ChatGPT subscription) so their
# requests bill to THEIR account and never route through us. Keys + the managed
# JWT live in ~/.byteask/byok-config.json (0600) which a local Python "sidecar"
# (byok_sidecar.py, loopback only) reads; the engine points at the sidecar, which
# routes per model: own-key -> provider direct; no key -> managed gateway (billed
# as today). The engine holds NO secret in BYOK mode (JWT migrated to the sidecar
# store), so a repo re-pointing base_url can't exfiltrate anything. Requires python3.
BYOK_PORT="${BYOK_SIDECAR_PORT:-8799}"
BYOK_CFG="$CODEX_HOME/byok-config.json"
BYOK_SIDECAR="$CODEX_HOME/byok_sidecar.py"
BYOK_MODELS="$CODEX_HOME/byteask_models.py"      # shared self-hosted-models helper

_models_ready() {
  [ -f "$BYOK_MODELS" ] || { echo "byteask: self-hosted models need the helper; run 'byteask --update'." >&2; return 1; }
  command -v python3 >/dev/null 2>&1 || { echo "byteask: self-hosted models need python3 (not found on PATH)." >&2; return 1; }
}

# Re-merge the registry's self/* endpoints into the model catalog (idempotent +
# atomic; never drops a base cloud model). Called at launch + after add/remove so
# /model always reflects the registry, and a `byteask --update` (which re-fetches a
# base catalog) can never permanently drop custom rows.
_models_merge() {
  [ -f "$BYOK_MODELS" ] || return 0
  command -v python3 >/dev/null 2>&1 || return 0
  [ -f "$CODEX_HOME/models-catalog.json" ] || return 0
  python3 "$BYOK_MODELS" merge-catalog "$BYOK_CFG" "$CODEX_HOME/models-catalog.json" 2>/dev/null || true
}

# True when at least one self-hosted endpoint is registered (cheap grep guard so the
# merge/python spawn never touches the launch path for the 99% who have none).
_has_self_endpoints() { [ -f "$BYOK_CFG" ] && grep -q '"endpoints"' "$BYOK_CFG" 2>/dev/null; }

# Merge/read the JSON config via python3 (BYOK requires python3 anyway, so no jq dep).
_byok_py() { python3 - "$@"; }

_byok_keys_count() {   # echo the number of keys set (0 if no config)
  [ -f "$BYOK_CFG" ] || { echo 0; return; }
  _byok_py "$BYOK_CFG" <<'PY' 2>/dev/null || echo 0
import json,sys
try: print(len((json.load(open(sys.argv[1])).get("keys") or {})))
except Exception: print(0)
PY
}

_byok_field() {   # $1=field -> stdout (jwt|local_token|gateway)
  [ -f "$BYOK_CFG" ] || return 0
  _byok_py "$BYOK_CFG" "$1" <<'PY' 2>/dev/null
import json,sys
try: print(json.load(open(sys.argv[1])).get(sys.argv[2]) or "")
except Exception: print("")
PY
}

# Merge fields into byok-config.json (0600). Args: key=value pairs; a "keys.<prov>"
# path sets a provider key, plain names set a top-level field. Empty value deletes.
_byok_merge() {
  _byok_py "$BYOK_CFG" "$@" <<'PY'
import json,sys,os
path=sys.argv[1]
try: cfg=json.load(open(path))
except Exception: cfg={}
if not isinstance(cfg,dict): cfg={}
cfg.setdefault("keys",{})
for pair in sys.argv[2:]:
    k,_,v=pair.partition("=")
    if k.startswith("keys."):
        prov=k[5:]
        if v: cfg["keys"][prov]=v
        else: cfg["keys"].pop(prov,None)
    else:
        if v: cfg[k]=v
        else: cfg.pop(k,None)
os.makedirs(os.path.dirname(path),exist_ok=True)
fd=os.open(path,os.O_WRONLY|os.O_CREAT|os.O_TRUNC,0o600); os.write(fd,json.dumps(cfg).encode()); os.close(fd)
try: os.chmod(path,0o600)
except Exception: pass
PY
}

# Current JWT: from byok-config first (BYOK mode), else config.toml (managed).
_byok_current_jwt() {
  _j="$(_byok_field jwt)"
  [ -n "$_j" ] && { echo "$_j"; return; }
  sed -n 's/^experimental_bearer_token = "\(.*\)"$/\1/p' "$CODEX_HOME/config.toml" 2>/dev/null | head -n1
}

# Emails that have completed sign-in on THIS machine. Used to skip the referral-code
# prompt on re-login: referrals only credit a brand-new signup, so a returning email
# should never be asked. Persists across logout (logout does not clear it).
_email_known()    { _ke="$CODEX_HOME/.known-emails"; [ -f "$_ke" ] && grep -qixF "$1" "$_ke" 2>/dev/null; }
_email_remember() { _ke="$CODEX_HOME/.known-emails"; _email_known "$1" || printf '%s\n' "$1" >> "$_ke" 2>/dev/null || true; }

# Authoritative "has this email signed in before?" via the gateway (works across
# machines, unlike the local cache). Echoes: yes | no | (empty = couldn't tell).
# Fail-open: any network error / older gateway without the route -> empty, and the
# caller falls back to _email_known. curl -G --data-urlencode encodes '+','@' safely.
_server_email_exists() {
  _se_gw="$(resolve_gateway)"; _se_gw="${_se_gw%/}"
  [ -n "$_se_gw" ] || return 0
  _se_out=$(curl -fsS -m 3 -G --data-urlencode "email=$1" "$_se_gw/auth/account-exists" 2>/dev/null) || return 0
  case "$_se_out" in
    *'"exists"'*true*)  printf 'yes' ;;
    *'"exists"'*false*) printf 'no' ;;
  esac
}
# True (=> SKIP the referral prompt) when the email is a returning user. Prefers the
# server's answer; falls back to the local cache when the gateway can't be reached.
_email_returning() {
  case "$(_server_email_exists "$1")" in
    yes) return 0 ;;
    no)  return 1 ;;
    *)   _email_known "$1" ;;
  esac
}

# One gateway probe per sign-in attempt. From a single /auth/account-exists call it
# sets two globals: _EMAIL_BLOCK (the reason the address is refused, else empty) and
# _EMAIL_EXISTS (yes|no|"" when the gateway can't be reached). Fail-open: any network
# error leaves both empty -> allow, with /auth/start still the authoritative gate.
_email_probe() {
  _EMAIL_BLOCK=""; _EMAIL_EXISTS=""
  _ep_gw="$(resolve_gateway)"; _ep_gw="${_ep_gw%/}"
  [ -n "$_ep_gw" ] || return 0
  _ep_out=$(curl -fsS -m 3 -G --data-urlencode "email=$1" "$_ep_gw/auth/account-exists" 2>/dev/null) || return 0
  case "$_ep_out" in
    *'"blocked"'*true*) _EMAIL_BLOCK="$(printf '%s' "$_ep_out" | _json error)"; return 0 ;;
  esac
  case "$_ep_out" in
    *'"exists"'*true*)  _EMAIL_EXISTS="yes" ;;
    *'"exists"'*false*) _EMAIL_EXISTS="no" ;;
  esac
}
# True (0) => this email is a NEW signup, so the referral prompt should be shown. Reads
# _EMAIL_EXISTS from the preceding _email_probe (local cache when the server was silent).
_email_is_new() {
  case "$_EMAIL_EXISTS" in
    yes) return 1 ;;
    no)  return 0 ;;
    *)   if _email_known "$1"; then return 1; else return 0; fi ;;
  esac
}

# Preserve the model line + catalog line across config rewrites.
_cfg_model()   { sed -n 's/^model = "\(.*\)"$/\1/p' "$CODEX_HOME/config.toml" 2>/dev/null | head -n1; }
_cfg_catalog() { grep -n '^model_catalog_json = ' "$CODEX_HOME/config.toml" 2>/dev/null | head -n1 | sed 's/^[0-9]*://'; }

# Managed config.toml (the today path). Shared by do_login, `byok off`, and do_logout.
# An EMPTY token ($4) writes an UNSIGNED config (no experimental_bearer_token line at all,
# not an empty one) so the launch-loop onboard check correctly sees "not signed in".
write_managed_config() {  # $1=model $2=catalog_line $3=gateway $4=token (empty = unsigned)
  {
    cat <<EOF
model = "$1"
model_provider = "byteask"
web_search = "live"
$2

[model_providers.byteask]
name = "ByteAsk"
base_url = "$3/byteask/v1"
wire_api = "responses"
requires_openai_auth = false
EOF
    [ -n "$4" ] && printf 'experimental_bearer_token = "%s"\n' "$4"
    cat <<EOF

[model_providers.byteask.http_headers]
x-openai-actor-authorization = "byteask"
EOF
  } > "$CODEX_HOME/config.toml"
}

# BYOK config.toml: engine -> local sidecar, NO secret in the engine (JWT is in the
# sidecar store). supports_websockets=false so the engine never sends previous_response_id
# down a WS the local demux can't honor across providers (codex review).
write_byok_config() {  # $1=model $2=catalog_line $3=local_token
  cat > "$CODEX_HOME/config.toml" <<EOF
model = "$1"
model_provider = "byok-local"
web_search = "live"
$2

[model_providers.byok-local]
name = "ByteAsk (your key)"
base_url = "http://127.0.0.1:$BYOK_PORT/byteask/v1"
wire_api = "responses"
requires_openai_auth = false
supports_websockets = false

[model_providers.byok-local.http_headers]
X-BYOK-Token = "$3"
x-openai-actor-authorization = "byteask"
EOF
}

# Ensure the loopback sidecar is running + current. Lazy shared singleton: health
# probe; (re)start if down or if byok_sidecar.py is newer than our start marker
# (picks up a `byteask --update`). Returns non-zero if it can't come up.
ensure_sidecar() {
  [ -n "${BYOK_SKIP_SIDECAR:-}" ] && return 0   # test seam: skip the real spawn
  [ -f "$BYOK_SIDECAR" ] || { echo "byteask: BYOK sidecar not installed; run 'byteask --update'." >&2; return 1; }
  _pidf="$CODEX_HOME/byok-sidecar.pid"
  if curl -fsS "http://127.0.0.1:$BYOK_PORT/healthz" >/dev/null 2>&1; then
    [ -f "$_pidf" ] && [ "$_pidf" -nt "$BYOK_SIDECAR" ] && return 0   # healthy + current
    kill "$(cat "$_pidf" 2>/dev/null)" 2>/dev/null || true            # stale -> restart
    sleep 1
  fi
  command -v python3 >/dev/null 2>&1 || { echo "byteask: BYOK needs python3 (not found on PATH)." >&2; return 1; }
  BYOK_SIDECAR_PORT="$BYOK_PORT" nohup python3 "$BYOK_SIDECAR" >"$CODEX_HOME/byok-sidecar.log" 2>&1 &
  echo $! > "$_pidf"
  _i=0; while [ "$_i" -lt 30 ]; do
    curl -fsS "http://127.0.0.1:$BYOK_PORT/healthz" >/dev/null 2>&1 && return 0
    sleep 0.1; _i=$((_i+1))
  done
  echo "byteask: BYOK sidecar didn't start (see $CODEX_HOME/byok-sidecar.log)." >&2; return 1
}

# Validate a key against the provider's own /models endpoint. 200 -> ok; a clear
# 401/403 -> reject; anything else (offline/odd) -> warn but allow (surfaced later).
validate_key() {  # $1=provider $2=key -> 0 ok/allow, 1 reject
  case "$1" in
    openai)    _u="https://api.openai.com/v1/models"; _h="Authorization: Bearer $2";;
    anthropic) _u="https://api.anthropic.com/v1/models"; _h="x-api-key: $2";;
    gemini)    _u="https://generativelanguage.googleapis.com/v1beta/models"; _h="x-goog-api-key: $2";;
    *) return 0;;
  esac
  _extra=""; [ "$1" = anthropic ] && _extra="-H anthropic-version:2023-06-01"
  _code=$(curl -o /dev/null -s -w "%{http_code}" --max-time 15 -H "$_h" $_extra "$_u" 2>/dev/null || echo 000)
  case "$_code" in
    2*) return 0;;
    401|403) echo "  That $1 key was rejected by the provider (HTTP $_code)." >&2; return 1;;
    *) echo "  Couldn't verify the $1 key right now (HTTP $_code) — saving it anyway." >&2; return 0;;
  esac
}

# Enter/refresh BYOK: migrate JWT into the sidecar store, ensure a local token,
# (re)start the sidecar, and flip config.toml to the byok-local provider.
_byok_enter() {
  _jwt="$(_byok_current_jwt)"
  _lt="$(_byok_field local_token)"
  if [ -z "$_lt" ]; then
    _lt="$(_byok_py <<'PY'
import secrets; print(secrets.token_hex(24))
PY
)"
  fi
  _gw="$(resolve_gateway)"; _gw="${_gw%/}"
  _byok_merge "jwt=$_jwt" "local_token=$_lt" "gateway=$_gw"
  ensure_sidecar || return 1
  write_byok_config "$(_cfg_model)" "$(_cfg_catalog)" "$_lt"
  return 0
}

# Prompt (hidden) + validate + store + activate ONE provider key. Returns 0/1 (NO
# exit) so the source menu can loop on failure. Reused by `byok set` (CLI) + the menu.
_byok_add_key() {   # $1=provider
  _prov="$1"
  [ -f "$BYOK_SIDECAR" ] || { echo "byteask: BYOK needs the sidecar; run 'byteask --update' first." >&2; return 1; }
  command -v python3 >/dev/null 2>&1 || { echo "byteask: BYOK needs python3 (not found)." >&2; return 1; }
  # Interactive TTY -> hidden prompt; piped stdin -> read it (scriptable).
  if [ -t 0 ]; then
    _ui_title "Add your $_prov key"
    _ui_note "Hidden while you paste; saved locally (0600), never sent to ByteAsk"
    _ui_gap
    printf "  Paste key: "
    # The engine's TUI can leave the terminal in bracketed-paste + raw mode after
    # /login. On macOS Terminal that swallows the submit newline (paste wrapped in
    # ESC[200~..ESC[201~, Enter arrives as CR not LF), so a plain hidden `read` hangs
    # while Linux terminals deliver the newline fine. Disable bracketed paste + force
    # canonical line-mode with CR->NL so Enter submits everywhere; then restore.
    printf '\033[?2004l' 2>/dev/null
    _KEY_STTY=$(stty -g 2>/dev/null)
    stty -echo icanon icrnl 2>/dev/null
    read -r _KEY
    [ -n "$_KEY_STTY" ] && stty "$_KEY_STTY" 2>/dev/null || stty echo 2>/dev/null
    echo
  else
    read -r _KEY
  fi
  [ -n "$_KEY" ] || { echo "No key entered." >&2; return 1; }
  validate_key "$_prov" "$_KEY" || { _KEY=""; return 1; }
  _byok_merge "keys.$_prov=$_KEY"; _KEY=""   # drop from shell memory
  _byok_enter || return 1
  echo "Saved your $_prov key."
  return 0
}

# One-line per-provider state for the source menu + status.
_byok_status_line() {
  _byok_py "$BYOK_CFG" <<'PY' 2>/dev/null || echo "OpenAI=managed  Anthropic=managed  Gemini=managed"
import json,sys
try: keys=json.load(open(sys.argv[1])).get("keys") or {}
except Exception: keys={}
lbl={"openai":"OpenAI","anthropic":"Anthropic","gemini":"Gemini"}
print("   ".join("%s: %s"%(lbl[p],"your key" if keys.get(p) else "managed") for p in ("openai","anthropic","gemini")))
PY
}

# Per-provider menu verb: "change" if that key is already set, else "add". One python
# call returns all three, space-separated (openai anthropic gemini), so the menu label
# tells the user which providers are keyed without reading the Current: line.
_byok_key_verbs() {
  _byok_py "$BYOK_CFG" <<'PY' 2>/dev/null || echo "add add add"
import json,sys
try: keys=json.load(open(sys.argv[1])).get("keys") or {}
except Exception: keys={}
print(" ".join("change" if keys.get(p) else "add" for p in ("openai","anthropic","gemini")))
PY
}

# Signed in iff a JWT exists AND (best-effort) is not expired. The exp claim is decoded
# read-only via python (fail-safe: no python or undecodable -> treat as valid). An expired
# token counts as signed-out so the launch loop re-onboards instead of hitting a 401.
_is_signed_in() {
  _si_jwt="$(_byok_current_jwt)"; [ -n "$_si_jwt" ] || return 1
  command -v python3 >/dev/null 2>&1 || return 0
  BYOK_JWT="$_si_jwt" python3 - <<'PY' 2>/dev/null
import os, base64, json, time, sys
try:
    seg = os.environ["BYOK_JWT"].split(".")[1]; seg += "=" * (-len(seg) % 4)
    exp = json.loads(base64.urlsafe_b64decode(seg)).get("exp")
    sys.exit(1 if (exp and int(exp) < int(time.time())) else 0)   # expired -> signed-out
except Exception:
    sys.exit(0)   # undecodable -> assume valid (fail-safe)
PY
}

# Interactive iff a real TTY, or BYOK_ASSUME_TTY is set (test seam: pipe menu input).
_interactive() { [ -n "${BYOK_ASSUME_TTY:-}" ] || { [ -t 0 ] && [ -t 1 ]; }; }

# The signed-in email = the "sub" claim of the JWT (base64url middle segment). Best-effort
# via python3; empty if unavailable (menu then shows a generic "signed in" line).
_current_email() {
  _ce_jwt="$(_byok_current_jwt)"; [ -n "$_ce_jwt" ] || return 0
  command -v python3 >/dev/null 2>&1 || return 0
  BYOK_JWT="$_ce_jwt" python3 - <<'PY' 2>/dev/null
import os, base64, json
try:
    seg = os.environ["BYOK_JWT"].split(".")[1]
    seg += "=" * (-len(seg) % 4)
    print(json.loads(base64.urlsafe_b64decode(seg)).get("sub", "") or "")
except Exception:
    pass
PY
}

byok_set() {   # $1=provider — the scriptable CLI command (exits on error)
  _prov="$1"
  case "$_prov" in openai|anthropic|gemini) ;; *)
    echo "usage: byteask byok set <openai|anthropic|gemini> [--subscription]" >&2; exit 2;; esac
  # ChatGPT subscription (OpenAI only): engine-native pure session, NOT the sidecar.
  if [ "$_prov" = openai ] && { [ "${2:-}" = "--subscription" ] || [ "${2:-}" = "--sub" ]; }; then
    byok_subscription; return; fi
  _byok_add_key "$_prov" || exit 1
  echo "Keyed providers bill to your account; other models use ByteAsk managed (counts toward your usage)."
  echo "Relaunching..."; return 0
}

byok_status() {
  _n="$(_byok_keys_count)"
  if [ "$_n" = 0 ]; then echo "BYOK: off (all traffic is managed)."; return 0; fi
  echo "BYOK: on. Keyed providers (billed to you):"
  _byok_py "$BYOK_CFG" <<'PY' 2>/dev/null
import json,sys
try: keys=json.load(open(sys.argv[1])).get("keys") or {}
except Exception: keys={}
for p in ("openai","anthropic","gemini"):
    print("  - %s: %s" % (p, "your key" if keys.get(p) else "managed"))
PY
  echo "  Un-keyed providers use ByteAsk managed (billed to you, counts toward your usage)."
  if curl -fsS "http://127.0.0.1:$BYOK_PORT/healthz" >/dev/null 2>&1; then
    echo "  sidecar: running on 127.0.0.1:$BYOK_PORT"
  else echo "  sidecar: not running (starts on next launch)"; fi
}

byok_remove() {   # $1=provider
  case "$1" in openai|anthropic|gemini) ;; *)
    echo "usage: byteask byok remove <openai|anthropic|gemini>" >&2; exit 2;; esac
  _byok_merge "keys.$1="
  if [ "$(_byok_keys_count)" = 0 ]; then byok_off; else
    _byok_enter || true; echo "Removed your $1 key."; fi
}

# Leave BYOK entirely: stop the sidecar, restore the managed config with the JWT.
byok_off() {
  _jwt="$(_byok_current_jwt)"; _gw="$(resolve_gateway)"; _gw="${_gw%/}"
  [ "$(_byok_keys_count)" != 0 ] && _off_had_keys=1 || _off_had_keys=0
  [ -f "$CODEX_HOME/byok-sidecar.pid" ] && kill "$(cat "$CODEX_HOME/byok-sidecar.pid" 2>/dev/null)" 2>/dev/null || true
  rm -f "$CODEX_HOME/byok-sidecar.pid" 2>/dev/null || true
  _byok_merge "keys.openai=" "keys.anthropic=" "keys.gemini="
  write_managed_config "$(_cfg_model)" "$(_cfg_catalog)" "$_gw" "$_jwt"
  if [ "$_off_had_keys" = 1 ]; then
    echo "Switched to ByteAsk managed (billed to ByteAsk, /usage as normal)."
  else
    echo "You're on ByteAsk managed (billed to ByteAsk, /usage as normal)."
  fi
}

# ChatGPT subscription (D13=A): a pure OpenAI-only session via the engine's own
# login flow. Not the sidecar (its OAuth/refresh is engine-native).
byok_subscription() {
  echo "Sign in with your ChatGPT subscription (Plus/Pro/Business)."
  echo "Note: OpenAI's own sign-in screen appears (its Codex OAuth app); your"
  echo "subscription is used per OpenAI's terms. Anthropic/Gemini keys don't mix"
  echo "into a subscription session — use API keys for that."
  "$ENGINE" login || { echo "ChatGPT sign-in failed." >&2; exit 1; }
  # Switch to the built-in OpenAI provider (engine auto-selects the ChatGPT backend
  # for AuthMode::Chatgpt); leaves the sidecar untouched.
  cat > "$CODEX_HOME/config.toml" <<EOF
model = "$(_cfg_model)"
model_provider = "openai"
web_search = "live"
$(_cfg_catalog)
EOF
  echo "ChatGPT subscription active (OpenAI-only session). 'byteask byok off' to return to managed."
}

do_byok() {
  case "${1:-}" in
    set)    shift; byok_set "$@";;
    remove|rm) shift; byok_remove "$@";;
    status|"") byok_status;;
    off)    byok_off;;
    *) echo "usage: byteask byok <set|status|remove|off> [provider]" >&2; exit 2;;
  esac
}

# ===================== self-hosted / custom models (byteask models) =========
# Point ByteAsk at the user's OWN OpenAI-compatible server (vLLM/TGI/SGLang/Ollama/
# LM Studio/llama.cpp/DGX). The endpoint lives in the 0600 byok-config registry; the
# sidecar routes self/<alias> DIRECT to their box; a slim catalog row makes it show
# in /model. Their compute, their data — never billed, never through us. All the heavy
# logic (atomic registry, catalog merge + slim template, discovery, wire probe, test)
# is in the shared python helper so the sh + ps1 wrappers stay thin + identical.

_self_hosted_howto() {
  _ui_title "Use your own hosted model"
  _ui_note "Point ByteAsk at your OpenAI-compatible server (vLLM/TGI/Ollama/LM Studio/DGX):"
  _ui_note "  byteask models add my-model --url http://your-host:8000"
  _ui_note "Then pick self/my-model in /model. Your compute, your data — never billed."
  _ui_gap
}

models_add() {   # <alias> --url U [--model M] [--key K] [--wire W] [--ctx N] [--tools T]
                 #         [--name D] [--ca-bundle P] [--insecure] [--header K=V] [--ollama]
  _models_ready || return 1
  _ma_alias="${1:-}"; [ $# -gt 0 ] && shift
  case "$_ma_alias" in ""|-*) echo "usage: byteask models add <alias> --url <endpoint>" >&2; return 2;; esac
  _ma_alias="${_ma_alias#self/}"
  U=""; MID=""; KEY=""; WIRE="auto"; CTX=""; TOOLS="auto"; DISP=""; CA=""; INS=""; HDRS=""; OLLAMA=0
  while [ $# -gt 0 ]; do case "$1" in
    --url) U="$2"; shift 2;;
    --model) MID="$2"; shift 2;;
    --key) KEY="$2"; shift 2;;
    --wire) WIRE="$2"; shift 2;;
    --ctx) CTX="$2"; shift 2;;
    --tools) TOOLS="$2"; shift 2;;
    --name) DISP="$2"; shift 2;;
    --ca-bundle) CA="$2"; shift 2;;
    --insecure) INS=1; shift;;
    --header) HDRS="$HDRS
$2"; shift 2;;
    --ollama) OLLAMA=1; shift;;
    *) echo "byteask models add: unknown option $1" >&2; return 2;;
  esac; done
  if [ "$OLLAMA" = 1 ]; then [ -n "$U" ] || U="http://localhost:11434"; fi
  [ -n "$U" ] || { echo "byteask models add: --url is required (or --ollama)." >&2; return 2; }
  # Discover the server's model id if not given (kills the #1 misconfig: wrong id).
  if [ -z "$MID" ]; then
    echo "  probing $U for available models..." >&2
    _ids=$(python3 "$BYOK_MODELS" discover "$U" "$KEY" "$CA" "$INS" 2>/dev/null || true)
    [ -n "$_ids" ] || { echo "  couldn't list models from $U/v1/models — pass --model <id>." >&2; return 1; }
    if [ -t 0 ] && [ -t 1 ]; then
      _n=$(printf '%s\n' "$_ids" | wc -l | tr -d ' '); _i=1
      printf '%s\n' "$_ids" | while IFS= read -r _m; do printf '    %d) %s\n' "$_i" "$_m" >&2; _i=$((_i+1)); done
      printf "  pick model [1]: " >&2; read -r _pick </dev/tty 2>/dev/null || _pick=""
      case "$_pick" in ''|*[!0-9]*) _pick=1;; esac
      MID=$(printf '%s\n' "$_ids" | sed -n "${_pick}p")
    else
      MID=$(printf '%s\n' "$_ids" | sed -n 1p)
    fi
    [ -n "$MID" ] || { echo "  no model selected." >&2; return 1; }
    echo "  model: $MID" >&2
  fi
  # Auto-detect the wire protocol once (Responses-native vs Chat-Completions).
  if [ "$WIRE" = auto ]; then
    WIRE=$(python3 "$BYOK_MODELS" probe-wire "$U" "$MID" "$KEY" "$CA" "$INS" 2>/dev/null || echo chat)
    echo "  wire: $WIRE" >&2
  fi
  # Build the endpoint JSON via python (env vars dodge shell quoting) + atomic save.
  _ep=$(BK_URL="$U" BK_MID="$MID" BK_KEY="$KEY" BK_WIRE="$WIRE" BK_CTX="$CTX" BK_TOOLS="$TOOLS" \
        BK_DISP="$DISP" BK_CA="$CA" BK_INS="$INS" BK_HDRS="$HDRS" python3 - <<'PY'
import os, json
ep = {"base_url": os.environ["BK_URL"], "model_id": os.environ["BK_MID"],
      "wire": os.environ.get("BK_WIRE") or "auto", "tools": os.environ.get("BK_TOOLS") or "auto"}
if os.environ.get("BK_KEY"):  ep["api_key"] = os.environ["BK_KEY"]
if os.environ.get("BK_CTX"):  ep["context_window"] = int(os.environ["BK_CTX"])
if os.environ.get("BK_DISP"): ep["display_name"] = os.environ["BK_DISP"]
if os.environ.get("BK_CA"):   ep["ca_bundle"] = os.environ["BK_CA"]
if os.environ.get("BK_INS"):  ep["insecure"] = True
hdrs = {}
for ln in (os.environ.get("BK_HDRS") or "").splitlines():
    ln = ln.strip()
    if "=" in ln:
        k, _, v = ln.partition("="); hdrs[k.strip()] = v.strip()
if hdrs: ep["headers"] = hdrs
print(json.dumps(ep))
PY
)
  printf '%s' "$_ep" | python3 "$BYOK_MODELS" add "$BYOK_CFG" "$_ma_alias" >/dev/null \
    || { echo "  failed to save the endpoint." >&2; return 1; }
  # Force BYOK-local mode so the engine points at the sidecar — else self/<alias> would
  # go to the managed gateway. (_byok_enter preserves the endpoints we just wrote.)
  _byok_enter || echo "  (warning: couldn't start the local sidecar — run 'byteask' to retry)" >&2
  _models_merge
  echo "Added self/$_ma_alias  ($MID, wire=$WIRE). Pick it in /model, or run:  byteask --model self/$_ma_alias"
  echo "Test it end-to-end:  byteask models test $_ma_alias"
}

models_list() {
  _models_ready || return 1
  python3 "$BYOK_MODELS" list "$BYOK_CFG"
  if [ "${1:-}" = --check ]; then
    echo "  checking reachability..."
    for _al in $(python3 "$BYOK_MODELS" list "$BYOK_CFG" --json 2>/dev/null \
                 | python3 -c 'import sys,json; print(" ".join(json.load(sys.stdin).keys()))' 2>/dev/null); do
      _u=$(python3 "$BYOK_MODELS" get "$BYOK_CFG" "$_al" base_url 2>/dev/null)
      _k=$(python3 "$BYOK_MODELS" get "$BYOK_CFG" "$_al" api_key 2>/dev/null)
      if python3 "$BYOK_MODELS" discover "$_u" "$_k" >/dev/null 2>&1; then
        echo "    self/$_al: reachable"
      else echo "    self/$_al: UNREACHABLE"; fi
    done
  fi
}

models_test() {
  _models_ready || return 1
  _mt="${1:-}"; _mt="${_mt#self/}"
  [ -n "$_mt" ] || { echo "usage: byteask models test <alias>" >&2; return 2; }
  python3 "$BYOK_MODELS" test "$BYOK_CFG" "$_mt"
}

models_remove() {
  _models_ready || return 1
  _mr="${1:-}"; _mr="${_mr#self/}"
  [ -n "$_mr" ] || { echo "usage: byteask models remove <alias>" >&2; return 2; }
  _res=$(python3 "$BYOK_MODELS" remove "$BYOK_CFG" "$_mr" 2>/dev/null || echo notfound)
  # If it was the ACTIVE model, switch back to a safe cloud default (else the next
  # launch has model=self/<gone> which fail-closes on every turn).
  if [ "$(_cfg_model)" = "self/$_mr" ]; then
    _def="${BYTEASK_MODEL:-gpt-5.4}"
    sed -i.bak "s|^model = \"self/$_mr\"\$|model = \"$_def\"|" "$CODEX_HOME/config.toml" 2>/dev/null || true
    rm -f "$CODEX_HOME/config.toml.bak" 2>/dev/null || true
    echo "  (was your active model; switched to $_def)"
  fi
  _models_merge
  case "$_res" in removed) echo "Removed self/$_mr.";; *) echo "No self-hosted model 'self/$_mr'.";; esac
}

do_models() {
  case "${1:-}" in
    add)        shift; models_add "$@";;
    list|ls)    shift; models_list "$@";;
    test)       shift; models_test "$@";;
    remove|rm)  shift; models_remove "$@";;
    ""|help|--help|-h)
      cat >&2 <<EOF
Use your OWN hosted model (vLLM/TGI/Ollama/LM Studio/DGX) — your compute, never billed:
  byteask models add <alias> --url http://host:8000 [--model ID] [--key K] [--ollama]
                             [--wire auto|responses|chat] [--ctx N] [--ca-bundle P] [--insecure]
  byteask models list [--check]
  byteask models test <alias>
  byteask models remove <alias>
Then pick self/<alias> in /model.
EOF
      ;;
    *) echo "usage: byteask models <add|list|test|remove>" >&2; exit 2;;
  esac
}

# Numbered fallback (no real TTY / raw mode unavailable): print options, read a number.
# Echoes the chosen 1-based index, or 0 to cancel.
# Menu chrome — clean section title + dim helper lines. All to STDERR (fd 2), matching
# _menu_pick's option rendering, so they sit directly above the menu and never leak into
# a $(...) capture. ANSI: bold title, dim notes; degrade gracefully on dumb terminals.
_ui_title() { printf '\n  \033[1m%s\033[0m\n' "$1" >&2; }
_ui_note()  { printf '  \033[2m%s\033[0m\n' "$1" >&2; }
_ui_gap()   { printf '\n' >&2; }

_menu_pick_numbered() {
  _mp_n=$#; _mp_j=1
  for _mp_o in "$@"; do printf '  %d) %s\n' "$_mp_j" "$_mp_o" >&2; _mp_j=$((_mp_j+1)); done
  printf '> ' >&2
  read -r _mp_sel || _mp_sel=""
  case "$_mp_sel" in
    ''|q|Q) echo 0 ;;
    *[!0-9]*) echo 0 ;;
    *) if [ "$_mp_sel" -ge 1 ] 2>/dev/null && [ "$_mp_sel" -le "$_mp_n" ] 2>/dev/null; then echo "$_mp_sel"; else echo 0; fi ;;
  esac
}

# Arrow-navigable single-select menu. Args = option labels. RENDERS to /dev/tty and
# echoes the chosen 1-based index (0 = cancel) on stdout, so call it as $(_menu_pick ...).
# Up/Down move; Enter/Space select; 1-9 jump-select; q/Esc cancel. Raw mode is always
# restored via a trap (terminal never left broken); falls back to a number prompt if the
# tty/raw mode isn't available (e.g. piped input in tests).
_menu_pick() {
  _mp_n=$#
  # Reads keystrokes from STDIN (fd 0), renders to STDERR (fd 2, since stdout carries the
  # return value through $(...)). Requires stdin to be a real tty; otherwise (pipe, tests)
  # fall back to a numbered prompt that reads the same stdin.
  [ -t 0 ] || { _menu_pick_numbered "$@"; return; }
  _mp_saved=$(stty -g 2>/dev/null) || { _menu_pick_numbered "$@"; return; }
  # -icrnl -inlcr so Enter reads as a literal CR (distinct from EOF, which reads empty).
  stty -echo -icanon -icrnl -inlcr min 1 time 0 2>/dev/null || { _menu_pick_numbered "$@"; return; }
  trap 'stty "$_mp_saved" 2>/dev/null' EXIT INT TERM
  # Preserve exact control bytes: $(...) strips trailing newlines, so append+strip an X
  # sentinel. That lets us tell EOF (empty) from Enter (CR or LF, terminal-dependent).
  _mp_esc=$(printf '\033'); _mp_cr=$(printf '\rX'); _mp_cr=${_mp_cr%X}; _mp_nl=$(printf '\nX'); _mp_nl=${_mp_nl%X}
  # Initial highlighted row (1-based) from _MENU_START; clamp to range, default 1.
  _mp_i=${_MENU_START:-1}
  { [ "$_mp_i" -ge 1 ] && [ "$_mp_i" -le "$_mp_n" ]; } 2>/dev/null || _mp_i=1
  _mp_first=1
  while : ; do
    if [ "$_mp_first" = 1 ]; then _mp_first=0; else printf '\033[%dA' "$_mp_n" >&2; fi
    _mp_j=1
    for _mp_o in "$@"; do
      if [ "$_mp_j" = "$_mp_i" ]; then printf '\r\033[K\033[1;36m> %s\033[0m\n' "$_mp_o" >&2
      else printf '\r\033[K  %s\n' "$_mp_o" >&2; fi
      _mp_j=$((_mp_j+1))
    done
    _mp_k=$(dd bs=1 count=1 2>/dev/null; printf X); _mp_k=${_mp_k%X}
    case "$_mp_k" in
      "$_mp_esc")
        _mp_k2=$(dd bs=1 count=1 2>/dev/null)
        _mp_k3=$(dd bs=1 count=1 2>/dev/null)
        case "$_mp_k2$_mp_k3" in
          '[A'|'OA') if [ "$_mp_i" -gt 1 ]; then _mp_i=$((_mp_i-1)); else _mp_i=$_mp_n; fi ;;
          '[B'|'OB') if [ "$_mp_i" -lt "$_mp_n" ]; then _mp_i=$((_mp_i+1)); else _mp_i=1; fi ;;
        esac ;;
      ' '|"$_mp_cr"|"$_mp_nl") stty "$_mp_saved" 2>/dev/null; trap - EXIT INT TERM; echo "$_mp_i"; return ;;   # Enter/Space = select
      '') stty "$_mp_saved" 2>/dev/null; trap - EXIT INT TERM; echo 0; return ;;   # EOF = cancel/done
      q|Q) stty "$_mp_saved" 2>/dev/null; trap - EXIT INT TERM; echo 0; return ;;   # q = cancel
      [1-9]) if [ "$_mp_k" -le "$_mp_n" ]; then stty "$_mp_saved" 2>/dev/null; trap - EXIT INT TERM; echo "$_mp_k"; return; fi ;;
    esac
  done
}

# Interactive source selection: a LOOPING per-provider manage view. Keys mix freely
# (OpenAI + Anthropic + Gemini can all be set); un-keyed providers use ByteAsk managed.
# No-op when non-interactive (keeps the managed default). Keys are typed in the SHELL
# (hidden), never the TUI. Arrow-navigable (Up/Down + Enter) or type a number.
do_source_menu() {
  _interactive || return 0
  while : ; do
    _ui_title "Set up your API keys"
    _ui_note "Current:  $(_byok_status_line)"
    _ui_note "Keyed providers bill to you; un-keyed ones use ByteAsk managed"
    _ui_note "Up/Down + Enter, or type a number"
    _ui_gap
    # First login (no keys): default the cursor to "ByteAsk managed" so a bare Enter
    # just uses managed. Once keys are set: default to "Done" so a stray Enter never
    # wipes them. (5 = ByteAsk managed, 6 = Done.)
    if [ "$(_byok_keys_count)" = 0 ]; then _mp_start=6; else _mp_start=7; fi
    # Per-provider verb: "change" if that key is already set, else "add".
    set -- $(_byok_key_verbs); _v_oa=${1:-add}; _v_an=${2:-add}; _v_ge=${3:-add}
    _choice=$(_MENU_START="$_mp_start" _menu_pick \
      "OpenAI     - $_v_oa key" \
      "Anthropic  - $_v_an key" \
      "Gemini     - $_v_ge key" \
      "Remove a key" \
      "Use your own hosted model (DGX/vLLM/Ollama)" \
      "Use ByteAsk managed" \
      "Done")
    case "$_choice" in
      1) _byok_add_key openai    || true ;;
      2) _byok_add_key anthropic || true ;;
      3) _byok_add_key gemini    || true ;;
      4) printf 'Remove which key? [openai/anthropic/gemini]: '; read -r _rp
         case "$_rp" in openai|anthropic|gemini) byok_remove "$_rp" ;; *) echo "  (unknown provider)" ;; esac ;;
      5) _self_hosted_howto ;;   # self-hosted models: show the how-to, keep the menu open
      6) byok_off; return 0 ;;   # "Use ByteAsk managed" is a terminal choice -> act + exit
      *) return 0 ;;   # 7 (Done) or 0 (cancel / q / Esc)
      # NB: ChatGPT-subscription was dropped from this menu (2026-07-12) — it's an
      # engine-native pure-OpenAI session that bypasses the ByteAsk skin (upstream
      # tips/model-sync). byok_subscription() + `byteask byok subscription` stay for
      # direct/manual use; managed + your-own-key remain fully ByteAsk-skinned.
    esac
  done
}

# `/login` + `byteask login`: EMAIL is compulsory (identity, every user), THEN the
# source menu. A signed-in user gets a two-choice (change source / different email).
do_account() {
  if _is_signed_in; then
    _interactive || return 0   # already signed in + non-interactive: nothing to do
    _ac_em="$(_current_email)"
    _ui_title "ByteAsk account"
    if [ -n "$_ac_em" ]; then _ui_note "Signed in as $_ac_em"; else _ui_note "Signed in"; fi
    _ui_note "Up/Down + Enter, or type a number"
    _ui_gap
    _choice=$(_menu_pick "Change your key or plan" "Sign in with a different email")
    case "$_choice" in 2) do_login --interactive ;; *) : ;; esac
    do_source_menu
  else
    do_login --interactive   # email first (compulsory); writes managed config + JWT
    do_source_menu           # then choose source (no-op if non-interactive)
  fi
}

do_login() {
  GATEWAY="$(resolve_gateway)"; EMAIL=""; MODEL="${BYTEASK_MODEL:-gpt-5.4}"; AUTOCLICK=0; REF=""
  while [ $# -gt 0 ]; do case "$1" in
    --gateway) GATEWAY="$2"; shift 2;;
    --email) EMAIL="$2"; shift 2;;
    --model) MODEL="$2"; shift 2;;
    --ref=*) REF="${1#--ref=}"; shift;;
    --auto-click) AUTOCLICK=1; shift;;
    --interactive) shift;;   # onboarding entry: a no-op arg so callers invoke
                             # do_login WITH args, replacing $@ (else a POSIX
                             # function inherits the caller's args, e.g. a prompt)
    *) echo "byteask login: unknown option $1" >&2; exit 2;;
  esac; done
  # Referral code (one-shot, first signup only): an explicit --ref= flag wins, then
  # BYTEASK_REF, then the file install.sh wrote. Validated; cleared after sign-in.
  [ -n "$REF" ] || REF="${BYTEASK_REF:-}"
  if [ -z "$REF" ] && [ -f "$CODEX_HOME/referral" ]; then
    REF="$(cat "$CODEX_HOME/referral" 2>/dev/null || echo '')"
  fi
  case "$REF" in *[!A-Za-z0-9_-]*) REF="" ;; esac
  [ "${#REF}" -le 64 ] || REF=""
  GATEWAY="${GATEWAY%/}"
  # Whether the email came from --email (can't re-prompt) vs. asked interactively.
  _email_was_arg=0; [ -n "$EMAIL" ] && _email_was_arg=1
  poll=""; link=""
  # Sign-in loop: ask for the email, VALIDATE it (one gateway probe) BEFORE anything
  # else, and on an unsupported / rejected address show the reason and re-ask for a
  # different email instead of closing the session. Only ask for a referral code once
  # the email is accepted.
  while : ; do
    [ -n "$EMAIL" ] || { printf "Email: "; read -r EMAIL || { echo >&2; exit 1; }; }
    _email_probe "$EMAIL"
    if [ -n "$_EMAIL_BLOCK" ]; then
      echo "$_EMAIL_BLOCK" >&2
      { [ "$_email_was_arg" = 1 ] || [ ! -t 0 ] || [ ! -t 1 ]; } && exit 1
      echo "Please enter a different email." >&2; EMAIL=""; continue
    fi
    # First signup with no referral code yet — offer to enter one (interactive TTY only;
    # skipped when a --ref / BYTEASK_REF / install-time code already set REF above, OR when
    # this email has signed in before — referrals only credit a NEW signup, so a returning
    # user is never asked). You and the referrer EACH get $10 once; validated + one-shot.
    if [ -z "$REF" ] && [ -t 0 ] && [ -t 1 ] && _email_is_new "$EMAIL"; then
      printf "Have a referral code? You both get \$10 of usage (press Enter to skip): "
      read -r _ref_in || _ref_in=""
      REF="$(printf '%s' "$_ref_in" | tr -d '[:space:]')"
      case "$REF" in *[!A-Za-z0-9_-]*) REF="" ;; esac
      [ "${#REF}" -le 64 ] || REF=""
    fi
    echo "Signing in to ByteAsk as $EMAIL ..."
    body="{\"email\":\"$EMAIL\"}"
    [ -n "$REF" ] && body="{\"email\":\"$EMAIL\",\"ref\":\"$REF\"}"
    start=$(curl -fsS -X POST "$GATEWAY/auth/start" -H 'content-type: application/json' -d "$body" || true)
    poll=$(printf '%s' "$start" | _json poll_token)
    link=$(printf '%s' "$start" | _json dev_magic_link)
    [ -n "$poll" ] && break
    # Sign-in didn't start. The unsupported-email case is caught up front by the probe
    # (re-prompt above); anything reaching here is a network/other error -> show it and
    # exit (don't loop on a transient failure). Surface the gateway's message if it sent
    # one (probe was offline) rather than the raw body.
    _err=$(printf '%s' "$start" | _json error)
    [ -n "$_err" ] || _err="sign-in failed${start:+: $start}"
    echo "$_err" >&2; exit 1
  done
  echo "  -> Check $EMAIL for a sign-in link and click it."
  [ -n "$link" ] && echo "  -> (dev) link: $link"
  # Dev mode (no email configured): the gateway returns the link, so complete sign-in automatically.
  [ -n "$link" ] && curl -fsS "$link" >/dev/null 2>&1 && echo "  -> sign-in confirmed"
  echo "  -> Waiting for confirmation ..."
  token=""; i=0
  while [ "$i" -lt 120 ]; do
    r=$(curl -fsS -X POST "$GATEWAY/auth/poll" -H 'content-type: application/json' -d "{\"poll_token\":\"$poll\"}" || echo '{}')
    [ "$(printf '%s' "$r" | _json status)" = approved ] && { token=$(printf '%s' "$r" | _json access_token); break; }
    i=$((i+1)); sleep 1
  done
  [ -n "$token" ] || { echo "sign-in timed out" >&2; exit 1; }
  mkdir -p "$CODEX_HOME"; printf '%s' "$GATEWAY" > "$CODEX_HOME/gateway"
  # Model catalog: adds Claude (opus/sonnet) to /model with correct metadata. It
  # REPLACES the engine's bundled catalog, so a bad file would break startup -- only
  # reference it after the download validates. Fail-safe: on any failure skip it
  # (Claude still routes via the gateway, just with a "metadata not found" warning).
  CATALOG_LINE=""
  if curl -fsSL --max-time 20 "$GATEWAY/models-catalog.json" -o "$CODEX_HOME/models-catalog.json.tmp" 2>/dev/null \
     && grep -q '"models"' "$CODEX_HOME/models-catalog.json.tmp" 2>/dev/null; then
    mv "$CODEX_HOME/models-catalog.json.tmp" "$CODEX_HOME/models-catalog.json"
    CATALOG_LINE="model_catalog_json = \"$CODEX_HOME/models-catalog.json\""
  else
    rm -f "$CODEX_HOME/models-catalog.json.tmp" 2>/dev/null || true
  fi
  write_managed_config "$MODEL" "$CATALOG_LINE" "$GATEWAY" "$token"
  rm -f "$CODEX_HOME/referral" 2>/dev/null || true   # one-shot: only the first signup is credited
  _email_remember "$EMAIL"                           # so a future re-login skips the referral prompt
  echo "Signed in as $EMAIL. You're ready: byteask \"...\""
}

# Full logout across ALL auth stores. ByteAsk auth lives in THREE places depending on
# mode: managed = experimental_bearer_token in config.toml; BYOK = JWT+keys in
# byok-config.json (+ a running sidecar holding them in memory); subscription/ApiKey =
# auth.json. The old logout only cleared config.toml, so a BYOK user stayed signed in.
# This clears every store, stops the sidecar, and resets to a managed + UNSIGNED config so
# the next launch onboards. Reports on the ACTUAL post-state (_is_signed_in checks all
# stores) so a read-only file can't fake a logout.
do_logout() {
  _lo_was_signed=0; _is_signed_in && _lo_was_signed=1
  # 1. Stop the BYOK sidecar (holds the user's keys + JWT in memory; keeps serving).
  if [ -f "$CODEX_HOME/byok-sidecar.pid" ]; then
    kill "$(cat "$CODEX_HOME/byok-sidecar.pid" 2>/dev/null)" 2>/dev/null || true
    rm -f "$CODEX_HOME/byok-sidecar.pid" 2>/dev/null || true
  fi
  # 2. Clear EVERY local credential store.
  rm -f "$BYOK_CFG" 2>/dev/null || true                # byok-config.json (BYOK keys + JWT)
  rm -f "$CODEX_HOME/auth.json" 2>/dev/null || true    # ChatGPT OAuth / `login --with-api-key`
  # 3. Reset to managed + UNSIGNED (no token line) so the next launch forces sign-in.
  _lo_model="$(_cfg_model)"; [ -n "$_lo_model" ] || _lo_model="${BYTEASK_MODEL:-gpt-5.4}"
  _lo_gw="$(resolve_gateway)"; _lo_gw="${_lo_gw%/}"
  write_managed_config "$_lo_model" "$(_cfg_catalog)" "$_lo_gw" ""
  # 4. Report on the ACTUAL post-state.
  if _is_signed_in; then
    echo "Couldn't fully log out — check permissions on $CODEX_HOME" >&2; return 1
  fi
  if [ "$_lo_was_signed" = 1 ]; then
    echo "Logged out of ByteAsk. Run 'byteask' to sign back in."
  else
    echo "You're not signed in to ByteAsk."
  fi
  return 0
}

case "${1:-}" in
  --version|-V|version) echo "byteask $VERSION"; exit 0;;
  --update|update|upgrade)
    echo "Updating ByteAsk CLI..."
    _gw="$(resolve_gateway)"; _gw="${_gw%/}"
    exec sh -c "curl -fsSL '$_gw/install.sh' | PREFIX='$SELF_DIR' sh";;
  login)
    shift
    case " $* " in
      *" --with-api-key "*|*" --api-key "*) exec "$ENGINE" login "$@";;  # engine-native api-key path
    esac
    # `byteask login --email X` -> email flow + source menu; bare `byteask login` -> account menu.
    if [ $# -gt 0 ]; then do_login "$@"; do_source_menu; else do_account; fi
    exit 0;;
  logout)
    _lc=0; do_logout || _lc=$?; exit "$_lc";;
  byok)
    shift; _byok_sub="${1:-}"; do_byok "$@"
    # `byok set/remove` reconfigure + want a fresh launch. Clear args at THIS level
    # (a function's `set --` can't touch the caller's $@) then fall through to launch.
    case "$_byok_sub" in set|remove|rm) set -- ;; *) exit 0;; esac;;
  models)
    shift; _mc=0; do_models "$@" || _mc=$?; exit "$_mc";;
  --help|-h)
    # Intercept BEFORE the engine so wrapper-only commands (byok, models) are
    # discoverable (R2-D3); then show the engine's own flags too.
    cat >&2 <<EOF
ByteAsk $VERSION
  byteask                        launch (signs you in on first run)
  byteask "<prompt>"             one-shot run
  byteask login | logout         manage your ByteAsk sign-in
  byteask byok <set|status|remove|off> [provider]   use your own OpenAI/Anthropic/Gemini key
  byteask models <add|list|test|remove>             use your OWN hosted model (DGX/vLLM/Ollama)
  byteask --update               update the CLI

Engine options:
EOF
    exec "$ENGINE" --help;;
  --uninstall-gdb-bridge)
    _gi="$HOME/.gdbinit"
    _gm="# ===== ByteAsk GDB bridge (added by the byteask installer) ====="
    if [ -f "$_gi" ] && grep -qF "$_gm" "$_gi" 2>/dev/null; then
      sed -i.bak '/# ===== ByteAsk GDB bridge (added by the byteask installer) =====/,/# ===== end ByteAsk GDB bridge =====/d' "$_gi" \
        && echo "Removed the ByteAsk gdb bridge block from $_gi (backup: $_gi.bak)."
    else
      echo "No ByteAsk gdb bridge block in $_gi; nothing to remove."
    fi
    exit 0;;
esac

# Non-blocking, cached, fail-open update check before launching the engine
# (skipped for --version/--update/login, which exit in the case above). Args
# are forwarded so an accepted in-line update can relaunch with them intact.
check_for_update "$@" || true

# Launch loop. Normally we just run the engine once. But the TUI's `/login` and
# `/logout` can't hot-swap the startup-loaded token, so they drop a one-word marker
# and exit; we read it here and (re)authenticate with the shell flow, then relaunch.
# We run (not exec) the engine so we regain control after it exits to act on that.
AUTH_REQ="$CODEX_HOME/.byteask-auth-request"
rm -f "$AUTH_REQ" 2>/dev/null || true
while : ; do
  # Not signed in yet (mode-C default, no token). On an interactive terminal, onboard
  # right here so a bare `byteask` just works like `claude`/`codex`: do_login prompts
  # for the email and writes the token; then we launch. A non-interactive run can't
  # prompt, so it keeps the one-line nudge instead of hanging on a read.
  CFG="$CODEX_HOME/config.toml"
  # Force sign-in whenever a ByteAsk-owned provider (managed OR BYOK) is active but there's
  # no valid session in ANY store. _is_signed_in checks config.toml token + byok-config JWT
  # (and expiry), so a logged-out BYOK user is caught here too. Non-ByteAsk providers
  # (openai/subscription, mode A/B) manage their own auth in the engine — left untouched.
  _launch_prov=$(sed -n 's/^model_provider = "\(.*\)"$/\1/p' "$CFG" 2>/dev/null | head -n1)
  if { [ "$_launch_prov" = byteask ] || [ "$_launch_prov" = byok-local ]; } && ! _is_signed_in; then
    if [ -t 0 ] && [ -t 1 ]; then
      echo "Welcome to ByteAsk — let's get you signed in (one time)."
      # `--interactive` (a no-op flag) forces a WITH-args call so do_login does NOT
      # inherit this script's "$@" (e.g. a `byteask "prompt"`); "$@" is preserved.
      do_account
    else
      echo "You're not signed in to ByteAsk. Run:  byteask login --email you@company.com" >&2
      exit 1
    fi
  fi

  # BYOK mode: the engine points at the loopback sidecar, so make sure it's up +
  # current before launching (lazy shared singleton; auto-restarts a stale one after
  # an update). Fail-open: if it can't start, launch anyway and surface per-call errors.
  if grep -q '^model_provider = "byok-local"' "$CFG" 2>/dev/null; then
    ensure_sidecar || echo "byteask: BYOK sidecar unavailable — own-key models may fail this run." >&2
  fi

  # Linux: the engine sandboxes model-run shell commands with bubblewrap; if it isn't
  # installed (install.sh tries, but couldn't — e.g. no sudo), run WITHOUT the sandbox so
  # commands don't fail and the model doesn't spin trying to "fix bubblewrap". Skipped if
  # you set your own sandbox flag; re-checked each launch, so a later bwrap install
  # automatically restores the sandbox.
  _SANDBOX_ARG=""
  if [ "$(uname -s 2>/dev/null)" = Linux ] && ! command -v bwrap >/dev/null 2>&1; then
    case " $* " in
      *" --sandbox "*|*" -s "*|*" --dangerously-bypass-approvals-and-sandbox "*|*" --full-auto "*) ;;
      *) _SANDBOX_ARG="--sandbox danger-full-access" ;;
    esac
  fi

  # Self-hosted models: keep the /model catalog in sync with the registry before the
  # engine reads it (idempotent + atomic). This is what makes custom rows survive a
  # `byteask --update` that re-fetched a base catalog. Gated so non-self users pay nothing.
  _has_self_endpoints && _models_merge

  _rc=0; "$ENGINE" $_SANDBOX_ARG "$@" || _rc=$?

  # The engine's TUI can leave the terminal in raw / bracketed-paste mode on exit
  # (notably the /login marker path). Reset it so EVERY shell prompt below (email,
  # referral, key paste) submits on Enter across ALL terminals — a dirty terminal
  # swallows the submit newline on macOS Terminal while Linux terminals tolerate it.
  [ -t 0 ] && [ -t 1 ] && { stty sane 2>/dev/null; printf '\033[?2004l' 2>/dev/null; }

  # No auth action requested -> propagate the engine's exit code and stop.
  [ -f "$AUTH_REQ" ] || exit "$_rc"
  _act=$(cat "$AUTH_REQ" 2>/dev/null || echo ""); rm -f "$AUTH_REQ" 2>/dev/null || true
  case "$_act" in
    logout*) _lc=0; do_logout || _lc=$?; exit "$_lc" ;;               # /logout: clear token, back to shell
    login*)  do_account; set -- ; continue ;;                        # /login: account menu (email+source), relaunch
    *)       exit "$_rc" ;;
  esac
done
