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 { 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
96async 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
131async 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
150fn 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
167enum CredentialSource {
169 StoredFile,
170 EnvApiKey,
171 EnvAuthTToken,
172 CliFlagUnset, }
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
197fn 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
223fn 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#[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 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 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 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 assert_eq!(parse_provider(&["--provider".to_string()]), None);
397 }
398}