#!/usr/bin/env bash
# claude-multiacc sandboxed test suite.
# No network, no real accounts, no quota: fake `claude` binary + file:// usage fixtures.
set -u

REPO_DIR="$(cd "$(dirname "$0")/.." && pwd -P)"
WORK="$(mktemp -d "${TMPDIR:-/tmp}/multiacc-test.XXXXXX")"
trap 'rm -rf "$WORK"' EXIT

PASS=0
FAIL=0
t_ok() { PASS=$((PASS+1)); printf 'ok   %s\n' "$1"; }
t_fail() { FAIL=$((FAIL+1)); printf 'FAIL %s%s\n' "$1" "${2:+ — $2}"; }
check() { # check <name> <expected-substring> <actual>
  case "$3" in
    *"$2"*) t_ok "$1" ;;
    *) t_fail "$1" "expected substring '$2', got: $(printf '%s' "$3" | head -c 200)" ;;
  esac
}

# ---- sandbox layout -------------------------------------------------------
export CLAUDE_ACCOUNTS_DIR="$WORK/accounts"
ACC="$CLAUDE_ACCOUNTS_DIR"
FAKEBIN="$WORK/fakebin"
mkdir -p "$ACC/tmp" "$FAKEBIN"

# Fake "real" claude: prints which config dir/token it ran under; scriptable failures.
cat > "$FAKEBIN/claude" <<'EOF'
#!/usr/bin/env bash
# fake real claude for tests (not a multiacc shim)
if [ "${1:-}" = "auth" ] && [ "${2:-}" = "status" ]; then
  if [ -n "${FAKE_AUTH_FAIL:-}" ]; then echo '{"loggedIn": false}'; exit 0; fi
  printf '{"loggedIn": true, "email": "%s"}\n' "${FAKE_EMAIL:-fake@test}"
  exit 0
fi
if [ "${1:-}" = "auth" ] && [ "${2:-}" = "login" ]; then
  [ -n "${FAKE_LOGIN_FAIL:-}" ] && { echo "login aborted" >&2; exit 1; }
  # simulate a completed full-scope login: write auto-refreshing creds to the config dir
  printf '{"claudeAiOauth":{"accessToken":"sk-ant-oat01-login","refreshToken":"r","expiresAt":9999999999999,"refreshTokenExpiresAt":9999999999999,"scopes":["user:inference"],"subscriptionType":"max"}}' > "${CLAUDE_CONFIG_DIR:-/dev/null}/.credentials.json"
  echo "Logged in."
  exit 0
fi
if [ "${1:-}" = "setup-token" ]; then
  echo "Open this sign-in link: https://claude.ai/oauth/authorize?fake=1"
  [ -n "${FAKE_TOKEN_FAIL:-}" ] && { echo "sign-in aborted" >&2; exit 1; }
  echo "Your token: sk-ant-oat01-FAKEFAKEFAKEFAKEFAKEFAKEFAKEFAKEFAKEFAKEFAKE"
  exit 0
fi
if [ $# -eq 0 ] && [ -n "${FAKE_DO_LOGIN:-}" ] && [ -n "${CLAUDE_CONFIG_DIR:-}" ]; then
  # simulate an interactive session in which the user completed /login
  printf '{"claudeAiOauth":{"accessToken":"sk-ant-oat01-new","refreshToken":"r","expiresAt":9999999999999,"refreshTokenExpiresAt":9999999999999}}' > "$CLAUDE_CONFIG_DIR/.credentials.json"
  exit 0
fi
ctl="${FAKE_CTL:-/nonexistent}"
acct="$(basename "${CLAUDE_CONFIG_DIR:-none}")"
if [ -f "$ctl" ] && grep -qx "fail:$acct" "$ctl" 2>/dev/null; then
  echo "API Error: 429 rate limit exceeded" >&2
  exit 1
fi
for a in "$@"; do
  case "$a" in
    --exit7) echo "ordinary failure, not auth related" >&2; exit 7 ;;
    --echo-stdin) cat; exit 0 ;;
  esac
done
echo "CFG=$acct TOK=${CLAUDE_CODE_OAUTH_TOKEN:-none}"
EOF
chmod +x "$FAKEBIN/claude"

export PATH="$REPO_DIR/bin:$FAKEBIN:$PATH"
export FAKE_CTL="$WORK/ctl"
# Neutralize any ambient state from the invoking environment.
unset CLAUDE_CONFIG_DIR CLAUDE_CODE_OAUTH_TOKEN CLAUDE_ACCOUNT CLAUDE_SHIM_ACTIVE 2>/dev/null || true
export CLAUDE_MULTIACC_NO_SYNC=1
# Fixtures are local file:// URLs with no rate limit, so the anti-429 fetch throttle
# is off by default here; the throttle test re-enables it explicitly.
export CLAUDE_MULTIACC_MIN_FETCH=0

now="$(date +%s)"

# ---- 1. passthrough: no manifest yet -------------------------------------
out="$(claude 2>&1)"
check "passthrough without manifest" "CFG=none" "$out"

# ---- manifest + two oauth accounts ----------------------------------------
cat > "$ACC/accounts.json" <<EOF
{
  "version": 1,
  "server": "root@203.0.113.1",
  "server_root": "/root/.claude-accounts",
  "server_repo": "/root/claude-multiacc",
  "threshold": 90,
  "accounts": [
    {"id": "acct-01", "email": "a@test", "home": "mac", "added_at": "2026-07-13T00:00:00Z"},
    {"id": "acct-02", "email": "b@test", "home": "mac", "added_at": "2026-07-13T00:00:00Z"}
  ]
}
EOF
for i in 01 02; do
  mkdir -p "$ACC/acct-$i"
  printf '{"claudeAiOauth":{"accessToken":"sk-ant-oat01-test%s","refreshToken":"r","expiresAt":9999999999999,"refreshTokenExpiresAt":9999999999999,"scopes":["user:inference"],"subscriptionType":"max"}}' "$i" > "$ACC/acct-$i/.credentials.json"
done

# ---- 2-4. passthrough guards ----------------------------------------------
out="$(CLAUDE_CONFIG_DIR=/tmp/other claude 2>&1)"
check "passthrough with CLAUDE_CONFIG_DIR" "CFG=other" "$out"
out="$(CLAUDE_CODE_OAUTH_TOKEN=sk-test claude 2>&1)"
check "passthrough with CLAUDE_CODE_OAUTH_TOKEN" "CFG=none" "$out"
out="$(CLAUDE_MULTIACC_DISABLE=1 claude 2>&1)"
check "passthrough when disabled" "CFG=none" "$out"

