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 { key: Some(key.to_string()), env: None };
80    if let Err(e) = upsert_credential(provider, cred) {
81        eprintln!("error: could not save credentials: {e}");
82        return EXIT_ERROR;
83    }
84    let path = match config::auth_path() {
85        Ok(p) => p,
86        Err(e) => {
87            eprintln!("warn: credentials saved, but could not resolve the config path: {e}");
88            return 0;
89        }
90    };
91    println!("Credentials saved to {}", path.display());
92    println!("Run `rpi auth check` to verify.");
93    0
94}
95
96/// `rpi auth check [--provider <id>] [--json]` — local readiness probe.
97async fn run_check(args: &[String]) -> i32 {
98    let provider = parse_provider(args).unwrap_or(DEFAULT_PROVIDER_ID);
99    let want_json = args.iter().any(|a| a == "--json");
100
101    let source = detect_credential(provider);
102    let ready = source.is_present();
103    if want_json {
104        let json = serde_json::json!({
105            "ready": ready,
106            "provider": provider,
107            "source": source.as_json_str(),
108        });
109        println!("{json}");
110    } else if ready {
111        let display = source.as_display().unwrap_or_default();
112        println!("ready ({display})");
113    } else {
114        println!("not_ready — no credentials found.");
115        eprintln!();
116        eprintln!(
117            "Set up credentials with one of:\n  \
118             - `rpi auth login`\n  \
119             - export ANTHROPIC_API_KEY=<key>\n  \
120             - export ANTHROPIC_AUTH_TOKEN=<bearer>\n  \
121             - pass --api-key <key>"
122        );
123    }
124    if ready {
125        0
126    } else {
127        EXIT_NOT_READY
128    }
129}
130
131/// `rpi auth logout [--provider <id>]` — drop the stored credential.
132async fn run_logout(args: &[String]) -> i32 {
133    let provider = parse_provider(args).unwrap_or(DEFAULT_PROVIDER_ID);
134    match delete_credential(provider) {
135        Ok(true) => {
136            println!("Removed stored credentials for \"{provider}\".");
137            0
138        }
139        Ok(false) => {
140            println!("No stored credential for \"{provider}\" (nothing to do).");
141            0
142        }
143        Err(e) => {
144            eprintln!("error: could not remove credentials: {e}");
145            EXIT_ERROR
146        }
147    }
148}
149
150/// Pull the `--provider <id>` value from a subcommand's args (defaults to
151/// `None`). Mirrors the TS `--provider` handshake before it falls back to the
152/// default.
153fn parse_provider(args: &[String]) -> Option<&str> {
154    let mut iter = args.iter();
155    while let Some(a) = iter.next() {
156        if a == "--provider" {
157            if let Some(v) = iter.next() {
158                return Some(v.as_str());
159            }
160        } else if let Some(rest) = a.strip_prefix("--provider=") {
161            return Some(rest);
162        }
163    }
164    None
165}
166
167/// Where a credential was found — used by `auth check` to report its source.
168enum CredentialSource {
169    StoredFile,
170    EnvApiKey,
171    EnvAuthTToken,
172    CliFlagUnset, // placeholder so the enum stays exhaustive; not used as "present"
173}
174
175impl CredentialSource {
176    fn is_present(&self) -> bool {
177        !matches!(self, CredentialSource::CliFlagUnset)
178    }
179    fn as_json_str(&self) -> &'static str {
180        match self {
181            CredentialSource::StoredFile => "auth.json",
182            CredentialSource::EnvApiKey => "ANTHROPIC_API_KEY",
183            CredentialSource::EnvAuthTToken => "ANTHROPIC_AUTH_TOKEN",
184            CredentialSource::CliFlagUnset => "none",
185        }
186    }
187    fn as_display(&self) -> Option<&'static str> {
188        match self {
189            CredentialSource::StoredFile => Some("key in ~/.rpi/auth.json"),
190            CredentialSource::EnvApiKey => Some("ANTHROPIC_API_KEY env var"),
191            CredentialSource::EnvAuthTToken => Some("ANTHROPIC_AUTH_TOKEN env var"),
192            CredentialSource::CliFlagUnset => None,
193        }
194    }
195}
196
197/// Detect the first credential source available for `provider` (mirrors the
198/// `provider::resolve` precedence, minus the `--api-key` flag which lives at
199/// the CLI layer; here we probe file + env only).
200fn detect_credential(provider: &str) -> CredentialSource {
201    if let Ok(store) = read_auth() {
202        if matches!(store.get(provider), Some(Credential::ApiKey { key: Some(k), .. }) if !k.is_empty())
203            || matches!(store.get(provider), Some(Credential::ApiKey { key: None, env: Some(_env), .. }))
204        {
205            return CredentialSource::StoredFile;
206        }
207    }
208    if std::env::var(crate::provider::ANTHROPIC_AUTH_TOKEN_ENV)
209        .map(|v| !v.is_empty())
210        .unwrap_or(false)
211    {
212        return CredentialSource::EnvAuthTToken;
213    }
214    if std::env::var(crate::provider::ANTHROPIC_API_KEY_ENV)
215        .map(|v| !v.is_empty())
216        .unwrap_or(false)
217    {
218        return CredentialSource::EnvApiKey;
219    }
220    CredentialSource::CliFlagUnset
221}
222
223/// `rpi auth --help`. Mirrors the TS auth-command usage but scoped to v1.
224fn print_auth_help() {
225    println!(
226        "Usage: {name} auth <subcommand> [options]
227
228Manage persisted Anthropic credentials in ~/.rpi/auth.json.
229
230Subcommands:
231  login   Prompt for an API key and save it (input is not echoed).
232  check   Report whether credentials are available (no network call).
233  logout  Remove the stored credential.
234
235Options:
236  --provider <id>   Provider id (v1: anthropic; default: anthropic)
237  --json            (check only) Emit a {{ready, provider, source}} JSON object
238
239Environment:
240  ANTHROPIC_API_KEY      Fallback API key (x-api-key) when no stored credential.
241  ANTHROPIC_AUTH_TOKEN   Fallback bearer token (Authorization: Bearer).
242
243Notes:
244  v1 supports only the anthropic provider. OAuth (Claude Pro/Max) is deferred.
245",
246        name = crate::APP_NAME
247    );
248}
249
250// Keep BTreeMap imported for future header-bearing credential variants without
251// triggering an unused-import in the current v1 shape.
252#[allow(dead_code)]
253fn _keep_btreemap() -> Option<BTreeMap<String, String>> {
254    None
255}
256
257#[cfg(test)]
258mod tests {
259    use super::*;
260    use crate::config::{
261        auth_path, upsert_credential, Credential, DEFAULT_PROVIDER_ID, test_support::env_lock,
262    };
263
264    /// Scope `RPI_CODING_AGENT_DIR` + the `ANTHROPIC_*` env vars to a temp dir
265    /// for the duration of a test. Holds the shared env lock for its whole
266    /// lifetime so it can't race with config/provider env-mutating tests.
267    struct TempConfig {
268        _guard: std::sync::MutexGuard<'static, ()>,
269        _tmp: tempfile::TempDir,
270        prev_dir: Option<std::ffi::OsString>,
271        prev_key: Option<std::ffi::OsString>,
272        prev_tok: Option<std::ffi::OsString>,
273    }
274    impl TempConfig {
275        fn new() -> Self {
276            let guard = env_lock().lock().unwrap();
277            let prev_dir = std::env::var_os(crate::config::CONFIG_DIR_ENV);
278            let prev_key = std::env::var_os(crate::provider::ANTHROPIC_API_KEY_ENV);
279            let prev_tok = std::env::var_os(crate::provider::ANTHROPIC_AUTH_TOKEN_ENV);
280            let tmp = tempfile::TempDir::new().unwrap();
281            std::env::set_var(crate::config::CONFIG_DIR_ENV, tmp.path());
282            std::env::remove_var(crate::provider::ANTHROPIC_API_KEY_ENV);
283            std::env::remove_var(crate::provider::ANTHROPIC_AUTH_TOKEN_ENV);
284            Self {
285                _guard: guard,
286                _tmp: tmp,
287                prev_dir,
288                prev_key,
289                prev_tok,
290            }
291        }
292    }
293    impl Drop for TempConfig {
294        fn drop(&mut self) {
295            restore(crate::config::CONFIG_DIR_ENV, self.prev_dir.take());
296            restore(crate::provider::ANTHROPIC_API_KEY_ENV, self.prev_key.take());
297            restore(crate::provider::ANTHROPIC_AUTH_TOKEN_ENV, self.prev_tok.take());
298        }
299    }
300    fn restore(name: &str, prev: Option<std::ffi::OsString>) {
301        match prev {
302            Some(v) => std::env::set_var(name, v),
303            None => std::env::remove_var(name),
304        }
305    }
306
307    #[tokio::test]
308    async fn check_not_ready_with_no_credentials() {
309        let _cfg = TempConfig::new();
310        let code = run_check(&[]).await;
311        assert_eq!(code, EXIT_NOT_READY);
312    }
313
314    #[tokio::test]
315    async fn check_ready_with_stored_credential() {
316        let _cfg = TempConfig::new();
317        upsert_credential(
318            DEFAULT_PROVIDER_ID,
319            Credential::ApiKey { key: Some("sk-stored".into()), env: None },
320        )
321        .unwrap();
322        let code = run_check(&[]).await;
323        assert_eq!(code, 0);
324    }
325
326    #[tokio::test]
327    async fn check_ready_with_env_api_key() {
328        let _cfg = TempConfig::new();
329        std::env::set_var(crate::provider::ANTHROPIC_API_KEY_ENV, "sk-env");
330        let code = run_check(&[]).await;
331        assert_eq!(code, 0);
332    }
333
334    #[tokio::test]
335    async fn check_json_outputs_object() {
336        let _cfg = TempConfig::new();
337        // Capture stdout is awkward in unit tests; just assert the exit code +
338        // that a stored cred flips `ready`.
339        upsert_credential(
340            DEFAULT_PROVIDER_ID,
341            Credential::ApiKey { key: Some("sk-x".into()), env: None },
342        )
343        .unwrap();
344        let code = run_check(&["--json".to_string()]).await;
345        assert_eq!(code, 0);
346    }
347
348    #[tokio::test]
349    async fn logout_removes_stored_credential() {
350        let _cfg = TempConfig::new();
351        upsert_credential(
352            DEFAULT_PROVIDER_ID,
353            Credential::ApiKey { key: Some("sk".into()), env: None },
354        )
355        .unwrap();
356        assert!(auth_path().unwrap().exists());
357        let code = run_logout(&[]).await;
358        assert_eq!(code, 0);
359        // The entry should be gone → check is now not_ready.
360        assert_eq!(run_check(&[]).await, EXIT_NOT_READY);
361    }
362
363    #[tokio::test]
364    async fn logout_when_empty_is_noop() {
365        let _cfg = TempConfig::new();
366        let code = run_logout(&[]).await;
367        assert_eq!(code, 0);
368    }
369
370    #[tokio::test]
371    async fn unknown_auth_subcommand_errors() {
372        let code = run(&["bogus".to_string()]).await;
373        assert_eq!(code, EXIT_ERROR);
374    }
375
376    #[tokio::test]
377    async fn auth_help_exits_zero() {
378        let code = run(&[]).await;
379        assert_eq!(code, 0);
380        let code = run(&["--help".to_string()]).await;
381        assert_eq!(code, 0);
382    }
383
384    #[test]
385    fn parse_provider_handles_both_forms() {
386        assert_eq!(parse_provider(&[]), None);
387        assert_eq!(
388            parse_provider(&["--provider".to_string(), "anthropic".to_string()]),
389            Some("anthropic")
390        );
391        assert_eq!(
392            parse_provider(&["--provider=custom".to_string()]),
393            Some("custom")
394        );
395        // A trailing flag with no value doesn't panic.
396        assert_eq!(parse_provider(&["--provider".to_string()]), None);
397    }
398}