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