# ---- 5. headroom selection: most WEEKLY headroom wins (session is only a tiebreaker) --
lj() { # lj <weekly> <session> <max>  -> a fresh limits.json body
  printf '{"fetched_at":%s,"weekly_percent":%s,"session_percent":%s,"max_percent":%s,"buckets":[]}' "$now" "$1" "$2" "$3"
}
# acct-01 weekly 80, acct-02 weekly 20 => always acct-02.
lj 80 10 80 > "$ACC/acct-01/limits.json"
lj 20 10 20 > "$ACC/acct-02/limits.json"
all2=1
for _ in $(seq 1 15); do
  case "$(claude 2>&1)" in *CFG=acct-02*) ;; *) all2=0 ;; esac
done
[ "$all2" = "1" ] && t_ok "picks the highest weekly-headroom account (weekly 20 over 80)" \
  || t_fail "headroom selection" "picked the more-utilized account"

# THE KEY CASE from the research: a high (but sub-threshold) SESSION bucket must NOT
# deprioritize an account whose weekly headroom is better. acct-01: session 85, weekly 10;
# acct-02: session 20, weekly 70. Both eligible (max<90). acct-01 is the better pick —
# its near-full bucket is the 5h session (self-heals), weekly is nearly untouched.
lj 10 85 85 > "$ACC/acct-01/limits.json"
lj 70 20 70 > "$ACC/acct-02/limits.json"
all1=1
for _ in $(seq 1 15); do
  case "$(claude 2>&1)" in *CFG=acct-01*) ;; *) all1=0 ;; esac
done
[ "$all1" = "1" ] && t_ok "high session does NOT beat better weekly headroom (10w/85s over 70w/20s)" \
  || t_fail "weekly-over-session" "ranked the account with less weekly headroom higher"

# equal weekly => session breaks the tie toward the account with more session headroom
lj 40 20 40 > "$ACC/acct-01/limits.json"
lj 40 80 80 > "$ACC/acct-02/limits.json"
all1=1
for _ in $(seq 1 15); do
  case "$(claude 2>&1)" in *CFG=acct-01*) ;; *) all1=0 ;; esac
done
[ "$all1" = "1" ] && t_ok "equal weekly: lower session wins the tiebreak" \
  || t_fail "session tiebreak" "did not use session to break a weekly tie"

# fully equal scores spread load across accounts
lj 10 10 10 > "$ACC/acct-01/limits.json"
lj 10 10 10 > "$ACC/acct-02/limits.json"
hits1=0; hits2=0
for _ in $(seq 1 40); do
  case "$(claude 2>&1)" in
    *CFG=acct-01*) hits1=$((hits1+1)) ;;
    *CFG=acct-02*) hits2=$((hits2+1)) ;;
  esac
done
{ [ "$hits1" -gt 0 ] && [ "$hits2" -gt 0 ] && [ $((hits1+hits2)) -eq 40 ]; } \
  && t_ok "equal scores spread randomly (acct-01=$hits1 acct-02=$hits2)" \
  || t_fail "tie spreading" "acct-01=$hits1 acct-02=$hits2 (want both >0, total 40)"

# opt-out: legacy uniform-random mode still available
hits1=0; hits2=0
lj 80 80 80 > "$ACC/acct-01/limits.json"
for _ in $(seq 1 40); do
  case "$(CLAUDE_SHIM_SELECT=random claude 2>&1)" in
    *CFG=acct-01*) hits1=$((hits1+1)) ;;
    *CFG=acct-02*) hits2=$((hits2+1)) ;;
  esac
done
{ [ "$hits1" -gt 0 ] && [ "$hits2" -gt 0 ]; } \
  && t_ok "CLAUDE_SHIM_SELECT=random restores uniform spread" \
  || t_fail "random opt-out" "acct-01=$hits1 acct-02=$hits2"

# stale telemetry is neutral, never assumed free
printf '{"fetched_at":1,"weekly_percent":1,"session_percent":1,"max_percent":1,"buckets":[]}' > "$ACC/acct-01/limits.json"
lj 30 30 30 > "$ACC/acct-02/limits.json"
out="$(claude 2>&1)"
check "stale 1% loses to fresh 30% (stale is not trusted)" "CFG=acct-02" "$out"
rm -f "$ACC"/acct-*/limits.json

# ---- 6. explicit pin -------------------------------------------------------
out="$(CLAUDE_ACCOUNT=acct-02 claude 2>&1)"
check "CLAUDE_ACCOUNT pin" "CFG=acct-02" "$out"

# ---- 6b. pin works for an auth-less dir (login ceremony path) ----------------
mkdir -p "$ACC/acct-07"
out="$(CLAUDE_ACCOUNT=acct-07 claude 2>&1)"
check "pin to auth-less dir (ceremony)" "CFG=acct-07" "$out"
rmdir "$ACC/acct-07"

# ---- 7. limited marker excludes account ------------------------------------
printf '%s\nbucket=weekly_scoped:Fable percent=95 reason=limits\n' "$((now+3600))" > "$ACC/acct-01/.limited"
all2=1
for _ in $(seq 1 15); do
  out="$(claude 2>&1)"
  case "$out" in *CFG=acct-02*) ;; *) all2=0 ;; esac
done
[ "$all2" = "1" ] && t_ok "limited account excluded from pool" || t_fail "limited account excluded" "acct-01 was still picked"

# ---- 7b. pin overrides marker ----------------------------------------------
out="$(CLAUDE_ACCOUNT=acct-01 claude 2>&1)"
check "explicit pin wins over marker" "CFG=acct-01" "$out"

# ---- 8. expired marker auto-clears ------------------------------------------
printf '%s\nbucket=session percent=95 reason=limits\n' "$((now-10))" > "$ACC/acct-01/.limited"
claude >/dev/null 2>&1
[ ! -f "$ACC/acct-01/.limited" ] && t_ok "expired marker auto-cleared" || t_fail "expired marker auto-cleared" "marker still present"

# ---- 9. all limited -> least utilized fallback ------------------------------
printf '%s\nx\n' "$((now+3600))" > "$ACC/acct-01/.limited"
printf '%s\nx\n' "$((now+3600))" > "$ACC/acct-02/.limited"
printf '{"fetched_at":%s,"max_percent":97,"buckets":[]}' "$now" > "$ACC/acct-01/limits.json"
printf '{"fetched_at":%s,"max_percent":91,"buckets":[]}' "$now" > "$ACC/acct-02/limits.json"
out="$(claude 2>&1)"
check "all-limited falls back to least utilized" "CFG=acct-02" "$out"
grep -q "all-limited fallback=acct-02" "$ACC/selection.log" \
  && t_ok "fallback logged" || t_fail "fallback logged" "no all-limited line in selection.log"
