From 948ce5eb5faba4e86317b45f6980150b57841c08 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 9 Jan 2026 00:32:48 +0000 Subject: [PATCH] feat(models): add oauth auth health --- CHANGELOG.md | 1 + docs/automation/auth-monitoring.md | 41 +++++ docs/cli/index.md | 3 + docs/concepts/models.md | 9 +- docs/concepts/oauth.md | 3 + docs/docs.json | 10 ++ docs/gateway/authentication.md | 95 +++++----- docs/gateway/doctor.md | 30 ++-- docs/scripts.md | 26 +++ docs/start/faq.md | 2 +- scripts/README-termux-auth.md | 63 ------- scripts/claude-auth-status.sh | 184 ++++++++++++++----- src/agents/auth-health.test.ts | 67 +++++++ src/agents/auth-health.ts | 227 ++++++++++++++++++++++++ src/cli/models-cli.ts | 5 + src/commands/doctor-auth.ts | 119 +++++++++++++ src/commands/doctor.ts | 11 +- src/commands/models/list.status.test.ts | 43 ++++- src/commands/models/list.ts | 102 ++++++++++- 19 files changed, 862 insertions(+), 179 deletions(-) create mode 100644 docs/automation/auth-monitoring.md create mode 100644 docs/scripts.md delete mode 100644 scripts/README-termux-auth.md create mode 100644 src/agents/auth-health.test.ts create mode 100644 src/agents/auth-health.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index ae65b97fd..a6899ebfa 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,7 @@ ## Unreleased +- Models: add OAuth expiry checks in doctor, expanded `models status` auth output (missing auth + `--check` exit codes). (#538) — thanks @latitudeki5223 - Security: per-agent mention patterns and group elevated directives now require explicit mention to avoid cross-agent toggles. - Config: support inline env vars in config (`env.*` / `env.vars`) and document env precedence. - Agent: enable adaptive context pruning by default for tool-result trimming. diff --git a/docs/automation/auth-monitoring.md b/docs/automation/auth-monitoring.md new file mode 100644 index 000000000..758c89265 --- /dev/null +++ b/docs/automation/auth-monitoring.md @@ -0,0 +1,41 @@ +--- +summary: "Monitor OAuth expiry for model providers" +read_when: + - Setting up auth expiry monitoring or alerts + - Automating Claude Code / Codex OAuth refresh checks +--- +# Auth monitoring + +Clawdbot exposes OAuth expiry health via `clawdbot models status`. Use that for +automation and alerting; scripts are optional extras for phone workflows. + +## Preferred: CLI check (portable) + +```bash +clawdbot models status --check +``` + +Exit codes: +- `0`: OK +- `1`: expired or missing credentials +- `2`: expiring soon (within 24h) + +This works in cron/systemd and requires no extra scripts. + +## Optional scripts (ops / phone workflows) + +These live under `scripts/` and are **optional**. They assume SSH access to the +gateway host and are tuned for systemd + Termux. + +- `scripts/claude-auth-status.sh` now uses `clawdbot models status --json` as the + source of truth (falling back to direct file reads if the CLI is unavailable), + so keep `clawdbot` on `PATH` for timers. +- `scripts/auth-monitor.sh`: cron/systemd timer target; sends alerts (ntfy or phone). +- `scripts/systemd/clawdbot-auth-monitor.{service,timer}`: systemd user timer. +- `scripts/claude-auth-status.sh`: Claude Code + Clawdbot auth checker (full/json/simple). +- `scripts/mobile-reauth.sh`: guided re‑auth flow over SSH. +- `scripts/termux-quick-auth.sh`: one‑tap widget status + open auth URL. +- `scripts/termux-auth-widget.sh`: full guided widget flow. +- `scripts/termux-sync-widget.sh`: sync Claude Code creds → Clawdbot. + +If you don’t need phone automation or systemd timers, skip these scripts. diff --git a/docs/cli/index.md b/docs/cli/index.md index 455e7cf3f..9f163018d 100644 --- a/docs/cli/index.md +++ b/docs/cli/index.md @@ -479,6 +479,9 @@ Options: Options: - `--json` - `--plain` +- `--check` (exit 1=expired/missing, 2=expiring) + +Always includes the auth overview and OAuth expiry status for profiles in the auth store. ### `models set ` Set `agent.model.primary`. diff --git a/docs/concepts/models.md b/docs/concepts/models.md index 58a86aaed..eee79d6ac 100644 --- a/docs/concepts/models.md +++ b/docs/concepts/models.md @@ -71,7 +71,14 @@ Shows configured models by default. Useful flags: ### `models status` Shows the resolved primary model, fallbacks, image model, and an auth overview -of configured providers. `--plain` prints only the resolved primary model. +of configured providers. It also surfaces OAuth expiry status for profiles found +in the auth store (warns within 24h by default). `--plain` prints only the +resolved primary model. +OAuth status is always shown (and included in `--json` output). If a configured +provider has no credentials, `models status` prints a **Missing auth** section. +JSON includes `auth.oauth` (warn window + profiles) and `auth.providers` +(effective auth per provider). +Use `--check` for automation (exit `1` when missing/expired, `2` when expiring). ## Scanning (OpenRouter free models) diff --git a/docs/concepts/oauth.md b/docs/concepts/oauth.md index 829984e69..15007bdcb 100644 --- a/docs/concepts/oauth.md +++ b/docs/concepts/oauth.md @@ -49,10 +49,13 @@ If you already signed in with the external CLIs *on the gateway host*, Clawdbot - Codex CLI: reads `~/.codex/auth.json` → profile `openai-codex:codex-cli` Sync happens when Clawdbot loads the auth store (so it stays up-to-date when the CLIs refresh tokens). +On macOS, the first read may trigger a Keychain prompt; run `clawdbot models status` +in a terminal once if the Gateway runs headless and can’t access the entry. How to verify: ```bash +clawdbot models status clawdbot providers list ``` diff --git a/docs/docs.json b/docs/docs.json index 37b51bfe9..84c3179aa 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -97,6 +97,14 @@ "source": "/bun", "destination": "/install/bun" }, + { + "source": "/auth-monitoring", + "destination": "/automation/auth-monitoring" + }, + { + "source": "/scripts", + "destination": "/scripts" + }, { "source": "/camera", "destination": "/nodes/camera" @@ -617,6 +625,7 @@ { "group": "Automation & Hooks", "pages": [ + "automation/auth-monitoring", "automation/webhook", "automation/gmail-pubsub", "automation/cron-jobs", @@ -690,6 +699,7 @@ { "group": "Reference & Templates", "pages": [ + "scripts", "reference/rpc", "reference/device-models", "reference/test", diff --git a/docs/gateway/authentication.md b/docs/gateway/authentication.md index 1f91927a4..3f1e3be5a 100644 --- a/docs/gateway/authentication.md +++ b/docs/gateway/authentication.md @@ -1,79 +1,82 @@ +--- +summary: "Model authentication: OAuth, API keys, and Claude Code token reuse" +read_when: + - Debugging model auth or OAuth expiry + - Documenting authentication or credential storage +--- # Authentication -Clawdbot uses Claude Code's authentication system for API access. By default, OAuth tokens expire every ~24 hours, requiring frequent re-authentication. For a better experience, you can set up a long-lived token that lasts **1 year**. +Clawdbot supports OAuth and API keys for model providers. For Anthropic +subscription accounts, the most stable path is to **reuse Claude Code OAuth +credentials**, including the 1‑year token created by `claude setup-token`. -## Long-Lived Token Setup (Recommended) +See [/concepts/oauth](/concepts/oauth) for the full OAuth flow and storage +layout. -Instead of daily re-auth, set up a 1-year token: +## Recommended: long‑lived Claude Code token + +Run this on the **gateway host** (the machine running the Gateway): ```bash claude setup-token ``` -This command will: -1. Prompt you to visit the Anthropic console -2. Create or copy an API key -3. Store it for Claude Code (and Clawdbot) - -After running `setup-token`, sync the credentials to Clawdbot: +This issues a long‑lived **OAuth token** (not an API key) and stores it for +Claude Code. Then sync and verify: ```bash -clawdbot doctor --yes +clawdbot models status +clawdbot doctor ``` -## Checking Auth Status - -To check your current authentication status: +Automation-friendly check (exit `1` when expired/missing, `2` when expiring): ```bash -# If you have the auth scripts installed -~/clawdbot/scripts/claude-auth-status.sh - -# Or check manually -cat ~/.claude/.credentials.json | jq '.claudeAiOauth.expiresAt' +clawdbot models status --check ``` -## How It Works +Optional ops scripts (systemd/Termux) are documented here: +[/automation/auth-monitoring](/automation/auth-monitoring) -1. **Claude Code** stores credentials in `~/.claude/.credentials.json` -2. **Clawdbot** syncs from Claude Code to `~/.clawdbot/agents/main/agent/auth-profiles.json` -3. The `clawdbot doctor --yes` command triggers this sync automatically +`clawdbot models status` loads Claude Code credentials into Clawdbot’s +`auth-profiles.json` and shows expiry (warns within 24h by default). +`clawdbot doctor` also performs the sync when it runs. -## Token Types +> `claude setup-token` requires an interactive TTY. -| Type | Duration | Setup | -|------|----------|-------| -| OAuth (default) | ~24 hours | Automatic on first run | -| Long-lived token | 1 year | `claude setup-token` | +## Checking model auth status + +```bash +clawdbot models status +clawdbot doctor +``` + +## How sync works + +1. **Claude Code** stores credentials in `~/.claude/.credentials.json` (or + Keychain on macOS). +2. **Clawdbot** syncs those into + `~/.clawdbot/agents//agent/auth-profiles.json` when the auth store is + loaded. +3. OAuth refresh happens automatically on use if a token is expired. ## Troubleshooting -### "No credentials found" error +### “No credentials found” -Run the doctor to sync credentials: +If the Anthropic OAuth profile is missing, run `claude setup-token` on the +**gateway host**, then re-check: ```bash -clawdbot doctor --yes +clawdbot models status ``` -Then restart the service: +### Token expiring/expired -```bash -systemctl --user restart clawdbot -``` - -### Token expired - -If your token has expired, run `claude setup-token` again in a terminal (not from within Claude Code, as it requires an interactive TTY). - -### Checking token expiry - -```bash -# Check both Claude Code and Clawdbot auth -cat ~/.claude/.credentials.json | jq '.claudeAiOauth.expiresAt' | xargs -I{} date -d @$(({}/1000)) -``` +Run `clawdbot models status` to confirm which profile is expiring. If the profile +is `anthropic:claude-cli`, rerun `claude setup-token`. ## Requirements -- Claude Max or Pro subscription (for `setup-token`) +- Claude Max or Pro subscription (for `claude setup-token`) - Claude Code CLI installed (`claude` command available) diff --git a/docs/gateway/doctor.md b/docs/gateway/doctor.md index 501fb37b5..60e31841d 100644 --- a/docs/gateway/doctor.md +++ b/docs/gateway/doctor.md @@ -61,6 +61,7 @@ cat ~/.clawdbot/clawdbot.json - Legacy on-disk state migration (sessions/agent dir/WhatsApp auth). - State integrity and permissions checks (sessions, transcripts, state dir). - Config file permission checks (chmod 600) when running locally. +- Model auth health: checks OAuth expiry and can refresh expiring tokens. - Legacy workspace dir detection (`~/clawdis`, `~/clawdbot`). - Sandbox image repair when sandboxing is enabled. - Legacy service migration and extra gateway detection. @@ -135,33 +136,40 @@ Doctor checks: - **Config file permissions**: warns if `~/.clawdbot/clawdbot.json` is group/world readable and offers to tighten to `600`. -### 5) Sandbox image repair +### 5) Model auth health (OAuth expiry) +Doctor inspects OAuth profiles in the auth store, warns when tokens are +expiring/expired, and can refresh them when safe. If the Anthropic Claude Code +profile is stale, it suggests `claude setup-token` on the gateway host. +Refresh prompts only appear when running interactively (TTY); `--non-interactive` +skips refresh attempts. + +### 6) Sandbox image repair When sandboxing is enabled, doctor checks Docker images and offers to build or switch to legacy names if the current image is missing. -### 6) Gateway service migrations and cleanup hints +### 7) Gateway service migrations and cleanup hints Doctor detects legacy Clawdis gateway services (launchd/systemd/schtasks) and offers to remove them and install the Clawdbot service using the current gateway port. It can also scan for extra gateway-like services and print cleanup hints to ensure only one gateway runs per machine. -### 7) Security warnings +### 8) Security warnings Doctor emits warnings when a provider is open to DMs without an allowlist, or when a policy is configured in a dangerous way. -### 8) systemd linger (Linux) +### 9) systemd linger (Linux) If running as a systemd user service, doctor ensures lingering is enabled so the gateway stays alive after logout. -### 9) Skills status +### 10) Skills status Doctor prints a quick summary of eligible/missing/blocked skills for the current workspace. -### 10) Gateway health check + restart +### 11) Gateway health check + restart Doctor runs a health check and offers to restart the gateway when it looks unhealthy. -### 11) Supervisor config audit + repair +### 12) Supervisor config audit + repair Doctor checks the installed supervisor config (launchd/systemd/schtasks) for missing or outdated defaults (e.g., systemd network-online dependencies and restart delay). When it finds a mismatch, it recommends an update and can @@ -174,24 +182,24 @@ Notes: - `clawdbot doctor --repair --force` overwrites custom supervisor configs. - You can always force a full rewrite via `clawdbot daemon install --force`. -### 12) Gateway runtime + port diagnostics +### 13) Gateway runtime + port diagnostics Doctor inspects the daemon runtime (PID, last exit status) and warns when the service is installed but not actually running. It also checks for port collisions on the gateway port (default `18789`) and reports likely causes (gateway already running, SSH tunnel). -### 13) Gateway runtime best practices +### 14) Gateway runtime best practices Doctor warns when the gateway service runs on Bun or a version-managed Node path (`nvm`, `fnm`, `volta`, `asdf`, etc.). WhatsApp + Telegram providers require Node, and version-manager paths can break after upgrades because the daemon does not load your shell init. Doctor offers to migrate to a system Node install when available (Homebrew/apt/choco). -### 14) Config write + wizard metadata +### 15) Config write + wizard metadata Doctor persists any config changes and stamps wizard metadata to record the doctor run. -### 15) Workspace tips (backup + memory system) +### 16) Workspace tips (backup + memory system) Doctor suggests a workspace memory system when missing and prints a backup tip if the workspace is not already under git. diff --git a/docs/scripts.md b/docs/scripts.md new file mode 100644 index 000000000..ed3b75a1b --- /dev/null +++ b/docs/scripts.md @@ -0,0 +1,26 @@ +--- +summary: "Repository scripts: purpose, scope, and safety notes" +read_when: + - Running scripts from the repo + - Adding or changing scripts under ./scripts +--- +# Scripts + +The `scripts/` directory contains helper scripts for local workflows and ops tasks. +Use these when a task is clearly tied to a script; otherwise prefer the CLI. + +## Conventions + +- Scripts are **optional** unless referenced in docs or release checklists. +- Prefer CLI surfaces when they exist (example: auth monitoring uses `clawdbot models status --check`). +- Assume scripts are host‑specific; read them before running on a new machine. + +## Auth monitoring scripts + +Auth monitoring scripts are documented here: +[/automation/auth-monitoring](/automation/auth-monitoring) + +## When adding scripts + +- Keep scripts focused and documented. +- Add a short entry in the relevant doc (or create one if missing). diff --git a/docs/start/faq.md b/docs/start/faq.md index e613463a7..d286b6196 100644 --- a/docs/start/faq.md +++ b/docs/start/faq.md @@ -346,7 +346,7 @@ It means the system attempted to use the auth profile ID `anthropic:default`, bu - **Make sure you’re editing the correct agent** - Multi‑agent setups mean there can be multiple `auth-profiles.json` files. - **Sanity‑check model/auth status** - - Use `/model status` to see configured models and whether providers are authenticated. + - Use `clawdbot models status` to see configured models and whether providers are authenticated. ### Why did it also try Google Gemini and fail? diff --git a/scripts/README-termux-auth.md b/scripts/README-termux-auth.md deleted file mode 100644 index 27c65fa2c..000000000 --- a/scripts/README-termux-auth.md +++ /dev/null @@ -1,63 +0,0 @@ -# Clawdbot Auth Management Scripts - -## Current Setup (Jan 2025) - -Using `claude setup-token` for **1-year long-lived token**. No daily re-auth needed. - -- **Token expires**: January 9, 2027 -- **Check status**: `./claude-auth-status.sh` - -## Scripts - -| Script | Purpose | -|--------|---------| -| `claude-auth-status.sh` | Check Claude Code + Clawdbot auth status | -| `mobile-reauth.sh` | Guided re-auth (only needed annually now) | -| `auth-monitor.sh` | Cron-able expiry monitor with notifications | -| `termux-quick-auth.sh` | Termux widget - one-tap status check | -| `termux-auth-widget.sh` | Termux widget - full guided re-auth flow | -| `setup-auth-system.sh` | Interactive setup wizard | - -## Quick Commands - -```bash -# Check auth status -~/clawdbot/scripts/claude-auth-status.sh - -# Sync Claude Code token to Clawdbot -clawdbot doctor --yes - -# Renew long-lived token (run in terminal, not Claude Code) -claude setup-token -``` - -## Termux Widget Setup (if needed) - -1. Install Termux + Termux:Widget from F-Droid -2. Create shortcuts dir: `mkdir -p ~/.shortcuts` -3. Copy widget script: - ```bash - scp l36:~/clawdbot/scripts/termux-quick-auth.sh ~/.shortcuts/ClawdAuth - chmod +x ~/.shortcuts/ClawdAuth - ``` -4. Set server: `echo 'export CLAWDBOT_SERVER=l36' >> ~/.bashrc` -5. Add Termux:Widget to home screen - -## How It Works - -1. `claude setup-token` creates a 1-year token in `~/.claude/.credentials.json` -2. `clawdbot doctor --yes` syncs it to `~/.clawdbot/agents/main/agent/auth-profiles.json` -3. Clawdbot uses the `anthropic:claude-cli` profile automatically - -## Troubleshooting - -```bash -# Check what's happening -~/clawdbot/scripts/claude-auth-status.sh full - -# Force sync from Claude Code -clawdbot doctor --yes - -# If token expired (annually), run in terminal: -claude setup-token -``` diff --git a/scripts/claude-auth-status.sh b/scripts/claude-auth-status.sh index 123d8c7ac..cf10b197d 100755 --- a/scripts/claude-auth-status.sh +++ b/scripts/claude-auth-status.sh @@ -16,20 +16,30 @@ NC='\033[0m' # No Color # Output mode: "full" (default), "json", or "simple" OUTPUT_MODE="${1:-full}" -check_claude_code_auth() { - if [ ! -f "$CLAUDE_CREDS" ]; then - echo "MISSING" - return 1 - fi +fetch_models_status_json() { + clawdbot models status --json 2>/dev/null || true +} - local expires_at - expires_at=$(jq -r '.claudeAiOauth.expiresAt // 0' "$CLAUDE_CREDS") - local now_ms=$(($(date +%s) * 1000)) +STATUS_JSON="$(fetch_models_status_json)" +USE_JSON=0 +if [ -n "$STATUS_JSON" ]; then + USE_JSON=1 +fi + +calc_status_from_expires() { + local expires_at="$1" + if ! [[ "$expires_at" =~ ^-?[0-9]+$ ]]; then + expires_at=0 + fi + local now_ms=$(( $(date +%s) * 1000 )) local diff_ms=$((expires_at - now_ms)) local hours=$((diff_ms / 3600000)) local mins=$(((diff_ms % 3600000) / 60000)) - if [ "$diff_ms" -lt 0 ]; then + if [ "$expires_at" -le 0 ]; then + echo "MISSING" + return 1 + elif [ "$diff_ms" -lt 0 ]; then echo "EXPIRED" return 1 elif [ "$diff_ms" -lt 3600000 ]; then @@ -41,34 +51,89 @@ check_claude_code_auth() { fi } +json_expires_for_claude_cli() { + echo "$STATUS_JSON" | jq -r ' + [.auth.oauth.profiles[] + | select(.provider == "anthropic" and .type == "oauth" and .source == "claude-cli") + | .expiresAt // 0] + | max // 0 + ' 2>/dev/null || echo "0" +} + +json_expires_for_anthropic_any() { + echo "$STATUS_JSON" | jq -r ' + [.auth.oauth.profiles[] + | select(.provider == "anthropic" and .type == "oauth") + | .expiresAt // 0] + | max // 0 + ' 2>/dev/null || echo "0" +} + +json_best_anthropic_profile() { + echo "$STATUS_JSON" | jq -r ' + [.auth.oauth.profiles[] + | select(.provider == "anthropic" and .type == "oauth") + | {id: .profileId, exp: (.expiresAt // 0)}] + | sort_by(.exp) | reverse | .[0].id // "none" + ' 2>/dev/null || echo "none" +} + +json_anthropic_api_key_count() { + echo "$STATUS_JSON" | jq -r ' + [.auth.providers[] | select(.provider == "anthropic") | .profiles.apiKey] + | max // 0 + ' 2>/dev/null || echo "0" +} + +check_claude_code_auth() { + if [ "$USE_JSON" -eq 1 ]; then + local expires_at + expires_at=$(json_expires_for_claude_cli) + calc_status_from_expires "$expires_at" + return $? + fi + + if [ ! -f "$CLAUDE_CREDS" ]; then + echo "MISSING" + return 1 + fi + + local expires_at + expires_at=$(jq -r '.claudeAiOauth.expiresAt // 0' "$CLAUDE_CREDS" 2>/dev/null || echo "0") + calc_status_from_expires "$expires_at" +} + check_clawdbot_auth() { + if [ "$USE_JSON" -eq 1 ]; then + local api_keys + api_keys=$(json_anthropic_api_key_count) + if ! [[ "$api_keys" =~ ^[0-9]+$ ]]; then + api_keys=0 + fi + local expires_at + expires_at=$(json_expires_for_anthropic_any) + + if [ "$expires_at" -le 0 ] && [ "$api_keys" -gt 0 ]; then + echo "OK:static" + return 0 + fi + + calc_status_from_expires "$expires_at" + return $? + fi + if [ ! -f "$CLAWDBOT_AUTH" ]; then echo "MISSING" return 1 fi - # Find the best Anthropic profile (prefer claude-cli, then any with latest expiry) local expires expires=$(jq -r ' [.profiles | to_entries[] | select(.value.provider == "anthropic") | .value.expires] | max // 0 - ' "$CLAWDBOT_AUTH") + ' "$CLAWDBOT_AUTH" 2>/dev/null || echo "0") - local now_ms=$(($(date +%s) * 1000)) - local diff_ms=$((expires - now_ms)) - local hours=$((diff_ms / 3600000)) - local mins=$(((diff_ms % 3600000) / 60000)) - - if [ "$diff_ms" -lt 0 ]; then - echo "EXPIRED" - return 1 - elif [ "$diff_ms" -lt 3600000 ]; then - echo "EXPIRING:${mins}m" - return 2 - else - echo "OK:${hours}h${mins}m" - return 0 - fi + calc_status_from_expires "$expires" } # JSON output mode @@ -76,8 +141,15 @@ if [ "$OUTPUT_MODE" = "json" ]; then claude_status=$(check_claude_code_auth 2>/dev/null || true) clawdbot_status=$(check_clawdbot_auth 2>/dev/null || true) - claude_expires=$(jq -r '.claudeAiOauth.expiresAt // 0' "$CLAUDE_CREDS" 2>/dev/null || echo "0") - clawdbot_expires=$(jq -r '.profiles["anthropic:default"].expires // 0' "$CLAWDBOT_AUTH" 2>/dev/null || echo "0") + claude_expires=0 + clawdbot_expires=0 + if [ "$USE_JSON" -eq 1 ]; then + claude_expires=$(json_expires_for_claude_cli) + clawdbot_expires=$(json_expires_for_anthropic_any) + else + claude_expires=$(jq -r '.claudeAiOauth.expiresAt // 0' "$CLAUDE_CREDS" 2>/dev/null || echo "0") + clawdbot_expires=$(jq -r '.profiles["anthropic:default"].expires // 0' "$CLAWDBOT_AUTH" 2>/dev/null || echo "0") + fi jq -n \ --arg cs "$claude_status" \ @@ -121,19 +193,28 @@ echo "" # Claude Code credentials echo "Claude Code (~/.claude/.credentials.json):" -if [ -f "$CLAUDE_CREDS" ]; then - expires_at=$(jq -r '.claudeAiOauth.expiresAt // 0' "$CLAUDE_CREDS") - sub_type=$(jq -r '.claudeAiOauth.subscriptionType // "unknown"' "$CLAUDE_CREDS") - rate_tier=$(jq -r '.claudeAiOauth.rateLimitTier // "unknown"' "$CLAUDE_CREDS") +if [ "$USE_JSON" -eq 1 ]; then + expires_at=$(json_expires_for_claude_cli) +else + expires_at=$(jq -r '.claudeAiOauth.expiresAt // 0' "$CLAUDE_CREDS" 2>/dev/null || echo "0") +fi - now_ms=$(($(date +%s) * 1000)) +if [ -f "$CLAUDE_CREDS" ]; then + sub_type=$(jq -r '.claudeAiOauth.subscriptionType // "unknown"' "$CLAUDE_CREDS" 2>/dev/null || echo "unknown") + rate_tier=$(jq -r '.claudeAiOauth.rateLimitTier // "unknown"' "$CLAUDE_CREDS" 2>/dev/null || echo "unknown") + echo " Subscription: $sub_type" + echo " Rate tier: $rate_tier" +fi + +if [ "$expires_at" -le 0 ]; then + echo -e " Status: ${RED}NOT FOUND${NC}" + echo " Action needed: Run 'claude setup-token'" +else + now_ms=$(( $(date +%s) * 1000 )) diff_ms=$((expires_at - now_ms)) hours=$((diff_ms / 3600000)) mins=$(((diff_ms % 3600000) / 60000)) - echo " Subscription: $sub_type" - echo " Rate tier: $rate_tier" - if [ "$diff_ms" -lt 0 ]; then echo -e " Status: ${RED}EXPIRED${NC}" echo " Action needed: Run 'claude setup-token' or re-authenticate" @@ -144,34 +225,41 @@ if [ -f "$CLAUDE_CREDS" ]; then echo -e " Status: ${GREEN}OK${NC}" echo " Expires: $(date -d @$((expires_at/1000))) (${hours}h ${mins}m)" fi -else - echo -e " Status: ${RED}NOT FOUND${NC}" - echo " Action needed: Run 'claude setup-token'" fi echo "" echo "Clawdbot Auth (~/.clawdbot/agents/main/agent/auth-profiles.json):" -if [ -f "$CLAWDBOT_AUTH" ]; then - # Find best Anthropic profile +if [ "$USE_JSON" -eq 1 ]; then + best_profile=$(json_best_anthropic_profile) + expires=$(json_expires_for_anthropic_any) + api_keys=$(json_anthropic_api_key_count) +else best_profile=$(jq -r ' .profiles | to_entries | map(select(.value.provider == "anthropic")) | sort_by(.value.expires) | reverse | .[0].key // "none" - ' "$CLAWDBOT_AUTH") - + ' "$CLAWDBOT_AUTH" 2>/dev/null || echo "none") expires=$(jq -r ' [.profiles | to_entries[] | select(.value.provider == "anthropic") | .value.expires] | max // 0 - ' "$CLAWDBOT_AUTH") + ' "$CLAWDBOT_AUTH" 2>/dev/null || echo "0") + api_keys=0 +fi - now_ms=$(($(date +%s) * 1000)) +echo " Profile: $best_profile" + +if [ "$expires" -le 0 ] && [ "$api_keys" -gt 0 ]; then + echo -e " Status: ${GREEN}OK${NC} (API key)" +elif [ "$expires" -le 0 ]; then + echo -e " Status: ${RED}NOT FOUND${NC}" + echo " Note: Run 'clawdbot doctor --yes' to sync from Claude Code" +else + now_ms=$(( $(date +%s) * 1000 )) diff_ms=$((expires - now_ms)) hours=$((diff_ms / 3600000)) mins=$(((diff_ms % 3600000) / 60000)) - echo " Profile: $best_profile" - if [ "$diff_ms" -lt 0 ]; then echo -e " Status: ${RED}EXPIRED${NC}" echo " Note: Run 'clawdbot doctor --yes' to sync from Claude Code" @@ -181,8 +269,6 @@ if [ -f "$CLAWDBOT_AUTH" ]; then echo -e " Status: ${GREEN}OK${NC}" echo " Expires: $(date -d @$((expires/1000))) (${hours}h ${mins}m)" fi -else - echo -e " Status: ${RED}NOT FOUND${NC}" fi echo "" diff --git a/src/agents/auth-health.test.ts b/src/agents/auth-health.test.ts new file mode 100644 index 000000000..4d11f9329 --- /dev/null +++ b/src/agents/auth-health.test.ts @@ -0,0 +1,67 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { + buildAuthHealthSummary, + DEFAULT_OAUTH_WARN_MS, +} from "./auth-health.js"; + +describe("buildAuthHealthSummary", () => { + const now = 1_700_000_000_000; + afterEach(() => { + vi.restoreAllMocks(); + }); + + it("classifies OAuth and API key profiles", () => { + vi.spyOn(Date, "now").mockReturnValue(now); + const store = { + version: 1, + profiles: { + "anthropic:ok": { + type: "oauth" as const, + provider: "anthropic", + access: "access", + refresh: "refresh", + expires: now + DEFAULT_OAUTH_WARN_MS + 60_000, + }, + "anthropic:expiring": { + type: "oauth" as const, + provider: "anthropic", + access: "access", + refresh: "refresh", + expires: now + 10_000, + }, + "anthropic:expired": { + type: "oauth" as const, + provider: "anthropic", + access: "access", + refresh: "refresh", + expires: now - 10_000, + }, + "anthropic:api": { + type: "api_key" as const, + provider: "anthropic", + key: "sk-ant-api", + }, + }, + }; + + const summary = buildAuthHealthSummary({ + store, + warnAfterMs: DEFAULT_OAUTH_WARN_MS, + }); + + const statuses = Object.fromEntries( + summary.profiles.map((profile) => [profile.profileId, profile.status]), + ); + + expect(statuses["anthropic:ok"]).toBe("ok"); + expect(statuses["anthropic:expiring"]).toBe("expiring"); + expect(statuses["anthropic:expired"]).toBe("expired"); + expect(statuses["anthropic:api"]).toBe("static"); + + const provider = summary.providers.find( + (entry) => entry.provider === "anthropic", + ); + expect(provider?.status).toBe("expired"); + }); +}); diff --git a/src/agents/auth-health.ts b/src/agents/auth-health.ts new file mode 100644 index 000000000..51e969b94 --- /dev/null +++ b/src/agents/auth-health.ts @@ -0,0 +1,227 @@ +import type { ClawdbotConfig } from "../config/config.js"; +import { + type AuthProfileCredential, + type AuthProfileStore, + CLAUDE_CLI_PROFILE_ID, + CODEX_CLI_PROFILE_ID, + resolveAuthProfileDisplayLabel, +} from "./auth-profiles.js"; + +export type AuthProfileSource = "claude-cli" | "codex-cli" | "store"; + +export type AuthProfileHealthStatus = + | "ok" + | "expiring" + | "expired" + | "missing" + | "static"; + +export type AuthProfileHealth = { + profileId: string; + provider: string; + type: "oauth" | "api_key"; + status: AuthProfileHealthStatus; + expiresAt?: number; + remainingMs?: number; + source: AuthProfileSource; + label: string; +}; + +export type AuthProviderHealthStatus = + | "ok" + | "expiring" + | "expired" + | "missing" + | "static"; + +export type AuthProviderHealth = { + provider: string; + status: AuthProviderHealthStatus; + expiresAt?: number; + remainingMs?: number; + profiles: AuthProfileHealth[]; +}; + +export type AuthHealthSummary = { + now: number; + warnAfterMs: number; + profiles: AuthProfileHealth[]; + providers: AuthProviderHealth[]; +}; + +export const DEFAULT_OAUTH_WARN_MS = 24 * 60 * 60 * 1000; + +export function resolveAuthProfileSource(profileId: string): AuthProfileSource { + if (profileId === CLAUDE_CLI_PROFILE_ID) return "claude-cli"; + if (profileId === CODEX_CLI_PROFILE_ID) return "codex-cli"; + return "store"; +} + +export function formatRemainingShort(remainingMs?: number): string { + if (remainingMs === undefined || Number.isNaN(remainingMs)) return "unknown"; + if (remainingMs <= 0) return "0m"; + const minutes = Math.max(1, Math.round(remainingMs / 60_000)); + if (minutes < 60) return `${minutes}m`; + const hours = Math.round(minutes / 60); + if (hours < 48) return `${hours}h`; + const days = Math.round(hours / 24); + return `${days}d`; +} + +function resolveOAuthStatus( + expiresAt: number | undefined, + now: number, + warnAfterMs: number, +): { status: AuthProfileHealthStatus; remainingMs?: number } { + if (!expiresAt || !Number.isFinite(expiresAt) || expiresAt <= 0) { + return { status: "missing" }; + } + const remainingMs = expiresAt - now; + if (remainingMs <= 0) { + return { status: "expired", remainingMs }; + } + if (remainingMs <= warnAfterMs) { + return { status: "expiring", remainingMs }; + } + return { status: "ok", remainingMs }; +} + +function buildProfileHealth(params: { + profileId: string; + credential: AuthProfileCredential; + store: AuthProfileStore; + cfg?: ClawdbotConfig; + now: number; + warnAfterMs: number; +}): AuthProfileHealth { + const { profileId, credential, store, cfg, now, warnAfterMs } = params; + const label = resolveAuthProfileDisplayLabel({ cfg, store, profileId }); + const source = resolveAuthProfileSource(profileId); + + if (credential.type === "api_key") { + return { + profileId, + provider: credential.provider, + type: "api_key", + status: "static", + source, + label, + }; + } + + const { status, remainingMs } = resolveOAuthStatus( + credential.expires, + now, + warnAfterMs, + ); + return { + profileId, + provider: credential.provider, + type: "oauth", + status, + expiresAt: credential.expires, + remainingMs, + source, + label, + }; +} + +export function buildAuthHealthSummary(params: { + store: AuthProfileStore; + cfg?: ClawdbotConfig; + warnAfterMs?: number; + providers?: string[]; +}): AuthHealthSummary { + const now = Date.now(); + const warnAfterMs = params.warnAfterMs ?? DEFAULT_OAUTH_WARN_MS; + const providerFilter = params.providers + ? new Set(params.providers.map((p) => p.trim()).filter(Boolean)) + : null; + + const profiles = Object.entries(params.store.profiles) + .filter(([_, cred]) => + providerFilter ? providerFilter.has(cred.provider) : true, + ) + .map(([profileId, credential]) => + buildProfileHealth({ + profileId, + credential, + store: params.store, + cfg: params.cfg, + now, + warnAfterMs, + }), + ) + .sort((a, b) => { + if (a.provider !== b.provider) { + return a.provider.localeCompare(b.provider); + } + return a.profileId.localeCompare(b.profileId); + }); + + const providersMap = new Map(); + for (const profile of profiles) { + const existing = providersMap.get(profile.provider); + if (!existing) { + providersMap.set(profile.provider, { + provider: profile.provider, + status: "missing", + profiles: [profile], + }); + } else { + existing.profiles.push(profile); + } + } + + if (providerFilter) { + for (const provider of providerFilter) { + if (!providersMap.has(provider)) { + providersMap.set(provider, { + provider, + status: "missing", + profiles: [], + }); + } + } + } + + for (const provider of providersMap.values()) { + if (provider.profiles.length === 0) { + provider.status = "missing"; + continue; + } + + const oauthProfiles = provider.profiles.filter((p) => p.type === "oauth"); + const apiKeyProfiles = provider.profiles.filter( + (p) => p.type === "api_key", + ); + + if (oauthProfiles.length === 0) { + provider.status = apiKeyProfiles.length > 0 ? "static" : "missing"; + continue; + } + + const expiryCandidates = oauthProfiles + .map((p) => p.expiresAt) + .filter((v): v is number => typeof v === "number" && Number.isFinite(v)); + if (expiryCandidates.length > 0) { + provider.expiresAt = Math.min(...expiryCandidates); + provider.remainingMs = provider.expiresAt - now; + } + + const statuses = oauthProfiles.map((p) => p.status); + if (statuses.includes("expired") || statuses.includes("missing")) { + provider.status = "expired"; + } else if (statuses.includes("expiring")) { + provider.status = "expiring"; + } else { + provider.status = "ok"; + } + } + + const providers = Array.from(providersMap.values()).sort((a, b) => + a.provider.localeCompare(b.provider), + ); + + return { now, warnAfterMs, profiles, providers }; +} diff --git a/src/cli/models-cli.ts b/src/cli/models-cli.ts index 484f512ea..85d7d8149 100644 --- a/src/cli/models-cli.ts +++ b/src/cli/models-cli.ts @@ -57,6 +57,11 @@ export function registerModelsCli(program: Command) { .description("Show configured model state") .option("--json", "Output JSON", false) .option("--plain", "Plain output", false) + .option( + "--check", + "Exit non-zero if auth is expiring/expired (1=expired/missing, 2=expiring)", + false, + ) .action(async (opts) => { try { await modelsStatusCommand(opts, defaultRuntime); diff --git a/src/commands/doctor-auth.ts b/src/commands/doctor-auth.ts index 4b6b662bc..d02cb4974 100644 --- a/src/commands/doctor-auth.ts +++ b/src/commands/doctor-auth.ts @@ -1,8 +1,16 @@ import { note } from "@clack/prompts"; import { + buildAuthHealthSummary, + DEFAULT_OAUTH_WARN_MS, + formatRemainingShort, +} from "../agents/auth-health.js"; +import { + CLAUDE_CLI_PROFILE_ID, + CODEX_CLI_PROFILE_ID, ensureAuthProfileStore, repairOAuthProfileIdMismatch, + resolveApiKeyForProfile, } from "../agents/auth-profiles.js"; import type { ClawdbotConfig } from "../config/config.js"; import type { DoctorPrompter } from "./doctor-prompter.js"; @@ -28,3 +36,114 @@ export async function maybeRepairAnthropicOAuthProfileId( if (!apply) return cfg; return repair.config; } + +type AuthIssue = { + profileId: string; + provider: string; + status: string; + remainingMs?: number; +}; + +function formatAuthIssueHint(issue: AuthIssue): string | null { + if ( + issue.provider === "anthropic" && + issue.profileId === CLAUDE_CLI_PROFILE_ID + ) { + return "Run `claude setup-token` on the gateway host."; + } + if ( + issue.provider === "openai-codex" && + issue.profileId === CODEX_CLI_PROFILE_ID + ) { + return "Run `codex login` (or `clawdbot configure` → OpenAI Codex OAuth)."; + } + return "Re-auth via `clawdbot configure` or `clawdbot onboard`."; +} + +function formatAuthIssueLine(issue: AuthIssue): string { + const remaining = + issue.remainingMs !== undefined + ? ` (${formatRemainingShort(issue.remainingMs)})` + : ""; + const hint = formatAuthIssueHint(issue); + return `- ${issue.profileId}: ${issue.status}${remaining}${hint ? ` — ${hint}` : ""}`; +} + +export async function noteAuthProfileHealth(params: { + cfg: ClawdbotConfig; + prompter: DoctorPrompter; + allowKeychainPrompt: boolean; +}): Promise { + const store = ensureAuthProfileStore(undefined, { + allowKeychainPrompt: params.allowKeychainPrompt, + }); + let summary = buildAuthHealthSummary({ + store, + cfg: params.cfg, + warnAfterMs: DEFAULT_OAUTH_WARN_MS, + }); + + const findIssues = () => + summary.profiles.filter( + (profile) => + profile.type === "oauth" && + (profile.status === "expired" || + profile.status === "expiring" || + profile.status === "missing"), + ); + + let issues = findIssues(); + if (issues.length === 0) return; + + const shouldRefresh = await params.prompter.confirmRepair({ + message: "Refresh expiring OAuth tokens now?", + initialValue: true, + }); + + if (shouldRefresh) { + const refreshTargets = issues.filter((issue) => + ["expired", "expiring", "missing"].includes(issue.status), + ); + const errors: string[] = []; + for (const profile of refreshTargets) { + try { + await resolveApiKeyForProfile({ + cfg: params.cfg, + store, + profileId: profile.profileId, + }); + } catch (err) { + errors.push( + `- ${profile.profileId}: ${err instanceof Error ? err.message : String(err)}`, + ); + } + } + if (errors.length > 0) { + note(errors.join("\n"), "OAuth refresh errors"); + } + summary = buildAuthHealthSummary({ + store: ensureAuthProfileStore(undefined, { + allowKeychainPrompt: false, + }), + cfg: params.cfg, + warnAfterMs: DEFAULT_OAUTH_WARN_MS, + }); + issues = findIssues(); + } + + if (issues.length > 0) { + note( + issues + .map((issue) => + formatAuthIssueLine({ + profileId: issue.profileId, + provider: issue.provider, + status: issue.status, + remainingMs: issue.remainingMs, + }), + ) + .join("\n"), + "Model auth", + ); + } +} diff --git a/src/commands/doctor.ts b/src/commands/doctor.ts index 70514c279..2db1a1cbf 100644 --- a/src/commands/doctor.ts +++ b/src/commands/doctor.ts @@ -26,7 +26,10 @@ import { GATEWAY_DAEMON_RUNTIME_OPTIONS, type GatewayDaemonRuntime, } from "./daemon-runtime.js"; -import { maybeRepairAnthropicOAuthProfileId } from "./doctor-auth.js"; +import { + maybeRepairAnthropicOAuthProfileId, + noteAuthProfileHealth, +} from "./doctor-auth.js"; import { buildGatewayRuntimeHints, formatGatewayRuntimeSummary, @@ -124,6 +127,12 @@ export async function doctorCommand( } cfg = await maybeRepairAnthropicOAuthProfileId(cfg, prompter); + await noteAuthProfileHealth({ + cfg, + prompter, + allowKeychainPrompt: + options.nonInteractive !== true && Boolean(process.stdin.isTTY), + }); const gatewayDetails = buildGatewayConnectionDetails({ config: cfg }); if (gatewayDetails.remoteFallbackNote) { note(gatewayDetails.remoteFallbackNote, "Gateway"); diff --git a/src/commands/models/list.status.test.ts b/src/commands/models/list.status.test.ts index b4310fad1..eab6df45a 100644 --- a/src/commands/models/list.status.test.ts +++ b/src/commands/models/list.status.test.ts @@ -77,12 +77,17 @@ vi.mock("../../agents/agent-paths.js", () => ({ resolveClawdbotAgentDir: mocks.resolveClawdbotAgentDir, })); -vi.mock("../../agents/auth-profiles.js", () => ({ - ensureAuthProfileStore: mocks.ensureAuthProfileStore, - listProfilesForProvider: mocks.listProfilesForProvider, - resolveAuthProfileDisplayLabel: mocks.resolveAuthProfileDisplayLabel, - resolveAuthStorePathForDisplay: mocks.resolveAuthStorePathForDisplay, -})); +vi.mock("../../agents/auth-profiles.js", async (importOriginal) => { + const actual = + await importOriginal(); + return { + ...actual, + ensureAuthProfileStore: mocks.ensureAuthProfileStore, + listProfilesForProvider: mocks.listProfilesForProvider, + resolveAuthProfileDisplayLabel: mocks.resolveAuthProfileDisplayLabel, + resolveAuthStorePathForDisplay: mocks.resolveAuthStorePathForDisplay, + }; +}); vi.mock("../../agents/model-auth.js", () => ({ resolveEnvApiKey: mocks.resolveEnvApiKey, @@ -126,6 +131,9 @@ describe("modelsStatusCommand auth overview", () => { expect(payload.auth.shellEnvFallback.appliedKeys).toContain( "OPENAI_API_KEY", ); + expect(payload.auth.missingProvidersInUse).toEqual([]); + expect(payload.auth.oauth.warnAfterMs).toBeGreaterThan(0); + expect(payload.auth.oauth.profiles.length).toBeGreaterThan(0); const providers = payload.auth.providers as Array<{ provider: string; @@ -152,4 +160,27 @@ describe("modelsStatusCommand auth overview", () => { ), ).toBe(true); }); + + it("exits non-zero when auth is missing", async () => { + const originalProfiles = { ...mocks.store.profiles }; + mocks.store.profiles = {}; + const localRuntime = { + log: vi.fn(), + error: vi.fn(), + exit: vi.fn(), + }; + const originalEnvImpl = mocks.resolveEnvApiKey.getMockImplementation(); + mocks.resolveEnvApiKey.mockImplementation(() => null); + + try { + await modelsStatusCommand( + { check: true, plain: true }, + localRuntime as never, + ); + expect(localRuntime.exit).toHaveBeenCalledWith(1); + } finally { + mocks.store.profiles = originalProfiles; + mocks.resolveEnvApiKey.mockImplementation(originalEnvImpl); + } + }); }); diff --git a/src/commands/models/list.ts b/src/commands/models/list.ts index a7ad9445d..5a3e178a3 100644 --- a/src/commands/models/list.ts +++ b/src/commands/models/list.ts @@ -7,6 +7,11 @@ import { } from "@mariozechner/pi-coding-agent"; import { resolveClawdbotAgentDir } from "../../agents/agent-paths.js"; +import { + buildAuthHealthSummary, + DEFAULT_OAUTH_WARN_MS, + formatRemainingShort, +} from "../../agents/auth-health.js"; import { type AuthProfileStore, ensureAuthProfileStore, @@ -599,7 +604,7 @@ export async function modelsListCommand( } export async function modelsStatusCommand( - opts: { json?: boolean; plain?: boolean }, + opts: { json?: boolean; plain?: boolean; check?: boolean }, runtime: RuntimeEnv, ) { ensureFlagCompatibility(opts); @@ -656,6 +661,7 @@ export async function modelsStatusCommand( .filter(Boolean), ); const providersFromModels = new Set(); + const providersInUse = new Set(); for (const raw of [ defaultLabel, ...fallbacks, @@ -666,6 +672,15 @@ export async function modelsStatusCommand( const parsed = parseModelRef(String(raw ?? ""), DEFAULT_PROVIDER); if (parsed?.provider) providersFromModels.add(parsed.provider); } + for (const raw of [ + defaultLabel, + ...fallbacks, + imageModel, + ...imageFallbacks, + ]) { + const parsed = parseModelRef(String(raw ?? ""), DEFAULT_PROVIDER); + if (parsed?.provider) providersInUse.add(parsed.provider); + } const providersFromEnv = new Set(); // Keep in sync with resolveEnvApiKey() mappings (we want visibility even when @@ -715,6 +730,12 @@ export async function modelsStatusCommand( Boolean(entry.modelsJson); return hasAny; }); + const providerAuthMap = new Map( + providerAuth.map((entry) => [entry.provider, entry]), + ); + const missingProvidersInUse = Array.from(providersInUse) + .filter((provider) => !providerAuthMap.has(provider)) + .sort((a, b) => a.localeCompare(b)); const providersWithOauth = providerAuth .filter( @@ -726,6 +747,29 @@ export async function modelsStatusCommand( return `${entry.provider} (${count})`; }); + const authHealth = buildAuthHealthSummary({ + store, + cfg, + warnAfterMs: DEFAULT_OAUTH_WARN_MS, + providers, + }); + const oauthProfiles = authHealth.profiles.filter( + (profile) => profile.type === "oauth", + ); + + const checkStatus = (() => { + const hasExpiredOrMissing = + oauthProfiles.some((profile) => + ["expired", "missing"].includes(profile.status), + ) || missingProvidersInUse.length > 0; + const hasExpiring = oauthProfiles.some( + (profile) => profile.status === "expiring", + ); + if (hasExpiredOrMissing) return 1; + if (hasExpiring) return 2; + return 0; + })(); + if (opts.json) { runtime.log( JSON.stringify( @@ -746,18 +790,30 @@ export async function modelsStatusCommand( appliedKeys: applied, }, providersWithOAuth: providersWithOauth, + missingProvidersInUse, providers: providerAuth, + oauth: { + warnAfterMs: authHealth.warnAfterMs, + profiles: authHealth.profiles, + providers: authHealth.providers, + }, }, }, null, 2, ), ); + if (opts.check) { + runtime.exit(checkStatus); + } return; } if (opts.plain) { runtime.log(resolvedLabel); + if (opts.check) { + runtime.exit(checkStatus); + } return; } @@ -933,4 +989,48 @@ export async function modelsStatusCommand( } runtime.log(`- ${theme.heading(entry.provider)} ${bits.join(separator)}`); } + + if (missingProvidersInUse.length > 0) { + runtime.log(""); + runtime.log(colorize(rich, theme.heading, "Missing auth")); + for (const provider of missingProvidersInUse) { + const hint = + provider === "anthropic" + ? "Run `claude setup-token` or `clawdbot configure`." + : "Run `clawdbot configure` or set an API key env var."; + runtime.log(`- ${theme.heading(provider)} ${hint}`); + } + } + + runtime.log(""); + runtime.log(colorize(rich, theme.heading, "OAuth status")); + if (oauthProfiles.length === 0) { + runtime.log(colorize(rich, theme.muted, "- none")); + return; + } + + const formatStatus = (status: string) => { + if (status === "ok") return colorize(rich, theme.success, "ok"); + if (status === "expiring") return colorize(rich, theme.warn, "expiring"); + if (status === "missing") return colorize(rich, theme.warn, "unknown"); + return colorize(rich, theme.error, "expired"); + }; + + for (const profile of oauthProfiles) { + const labelText = profile.label || profile.profileId; + const label = colorize(rich, theme.accent, labelText); + const status = formatStatus(profile.status); + const expiry = profile.expiresAt + ? ` expires in ${formatRemainingShort(profile.remainingMs)}` + : " expires unknown"; + const source = + profile.source !== "store" + ? colorize(rich, theme.muted, ` (${profile.source})`) + : ""; + runtime.log(`- ${label} ${status}${expiry}${source}`); + } + + if (opts.check) { + runtime.exit(checkStatus); + } }