use std::collections::BTreeMap;
use crate::config::{
self, delete_credential, read_auth, upsert_credential, Credential, DEFAULT_PROVIDER_ID,
};
const EXIT_NOT_READY: i32 = 1;
const EXIT_ERROR: i32 = 2;
pub async fn run(args: &[String]) -> i32 {
let sub = args.first().map(|s| s.as_str()).unwrap_or("");
match sub {
"login" => run_login(&args[1..]).await,
"check" => run_check(&args[1..]).await,
"logout" => run_logout(&args[1..]).await,
"--help" | "-h" | "help" | "" => {
print_auth_help();
0
}
other => {
eprintln!("error: unknown auth subcommand \"{other}\"");
eprintln!();
print_auth_help();
EXIT_ERROR
}
}
}
async fn run_login(args: &[String]) -> i32 {
let provider = parse_provider(args).unwrap_or(DEFAULT_PROVIDER_ID);
if provider != DEFAULT_PROVIDER_ID {
eprintln!(
"error: v1 only supports the \"{DEFAULT_PROVIDER_ID}\" provider for login (got \"{provider}\")"
);
return EXIT_ERROR;
}
eprint!("Enter Anthropic API key: ");
let key = match rpassword::read_password() {
Ok(k) => k,
Err(e) => {
eprintln!();
eprintln!("error: could not read the key from the terminal: {e}");
return EXIT_ERROR;
}
};
eprintln!();
let key = key.trim();
if key.is_empty() {
eprintln!("error: an empty key was entered; nothing saved.");
return EXIT_ERROR;
}
let cred = Credential::ApiKey {
key: Some(key.to_string()),
env: None,
};
if let Err(e) = upsert_credential(provider, cred) {
eprintln!("error: could not save credentials: {e}");
return EXIT_ERROR;
}
let path = match config::auth_path() {
Ok(p) => p,
Err(e) => {
eprintln!("warn: credentials saved, but could not resolve the config path: {e}");
return 0;
}
};
println!("Credentials saved to {}", path.display());
println!("Run `rpi auth check` to verify.");
0
}
async fn run_check(args: &[String]) -> i32 {
let provider = parse_provider(args).unwrap_or(DEFAULT_PROVIDER_ID);
let want_json = args.iter().any(|a| a == "--json");
let source = detect_credential(provider);
let ready = source.is_present();
if want_json {
let json = serde_json::json!({
"ready": ready,
"provider": provider,
"source": source.as_json_str(),
});
println!("{json}");
} else if ready {
let display = source.as_display().unwrap_or_default();
println!("ready ({display})");
} else {
println!("not_ready — no credentials found.");
eprintln!();
eprintln!(
"Set up credentials with one of:\n \
- `rpi auth login`\n \
- export ANTHROPIC_API_KEY=<key>\n \
- export ANTHROPIC_AUTH_TOKEN=<bearer>\n \
- pass --api-key <key>"
);
}
if ready {
0
} else {
EXIT_NOT_READY
}
}
async fn run_logout(args: &[String]) -> i32 {
let provider = parse_provider(args).unwrap_or(DEFAULT_PROVIDER_ID);
match delete_credential(provider) {
Ok(true) => {
println!("Removed stored credentials for \"{provider}\".");
0
}
Ok(false) => {
println!("No stored credential for \"{provider}\" (nothing to do).");
0
}
Err(e) => {
eprintln!("error: could not remove credentials: {e}");
EXIT_ERROR
}
}
}
fn parse_provider(args: &[String]) -> Option<&str> {
let mut iter = args.iter();
while let Some(a) = iter.next() {
if a == "--provider" {
if let Some(v) = iter.next() {
return Some(v.as_str());
}
} else if let Some(rest) = a.strip_prefix("--provider=") {
return Some(rest);
}
}
None
}
enum CredentialSource {
StoredFile,
ModelsJson,
EnvApiKey,
EnvAuthTToken,
EnvOpenAiApiKey,
CliFlagUnset, }
impl CredentialSource {
fn is_present(&self) -> bool {
!matches!(self, CredentialSource::CliFlagUnset)
}
fn as_json_str(&self) -> &'static str {
match self {
CredentialSource::StoredFile => "auth.json",
CredentialSource::ModelsJson => "models.json",
CredentialSource::EnvApiKey => "ANTHROPIC_API_KEY",
CredentialSource::EnvAuthTToken => "ANTHROPIC_AUTH_TOKEN",
CredentialSource::EnvOpenAiApiKey => "OPENAI_API_KEY",
CredentialSource::CliFlagUnset => "none",
}
}
fn as_display(&self) -> Option<&'static str> {
match self {
CredentialSource::StoredFile => Some("key in ~/.rpi/auth.json"),
CredentialSource::ModelsJson => Some("apiKey in ~/.rpi/agent/models.json"),
CredentialSource::EnvApiKey => Some("ANTHROPIC_API_KEY env var"),
CredentialSource::EnvAuthTToken => Some("ANTHROPIC_AUTH_TOKEN env var"),
CredentialSource::EnvOpenAiApiKey => Some("OPENAI_API_KEY env var"),
CredentialSource::CliFlagUnset => None,
}
}
}
fn detect_credential(provider: &str) -> CredentialSource {
if let Ok(store) = read_auth() {
if matches!(store.get(provider), Some(Credential::ApiKey { key: Some(k), .. }) if !k.is_empty())
|| matches!(
store.get(provider),
Some(Credential::ApiKey {
key: None,
env: Some(_env),
..
})
)
{
return CredentialSource::StoredFile;
}
}
let configured = config::load_models_config().ok().and_then(|models| {
models
.providers
.into_iter()
.find(|(id, cfg)| {
id.eq_ignore_ascii_case(provider)
|| ((provider.eq_ignore_ascii_case("openai")
|| provider.eq_ignore_ascii_case("openai-completions"))
&& config::provider_is_openai_completions(cfg))
})
.map(|(_, cfg)| cfg)
});
if configured.as_ref().is_some_and(|cfg| {
cfg.api_key
.as_deref()
.filter(|key| !key.is_empty())
.and_then(|key| config::resolve_config_value(key, None))
.is_some()
}) {
return CredentialSource::ModelsJson;
}
let is_openai = provider.eq_ignore_ascii_case("openai")
|| provider.eq_ignore_ascii_case("openai-completions")
|| configured
.as_ref()
.is_some_and(config::provider_is_openai_completions);
if is_openai
&& std::env::var(crate::provider::OPENAI_API_KEY_ENV)
.map(|v| !v.is_empty())
.unwrap_or(false)
{
return CredentialSource::EnvOpenAiApiKey;
}
if !is_openai
&& std::env::var(crate::provider::ANTHROPIC_AUTH_TOKEN_ENV)
.map(|v| !v.is_empty())
.unwrap_or(false)
{
return CredentialSource::EnvAuthTToken;
}
if !is_openai
&& std::env::var(crate::provider::ANTHROPIC_API_KEY_ENV)
.map(|v| !v.is_empty())
.unwrap_or(false)
{
return CredentialSource::EnvApiKey;
}
CredentialSource::CliFlagUnset
}
fn print_auth_help() {
println!(
"Usage: {name} auth <subcommand> [options]
Manage persisted credentials and inspect models.json provider authentication.
Subcommands:
login Prompt for an API key and save it (input is not echoed).
check Report whether credentials are available (no network call).
logout Remove the stored credential.
Options:
--provider <id> Provider id (default: anthropic)
--json (check only) Emit a {{ready, provider, source}} JSON object
Environment:
ANTHROPIC_API_KEY Fallback API key (x-api-key) when no stored credential.
ANTHROPIC_AUTH_TOKEN Fallback bearer token (Authorization: Bearer).
OPENAI_API_KEY Fallback bearer token for openai-completions.
Notes:
`login` stores Anthropic credentials. OpenAI-compatible providers normally
store apiKey in ~/.rpi/agent/models.json. OAuth remains deferred.
",
name = crate::APP_NAME
);
}
#[allow(dead_code)]
fn _keep_btreemap() -> Option<BTreeMap<String, String>> {
None
}
#[cfg(test)]
mod tests {
use super::*;
use crate::config::{
auth_path, test_support::env_lock, upsert_credential, Credential, DEFAULT_PROVIDER_ID,
};
struct TempConfig {
_guard: std::sync::MutexGuard<'static, ()>,
_tmp: tempfile::TempDir,
prev_dir: Option<std::ffi::OsString>,
prev_key: Option<std::ffi::OsString>,
prev_tok: Option<std::ffi::OsString>,
prev_openai_key: Option<std::ffi::OsString>,
}
impl TempConfig {
fn new() -> Self {
let guard = env_lock().lock().unwrap();
let prev_dir = std::env::var_os(crate::config::CONFIG_DIR_ENV);
let prev_key = std::env::var_os(crate::provider::ANTHROPIC_API_KEY_ENV);
let prev_tok = std::env::var_os(crate::provider::ANTHROPIC_AUTH_TOKEN_ENV);
let prev_openai_key = std::env::var_os(crate::provider::OPENAI_API_KEY_ENV);
let tmp = tempfile::TempDir::new().unwrap();
std::env::set_var(crate::config::CONFIG_DIR_ENV, tmp.path());
std::env::remove_var(crate::provider::ANTHROPIC_API_KEY_ENV);
std::env::remove_var(crate::provider::ANTHROPIC_AUTH_TOKEN_ENV);
std::env::remove_var(crate::provider::OPENAI_API_KEY_ENV);
Self {
_guard: guard,
_tmp: tmp,
prev_dir,
prev_key,
prev_tok,
prev_openai_key,
}
}
}
impl Drop for TempConfig {
fn drop(&mut self) {
restore(crate::config::CONFIG_DIR_ENV, self.prev_dir.take());
restore(crate::provider::ANTHROPIC_API_KEY_ENV, self.prev_key.take());
restore(
crate::provider::ANTHROPIC_AUTH_TOKEN_ENV,
self.prev_tok.take(),
);
restore(
crate::provider::OPENAI_API_KEY_ENV,
self.prev_openai_key.take(),
);
}
}
fn restore(name: &str, prev: Option<std::ffi::OsString>) {
match prev {
Some(v) => std::env::set_var(name, v),
None => std::env::remove_var(name),
}
}
#[tokio::test]
async fn check_not_ready_with_no_credentials() {
let _cfg = TempConfig::new();
let code = run_check(&[]).await;
assert_eq!(code, EXIT_NOT_READY);
}
#[tokio::test]
async fn check_ready_with_stored_credential() {
let _cfg = TempConfig::new();
upsert_credential(
DEFAULT_PROVIDER_ID,
Credential::ApiKey {
key: Some("sk-stored".into()),
env: None,
},
)
.unwrap();
let code = run_check(&[]).await;
assert_eq!(code, 0);
}
#[tokio::test]
async fn check_ready_with_env_api_key() {
let _cfg = TempConfig::new();
std::env::set_var(crate::provider::ANTHROPIC_API_KEY_ENV, "sk-env");
let code = run_check(&[]).await;
assert_eq!(code, 0);
}
#[tokio::test]
async fn check_ready_with_openai_models_json_key() {
let _cfg = TempConfig::new();
std::fs::write(
crate::config::models_path().unwrap(),
r#"{"providers":{"gateway":{"api":"openai-completions","apiKey":"key","models":[{"id":"gpt-test"}]}}}"#,
)
.unwrap();
let code = run_check(&["--provider".to_string(), "gateway".to_string()]).await;
assert_eq!(code, 0);
}
#[tokio::test]
async fn check_json_outputs_object() {
let _cfg = TempConfig::new();
upsert_credential(
DEFAULT_PROVIDER_ID,
Credential::ApiKey {
key: Some("sk-x".into()),
env: None,
},
)
.unwrap();
let code = run_check(&["--json".to_string()]).await;
assert_eq!(code, 0);
}
#[tokio::test]
async fn logout_removes_stored_credential() {
let _cfg = TempConfig::new();
upsert_credential(
DEFAULT_PROVIDER_ID,
Credential::ApiKey {
key: Some("sk".into()),
env: None,
},
)
.unwrap();
assert!(auth_path().unwrap().exists());
let code = run_logout(&[]).await;
assert_eq!(code, 0);
assert_eq!(run_check(&[]).await, EXIT_NOT_READY);
}
#[tokio::test]
async fn logout_when_empty_is_noop() {
let _cfg = TempConfig::new();
let code = run_logout(&[]).await;
assert_eq!(code, 0);
}
#[tokio::test]
async fn unknown_auth_subcommand_errors() {
let code = run(&["bogus".to_string()]).await;
assert_eq!(code, EXIT_ERROR);
}
#[tokio::test]
async fn auth_help_exits_zero() {
let code = run(&[]).await;
assert_eq!(code, 0);
let code = run(&["--help".to_string()]).await;
assert_eq!(code, 0);
}
#[test]
fn parse_provider_handles_both_forms() {
assert_eq!(parse_provider(&[]), None);
assert_eq!(
parse_provider(&["--provider".to_string(), "anthropic".to_string()]),
Some("anthropic")
);
assert_eq!(
parse_provider(&["--provider=custom".to_string()]),
Some("custom")
);
assert_eq!(parse_provider(&["--provider".to_string()]), None);
}
}