rm -f "$ACC/acct-01/.limited" "$ACC/acct-02/.limited"

# ---- 9b. codex-review regressions: auth/marker/threshold hardening -------------
# empty .credentials.json must NOT count as auth (interrupted write)
mkdir -p "$ACC/acct-06"
: > "$ACC/acct-06/.credentials.json"
out="$(claude 2>&1)"
case "$out" in *CFG=acct-06*) t_fail "empty creds not selectable" "acct-06 was picked" ;;
  *) t_ok "empty .credentials.json is not treated as auth" ;; esac
rm -rf "$ACC/acct-06"

# fresh telemetry >= threshold excludes even when the .limited marker is missing
printf '{"fetched_at":%s,"max_percent":95,"buckets":[]}' "$now" > "$ACC/acct-01/limits.json"
printf '{"fetched_at":%s,"max_percent":10,"buckets":[]}' "$now" > "$ACC/acct-02/limits.json"
rm -f "$ACC"/acct-*/.limited
all2=1
for _ in $(seq 1 12); do
  out="$(claude 2>&1)"
  case "$out" in *CFG=acct-02*) ;; *) all2=0 ;; esac
done
[ "$all2" = "1" ] && t_ok "telemetry backstop excludes >=90% without a marker" \
  || t_fail "telemetry backstop" "acct-01 (95%) was still selected"

# ...but STALE >=90% telemetry must not exclude (fail open, no invented exclusions)
printf '{"fetched_at":1,"max_percent":95,"buckets":[]}' > "$ACC/acct-01/limits.json"
rm -f "$ACC/acct-02/.credentials.json"   # leave acct-01 as the only candidate
out="$(claude 2>&1)"
check "stale >=90% telemetry does not block (fail open)" "CFG=acct-01" "$out"
printf '{"claudeAiOauth":{"accessToken":"sk-ant-oat01-test02","refreshToken":"r","expiresAt":9999999999999,"refreshTokenExpiresAt":9999999999999,"scopes":["user:inference"],"subscriptionType":"max"}}' > "$ACC/acct-02/.credentials.json"

# a garbled/partial marker is treated as ACTIVE and never deleted (concurrent-write race)
printf 'GARBAGE-NOT-AN-EPOCH\n' > "$ACC/acct-01/.limited"
printf '{"fetched_at":%s,"max_percent":10,"buckets":[]}' "$now" > "$ACC/acct-01/limits.json"
out="$(claude 2>&1)"
case "$out" in *CFG=acct-02*) t_ok "garbled marker treated as active (excluded)" ;;
  *) t_fail "garbled marker" "acct-01 was selected despite an unparseable marker" ;; esac
[ -f "$ACC/acct-01/.limited" ] && t_ok "garbled marker not deleted by the shim" \
  || t_fail "garbled marker deleted" "shim destroyed a possibly-mid-write marker"
rm -f "$ACC"/acct-*/.limited "$ACC"/acct-*/limits.json

# ---- 10. token-only account exports CLAUDE_CODE_OAUTH_TOKEN ------------------
mkdir -p "$ACC/acct-03"
printf 'sk-ant-oat01-tok-for-03' > "$ACC/acct-03/server.token"
out="$(CLAUDE_ACCOUNT=acct-03 claude 2>&1)"
check "token-only account exports token" "TOK=sk-ant-oat01-tok-for-03" "$out"

# ---- 10b. user args survive selection (regression: pick_best must not touch "$@") --
out="$(claude --echo-stdin </dev/null 2>&1)"
[ -z "$out" ] && t_ok "args reach the real binary intact (no positional clobber)" \
  || t_fail "arg passthrough" "--echo-stdin ignored, got: $out"

# ---- 11. exit code passthrough (no retry on non-auth failure) ----------------
claude -p --exit7 >/dev/null 2>"$WORK/err11"
rc=$?
[ "$rc" = "7" ] && t_ok "exit code passthrough (rc=7)" || t_fail "exit code passthrough" "rc=$rc"

# ---- 12. -p retry on rate limit switches account -----------------------------
rm -f "$ACC"/acct-*/.limited
echo "fail:acct-01" > "$FAKE_CTL"
ok12=1
for _ in $(seq 1 10); do
  out="$(claude -p hello < /dev/null 2>/dev/null)"
  rc=$?
  { [ "$rc" = "0" ] && case "$out" in *CFG=acct-0[23]*) true ;; *) false ;; esac; } || ok12=0
done
[ "$ok12" = "1" ] && t_ok "-p retry recovers via another account" || t_fail "-p retry" "some run failed or used acct-01 output"
[ -f "$ACC/acct-01/.limited" ] && t_ok "failed account got error-cooldown marker" || t_fail "cooldown marker" "missing"
rm -f "$FAKE_CTL" "$ACC/acct-01/.limited"

# ---- 12b. pipe stdin skips retry buffering but passes bytes through -----------
out="$(printf 'pipe-data' | claude -p --echo-stdin 2>/dev/null)"
[ "$out" = "pipe-data" ] && t_ok "pipe stdin passes through (no retry buffering)" || t_fail "pipe stdin passthrough" "got: $out"

# ---- 12c. HOME unset: shim still fails open into passthrough ------------------
out="$(env -u HOME -u CLAUDE_ACCOUNTS_DIR claude 2>&1)"
check "HOME unset -> passthrough, no crash" "CFG=none" "$out"

# ---- 12d. error-cooldown marker survives a clean limits pass -------------------
printf '%s\nbucket=error-cooldown percent=? reason=error-cooldown\n' "$(( $(date +%s) + 600 ))" > "$ACC/acct-01/.limited"
cat > "$WORK/usage-mid.json" <<'EOF'
{"limits":[{"kind":"session","percent":10,"resets_at":"2099-01-01T00:00:00+00:00","scope":null}]}
EOF
CLAUDE_MULTIACC_USAGE_URL="file://$WORK/usage-mid.json" claude-accounts limits --quiet
[ -f "$ACC/acct-01/.limited" ] && t_ok "error-cooldown marker survives clean limits refresh" || t_fail "cooldown vs limits" "marker was cleared early"
rm -f "$ACC/acct-01/.limited"

# ---- 12e. TTY stdin: retry disabled so the terminal is never swapped for /dev/null --
if command -v script >/dev/null 2>&1; then
  # `claude -p` on a TTY with no prompt arg reads the terminal. Under a PTY the shim
  # must take the plain exec path (stdin inherited), never the buffered retry path.
  if [ "$(uname -s)" = "Darwin" ]; then
    ptyout="$(script -q /dev/null env PATH="$PATH" CLAUDE_ACCOUNTS_DIR="$ACC" FAKE_CTL="$FAKE_CTL" claude -p --echo-stdin <<'PTYIN' 2>/dev/null
