Skip to main content

rpi_cli/
auth.rs

1//! `rpi auth` subcommand — persistent credential management. Mirrors the
2//! Rust-relevant slice of the TS `packages/coding-agent/src/cli/auth-command.ts`
3//! + `main.ts:runAuthCommand` + `core/auth-check.ts`.
4//!
5//! v1 ships three actions:
6//!
7//! - `rpi auth login`  — legacy API-key prompt (no echo, via `rpassword`) for
8//!   the default provider and persist it to `~/.rpi/auth.json`.
9//!   Mirrors the upstream `/login` TUI's `type:"secret"` prompt + `modify`.
10//! - `rpi auth check`  — local-only probe of `auth.json` + the `ANTHROPIC_*`
11//!   env vars; reports `ready`/`not_ready` (no network call — the upstream
12//!   `--no-refresh` equivalent). `--json` emits a structured result.
13//! - `rpi auth logout` — drop the selected provider entry from `auth.json`
14//!   (env vars are left untouched).
15//!
16//! # Not ported (deferred — see the initial port notes (retired))
17//!
18//! Provider-specific OAuth implementations belong in extensions. The host only
19//! understands the standard OAuth action response and scoped credential format.
20
21use std::collections::BTreeMap;
22use std::io::{self, Write};
23use std::sync::Arc;
24
25use crate::config::{
26    self, delete_credential, read_auth, upsert_credential, Credential, DEFAULT_PROVIDER_ID,
27};
28/// Exit code for a missing-credential `auth check` (mirrors upstream's
29/// non-zero `auth check` when not ready).
30const EXIT_NOT_READY: i32 = 1;
31/// Exit code for an operational error (IO failure, bad args).
32const EXIT_ERROR: i32 = 2;
33
34/// `rpi auth <sub> [args]` entry. `args` is the slice *after* `auth` (i.e. the
35/// subcommand + its flags). Returns the process exit code. Async to match the
36/// `app::run` shape, though v1 does no async work here.
37pub async fn run(args: &[String]) -> i32 {
38    run_with_extensions(args, None).await
39}
40
41/// Run auth with an optional extension snapshot. The caller must keep the
42/// matching extension session alive until this future completes.
43pub async fn run_with_extensions(
44    args: &[String],
45    snapshot: Option<Arc<rpi_extensions::RegistrySnapshot>>,
46) -> i32 {
47    let sub = args.first().map(|s| s.as_str()).unwrap_or("");
48    match sub {
49        "login" => run_login(&args[1..], snapshot.as_ref()).await,
50        "check" | "status" => run_check(&args[1..]).await,
51        "refresh" => run_refresh(&args[1..], snapshot.as_ref()).await,
52        "logout" => run_logout(&args[1..], snapshot.as_ref()).await,
53        "--help" | "-h" | "help" | "" => {
54            print_auth_help();
55            0
56        }
57        other => {
58            eprintln!("error: unknown auth subcommand \"{other}\"");
59            eprintln!();
60            print_auth_help();
61            EXIT_ERROR
62        }
63    }
64}
65
66/// `rpi auth login [--provider <id>]` — prompt for a key and persist it.
67async fn run_login(
68    args: &[String],
69    snapshot: Option<&Arc<rpi_extensions::RegistrySnapshot>>,
70) -> i32 {
71    let provider = parse_provider(args).unwrap_or(DEFAULT_PROVIDER_ID);
72    if let Some(snapshot) = snapshot {
73        if snapshot.oauth_provider(provider).is_some() {
74            return run_oauth_login(provider, snapshot).await;
75        }
76    }
77    // Compatibility API-key login. This path is provider-neutral; OAuth
78    // behavior is supplied by the matching extension above.
79    eprint!("Enter API key for {provider}: ");
80    let key = match rpassword::read_password() {
81        Ok(k) => k,
82        Err(e) => {
83            eprintln!();
84            eprintln!("error: could not read the key from the terminal: {e}");
85            return EXIT_ERROR;
86        }
87    };
88    eprintln!();
89    let key = key.trim();
90    if key.is_empty() {
91        eprintln!("error: an empty key was entered; nothing saved.");
92        return EXIT_ERROR;
93    }
94    let cred = Credential::ApiKey {
95        key: Some(key.to_string()),
96        env: None,
97    };
98    if let Err(e) = upsert_credential(provider, cred) {
99        eprintln!("error: could not save credentials: {e}");
100        return EXIT_ERROR;
101    }
102    let path = match config::auth_path() {
103        Ok(p) => p,
104        Err(e) => {
105            eprintln!("warn: credentials saved, but could not resolve the config path: {e}");
106            return 0;
107        }
108    };
109    println!("Credentials saved to {}", path.display());
110    println!("Run `rpi auth check` to verify.");
111    0
112}
113
114/// `rpi auth check [--provider <id>] [--json]` — local readiness probe.
115async fn run_check(args: &[String]) -> i32 {
116    let provider = parse_provider(args).unwrap_or(DEFAULT_PROVIDER_ID);
117    let want_json = args.iter().any(|a| a == "--json");
118
119    let source = detect_credential(provider);
120    let ready = source.is_present();
121    if want_json {
122        let json = serde_json::json!({
123            "ready": ready,
124            "provider": provider,
125            "source": source.as_json_str(),
126        });
127        println!("{json}");
128    } else if ready {
129        let display = source.as_display().unwrap_or_default();
130        println!("ready ({display})");
131    } else {
132        println!("not_ready — no credentials found.");
133        eprintln!();
134        eprintln!(
135            "Set up credentials with one of:\n  \
136             - `rpi auth login`\n  \
137             - export ANTHROPIC_API_KEY=<key>\n  \
138             - export ANTHROPIC_AUTH_TOKEN=<bearer>\n  \
139             - pass --api-key <key>"
140        );
141    }
142    if ready {
143        0
144    } else {
145        EXIT_NOT_READY
146    }
147}
148
149/// `rpi auth logout [--provider <id>]` — drop the stored credential.
150async fn run_refresh(
151    args: &[String],
152    snapshot: Option<&Arc<rpi_extensions::RegistrySnapshot>>,
153) -> i32 {
154    let provider = parse_provider(args).unwrap_or(DEFAULT_PROVIDER_ID);
155    let Some(snapshot) = snapshot else {
156        eprintln!("error: no OAuth extensions are loaded");
157        return EXIT_ERROR;
158    };
159    let Some(Credential::Oauth {
160        access,
161        refresh,
162        expires,
163    }) = config::read_oauth_token(provider).ok().flatten()
164    else {
165        eprintln!("error: no stored OAuth credentials for \"{provider}\"");
166        return EXIT_NOT_READY;
167    };
168    let response = match rpi_extensions::request_oauth(
169        snapshot,
170        provider,
171        "refresh",
172        serde_json::json!({"credential": {"access": access, "refresh": refresh, "expires_at": expires}}),
173    ) {
174        Ok(value) => value,
175        Err(error) => {
176            eprintln!("error: OAuth refresh failed: {error}");
177            return EXIT_ERROR;
178        }
179    };
180    let response = match rpi_extensions::OAuthActionResponse::parse(response) {
181        Ok(response) => response,
182        Err(error) => {
183            eprintln!("error: invalid OAuth refresh response: {error}");
184            return EXIT_ERROR;
185        }
186    };
187    let Some(token) = (match response.credential() {
188        Ok(token) => token,
189        Err(error) => {
190            eprintln!("error: invalid OAuth refresh credential: {error}");
191            return EXIT_ERROR;
192        }
193    }) else {
194        eprintln!("error: OAuth refresh returned no credential");
195        return EXIT_ERROR;
196    };
197    if let Err(error) =
198        config::upsert_oauth_token(provider, token.access, token.refresh, token.expires_at)
199    {
200        eprintln!("error: could not save refreshed OAuth credentials: {error}");
201        return EXIT_ERROR;
202    }
203    println!("OAuth credentials refreshed for \"{provider}\".");
204    0
205}
206
207async fn run_logout(
208    args: &[String],
209    snapshot: Option<&Arc<rpi_extensions::RegistrySnapshot>>,
210) -> i32 {
211    let provider = parse_provider(args).unwrap_or(DEFAULT_PROVIDER_ID);
212    if let Ok(Some(Credential::Oauth {
213        access, refresh, ..
214    })) = config::read_oauth_token(provider)
215    {
216        let Some(snapshot) = snapshot else {
217            eprintln!("error: cannot revoke OAuth credentials: no OAuth extensions are loaded");
218            return EXIT_ERROR;
219        };
220        let response = match rpi_extensions::request_oauth(
221            snapshot,
222            provider,
223            "revoke",
224            serde_json::json!({"credential": {"access": access, "refresh": refresh}}),
225        ) {
226            Ok(response) => response,
227            Err(error) => {
228                eprintln!("error: OAuth revoke failed; local credentials were kept: {error}");
229                return EXIT_ERROR;
230            }
231        };
232        match rpi_extensions::OAuthActionResponse::parse(response) {
233            Ok(response) if response.kind == rpi_extensions::OAuthResponseKind::Success => {}
234            Ok(_) => {
235                eprintln!(
236                    "error: OAuth revoke did not return success; local credentials were kept"
237                );
238                return EXIT_ERROR;
239            }
240            Err(error) => {
241                eprintln!(
242                    "error: invalid OAuth revoke response; local credentials were kept: {error}"
243                );
244                return EXIT_ERROR;
245            }
246        }
247        /*
248         */
249    }
250    match delete_credential(provider) {
251        Ok(true) => {
252            println!("Removed stored credentials for \"{provider}\".");
253            0
254        }
255        Ok(false) => {
256            println!("No stored credential for \"{provider}\" (nothing to do).");
257            0
258        }
259        Err(e) => {
260            eprintln!("error: could not remove credentials: {e}");
261            EXIT_ERROR
262        }
263    }
264}
265
266/// Pull the `--provider <id>` value from a subcommand's args (defaults to
267/// `None`). Mirrors the TS `--provider` handshake before it falls back to the
268/// default.
269async fn run_oauth_login(provider: &str, snapshot: &Arc<rpi_extensions::RegistrySnapshot>) -> i32 {
270    let mut input = serde_json::json!({});
271    let mut action = "begin";
272    loop {
273        let raw = match rpi_extensions::request_oauth(snapshot, provider, action, input) {
274            Ok(value) => value,
275            Err(error) => {
276                eprintln!("error: OAuth {action} failed: {error}");
277                return EXIT_ERROR;
278            }
279        };
280        let response = match rpi_extensions::OAuthActionResponse::parse(raw) {
281            Ok(response) => response,
282            Err(error) => {
283                eprintln!("error: invalid OAuth response: {error}");
284                return EXIT_ERROR;
285            }
286        };
287        match response.kind {
288            rpi_extensions::OAuthResponseKind::Credential => {
289                let Some(token) = response.credential().ok().flatten() else {
290                    eprintln!("error: OAuth response contained no valid credential");
291                    return EXIT_ERROR;
292                };
293                if let Err(error) = config::upsert_oauth_token(
294                    provider,
295                    token.access,
296                    token.refresh,
297                    token.expires_at,
298                ) {
299                    eprintln!("error: could not save OAuth credentials: {error}");
300                    return EXIT_ERROR;
301                }
302                println!("OAuth credentials saved for \"{provider}\".");
303                return 0;
304            }
305            rpi_extensions::OAuthResponseKind::Error => {
306                eprintln!("error: OAuth provider rejected the request");
307                return EXIT_ERROR;
308            }
309            rpi_extensions::OAuthResponseKind::Success => {
310                eprintln!("error: OAuth login completed without credentials");
311                return EXIT_ERROR;
312            }
313            rpi_extensions::OAuthResponseKind::Interaction => {
314                let display = response.payload.get("display").unwrap_or(&response.payload);
315                if let Some(title) = display.get("title").and_then(serde_json::Value::as_str) {
316                    println!("{title}");
317                }
318                if let Some(url) = display.get("url").and_then(serde_json::Value::as_str) {
319                    println!("{url}");
320                }
321                if let Some(instructions) = display
322                    .get("instructions")
323                    .and_then(serde_json::Value::as_array)
324                {
325                    for instruction in instructions.iter().filter_map(serde_json::Value::as_str) {
326                        println!("{instruction}");
327                    }
328                }
329                eprint!("OAuth input: ");
330                let _ = io::stderr().flush();
331                let mut line = String::new();
332                if io::stdin().read_line(&mut line).is_err() || line.trim().is_empty() {
333                    eprintln!("error: empty OAuth input; nothing saved.");
334                    return EXIT_ERROR;
335                }
336                input = serde_json::json!({"value": line.trim()});
337                action = "exchange";
338            }
339        }
340    }
341}
342
343fn parse_provider(args: &[String]) -> Option<&str> {
344    let mut iter = args.iter();
345    while let Some(a) = iter.next() {
346        if a == "--provider" {
347            if let Some(v) = iter.next() {
348                return Some(v.as_str());
349            }
350        } else if let Some(rest) = a.strip_prefix("--provider=") {
351            return Some(rest);
352        }
353    }
354    None
355}
356
357/// Where a credential was found — used by `auth check` to report its source.
358enum CredentialSource {
359    StoredApiKey,
360    StoredOauth,
361    ModelsJson,
362    EnvApiKey,
363    EnvAuthTToken,
364    EnvOpenAiApiKey,
365    CliFlagUnset, // placeholder so the enum stays exhaustive; not used as "present"
366}
367
368impl CredentialSource {
369    fn is_present(&self) -> bool {
370        !matches!(self, CredentialSource::CliFlagUnset)
371    }
372    fn as_json_str(&self) -> &'static str {
373        match self {
374            CredentialSource::StoredApiKey => "auth.json",
375            CredentialSource::StoredOauth => "oauth",
376            CredentialSource::ModelsJson => "models.json",
377            CredentialSource::EnvApiKey => "ANTHROPIC_API_KEY",
378            CredentialSource::EnvAuthTToken => "ANTHROPIC_AUTH_TOKEN",
379            CredentialSource::EnvOpenAiApiKey => "OPENAI_API_KEY",
380            CredentialSource::CliFlagUnset => "none",
381        }
382    }
383    fn as_display(&self) -> Option<&'static str> {
384        match self {
385            CredentialSource::StoredApiKey => Some("key in ~/.rpi/auth.json"),
386            CredentialSource::StoredOauth => Some("OAuth token in ~/.rpi/auth.json"),
387            CredentialSource::ModelsJson => Some("apiKey in ~/.rpi/agent/models.json"),
388            CredentialSource::EnvApiKey => Some("ANTHROPIC_API_KEY env var"),
389            CredentialSource::EnvAuthTToken => Some("ANTHROPIC_AUTH_TOKEN env var"),
390            CredentialSource::EnvOpenAiApiKey => Some("OPENAI_API_KEY env var"),
391            CredentialSource::CliFlagUnset => None,
392        }
393    }
394}
395
396/// Detect the first credential source available for `provider` (mirrors the
397/// `provider::resolve` precedence, minus the `--api-key` flag which lives at
398/// the CLI layer; here we probe file + env only).
399fn detect_credential(provider: &str) -> CredentialSource {
400    if let Ok(store) = read_auth() {
401        match store.get(provider) {
402            Some(Credential::ApiKey { key: Some(k), .. }) if !k.is_empty() => {
403                return CredentialSource::StoredApiKey;
404            }
405            Some(Credential::ApiKey {
406                key: None,
407                env: Some(_),
408                ..
409            }) => {
410                return CredentialSource::StoredApiKey;
411            }
412            Some(Credential::Oauth {
413                access, expires, ..
414            }) if !access.is_empty()
415                && (*expires == 0
416                    || *expires
417                        > std::time::SystemTime::now()
418                            .duration_since(std::time::UNIX_EPOCH)
419                            .map(|duration| duration.as_secs() as i64)
420                            .unwrap_or(i64::MAX)) =>
421            {
422                return CredentialSource::StoredOauth;
423            }
424            _ => {}
425        }
426    }
427    let configured = config::load_models_config().ok().and_then(|models| {
428        models
429            .providers
430            .into_iter()
431            .find(|(id, cfg)| {
432                id.eq_ignore_ascii_case(provider)
433                    || ((provider.eq_ignore_ascii_case("openai")
434                        || provider.eq_ignore_ascii_case("openai-completions"))
435                        && config::provider_is_openai_completions(cfg))
436            })
437            .map(|(_, cfg)| cfg)
438    });
439    if configured.as_ref().is_some_and(|cfg| {
440        cfg.api_key
441            .as_deref()
442            .filter(|key| !key.is_empty())
443            .and_then(|key| config::resolve_config_value(key, None))
444            .is_some()
445    }) {
446        return CredentialSource::ModelsJson;
447    }
448    let is_openai = provider.eq_ignore_ascii_case("openai")
449        || provider.eq_ignore_ascii_case("openai-completions")
450        || configured
451            .as_ref()
452            .is_some_and(config::provider_is_openai_completions);
453    if is_openai
454        && std::env::var(crate::provider::OPENAI_API_KEY_ENV)
455            .map(|v| !v.is_empty())
456            .unwrap_or(false)
457    {
458        return CredentialSource::EnvOpenAiApiKey;
459    }
460    if !is_openai
461        && std::env::var(crate::provider::ANTHROPIC_AUTH_TOKEN_ENV)
462            .map(|v| !v.is_empty())
463            .unwrap_or(false)
464    {
465        return CredentialSource::EnvAuthTToken;
466    }
467    if !is_openai
468        && std::env::var(crate::provider::ANTHROPIC_API_KEY_ENV)
469            .map(|v| !v.is_empty())
470            .unwrap_or(false)
471    {
472        return CredentialSource::EnvApiKey;
473    }
474    CredentialSource::CliFlagUnset
475}
476
477/// `rpi auth --help`. Mirrors the TS auth-command usage but scoped to v1.
478fn print_auth_help() {
479    println!(
480        "Usage: {name} auth <subcommand> [options]
481
482Manage persisted credentials and inspect models.json provider authentication.
483
484Subcommands:
485  login   Prompt for an API key, or run an OAuth provider login.
486  check   Report whether credentials are available (no network call).
487  status  Alias for check; never prints token material.
488  refresh Refresh an OAuth token through its provider extension.
489  logout  Revoke OAuth credentials, then remove the stored credential.
490
491Options:
492  --provider <id>   Provider id (default: anthropic)
493  --json            (check only) Emit a {{ready, provider, source}} JSON object
494
495Environment:
496  ANTHROPIC_API_KEY      Fallback API key (x-api-key) when no stored credential.
497  ANTHROPIC_AUTH_TOKEN   Fallback bearer token (Authorization: Bearer).
498  OPENAI_API_KEY         Fallback bearer token for openai-completions.
499
500Notes:
501  `login --provider <id>` uses the registered OAuth extension when one exists;
502  otherwise it is a provider-neutral API-key compatibility prompt. The host
503  only handles standard interaction and credential responses for OAuth.
504  `refresh` and `logout` use extension actions. API-key providers may still
505  use ~/.rpi/agent/models.json or environment variables.
506",
507        name = crate::APP_NAME
508    );
509}
510
511// Keep BTreeMap imported for future header-bearing credential variants without
512// triggering an unused-import in the current v1 shape.
513#[allow(dead_code)]
514fn _keep_btreemap() -> Option<BTreeMap<String, String>> {
515    None
516}
517
518#[cfg(test)]
519mod tests {
520    use super::*;
521    use crate::config::{
522        auth_path, test_support::env_lock, upsert_credential, Credential, DEFAULT_PROVIDER_ID,
523    };
524
525    /// Scope `RPI_CODING_AGENT_DIR` + the `ANTHROPIC_*` env vars to a temp dir
526    /// for the duration of a test. Holds the shared env lock for its whole
527    /// lifetime so it can't race with config/provider env-mutating tests.
528    struct TempConfig {
529        _guard: std::sync::MutexGuard<'static, ()>,
530        _tmp: tempfile::TempDir,
531        prev_dir: Option<std::ffi::OsString>,
532        prev_key: Option<std::ffi::OsString>,
533        prev_tok: Option<std::ffi::OsString>,
534        prev_openai_key: Option<std::ffi::OsString>,
535    }
536    impl TempConfig {
537        fn new() -> Self {
538            let guard = env_lock().lock().unwrap();
539            let prev_dir = std::env::var_os(crate::config::CONFIG_DIR_ENV);
540            let prev_key = std::env::var_os(crate::provider::ANTHROPIC_API_KEY_ENV);
541            let prev_tok = std::env::var_os(crate::provider::ANTHROPIC_AUTH_TOKEN_ENV);
542            let prev_openai_key = std::env::var_os(crate::provider::OPENAI_API_KEY_ENV);
543            let tmp = tempfile::TempDir::new().unwrap();
544            std::env::set_var(crate::config::CONFIG_DIR_ENV, tmp.path());
545            std::env::remove_var(crate::provider::ANTHROPIC_API_KEY_ENV);
546            std::env::remove_var(crate::provider::ANTHROPIC_AUTH_TOKEN_ENV);
547            std::env::remove_var(crate::provider::OPENAI_API_KEY_ENV);
548            Self {
549                _guard: guard,
550                _tmp: tmp,
551                prev_dir,
552                prev_key,
553                prev_tok,
554                prev_openai_key,
555            }
556        }
557    }
558    impl Drop for TempConfig {
559        fn drop(&mut self) {
560            restore(crate::config::CONFIG_DIR_ENV, self.prev_dir.take());
561            restore(crate::provider::ANTHROPIC_API_KEY_ENV, self.prev_key.take());
562            restore(
563                crate::provider::ANTHROPIC_AUTH_TOKEN_ENV,
564                self.prev_tok.take(),
565            );
566            restore(
567                crate::provider::OPENAI_API_KEY_ENV,
568                self.prev_openai_key.take(),
569            );
570        }
571    }
572    fn restore(name: &str, prev: Option<std::ffi::OsString>) {
573        match prev {
574            Some(v) => std::env::set_var(name, v),
575            None => std::env::remove_var(name),
576        }
577    }
578
579    #[test]
580    fn standard_oauth_credential_response_is_parsed() {
581        let response = rpi_extensions::OAuthActionResponse::parse(serde_json::json!({
582            "version": 1,
583            "kind": "credential",
584            "payload": {
585                "access": "access",
586                "refresh": "refresh",
587                "expires_at": 123
588            }
589        }))
590        .unwrap();
591        let credential = response.credential().unwrap().unwrap();
592        assert_eq!(credential.access, "access");
593        assert_eq!(credential.refresh, "refresh");
594        assert_eq!(credential.expires_at, 123);
595    }
596
597    #[tokio::test]
598    async fn check_not_ready_with_no_credentials() {
599        let _cfg = TempConfig::new();
600        let code = run_check(&[]).await;
601        assert_eq!(code, EXIT_NOT_READY);
602    }
603
604    #[tokio::test]
605    async fn check_ready_with_stored_credential() {
606        let _cfg = TempConfig::new();
607        upsert_credential(
608            DEFAULT_PROVIDER_ID,
609            Credential::ApiKey {
610                key: Some("sk-stored".into()),
611                env: None,
612            },
613        )
614        .unwrap();
615        let code = run_check(&[]).await;
616        assert_eq!(code, 0);
617    }
618
619    #[tokio::test]
620    async fn check_ready_with_oauth_credential() {
621        let _cfg = TempConfig::new();
622        crate::config::upsert_oauth_token(
623            DEFAULT_PROVIDER_ID,
624            "access".into(),
625            "refresh".into(),
626            0,
627        )
628        .unwrap();
629        assert_eq!(run_check(&[]).await, 0);
630    }
631
632    #[tokio::test]
633    async fn check_rejects_expired_oauth_credential() {
634        let _cfg = TempConfig::new();
635        crate::config::upsert_oauth_token(
636            DEFAULT_PROVIDER_ID,
637            "access".into(),
638            "refresh".into(),
639            1,
640        )
641        .unwrap();
642        assert_eq!(run_check(&[]).await, EXIT_NOT_READY);
643    }
644
645    #[tokio::test]
646    async fn check_ready_with_env_api_key() {
647        let _cfg = TempConfig::new();
648        std::env::set_var(crate::provider::ANTHROPIC_API_KEY_ENV, "sk-env");
649        let code = run_check(&[]).await;
650        assert_eq!(code, 0);
651    }
652
653    #[tokio::test]
654    async fn check_ready_with_openai_models_json_key() {
655        let _cfg = TempConfig::new();
656        std::fs::write(
657            crate::config::models_path().unwrap(),
658            r#"{"providers":{"gateway":{"api":"openai-completions","apiKey":"key","models":[{"id":"gpt-test"}]}}}"#,
659        )
660        .unwrap();
661        let code = run_check(&["--provider".to_string(), "gateway".to_string()]).await;
662        assert_eq!(code, 0);
663    }
664
665    #[tokio::test]
666    async fn check_json_outputs_object() {
667        let _cfg = TempConfig::new();
668        // Capture stdout is awkward in unit tests; just assert the exit code +
669        // that a stored cred flips `ready`.
670        upsert_credential(
671            DEFAULT_PROVIDER_ID,
672            Credential::ApiKey {
673                key: Some("sk-x".into()),
674                env: None,
675            },
676        )
677        .unwrap();
678        let code = run_check(&["--json".to_string()]).await;
679        assert_eq!(code, 0);
680    }
681
682    #[tokio::test]
683    async fn logout_removes_stored_credential() {
684        let _cfg = TempConfig::new();
685        upsert_credential(
686            DEFAULT_PROVIDER_ID,
687            Credential::ApiKey {
688                key: Some("sk".into()),
689                env: None,
690            },
691        )
692        .unwrap();
693        assert!(auth_path().unwrap().exists());
694        let code = run_logout(&[], None).await;
695        assert_eq!(code, 0);
696        // The entry should be gone → check is now not_ready.
697        assert_eq!(run_check(&[]).await, EXIT_NOT_READY);
698    }
699
700    #[tokio::test]
701    async fn logout_when_empty_is_noop() {
702        let _cfg = TempConfig::new();
703        let code = run_logout(&[], None).await;
704        assert_eq!(code, 0);
705    }
706
707    #[tokio::test]
708    async fn unknown_auth_subcommand_errors() {
709        let code = run(&["bogus".to_string()]).await;
710        assert_eq!(code, EXIT_ERROR);
711    }
712
713    #[tokio::test]
714    async fn auth_help_exits_zero() {
715        let code = run(&[]).await;
716        assert_eq!(code, 0);
717        let code = run(&["--help".to_string()]).await;
718        assert_eq!(code, 0);
719    }
720
721    #[test]
722    fn parse_provider_handles_both_forms() {
723        assert_eq!(parse_provider(&[]), None);
724        assert_eq!(
725            parse_provider(&["--provider".to_string(), "anthropic".to_string()]),
726            Some("anthropic")
727        );
728        assert_eq!(
729            parse_provider(&["--provider=custom".to_string()]),
730            Some("custom")
731        );
732        // A trailing flag with no value doesn't panic.
733        assert_eq!(parse_provider(&["--provider".to_string()]), None);
734    }
735}