1use std::collections::BTreeMap;
23
24use crate::config::{
25 self, delete_credential, read_auth, upsert_credential, Credential, DEFAULT_PROVIDER_ID,
26};
27const EXIT_NOT_READY: i32 = 1;
30const EXIT_ERROR: i32 = 2;
32
33pub 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
55async 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
99async 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
134async 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
153fn 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
170enum CredentialSource {
172 StoredFile,
173 ModelsJson,
174 EnvApiKey,
175 EnvAuthTToken,
176 EnvOpenAiApiKey,
177 CliFlagUnset, }
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
206fn 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
274fn 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#[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 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 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 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 assert_eq!(parse_provider(&["--provider".to_string()]), None);
482 }
483}