tty-typed-prompt
PTYIN
)"
  else
    ptyout="$(script -qec "claude -p --echo-stdin" /dev/null <<'PTYIN' 2>/dev/null
tty-typed-prompt
PTYIN
)"
  fi
  case "$ptyout" in
    *tty-typed-prompt*) t_ok "TTY stdin reaches claude (retry path does not eat it)" ;;
    *) t_fail "TTY stdin" "terminal input was lost: $(printf '%s' "$ptyout" | head -c 80)" ;;
  esac
else
  t_ok "TTY stdin test skipped (no script(1))"
fi

# ---- 13. stdin/stdout byte fidelity through retry path -----------------------
printf 'line1\nline2 with spaces\n' > "$WORK/stdin13"
out="$(claude -p --echo-stdin < "$WORK/stdin13" 2>/dev/null)"
expected="$(cat "$WORK/stdin13")"
[ "$out" = "$expected" ] && t_ok "stdin/stdout byte fidelity (-p pipe)" || t_fail "stdin fidelity" "got: $out"

# ---- 14. selection log written ------------------------------------------------
grep -qE '^[0-9]{4}-[0-9]{2}-[0-9]{2}T.*acct-0[123] weekly=[0-9?]+% session=[0-9?]+% pwd=' "$ACC/selection.log" \
  && t_ok "selection.log format" || t_fail "selection.log format" "no matching lines"
grep -qE 'sk-ant-oat|accessToken|refreshToken' "$ACC/selection.log" \
  && t_fail "selection.log has no secrets" "a token leaked into the log" \
  || t_ok "selection.log leaks no secrets"

# ---- 15. CLI: list / import / remove ------------------------------------------
out="$(claude-accounts list 2>&1)"
check "list shows accounts" "acct-01" "$out"
printf '{"claudeAiOauth":{"accessToken":"sk-ant-oat01-t4","refreshToken":"r","expiresAt":9999999999999,"refreshTokenExpiresAt":9999999999999}}' > "$WORK/import-creds.json"
out="$(claude-accounts import c@test --id acct-04 --creds "$WORK/import-creds.json" --mode copy --no-sync 2>&1)"
check "import account" "Imported acct-04" "$out"
[ -f "$ACC/acct-04/.credentials.json" ] && t_ok "import copied credentials" || t_fail "import copied credentials" "file missing"
out="$(claude-accounts list 2>&1)"
check "imported account listed" "c@test" "$out"
out="$(claude-accounts remove acct-04 --yes 2>&1)"
check "remove account" "Removed acct-04" "$out"
[ ! -d "$ACC/acct-04" ] && t_ok "remove deleted dir" || t_fail "remove deleted dir" "dir still there"

# ---- 15b. duplicate-email guards ------------------------------------------------
# add of a named, already-present email => graceful SKIP before any sign-in (exit 0)
out="$(claude-accounts add a@test 2>&1)"
rc=$?
check "add skips a duplicate email with a message" "already added as acct-01 — skipping" "$out"
[ "$rc" = "0" ] && t_ok "duplicate add exits 0 (graceful skip)" || t_fail "duplicate add rc" "rc=$rc"
[ ! -d "$ACC/acct-04" ] && t_ok "duplicate add created nothing" || t_fail "duplicate add" "dir created"
# import stays strict (it's the lower-level command): refuses a duplicate
out="$(claude-accounts import a@test --id acct-09 --no-sync 2>&1)"
rc=$?
check "import refuses duplicate email" "already registered as acct-01" "$out"
[ ! -d "$ACC/acct-09" ] && t_ok "duplicate import created nothing" || t_fail "duplicate import" "dir created"
out="$(claude-accounts import a@test --id acct-01 --no-sync 2>&1)"
check "import same-id update allowed" "Imported acct-01" "$out"

# ---- 15c. login-first add (DEFAULT full-login flow): registers after verified auth --
out="$(CLAUDE_MULTIACC_FORCE_TTY=1 FAKE_EMAIL=new@test claude-accounts add new@test 2>&1)"
check "add registers after verified login" "Registered acct-04 for new@test" "$out"
[ -f "$ACC/acct-04/.credentials.json" ] && t_ok "default add writes .credentials.json (full login)" || t_fail "add creds" "missing"
[ ! -f "$ACC/acct-04/server.token" ] && t_ok "default add does NOT mint a setup-token" || t_fail "add token" "unexpected server.token"
out="$(CLAUDE_ACCOUNT=acct-04 claude 2>&1)"
check "new account usable via pin (oauth creds)" "CFG=acct-04" "$out"
claude-accounts remove acct-04 --yes >/dev/null 2>&1

# ---- 15c1. add --token: portable setup-token instead of creds ------------------------
out="$(CLAUDE_MULTIACC_FORCE_TTY=1 FAKE_EMAIL=tok@test claude-accounts add tok@test --token 2>&1)"
check "add --token registers via portable token" "Registered acct-04 for tok@test" "$out"
[ -s "$ACC/acct-04/server.token" ] && t_ok "add --token writes server.token" || t_fail "add --token" "missing"
out="$(CLAUDE_ACCOUNT=acct-04 claude 2>&1)"
check "token account exports CLAUDE_CODE_OAUTH_TOKEN" "TOK=sk-ant-oat01-FAKE" "$out"
claude-accounts remove acct-04 --yes >/dev/null 2>&1

# ---- 15c0. add with NO email: derives it from the verified sign-in -------------------
out="$(CLAUDE_MULTIACC_FORCE_TTY=1 FAKE_EMAIL=derived@test claude-accounts add 2>&1)"
check "add without email registers the signed-in address" "Registered acct-04 for derived@test" "$out"
claude-accounts list 2>&1 | grep -q "derived@test" && t_ok "email-less add lands in the manifest" \
  || t_fail "email-less add" "derived@test not registered"
