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 manifest = snapshot.and_then(|snapshot| snapshot.provider_manifest(provider));
376    let resolved =
377        crate::credentials::resolve(provider, models.providers.get(provider), &store, manifest);
378    if resolved.source.is_present() {
379        return Ok(resolved.source);
380    }
381    let manifests = snapshot.map_or_else(Vec::new, |snapshot| {
382        snapshot
383            .providers()
384            .iter()
385            .filter_map(|provider| snapshot.provider_manifest(&provider.provider_id).cloned())
386            .collect()
387    });
388    let catalog = crate::provider::execution_catalog_with_manifests(&manifests)?;
389    if catalog.iter().any(|model| {
390        model.provider == provider && crate::credentials::has_header_auth(model.headers.as_ref())
391    }) {
392        return Ok(CredentialSource::Headers);
393    }
394    Ok(CredentialSource::Missing)
395}
396
397/// `rpi auth --help`. Mirrors the TS auth-command usage but scoped to v1.
398fn print_auth_help() {
399    println!(
400        "Usage: {name} auth <subcommand> [options]
401
402Manage persisted credentials and inspect provider package authentication.
403
404Subcommands:
405  login   Prompt for an API key, or run an OAuth provider login.
406  check   Report whether credentials are available (no network call).
407  status  Alias for check; never prints token material.
408  refresh Refresh an OAuth token through its provider extension.
409  logout  Revoke OAuth credentials, then remove the stored credential.
410
411Options:
412  --provider <id>   Provider id (default: anthropic)
413  --extension <path>  Load a provider or OAuth extension explicitly.
414  --no-extensions     Disable extension discovery and package auth declarations.
415  --json            (check only) Emit a {{ready, provider, source}} JSON object
416
417Environment:
418  ANTHROPIC_API_KEY      Fallback API key (x-api-key) when no stored credential.
419  ANTHROPIC_AUTH_TOKEN   Fallback bearer token (Authorization: Bearer).
420  OPENAI_API_KEY         Fallback bearer token for the built-in openai identity.
421  Other providers       Environment names come from installed package declarations.
422
423Notes:
424  `login --provider <id>` uses the registered OAuth extension when one exists;
425  otherwise it is a provider-neutral API-key compatibility prompt. The host
426  only handles standard interaction and credential responses for OAuth.
427  `refresh` and `logout` use extension actions. API-key providers may still
428  use ~/.rpi/agent/models.json or environment variables.
429",
430        name = crate::APP_NAME
431    );
432}
433
434#[cfg(test)]
435mod tests {
436    use super::*;
437    use crate::config::{
438        auth_path, test_support::env_lock, upsert_credential, Credential, DEFAULT_PROVIDER_ID,
439    };
440
441    /// Scope `RPI_CODING_AGENT_DIR` + the `ANTHROPIC_*` env vars to a temp dir
442    /// for the duration of a test. Holds the shared env lock for its whole
443    /// lifetime so it can't race with config/provider env-mutating tests.
444    struct TempConfig {
445        _guard: std::sync::MutexGuard<'static, ()>,
446        _tmp: tempfile::TempDir,
447        prev_dir: Option<std::ffi::OsString>,
448        prev_key: Option<std::ffi::OsString>,
449        prev_tok: Option<std::ffi::OsString>,
450        prev_openai_key: Option<std::ffi::OsString>,
451    }
452    impl TempConfig {
453        fn new() -> Self {
454            let guard = env_lock().lock().unwrap();
455            let prev_dir = std::env::var_os(crate::config::CONFIG_DIR_ENV);
456            let prev_key = std::env::var_os(crate::provider::ANTHROPIC_API_KEY_ENV);
457            let prev_tok = std::env::var_os(crate::provider::ANTHROPIC_AUTH_TOKEN_ENV);
458            let prev_openai_key = std::env::var_os(crate::provider::OPENAI_API_KEY_ENV);
459            let tmp = tempfile::TempDir::new().unwrap();
460            std::env::set_var(crate::config::CONFIG_DIR_ENV, tmp.path());
461            std::env::remove_var(crate::provider::ANTHROPIC_API_KEY_ENV);
462            std::env::remove_var(crate::provider::ANTHROPIC_AUTH_TOKEN_ENV);
463            std::env::remove_var(crate::provider::OPENAI_API_KEY_ENV);
464            Self {
465                _guard: guard,
466                _tmp: tmp,
467                prev_dir,
468                prev_key,
469                prev_tok,
470                prev_openai_key,
471            }
472        }
473    }
474    impl Drop for TempConfig {
475        fn drop(&mut self) {
476            restore(crate::config::CONFIG_DIR_ENV, self.prev_dir.take());
477            restore(crate::provider::ANTHROPIC_API_KEY_ENV, self.prev_key.take());
478            restore(
479                crate::provider::ANTHROPIC_AUTH_TOKEN_ENV,
480                self.prev_tok.take(),
481            );
482            restore(
483                crate::provider::OPENAI_API_KEY_ENV,
484                self.prev_openai_key.take(),
485            );
486        }
487    }
488    fn restore(name: &str, prev: Option<std::ffi::OsString>) {
489        match prev {
490            Some(v) => std::env::set_var(name, v),
491            None => std::env::remove_var(name),
492        }
493    }
494
495    #[test]
496    fn standard_oauth_credential_response_is_parsed() {
497        let response = rpi_extensions::OAuthActionResponse::parse(serde_json::json!({
498            "version": 1,
499            "kind": "credential",
500            "payload": {
501                "access": "access",
502                "refresh": "refresh",
503                "expires_at": 123
504            }
505        }))
506        .unwrap();
507        let credential = response.credential().unwrap().unwrap();
508        assert_eq!(credential.access, "access");
509        assert_eq!(credential.refresh, "refresh");
510        assert_eq!(credential.expires_at, 123);
511    }
512
513    #[tokio::test]
514    async fn check_not_ready_with_no_credentials() {
515        let _cfg = TempConfig::new();
516        let code = run_check(&[], None).await;
517        assert_eq!(code, EXIT_NOT_READY);
518    }
519
520    #[tokio::test]
521    async fn check_ready_with_stored_credential() {
522        let _cfg = TempConfig::new();
523        upsert_credential(
524            DEFAULT_PROVIDER_ID,
525            Credential::ApiKey {
526                key: Some("sk-stored".into()),
527                env: None,
528            },
529        )
530        .unwrap();
531        let code = run_check(&[], None).await;
532        assert_eq!(code, 0);
533    }
534
535    #[tokio::test]
536    async fn check_ready_with_oauth_credential() {
537        let _cfg = TempConfig::new();
538        crate::config::upsert_oauth_token(
539            DEFAULT_PROVIDER_ID,
540            "access".into(),
541            "refresh".into(),
542            0,
543        )
544        .unwrap();
545        assert_eq!(run_check(&[], None).await, 0);
546    }
547
548    #[tokio::test]
549    async fn check_rejects_expired_oauth_credential() {
550        let _cfg = TempConfig::new();
551        crate::config::upsert_oauth_token(
552            DEFAULT_PROVIDER_ID,
553            "access".into(),
554            "refresh".into(),
555            1,
556        )
557        .unwrap();
558        assert_eq!(run_check(&[], None).await, EXIT_NOT_READY);
559    }
560
561    #[tokio::test]
562    async fn check_ready_with_env_api_key() {
563        let _cfg = TempConfig::new();
564        std::env::set_var(crate::provider::ANTHROPIC_API_KEY_ENV, "sk-env");
565        let code = run_check(&[], None).await;
566        assert_eq!(code, 0);
567    }
568
569    #[test]
570    fn check_does_not_borrow_credentials_from_another_provider_or_protocol() {
571        let _cfg = TempConfig::new();
572        std::env::set_var(crate::provider::ANTHROPIC_API_KEY_ENV, "anthropic-key");
573        std::env::set_var(crate::provider::OPENAI_API_KEY_ENV, "openai-key");
574        std::fs::write(
575            config::models_path().unwrap(),
576            r#"{"providers":{
577            "gateway":{"api":"openai-completions","models":[{"id":"model"}]},
578            "another":{"api":"openai-completions","apiKey":"another-key","models":[{"id":"other"}]}
579        }}"#,
580        )
581        .unwrap();
582        for provider in ["gateway", "unknown", "openai-completions", "OpenAI"] {
583            assert_eq!(
584                detect_credential(provider, None).unwrap(),
585                CredentialSource::Missing,
586                "{provider}"
587            );
588        }
589        // A gateway key also must not make the built-in OpenAI identity ready.
590        std::env::remove_var(crate::provider::OPENAI_API_KEY_ENV);
591        assert_eq!(
592            detect_credential("openai", None).unwrap(),
593            CredentialSource::Missing
594        );
595    }
596
597    #[test]
598    fn check_resolves_stored_references_and_rejects_empty_or_unresolved_values() {
599        let _cfg = TempConfig::new();
600        upsert_credential(
601            "gateway",
602            Credential::ApiKey {
603                key: Some("$RPI_TEST_STORED_KEY".into()),
604                env: Some(std::collections::BTreeMap::from([(
605                    "RPI_TEST_STORED_KEY".into(),
606                    "stored-key".into(),
607                )])),
608            },
609        )
610        .unwrap();
611        assert_eq!(
612            detect_credential("gateway", None).unwrap(),
613            CredentialSource::StoredApiKey
614        );
615        for key in [Some("$RPI_TEST_AUTH_MISSING_VARIABLE"), Some("   "), None] {
616            upsert_credential(
617                "gateway",
618                Credential::ApiKey {
619                    key: key.map(str::to_string),
620                    env: None,
621                },
622            )
623            .unwrap();
624            assert_eq!(
625                detect_credential("gateway", None).unwrap(),
626                CredentialSource::Missing
627            );
628        }
629    }
630
631    #[tokio::test]
632    async fn check_reports_config_errors_and_respects_resolved_header_auth() {
633        let _cfg = TempConfig::new();
634        std::fs::write(
635            config::models_path().unwrap(),
636            r#"{"providers":{"gateway":{
637            "api":"openai-completions","models":[{"id":"model","headers":{"Authorization":""}}]
638        }}}"#,
639        )
640        .unwrap();
641        assert_eq!(
642            detect_credential("gateway", None).unwrap(),
643            CredentialSource::Missing
644        );
645        std::fs::write(config::models_path().unwrap(), r#"{"providers":{"gateway":{
646            "api":"openai-completions","models":[{"id":"model","headers":{"Authorization":"Bearer key"}}]
647        }}}"#).unwrap();
648        assert_eq!(
649            detect_credential("gateway", None).unwrap(),
650            CredentialSource::Headers
651        );
652        std::fs::write(config::auth_path().unwrap(), "{invalid").unwrap();
653        assert_eq!(run_check(&[], None).await, EXIT_ERROR);
654    }
655
656    #[tokio::test]
657    async fn check_ready_with_openai_models_json_key() {
658        let _cfg = TempConfig::new();
659        std::fs::write(
660            crate::config::models_path().unwrap(),
661            r#"{"providers":{"gateway":{"api":"openai-completions","apiKey":"key","models":[{"id":"gpt-test"}]}}}"#,
662        )
663        .unwrap();
664        let code = run_check(&["--provider".to_string(), "gateway".to_string()], None).await;
665        assert_eq!(code, 0);
666    }
667
668    #[tokio::test]
669    async fn check_json_outputs_object() {
670        let _cfg = TempConfig::new();
671        // Capture stdout is awkward in unit tests; just assert the exit code +
672        // that a stored cred flips `ready`.
673        upsert_credential(
674            DEFAULT_PROVIDER_ID,
675            Credential::ApiKey {
676                key: Some("sk-x".into()),
677                env: None,
678            },
679        )
680        .unwrap();
681        let code = run_check(&["--json".to_string()], None).await;
682        assert_eq!(code, 0);
683    }
684
685    #[tokio::test]
686    async fn logout_removes_stored_credential() {
687        let _cfg = TempConfig::new();
688        upsert_credential(
689            DEFAULT_PROVIDER_ID,
690            Credential::ApiKey {
691                key: Some("sk".into()),
692                env: None,
693            },
694        )
695        .unwrap();
696        assert!(auth_path().unwrap().exists());
697        let code = run_logout(&[], None).await;
698        assert_eq!(code, 0);
699        // The entry should be gone → check is now not_ready.
700        assert_eq!(run_check(&[], None).await, EXIT_NOT_READY);
701    }
702
703    #[tokio::test]
704    async fn logout_when_empty_is_noop() {
705        let _cfg = TempConfig::new();
706        let code = run_logout(&[], None).await;
707        assert_eq!(code, 0);
708    }
709
710    #[tokio::test]
711    async fn unknown_auth_subcommand_errors() {
712        let code = run(&["bogus".to_string()]).await;
713        assert_eq!(code, EXIT_ERROR);
714    }
715
716    #[tokio::test]
717    async fn auth_help_exits_zero() {
718        let code = run(&[]).await;
719        assert_eq!(code, 0);
720        let code = run(&["--help".to_string()]).await;
721        assert_eq!(code, 0);
722    }
723
724    #[test]
725    fn parse_provider_handles_both_forms() {
726        assert_eq!(parse_provider(&[]), None);
727        assert_eq!(
728            parse_provider(&["--provider".to_string(), "anthropic".to_string()]),
729            Some("anthropic")
730        );
731        assert_eq!(
732            parse_provider(&["--provider=custom".to_string()]),
733            Some("custom")
734        );
735        // A trailing flag with no value doesn't panic.
736        assert_eq!(parse_provider(&["--provider".to_string()]), None);
737    }
738}