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`  — prompt (no echo, via `rpassword`) for an Anthropic API
8//!   key and persist it to `~/.rpi/auth.json` (atomic write + 0o600 on Unix).
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 `anthropic` entry from `auth.json` (env vars
14//!   are left untouched, matching upstream `/logout` semantics).
15//!
16//! # Not ported (deferred — see `docs/m6-cli-open-questions.md`)
17//!
18//! OAuth device-code login (Claude Pro/Max subscriptions), the full TUI
19//! `--provider` picker, and `auth print-api-key`/`print-bearer-token`. Only
20//! the `anthropic` provider id is handled.
21
22use std::collections::BTreeMap;
23
24use crate::config::{
25    self, delete_credential, read_auth, upsert_credential, Credential, DEFAULT_PROVIDER_ID,
26};
27/// Exit code for a missing-credential `auth check` (mirrors upstream's
28/// non-zero `auth check` when not ready).
29const EXIT_NOT_READY: i32 = 1;
30/// Exit code for an operational error (IO failure, bad args).
31const EXIT_ERROR: i32 = 2;
32
33/// `rpi auth <sub> [args]` entry. `args` is the slice *after* `auth` (i.e. the
34/// subcommand + its flags). Returns the process exit code. Async to match the
35/// `app::run` shape, though v1 does no async work here.
36pub async fn run(args: &[String]) -> i32 {
37    let sub = args.first().map(|s| s.as_str()).unwrap_or("");
38    match sub {
39        "login" => run_login(&args[1..]).await,
40        "check" => run_check(&args[1..]).await,
41        "logout" => run_logout(&args[1..]).await,
42        "--help" | "-h" | "help" | "" => {
43            print_auth_help();
44            0
45        }
46        other => {
47            eprintln!("error: unknown auth subcommand \"{other}\"");
48            eprintln!();
49            print_auth_help();
50            EXIT_ERROR
51        }
52    }
53}
54
55/// `rpi auth login [--provider <id>]` — prompt for a key and persist it.
56async fn run_login(args: &[String]) -> i32 {
57    let provider = parse_provider(args).unwrap_or(DEFAULT_PROVIDER_ID);
58    if provider != DEFAULT_PROVIDER_ID {
59        eprintln!(
60            "error: v1 only supports the \"{DEFAULT_PROVIDER_ID}\" provider for login (got \"{provider}\")"
61        );
62        return EXIT_ERROR;
63    }
64    eprint!("Enter Anthropic API key: ");
65    let key = match rpassword::read_password() {
66        Ok(k) => k,
67        Err(e) => {
68            eprintln!();
69            eprintln!("error: could not read the key from the terminal: {e}");
70            return EXIT_ERROR;
71        }
72    };
73    eprintln!();
74    let key = key.trim();
75    if key.is_empty() {
76        eprintln!("error: an empty key was entered; nothing saved.");
77        return EXIT_ERROR;
78    }
79    let cred = Credential::ApiKey {
80        key: Some(key.to_string()),
81        env: None,
82    };
83    if let Err(e) = upsert_credential(provider, cred) {
84        eprintln!("error: could not save credentials: {e}");
85        return EXIT_ERROR;
86    }
87    let path = match config::auth_path() {
88        Ok(p) => p,
89        Err(e) => {
90            eprintln!("warn: credentials saved, but could not resolve the config path: {e}");
91            return 0;
92        }
93    };
94    println!("Credentials saved to {}", path.display());
95    println!("Run `rpi auth check` to verify.");
96    0
97}
98
99/// `rpi auth check [--provider <id>] [--json]` — local readiness probe.
100async fn run_check(args: &[String]) -> i32 {
101    let provider = parse_provider(args).unwrap_or(DEFAULT_PROVIDER_ID);
102    let want_json = args.iter().any(|a| a == "--json");
103
104    let source = detect_credential(provider);
105    let ready = source.is_present();
106    if want_json {
107        let json = serde_json::json!({
108            "ready": ready,
109            "provider": provider,
110            "source": source.as_json_str(),
111        });
112        println!("{json}");
113    } else if ready {
114        let display = source.as_display().unwrap_or_default();
115        println!("ready ({display})");
116    } else {
117        println!("not_ready — no credentials found.");
118        eprintln!();
119        eprintln!(
120            "Set up credentials with one of:\n  \
121             - `rpi auth login`\n  \
122             - export ANTHROPIC_API_KEY=<key>\n  \
123             - export ANTHROPIC_AUTH_TOKEN=<bearer>\n  \
124             - pass --api-key <key>"
125        );
126    }
127    if ready {
128        0
129    } else {
130        EXIT_NOT_READY
131    }
132}
133
134/// `rpi auth logout [--provider <id>]` — drop the stored credential.
135async fn run_logout(args: &[String]) -> i32 {
136    let provider = parse_provider(args).unwrap_or(DEFAULT_PROVIDER_ID);
137    match delete_credential(provider) {
138        Ok(true) => {
139            println!("Removed stored credentials for \"{provider}\".");
140            0
141        }
142        Ok(false) => {
143            println!("No stored credential for \"{provider}\" (nothing to do).");
144            0
145        }
146        Err(e) => {
147            eprintln!("error: could not remove credentials: {e}");
148            EXIT_ERROR
149        }
150    }
151}
152
153/// Pull the `--provider <id>` value from a subcommand's args (defaults to
154/// `None`). Mirrors the TS `--provider` handshake before it falls back to the
155/// default.
156fn parse_provider(args: &[String]) -> Option<&str> {
157    let mut iter = args.iter();
158    while let Some(a) = iter.next() {
159        if a == "--provider" {
160            if let Some(v) = iter.next() {
161                return Some(v.as_str());
162            }
163        } else if let Some(rest) = a.strip_prefix("--provider=") {
164            return Some(rest);
165        }
166    }
167    None
168}
169
170/// Where a credential was found — used by `auth check` to report its source.
171enum CredentialSource {
172    StoredFile,
173    ModelsJson,
174    EnvApiKey,
175    EnvAuthTToken,
176    EnvOpenAiApiKey,
177    CliFlagUnset, // placeholder so the enum stays exhaustive; not used as "present"
178}
179
180impl CredentialSource {
181    fn is_present(&self) -> bool {
182        !matches!(self, CredentialSource::CliFlagUnset)
183    }
184    fn as_json_str(&self) -> &'static str {
185        match self {
186            CredentialSource::StoredFile => "auth.json",
187            CredentialSource::ModelsJson => "models.json",
188            CredentialSource::EnvApiKey => "ANTHROPIC_API_KEY",
189            CredentialSource::EnvAuthTToken => "ANTHROPIC_AUTH_TOKEN",
190            CredentialSource::EnvOpenAiApiKey => "OPENAI_API_KEY",
191            CredentialSource::CliFlagUnset => "none",
192        }
193    }
194    fn as_display(&self) -> Option<&'static str> {
195        match self {
196            CredentialSource::StoredFile => Some("key in ~/.rpi/auth.json"),
197            CredentialSource::ModelsJson => Some("apiKey in ~/.rpi/agent/models.json"),
198            CredentialSource::EnvApiKey => Some("ANTHROPIC_API_KEY env var"),
199            CredentialSource::EnvAuthTToken => Some("ANTHROPIC_AUTH_TOKEN env var"),
200            CredentialSource::EnvOpenAiApiKey => Some("OPENAI_API_KEY env var"),
201            CredentialSource::CliFlagUnset => None,
202        }
203    }
204}
205
206/// Detect the first credential source available for `provider` (mirrors the
207/// `provider::resolve` precedence, minus the `--api-key` flag which lives at
208/// the CLI layer; here we probe file + env only).
209fn detect_credential(provider: &str) -> CredentialSource {
210    if let Ok(store) = read_auth() {
211        if matches!(store.get(provider), Some(Credential::ApiKey { key: Some(k), .. }) if !k.is_empty())
212            || matches!(
213                store.get(provider),
214                Some(Credential::ApiKey {
215                    key: None,
216                    env: Some(_env),
217                    ..
218                })
219            )
220        {
221            return CredentialSource::StoredFile;
222        }
223    }
224    let configured = config::load_models_config().ok().and_then(|models| {
225        models
226            .providers
227            .into_iter()
228            .find(|(id, cfg)| {
229                id.eq_ignore_ascii_case(provider)
230                    || ((provider.eq_ignore_ascii_case("openai")
231                        || provider.eq_ignore_ascii_case("openai-completions"))
232                        && config::provider_is_openai_completions(cfg))
233            })
234            .map(|(_, cfg)| cfg)
235    });
236    if configured.as_ref().is_some_and(|cfg| {
237        cfg.api_key
238            .as_deref()
239            .filter(|key| !key.is_empty())
240            .and_then(|key| config::resolve_config_value(key, None))
241            .is_some()
242    }) {
243        return CredentialSource::ModelsJson;
244    }
245    let is_openai = provider.eq_ignore_ascii_case("openai")
246        || provider.eq_ignore_ascii_case("openai-completions")
247        || configured
248            .as_ref()
249            .is_some_and(config::provider_is_openai_completions);
250    if is_openai
251        && std::env::var(crate::provider::OPENAI_API_KEY_ENV)
252            .map(|v| !v.is_empty())
253            .unwrap_or(false)
254    {
255        return CredentialSource::EnvOpenAiApiKey;
256    }
257    if !is_openai
258        && std::env::var(crate::provider::ANTHROPIC_AUTH_TOKEN_ENV)
259            .map(|v| !v.is_empty())
260            .unwrap_or(false)
261    {
262        return CredentialSource::EnvAuthTToken;
263    }
264    if !is_openai
265        && std::env::var(crate::provider::ANTHROPIC_API_KEY_ENV)
266            .map(|v| !v.is_empty())
267            .unwrap_or(false)
268    {
269        return CredentialSource::EnvApiKey;
270    }
271    CredentialSource::CliFlagUnset
272}
273
274/// `rpi auth --help`. Mirrors the TS auth-command usage but scoped to v1.
275fn print_auth_help() {
276    println!(
277        "Usage: {name} auth <subcommand> [options]
278
279Manage persisted credentials and inspect models.json provider authentication.
280
281Subcommands:
282  login   Prompt for an API key and save it (input is not echoed).
283  check   Report whether credentials are available (no network call).
284  logout  Remove the stored credential.
285
286Options:
287  --provider <id>   Provider id (default: anthropic)
288  --json            (check only) Emit a {{ready, provider, source}} JSON object
289
290Environment:
291  ANTHROPIC_API_KEY      Fallback API key (x-api-key) when no stored credential.
292  ANTHROPIC_AUTH_TOKEN   Fallback bearer token (Authorization: Bearer).
293  OPENAI_API_KEY         Fallback bearer token for openai-completions.
294
295Notes:
296  `login` stores Anthropic credentials. OpenAI-compatible providers normally
297  store apiKey in ~/.rpi/agent/models.json. OAuth remains deferred.
298",
299        name = crate::APP_NAME
300    );
301}
302
303// Keep BTreeMap imported for future header-bearing credential variants without
304// triggering an unused-import in the current v1 shape.
305#[allow(dead_code)]
306fn _keep_btreemap() -> Option<BTreeMap<String, String>> {
307    None
308}
309
310#[cfg(test)]
311mod tests {
312    use super::*;
313    use crate::config::{
314        auth_path, test_support::env_lock, upsert_credential, Credential, DEFAULT_PROVIDER_ID,
315    };
316
317    /// Scope `RPI_CODING_AGENT_DIR` + the `ANTHROPIC_*` env vars to a temp dir
318    /// for the duration of a test. Holds the shared env lock for its whole
319    /// lifetime so it can't race with config/provider env-mutating tests.
320    struct TempConfig {
321        _guard: std::sync::MutexGuard<'static, ()>,
322        _tmp: tempfile::TempDir,
323        prev_dir: Option<std::ffi::OsString>,
324        prev_key: Option<std::ffi::OsString>,
325        prev_tok: Option<std::ffi::OsString>,
326        prev_openai_key: Option<std::ffi::OsString>,
327    }
328    impl TempConfig {
329        fn new() -> Self {
330            let guard = env_lock().lock().unwrap();
331            let prev_dir = std::env::var_os(crate::config::CONFIG_DIR_ENV);
332            let prev_key = std::env::var_os(crate::provider::ANTHROPIC_API_KEY_ENV);
333            let prev_tok = std::env::var_os(crate::provider::ANTHROPIC_AUTH_TOKEN_ENV);
334            let prev_openai_key = std::env::var_os(crate::provider::OPENAI_API_KEY_ENV);
335            let tmp = tempfile::TempDir::new().unwrap();
336            std::env::set_var(crate::config::CONFIG_DIR_ENV, tmp.path());
337            std::env::remove_var(crate::provider::ANTHROPIC_API_KEY_ENV);
338            std::env::remove_var(crate::provider::ANTHROPIC_AUTH_TOKEN_ENV);
339            std::env::remove_var(crate::provider::OPENAI_API_KEY_ENV);
340            Self {
341                _guard: guard,
342                _tmp: tmp,
343                prev_dir,
344                prev_key,
345                prev_tok,
346                prev_openai_key,
347            }
348        }
349    }
350    impl Drop for TempConfig {
351        fn drop(&mut self) {
352            restore(crate::config::CONFIG_DIR_ENV, self.prev_dir.take());
353            restore(crate::provider::ANTHROPIC_API_KEY_ENV, self.prev_key.take());
354            restore(
355                crate::provider::ANTHROPIC_AUTH_TOKEN_ENV,
356                self.prev_tok.take(),
357            );
358            restore(
359                crate::provider::OPENAI_API_KEY_ENV,
360                self.prev_openai_key.take(),
361            );
362        }
363    }
364    fn restore(name: &str, prev: Option<std::ffi::OsString>) {
365        match prev {
366            Some(v) => std::env::set_var(name, v),
367            None => std::env::remove_var(name),
368        }
369    }
370
371    #[tokio::test]
372    async fn check_not_ready_with_no_credentials() {
373        let _cfg = TempConfig::new();
374        let code = run_check(&[]).await;
375        assert_eq!(code, EXIT_NOT_READY);
376    }
377
378    #[tokio::test]
379    async fn check_ready_with_stored_credential() {
380        let _cfg = TempConfig::new();
381        upsert_credential(
382            DEFAULT_PROVIDER_ID,
383            Credential::ApiKey {
384                key: Some("sk-stored".into()),
385                env: None,
386            },
387        )
388        .unwrap();
389        let code = run_check(&[]).await;
390        assert_eq!(code, 0);
391    }
392
393    #[tokio::test]
394    async fn check_ready_with_env_api_key() {
395        let _cfg = TempConfig::new();
396        std::env::set_var(crate::provider::ANTHROPIC_API_KEY_ENV, "sk-env");
397        let code = run_check(&[]).await;
398        assert_eq!(code, 0);
399    }
400
401    #[tokio::test]
402    async fn check_ready_with_openai_models_json_key() {
403        let _cfg = TempConfig::new();
404        std::fs::write(
405            crate::config::models_path().unwrap(),
406            r#"{"providers":{"gateway":{"api":"openai-completions","apiKey":"key","models":[{"id":"gpt-test"}]}}}"#,
407        )
408        .unwrap();
409        let code = run_check(&["--provider".to_string(), "gateway".to_string()]).await;
410        assert_eq!(code, 0);
411    }
412
413    #[tokio::test]
414    async fn check_json_outputs_object() {
415        let _cfg = TempConfig::new();
416        // Capture stdout is awkward in unit tests; just assert the exit code +
417        // that a stored cred flips `ready`.
418        upsert_credential(
419            DEFAULT_PROVIDER_ID,
420            Credential::ApiKey {
421                key: Some("sk-x".into()),
422                env: None,
423            },
424        )
425        .unwrap();
426        let code = run_check(&["--json".to_string()]).await;
427        assert_eq!(code, 0);
428    }
429
430    #[tokio::test]
431    async fn logout_removes_stored_credential() {
432        let _cfg = TempConfig::new();
433        upsert_credential(
434            DEFAULT_PROVIDER_ID,
435            Credential::ApiKey {
436                key: Some("sk".into()),
437                env: None,
438            },
439        )
440        .unwrap();
441        assert!(auth_path().unwrap().exists());
442        let code = run_logout(&[]).await;
443        assert_eq!(code, 0);
444        // The entry should be gone → check is now not_ready.
445        assert_eq!(run_check(&[]).await, EXIT_NOT_READY);
446    }
447
448    #[tokio::test]
449    async fn logout_when_empty_is_noop() {
450        let _cfg = TempConfig::new();
451        let code = run_logout(&[]).await;
452        assert_eq!(code, 0);
453    }
454
455    #[tokio::test]
456    async fn unknown_auth_subcommand_errors() {
457        let code = run(&["bogus".to_string()]).await;
458        assert_eq!(code, EXIT_ERROR);
459    }
460
461    #[tokio::test]
462    async fn auth_help_exits_zero() {
463        let code = run(&[]).await;
464        assert_eq!(code, 0);
465        let code = run(&["--help".to_string()]).await;
466        assert_eq!(code, 0);
467    }
468
469    #[test]
470    fn parse_provider_handles_both_forms() {
471        assert_eq!(parse_provider(&[]), None);
472        assert_eq!(
473            parse_provider(&["--provider".to_string(), "anthropic".to_string()]),
474            Some("anthropic")
475        );
476        assert_eq!(
477            parse_provider(&["--provider=custom".to_string()]),
478            Some("custom")
479        );
480        // A trailing flag with no value doesn't panic.
481        assert_eq!(parse_provider(&["--provider".to_string()]), None);
482    }
483}