claude-accounts remove acct-04 --yes >/dev/null 2>&1
# add with no email, but the signed-in email is already registered => refuse + clean up
out="$(CLAUDE_MULTIACC_FORCE_TTY=1 FAKE_EMAIL=a@test claude-accounts add 2>&1)"
rc=$?
check "email-less add skips a duplicate signed-in email" "a@test is already added as acct-01 — skipping" "$out"
[ "$rc" = "0" ] && t_ok "email-less duplicate skip exits 0" || t_fail "email-less dup rc" "rc=$rc"
[ ! -d "$ACC/acct-04" ] && t_ok "email-less duplicate cleaned up" || t_fail "email-less dup cleanup" "dir left"
# add with no email but identity can't be read back => refuse (can't label the account)
out="$(CLAUDE_MULTIACC_FORCE_TTY=1 FAKE_AUTH_FAIL=1 claude-accounts add 2>&1)"
rc=$?
check "email-less add needs a readable identity" "identity could not be read back" "$out"
[ ! -d "$ACC/acct-04" ] && t_ok "unverifiable email-less add cleaned up" || t_fail "unverifiable cleanup" "dir left"

# ---- 15c2. --tui is accepted as a no-op alias (full login is the default now) --------
out="$(CLAUDE_MULTIACC_FORCE_TTY=1 FAKE_EMAIL=tui@test claude-accounts add tui@test --tui 2>&1)"
check "add --tui still works (alias for default login)" "Registered acct-04 for tui@test" "$out"
[ -f "$ACC/acct-04/.credentials.json" ] && t_ok "--tui writes creds like the default" || t_fail "tui creds" "missing"
claude-accounts remove acct-04 --yes >/dev/null 2>&1

# ---- 15d. aborted login leaves zero traces ------------------------------------------
out="$(CLAUDE_MULTIACC_FORCE_TTY=1 FAKE_LOGIN_FAIL=1 claude-accounts add ghost@test 2>&1)"
rc=$?
check "aborted login detected" "login failed or aborted" "$out"
[ "$rc" != "0" ] && t_ok "aborted add exits nonzero" || t_fail "aborted add rc" "rc=0"
[ ! -d "$ACC/acct-04" ] && t_ok "aborted add cleaned up its dir" || t_fail "aborted add cleanup" "dir left behind"
claude-accounts list 2>&1 | grep -q ghost@test && t_fail "aborted add not in manifest" "ghost@test registered" || t_ok "aborted add not in manifest"
# aborted --token path too
out="$(CLAUDE_MULTIACC_FORCE_TTY=1 FAKE_TOKEN_FAIL=1 claude-accounts add ghost2@test --token 2>&1)"
rc=$?
check "aborted --token add detected" "sign-in failed or aborted" "$out"
[ ! -d "$ACC/acct-04" ] && t_ok "aborted --token add cleaned up" || t_fail "aborted token cleanup" "dir left"

# ---- 15e. sign-in as an already-registered email is rejected + cleaned ---------------
out="$(CLAUDE_MULTIACC_FORCE_TTY=1 FAKE_EMAIL=a@test claude-accounts add brand@test 2>&1)"
rc=$?
check "wrong-account sign-in skips (already added)" "a@test is already added as acct-01 — skipping" "$out"
[ "$rc" = "0" ] && t_ok "wrong-account skip exits 0" || t_fail "wrong-account rc" "rc=$rc"
[ ! -d "$ACC/acct-04" ] && t_ok "wrong-account sign-in cleaned up" || t_fail "wrong-account cleanup" "dir left behind"

# ---- 15f. login command completes auth for an existing auth-less account -------------
claude-accounts import pending@test --id acct-08 --no-sync >/dev/null 2>&1
out="$(CLAUDE_MULTIACC_FORCE_TTY=1 FAKE_EMAIL=pending@test claude-accounts login acct-08 2>&1)"
check "login completes existing account (full login)" "acct-08 login saved" "$out"
[ -f "$ACC/acct-08/.credentials.json" ] && t_ok "login writes .credentials.json" || t_fail "login creds" "missing"
out="$(CLAUDE_MULTIACC_FORCE_TTY=1 FAKE_EMAIL=other@test claude-accounts login acct-08 2>&1)"
rc=$?
check "login email mismatch refused" "nothing saved" "$out"
[ "$rc" != "0" ] && t_ok "mismatched login exits nonzero" || t_fail "mismatched login rc" "rc=0"
# login --token writes a portable token for the same account
out="$(CLAUDE_MULTIACC_FORCE_TTY=1 FAKE_EMAIL=pending@test claude-accounts login acct-08 --token 2>&1)"
check "login --token saves a portable token" "acct-08 token saved" "$out"
[ -s "$ACC/acct-08/server.token" ] && t_ok "login --token writes server.token" || t_fail "login token" "missing"
claude-accounts remove acct-08 --yes >/dev/null 2>&1

# ---- 15g. parallel adds (different terminals) get distinct ids, no lock-busy error ----
( CLAUDE_MULTIACC_FORCE_TTY=1 FAKE_EMAIL=par1@test claude-accounts add >"$WORK/p1.out" 2>&1 ) &
pA=$!
( CLAUDE_MULTIACC_FORCE_TTY=1 FAKE_EMAIL=par2@test claude-accounts add >"$WORK/p2.out" 2>&1 ) &
pB=$!
wait "$pA"; wait "$pB"
both="$(cat "$WORK/p1.out" "$WORK/p2.out" 2>/dev/null)"
printf '%s' "$both" | grep -q "in progress" \
  && t_fail "parallel add: no lock-busy error" "got the 'another add in progress' error" \
  || t_ok "parallel add: neither reports 'another add in progress'"
regA="$(claude-accounts list 2>&1 | grep par1@test | awk '{print $1}')"
regB="$(claude-accounts list 2>&1 | grep par2@test | awk '{print $1}')"
{ [ -n "$regA" ] && [ -n "$regB" ] && [ "$regA" != "$regB" ]; } \
  && t_ok "parallel add: both registered on distinct ids ($regA, $regB)" \
  || t_fail "parallel add ids" "regA=$regA regB=$regB"
# and no duplicate/second entry crept in for either email
{ [ "$(claude-accounts list 2>&1 | grep -c par1@test)" = "1" ] && [ "$(claude-accounts list 2>&1 | grep -c par2@test)" = "1" ]; } \
  && t_ok "parallel add: exactly one entry per email" || t_fail "parallel add dup" "duplicate created"
[ -n "$regA" ] && claude-accounts remove "$regA" --yes >/dev/null 2>&1
[ -n "$regB" ] && claude-accounts remove "$regB" --yes >/dev/null 2>&1

# ---- 15h. two parallel adds signing into the SAME email: exactly one wins ------------
( CLAUDE_MULTIACC_FORCE_TTY=1 FAKE_EMAIL=same@test claude-accounts add >"$WORK/s1.out" 2>&1 ) &
sA=$!
( CLAUDE_MULTIACC_FORCE_TTY=1 FAKE_EMAIL=same@test claude-accounts add >"$WORK/s2.out" 2>&1 ) &
sB=$!
wait "$sA"; wait "$sB"
n="$(claude-accounts list 2>&1 | grep -c same@test)"
[ "$n" = "1" ] && t_ok "same-email parallel add: exactly one registered (other skipped)" \
  || t_fail "same-email parallel add" "count=$n (expected 1)"
cat "$WORK/s1.out" "$WORK/s2.out" 2>/dev/null | grep -q "already added as\|skipping" \
  && t_ok "same-email parallel add: loser skipped gracefully" || t_fail "same-email skip msg" "no skip message"
regS="$(claude-accounts list 2>&1 | grep same@test | awk '{print $1}')"
[ -n "$regS" ] && claude-accounts remove "$regS" --yes >/dev/null 2>&1

# ---- 15i. dedupe removes accounts registered twice (keeps one per email) --------------
out="$(claude-accounts dedupe 2>&1)"
check "dedupe on a clean pool is a no-op" "No duplicate accounts" "$out"
# manufacture a duplicate directly in the manifest (a pre-fix leftover) + a dir for it
claude-accounts import twin@test --id acct-06 --creds "$WORK/import-creds.json" --mode copy --no-sync >/dev/null 2>&1
python3 - "$ACC/accounts.json" <<'EOF'
import json, os, sys
d = json.load(open(sys.argv[1]))
d['accounts'].append({'id': 'acct-07', 'email': 'twin@test', 'home': 'mac'})
json.dump(d, open(sys.argv[1] + '.tmp', 'w'), indent=2); os.replace(sys.argv[1] + '.tmp', sys.argv[1])
EOF
mkdir -p "$ACC/acct-07"
out="$(claude-accounts list 2>&1)"
check "list warns about a duplicate email" "twin@test is registered 2x" "$out"
out="$(claude-accounts dedupe --yes 2>&1)"
check "dedupe reports removal" "Removed 1 duplicate" "$out"
[ "$(claude-accounts list 2>&1 | grep -c twin@test)" = "1" ] && t_ok "dedupe keeps exactly one per email" || t_fail "dedupe count" "not 1"
# it kept the authed one (acct-06 has creds), removed the bare acct-07
claude-accounts list 2>&1 | grep -q "acct-06.*twin@test" && t_ok "dedupe kept the authenticated account" || t_fail "dedupe keep-authed" "kept the wrong one"
claude-accounts remove acct-06 --yes >/dev/null 2>&1

# ---- 16. CLI: limits marking via fixture endpoint ------------------------------
cat > "$WORK/usage-high.json" <<'EOF'
{"limits":[
  {"kind":"session","percent":29,"resets_at":"2099-01-01T00:00:00+00:00","scope":null},
  {"kind":"weekly_all","percent":55,"resets_at":"2099-01-02T00:00:00+00:00","scope":null},
  {"kind":"weekly_scoped","percent":93,"resets_at":"2099-01-03T00:00:00+00:00","scope":{"model":{"display_name":"Fable"}}}
]}
EOF
out="$(CLAUDE_MULTIACC_USAGE_URL="file://$WORK/usage-high.json" claude-accounts limits 2>&1)"
check "limits marks Fable bucket >=90%" "LIMITED weekly_scoped:Fable at 93%" "$out"
[ -f "$ACC/acct-01/.limited" ] && t_ok ".limited written by limits" || t_fail ".limited written" "missing"
grep -q "weekly_scoped:Fable" "$ACC/acct-01/limits.json" \
  && t_ok "limits.json has Fable bucket" || t_fail "limits.json Fable bucket" "missing"
out="$(CLAUDE_ACCOUNT='' claude 2>&1)"  # pool should now avoid marked accounts (all marked -> fallback fine)
cat > "$WORK/usage-low.json" <<'EOF'
{"limits":[
  {"kind":"session","percent":10,"resets_at":"2099-01-01T00:00:00+00:00","scope":null},
  {"kind":"weekly_scoped","percent":45,"resets_at":"2099-01-03T00:00:00+00:00","scope":{"model":{"display_name":"Fable"}}}
]}
EOF
out="$(CLAUDE_MULTIACC_USAGE_URL="file://$WORK/usage-low.json" claude-accounts limits 2>&1)"
check "limits clears marker under threshold" "marker cleared" "$out"
[ ! -f "$ACC/acct-01/.limited" ] && t_ok "marker removed after clear" || t_fail "marker removed" "still present"

# ---- 16-weekly. limits classifies session vs weekly and records both signals --------
cat > "$WORK/usage-3bucket.json" <<'EOF'
{"limits":[
  {"kind":"session","group":"session","percent":88,"resets_at":"2099-01-01T00:00:00+00:00","scope":null},
  {"kind":"weekly_all","group":"weekly","percent":40,"resets_at":"2099-01-05T00:00:00+00:00","scope":null},
  {"kind":"weekly_scoped","group":"weekly","percent":55,"resets_at":"2099-01-05T00:00:00+00:00","scope":{"model":{"display_name":"Fable"}}}
]}
EOF
CLAUDE_MULTIACC_USAGE_URL="file://$WORK/usage-3bucket.json" claude-accounts limits --quiet
python3 - "$ACC/acct-01/limits.json" <<'EOF'
import json, sys
d = json.load(open(sys.argv[1]))
assert d["session_percent"] == 88, d
assert d["weekly_percent"] == 55, d          # max of the two weekly buckets, not session
assert d["max_percent"] == 88, d             # peak of ALL buckets (drives exclusion)
groups = {b["name"]: b["group"] for b in d["buckets"]}
assert groups["session"] == "session", groups
assert groups["weekly_all"] == "weekly", groups
assert groups["weekly_scoped:Fable"] == "weekly", groups
EOF
[ $? -eq 0 ] && t_ok "limits records weekly_percent(55) + session_percent(88) with correct groups" \
  || t_fail "weekly/session classification" "see limits.json"
# a session bucket at 88 must NOT poison weekly ranking: score uses weekly(55), not 88
sc="$(python3 -c "import json;d=json.load(open('$ACC/acct-01/limits.json'));print(d['weekly_percent']*1000+d['session_percent'])")"
[ "$sc" = "55088" ] && t_ok "ranking score weighs weekly over session (55088)" \
  || t_fail "ranking score" "got $sc"
rm -f "$ACC/acct-01/limits.json" "$ACC/acct-01/.limited"

# ---- 16a. future-proofing: works if the Fable bucket separation disappears ---------
cat > "$WORK/usage-no-fable.json" <<'EOF'
{"limits":[
  {"kind":"session","percent":20,"resets_at":"2099-01-01T00:00:00+00:00","scope":null},
  {"kind":"weekly_all","percent":95,"resets_at":"2099-01-02T00:00:00+00:00","scope":null}
]}
EOF
out="$(CLAUDE_MULTIACC_USAGE_URL="file://$WORK/usage-no-fable.json" claude-accounts limits 2>&1)"
check "no-Fable payload still marks on weekly_all" "LIMITED weekly_all at 95%" "$out"
grep -q "weekly_scoped" "$ACC/acct-01/limits.json" && t_fail "no stale Fable bucket" "old bucket kept" || t_ok "buckets reflect current payload only"

# ---- 16a2. legacy payload (no limits[] at all) falls back to five_hour/seven_day ----
cat > "$WORK/usage-legacy.json" <<'EOF'
{"five_hour":{"utilization":12.0,"resets_at":"2099-01-01T00:00:00+00:00"},
 "seven_day":{"utilization":34.0,"resets_at":"2099-01-02T00:00:00+00:00"}}
EOF
out="$(CLAUDE_MULTIACC_USAGE_URL="file://$WORK/usage-legacy.json" claude-accounts limits 2>&1)"
check "legacy payload parsed via fallback" "five_hour=12%" "$out"
check "legacy marker cleared under threshold" "acct-01: ok" "$out"

# ---- 16a3. malformed/garbage payload entries never crash the refresher --------------
cat > "$WORK/usage-garbage.json" <<'EOF'
{"limits":[
  {"kind":"session","percent":"NaNsense","scope":{"model":"stringnotdict"}},
  "not-even-a-dict",
  {"percent":41,"scope":{"model":{"display_name":null,"id":"claude-fable-5"}}},
  {"kind":"weekly_all","percent":null}
]}
EOF
out="$(CLAUDE_MULTIACC_USAGE_URL="file://$WORK/usage-garbage.json" claude-accounts limits 2>&1)"
rc=$?
[ "$rc" = "0" ] && t_ok "garbage payload exits 0 (fail open)" || t_fail "garbage payload rc" "rc=$rc: $out"
check "parseable entry survives garbage siblings" "unknown:claude-fable-5=41%" "$out"

# ---- 16a4. fetch throttle: fresh data is not re-fetched -----------------------------
CLAUDE_MULTIACC_USAGE_URL="file://$WORK/usage-low.json" claude-accounts limits --quiet
before="$(python3 -c "import json; print(json.load(open('$ACC/acct-01/limits.json'))['fetched_at'])")"
mv "$WORK/usage-low.json" "$WORK/usage-low.hidden"   # a real fetch would now fail loudly
out="$(CLAUDE_MULTIACC_MIN_FETCH=45 CLAUDE_MULTIACC_USAGE_URL="file://$WORK/usage-low.json" claude-accounts limits 2>&1)"
after="$(python3 -c "import json; print(json.load(open('$ACC/acct-01/limits.json'))['fetched_at'])")"
{ [ "$before" = "$after" ] && ! printf '%s' "$out" | grep -q "fetch failed"; } \
  && t_ok "fresh data skips re-fetch (rate-limit protection)" \
  || t_fail "fetch throttle" "re-fetched despite fresh data: $out"
mv "$WORK/usage-low.hidden" "$WORK/usage-low.json"
out="$(CLAUDE_MULTIACC_MIN_FETCH=45 CLAUDE_MULTIACC_USAGE_URL="file://$WORK/usage-low.json" claude-accounts limits --force 2>&1)"
check "--force bypasses the throttle" "acct-01: ok" "$out"

# ---- 16a5. 429 sets backoff, is honored, and clears on success -----------------------
python3 - "$ACC/acct-01/limits.json" <<'EOF'
import json, os, sys, time
p = sys.argv[1]
d = json.load(open(p))
d['fetched_at'] = 0                        # stale enough to fetch
d['retry_after'] = int(time.time()) + 600  # but a 429 backoff is in force
d['backoff'] = 600
json.dump(d, open(p + '.tmp', 'w'), indent=1); os.replace(p + '.tmp', p)
EOF
out="$(CLAUDE_MULTIACC_USAGE_URL="file://$WORK/usage-low.json" claude-accounts limits 2>&1)"
check "429 backoff honored" "acct-01: backing off after 429" "$out"
out="$(CLAUDE_MULTIACC_USAGE_URL="file://$WORK/usage-low.json" claude-accounts limits --force 2>&1)"
check "--force overrides backoff" "acct-01: ok" "$out"
python3 -c "
import json, sys
d = json.load(open('$ACC/acct-01/limits.json'))
sys.exit(0 if 'retry_after' not in d and 'backoff' not in d else 1)" \
  && t_ok "successful fetch clears backoff state" || t_fail "backoff cleared" "retry_after/backoff persisted"

# ---- 16b. expired-bearer account skipped gracefully (fail open) -----------------
mkdir -p "$ACC/acct-05"
printf '{"claudeAiOauth":{"accessToken":"sk-ant-oat01-old","refreshToken":"r","expiresAt":1000,"refreshTokenExpiresAt":9999999999999}}' > "$ACC/acct-05/.credentials.json"
claude-accounts import e@test --id acct-05 --no-sync >/dev/null 2>&1
out="$(CLAUDE_MULTIACC_USAGE_URL="file://$WORK/usage-low.json" claude-accounts limits 2>&1)"
rc=$?
check "expired bearer logged, not fatal" "acct-05: no fresh bearer" "$out"
[ "$rc" = "0" ] && t_ok "limits exits 0 with expired-bearer account" || t_fail "limits exit code" "rc=$rc"
[ ! -f "$ACC/acct-05/limits.json" ] && t_ok "no limits.json fabricated for expired account" || t_fail "expired acct limits.json" "unexpectedly written"
claude-accounts remove acct-05 --yes >/dev/null 2>&1

# ---- 16c. codex-review: security hardening -----------------------------------------
# path traversal via a hand-edited manifest id must never touch the filesystem
mkdir -p "$WORK/canary" && : > "$WORK/canary/DO_NOT_DELETE"
cp "$ACC/accounts.json" "$WORK/manifest.bak"
python3 - "$ACC/accounts.json" <<'EOF'
import json, os, sys
d = json.load(open(sys.argv[1]))
d['accounts'].append({'id': '../canary', 'email': 'evil@test', 'home': 'mac'})
json.dump(d, open(sys.argv[1] + '.tmp', 'w'), indent=2); os.replace(sys.argv[1] + '.tmp', sys.argv[1])
EOF
out="$(claude-accounts remove ../canary --yes 2>&1)"
rc=$?
[ -f "$WORK/canary/DO_NOT_DELETE" ] && t_ok "remove refuses path-traversal id (no deletion)" \
  || t_fail "path traversal" "remove ../canary DELETED files outside the pool"
[ "$rc" != "0" ] && t_ok "traversal id rejected nonzero" || t_fail "traversal rc" "rc=0"
claude-accounts list 2>&1 | grep -q "\.\./canary" \
  && t_fail "invalid ids filtered from listings" "traversal id surfaced" \
  || t_ok "invalid manifest ids are filtered out"
cp "$WORK/manifest.bak" "$ACC/accounts.json"

# sync is Mac-only (source of truth); its input-validation guards can only be exercised
# on Darwin. On Linux `sync` refuses up front, so skip with a note rather than fail.
if [ "$(uname -s)" = "Darwin" ]; then
  # remote command injection via manifest server_root
  python3 - "$ACC/accounts.json" <<'EOF'
import json, os, sys
d = json.load(open(sys.argv[1]))
d['server_root'] = "/tmp/x'; touch /tmp/multiacc_PWNED; #"
json.dump(d, open(sys.argv[1] + '.tmp', 'w'), indent=2); os.replace(sys.argv[1] + '.tmp', sys.argv[1])
EOF
  rm -f /tmp/multiacc_PWNED
  out="$(CLAUDE_MULTIACC_NO_SYNC=0 claude-accounts sync 2>&1)"
  [ ! -f /tmp/multiacc_PWNED ] && t_ok "sync rejects injected server_root (no command executed)" \
    || { t_fail "command injection" "server_root injection EXECUTED"; rm -f /tmp/multiacc_PWNED; }
  check "injected server_root refused" "not a plain absolute path" "$out"
  cp "$WORK/manifest.bak" "$ACC/accounts.json"

  # accountless manifest must not blank the server pool
  python3 - "$ACC/accounts.json" <<'EOF'
import json, os, sys
d = json.load(open(sys.argv[1])); d['accounts'] = []
json.dump(d, open(sys.argv[1] + '.tmp', 'w'), indent=2); os.replace(sys.argv[1] + '.tmp', sys.argv[1])
EOF
  out="$(CLAUDE_MULTIACC_NO_SYNC=0 claude-accounts sync 2>&1)"
  rc=$?
  check "empty manifest refuses to sync" "refusing to blank the server pool" "$out"
  [ "$rc" != "0" ] && t_ok "empty-manifest sync exits nonzero" || t_fail "empty sync rc" "rc=0"
  cp "$WORK/manifest.bak" "$ACC/accounts.json"
else
  t_ok "sync validation tests skipped (Mac-only feature; server refuses sync by design)"
fi

# API keys are never accepted as credentials (subscription-only requirement)
printf 'sk-ant-api03-AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' > "$WORK/apikey.txt"
out="$(claude-accounts import apikey@test --id acct-11 --token-file "$WORK/apikey.txt" --no-sync 2>&1)"
rc=$?
check "import rejects an API key as token" "not a subscription setup-token" "$out"
[ ! -d "$ACC/acct-11" ] && t_ok "API-key import created nothing" || t_fail "apikey import" "acct-11 created"
out="$(printf 'sk-ant-api03-ZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZ' | claude-accounts mint acct-01 --paste 2>&1)"
check "mint --paste rejects an API key" "not a subscription setup-token" "$out"

out="$(claude-accounts verify --quick 2>&1)"
check "verify --quick passes oauth accounts" "acct-01 a@test: OK" "$out"
check "verify --quick counts" "0 failure(s)" "$out"

# ---- 18. CLI: status renders ------------------------------------------------------
out="$(claude-accounts status 2>&1)"
check "status shows threshold" "90%" "$out"
check "status shows account" "a@test" "$out"

# ---- 19. npm layer: cli.mjs dispatch + self-update + postinstall guards -------------
if command -v node >/dev/null 2>&1; then
  CLI="$REPO_DIR/bin/cli.mjs"
  pkgver="$(node -e "console.log(require('$REPO_DIR/package.json').version)")"
  out="$(node "$CLI" --version 2>&1)"
  check "cli --version matches package.json" "$pkgver" "$out"
  out="$(node "$CLI" --help 2>&1)"
  check "cli --help documents install" "install or update the addon" "$out"
  # passthrough to claude-accounts
  out="$(node "$CLI" list 2>&1)"
  check "cli passes through to claude-accounts (list)" "acct-01" "$out"
  out="$(node "$CLI" status 2>&1)"
  check "cli doctor/status passthrough" "threshold" "$out"
  # self-update on a non-npm, non-git tree is a logged no-op (never errors)
  out="$(claude-accounts self-update 2>&1)"
  rc=$?
  [ "$rc" = "0" ] && t_ok "self-update no-op exits 0 on a plain checkout" || t_fail "self-update rc" "rc=$rc"
  case "$out" in *"update manually"*|*"already latest"*|*"git pull"*) t_ok "self-update reports its path" ;;
    *) t_fail "self-update message" "unexpected: $out" ;; esac
  # postinstall must SKIP for a non-global install and never fail
  out="$(node "$REPO_DIR/scripts/postinstall.mjs" 2>&1)"
  rc=$?
  { [ "$rc" = "0" ] && printf '%s' "$out" | grep -q "skipping auto-setup"; } \
    && t_ok "postinstall skips (and exits 0) for a non-global install" \
    || t_fail "postinstall guard" "rc=$rc out=$out"
  out="$(CI=1 npm_config_global=true node "$REPO_DIR/scripts/postinstall.mjs" 2>&1)"
  printf '%s' "$out" | grep -q "CI environment" \
    && t_ok "postinstall skips under CI even when global" || t_fail "postinstall CI guard" "$out"
  # package.json is valid and ships the essential files list
  node -e "
    const p=require('$REPO_DIR/package.json');
    const need=['bin/','lib/','install.sh','scripts/postinstall.mjs'];
    if(!/^(\.\/)?bin\/cli\.mjs$/.test(p.bin['claude-multiacc'])) { console.error('bad bin'); process.exit(1); }
    for(const f of need) if(!p.files.includes(f)) { console.error('missing file entry: '+f); process.exit(1); }
    if(p.scripts.postinstall!=='node scripts/postinstall.mjs'){ console.error('bad postinstall'); process.exit(1); }
  " && t_ok "package.json bin/files/postinstall wired correctly" || t_fail "package.json" "see errors above"
else
  t_ok "npm-layer tests skipped (node not installed)"
fi

# ---- summary ---------------------------------------------------------------------
echo
echo "passed: $PASS  failed: $FAIL"
[ "$FAIL" = "0" ] || exit 1
