use std::path::Path;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct AuthInfo {
pub provider: String,
pub plan: Option<String>,
pub email: Option<String>,
}
const KNOWN_AGENTS: &[(&str, &str)] = &[
("claude", "claude.ai"),
("codex", "openai"),
("gemini", "google"),
];
pub fn agent_provider_for_command(command: &str) -> Option<&'static str> {
let lower = command.to_lowercase();
for &(agent, provider) in KNOWN_AGENTS {
for (i, _) in lower.match_indices(agent) {
let before_ok = i == 0
|| lower.as_bytes().get(i - 1).is_some_and(|&b| {
b == b' ' || b == b'/' || b == b'\t' || b == b'\n' || b == b';' || b == b'&'
});
let after = i + agent.len();
let after_ok = after >= lower.len()
|| lower.as_bytes().get(after).is_some_and(|&b| {
b == b' ' || b == b'\t' || b == b'\n' || b == b';' || b == b'&'
});
if before_ok && after_ok {
return Some(provider);
}
}
}
None
}
pub fn extract_claude_auth(claude_dir: &Path) -> Option<AuthInfo> {
let creds_path = claude_dir.join(".credentials.json");
let json_str = read_file_to_string(&creds_path)?;
parse_claude_credentials(&json_str)
}
fn parse_claude_credentials(json_str: &str) -> Option<AuthInfo> {
let value: serde_json::Value = serde_json::from_str(json_str).ok()?;
let mut plan = None;
let mut email = None;
if let Some(oauth) = value.get("claudeAiOauth") {
plan = oauth
.get("subscriptionType")
.and_then(|v| v.as_str())
.map(str::to_lowercase);
email = oauth
.get("email")
.and_then(|v| v.as_str())
.map(String::from);
if email.is_none() {
email = oauth
.get("account")
.and_then(|a| a.get("email"))
.and_then(|v| v.as_str())
.map(String::from);
}
}
if plan.is_none() {
plan = value
.get("subscriptionType")
.and_then(|v| v.as_str())
.map(str::to_lowercase);
}
if email.is_none() {
email = value
.get("email")
.and_then(|v| v.as_str())
.map(String::from);
}
if plan.is_none() && email.is_none() {
return None;
}
Some(AuthInfo {
provider: "claude.ai".to_owned(),
plan,
email,
})
}
pub fn extract_codex_auth(codex_dir: &Path) -> Option<AuthInfo> {
let auth_path = codex_dir.join("auth.json");
let json_str = read_file_to_string(&auth_path)?;
parse_codex_credentials(&json_str)
}
fn parse_codex_credentials(json_str: &str) -> Option<AuthInfo> {
let value: serde_json::Value = serde_json::from_str(json_str).ok()?;
let plan = value
.get("plan")
.and_then(|v| v.as_str())
.map(str::to_lowercase);
let email = value
.get("email")
.and_then(|v| v.as_str())
.map(String::from);
if plan.is_none() && email.is_none() {
return None;
}
Some(AuthInfo {
provider: "openai".to_owned(),
plan,
email,
})
}
pub fn extract_gemini_auth(gemini_dir: &Path) -> Option<AuthInfo> {
for filename in &["config.json", "credentials.json"] {
let path = gemini_dir.join(filename);
if let Some(json_str) = read_file_to_string(&path)
&& let Some(info) = parse_gemini_credentials(&json_str)
{
return Some(info);
}
}
None
}
fn parse_gemini_credentials(json_str: &str) -> Option<AuthInfo> {
let value: serde_json::Value = serde_json::from_str(json_str).ok()?;
let plan = value
.get("plan")
.or_else(|| value.get("tier"))
.and_then(|v| v.as_str())
.map(str::to_lowercase);
let email = value
.get("email")
.or_else(|| value.get("account"))
.and_then(|v| v.as_str())
.map(String::from);
if plan.is_none() && email.is_none() {
return None;
}
Some(AuthInfo {
provider: "google".to_owned(),
plan,
email,
})
}
#[cfg(not(coverage))]
pub fn detect_auth_info() -> Vec<AuthInfo> {
let mut results = Vec::new();
if let Some(home) = dirs::home_dir() {
if let Some(info) = extract_claude_auth(&home.join(".claude")) {
results.push(info);
}
if let Some(info) = extract_codex_auth(&home.join(".codex")) {
results.push(info);
}
if let Some(info) = extract_gemini_auth(&home.join(".gemini")) {
results.push(info);
}
}
results
}
#[cfg(coverage)]
pub fn detect_auth_info() -> Vec<AuthInfo> {
Vec::new()
}
#[cfg(not(coverage))]
pub fn detect_auth_for_command(command: &str) -> Option<AuthInfo> {
let provider = agent_provider_for_command(command)?;
let home = dirs::home_dir()?;
match provider {
"claude.ai" => extract_claude_auth(&home.join(".claude")),
"openai" => extract_codex_auth(&home.join(".codex")),
"google" => extract_gemini_auth(&home.join(".gemini")),
_ => None,
}
}
#[cfg(coverage)]
pub fn detect_auth_for_command(command: &str) -> Option<AuthInfo> {
agent_provider_for_command(command)?;
None
}
fn read_file_to_string(path: &Path) -> Option<String> {
std::fs::read_to_string(path).ok()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_detects_claude_command() {
assert_eq!(
agent_provider_for_command("claude -p 'review code'"),
Some("claude.ai")
);
}
#[test]
fn test_detects_claude_with_path() {
assert_eq!(
agent_provider_for_command("/usr/local/bin/claude --help"),
Some("claude.ai")
);
}
#[test]
fn test_detects_codex_command() {
assert_eq!(
agent_provider_for_command("codex run tests"),
Some("openai")
);
}
#[test]
fn test_detects_gemini_command() {
assert_eq!(agent_provider_for_command("gemini chat"), Some("google"));
}
#[test]
fn test_no_agent_in_command() {
assert_eq!(agent_provider_for_command("cargo test --workspace"), None);
}
#[test]
fn test_no_partial_match() {
assert_eq!(agent_provider_for_command("claudette run"), None);
}
#[test]
fn test_agent_after_semicolon() {
assert_eq!(
agent_provider_for_command("cd /repo; claude -p 'fix'"),
Some("claude.ai")
);
}
#[test]
fn test_agent_after_ampersand() {
assert_eq!(
agent_provider_for_command("export FOO=1 && codex run"),
Some("openai")
);
}
#[test]
fn test_agent_at_end_of_command() {
assert_eq!(agent_provider_for_command("exec claude"), Some("claude.ai"));
}
#[test]
fn test_empty_command() {
assert_eq!(agent_provider_for_command(""), None);
}
#[test]
fn test_parse_claude_with_oauth() {
let json = r#"{
"claudeAiOauth": {
"subscriptionType": "Max",
"email": "user@example.com"
}
}"#;
let info = parse_claude_credentials(json).unwrap();
assert_eq!(info.provider, "claude.ai");
assert_eq!(info.plan.as_deref(), Some("max"));
assert_eq!(info.email.as_deref(), Some("user@example.com"));
}
#[test]
fn test_parse_claude_with_oauth_account_email() {
let json = r#"{
"claudeAiOauth": {
"subscriptionType": "Pro",
"account": { "email": "nested@example.com" }
}
}"#;
let info = parse_claude_credentials(json).unwrap();
assert_eq!(info.plan.as_deref(), Some("pro"));
assert_eq!(info.email.as_deref(), Some("nested@example.com"));
}
#[test]
fn test_parse_claude_top_level_fields() {
let json = r#"{
"subscriptionType": "Plus",
"email": "toplevel@example.com"
}"#;
let info = parse_claude_credentials(json).unwrap();
assert_eq!(info.plan.as_deref(), Some("plus"));
assert_eq!(info.email.as_deref(), Some("toplevel@example.com"));
}
#[test]
fn test_parse_claude_no_useful_fields() {
let json = r#"{"accessToken": "abc123"}"#;
assert!(parse_claude_credentials(json).is_none());
}
#[test]
fn test_parse_claude_invalid_json() {
assert!(parse_claude_credentials("not json").is_none());
}
#[test]
fn test_parse_claude_empty() {
assert!(parse_claude_credentials("").is_none());
}
#[test]
fn test_parse_claude_plan_only() {
let json = r#"{"claudeAiOauth": {"subscriptionType": "Max"}}"#;
let info = parse_claude_credentials(json).unwrap();
assert_eq!(info.plan.as_deref(), Some("max"));
assert!(info.email.is_none());
}
#[test]
fn test_parse_codex_full() {
let json = r#"{"plan": "Plus", "email": "codex@example.com"}"#;
let info = parse_codex_credentials(json).unwrap();
assert_eq!(info.provider, "openai");
assert_eq!(info.plan.as_deref(), Some("plus"));
assert_eq!(info.email.as_deref(), Some("codex@example.com"));
}
#[test]
fn test_parse_codex_no_useful_fields() {
let json = r#"{"apiKey": "sk-..."}"#;
assert!(parse_codex_credentials(json).is_none());
}
#[test]
fn test_parse_codex_invalid_json() {
assert!(parse_codex_credentials("{bad}").is_none());
}
#[test]
fn test_parse_codex_plan_only() {
let json = r#"{"plan": "Pro"}"#;
let info = parse_codex_credentials(json).unwrap();
assert_eq!(info.plan.as_deref(), Some("pro"));
assert!(info.email.is_none());
}
#[test]
fn test_parse_gemini_full() {
let json = r#"{"plan": "Ultra", "email": "gemini@google.com"}"#;
let info = parse_gemini_credentials(json).unwrap();
assert_eq!(info.provider, "google");
assert_eq!(info.plan.as_deref(), Some("ultra"));
assert_eq!(info.email.as_deref(), Some("gemini@google.com"));
}
#[test]
fn test_parse_gemini_with_tier() {
let json = r#"{"tier": "Pro", "account": "user@gmail.com"}"#;
let info = parse_gemini_credentials(json).unwrap();
assert_eq!(info.plan.as_deref(), Some("pro"));
assert_eq!(info.email.as_deref(), Some("user@gmail.com"));
}
#[test]
fn test_parse_gemini_no_useful_fields() {
let json = r#"{"token": "abc"}"#;
assert!(parse_gemini_credentials(json).is_none());
}
#[test]
fn test_parse_gemini_invalid_json() {
assert!(parse_gemini_credentials("???").is_none());
}
#[test]
fn test_extract_claude_auth_reads_credentials_file() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(
dir.path().join(".credentials.json"),
r#"{"claudeAiOauth": {"subscriptionType": "Max", "email": "user@example.com"}}"#,
)
.unwrap();
let info = extract_claude_auth(dir.path()).unwrap();
assert_eq!(info.provider, "claude.ai");
assert_eq!(info.plan.as_deref(), Some("max"));
assert_eq!(info.email.as_deref(), Some("user@example.com"));
}
#[test]
fn test_extract_codex_auth_reads_auth_file() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(
dir.path().join("auth.json"),
r#"{"plan": "Plus", "email": "codex@example.com"}"#,
)
.unwrap();
let info = extract_codex_auth(dir.path()).unwrap();
assert_eq!(info.provider, "openai");
assert_eq!(info.plan.as_deref(), Some("plus"));
assert_eq!(info.email.as_deref(), Some("codex@example.com"));
}
#[test]
fn test_extract_gemini_auth_reads_config_file() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(
dir.path().join("config.json"),
r#"{"tier": "Pro", "email": "gemini@example.com"}"#,
)
.unwrap();
let info = extract_gemini_auth(dir.path()).unwrap();
assert_eq!(info.provider, "google");
assert_eq!(info.plan.as_deref(), Some("pro"));
assert_eq!(info.email.as_deref(), Some("gemini@example.com"));
}
#[test]
fn test_extract_gemini_auth_falls_back_to_credentials_file() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(
dir.path().join("credentials.json"),
r#"{"account": "fallback@gmail.com"}"#,
)
.unwrap();
let info = extract_gemini_auth(dir.path()).unwrap();
assert_eq!(info.email.as_deref(), Some("fallback@gmail.com"));
}
#[test]
fn test_extract_claude_auth_unparseable_file() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(dir.path().join(".credentials.json"), "not json").unwrap();
assert!(extract_claude_auth(dir.path()).is_none());
}
#[test]
fn test_extract_claude_auth_nonexistent_dir() {
let info = extract_claude_auth(Path::new("/nonexistent/path/.claude"));
assert!(info.is_none());
}
#[test]
fn test_extract_codex_auth_nonexistent_dir() {
assert!(extract_codex_auth(Path::new("/nonexistent/.codex")).is_none());
}
#[test]
fn test_extract_gemini_auth_nonexistent_dir() {
assert!(extract_gemini_auth(Path::new("/nonexistent/.gemini")).is_none());
}
#[test]
fn test_detect_auth_info_returns_vec() {
let _ = detect_auth_info();
}
#[test]
fn test_detect_auth_for_command_no_agent() {
assert!(detect_auth_for_command("cargo build").is_none());
}
#[test]
fn test_detect_auth_for_command_with_agent() {
let result = detect_auth_for_command("claude -p 'test'");
let _ = result;
}
#[test]
fn test_no_match_for_substring_codex() {
assert_eq!(agent_provider_for_command("mycodex run"), None);
}
#[test]
fn test_agent_with_pipe() {
assert_eq!(
agent_provider_for_command("echo foo | claude -p 'fix'"),
Some("claude.ai")
);
}
#[test]
fn test_agent_case_insensitive() {
assert_eq!(
agent_provider_for_command("Claude -p 'test'"),
Some("claude.ai")
);
assert_eq!(agent_provider_for_command("CODEX run"), Some("openai"));
assert_eq!(agent_provider_for_command("GEMINI chat"), Some("google"));
}
#[test]
fn test_agent_after_tab() {
assert_eq!(
agent_provider_for_command("cd /repo\tclaude -p 'fix'"),
Some("claude.ai")
);
}
#[test]
fn test_agent_after_newline() {
assert_eq!(
agent_provider_for_command("export FOO=1\nclaude -p 'fix'"),
Some("claude.ai")
);
}
#[test]
fn test_agent_gemini_at_end() {
assert_eq!(agent_provider_for_command("exec gemini"), Some("google"));
}
#[test]
fn test_no_match_agent_in_middle_of_word() {
assert_eq!(agent_provider_for_command("geminist run"), None);
}
#[test]
fn test_parse_claude_credentials_array_json() {
assert!(parse_claude_credentials("[1, 2, 3]").is_none());
}
#[test]
fn test_parse_claude_credentials_number_json() {
assert!(parse_claude_credentials("42").is_none());
}
#[test]
fn test_parse_claude_credentials_null() {
assert!(parse_claude_credentials("null").is_none());
}
#[test]
fn test_parse_codex_credentials_array_json() {
assert!(parse_codex_credentials("[\"a\", \"b\"]").is_none());
}
#[test]
fn test_parse_codex_credentials_empty_object() {
assert!(parse_codex_credentials("{}").is_none());
}
#[test]
fn test_parse_gemini_credentials_array_json() {
assert!(parse_gemini_credentials("[1]").is_none());
}
#[test]
fn test_parse_gemini_credentials_empty_object() {
assert!(parse_gemini_credentials("{}").is_none());
}
#[test]
fn test_parse_claude_email_only() {
let json = r#"{"email": "user@example.com"}"#;
let info = parse_claude_credentials(json).unwrap();
assert!(info.plan.is_none());
assert_eq!(info.email.as_deref(), Some("user@example.com"));
}
#[test]
fn test_parse_codex_email_only() {
let json = r#"{"email": "codex@example.com"}"#;
let info = parse_codex_credentials(json).unwrap();
assert!(info.plan.is_none());
assert_eq!(info.email.as_deref(), Some("codex@example.com"));
}
#[test]
fn test_parse_gemini_email_via_account_field() {
let json = r#"{"account": "user@gmail.com"}"#;
let info = parse_gemini_credentials(json).unwrap();
assert!(info.plan.is_none());
assert_eq!(info.email.as_deref(), Some("user@gmail.com"));
}
#[test]
fn test_parse_gemini_plan_via_tier_field() {
let json = r#"{"tier": "Enterprise"}"#;
let info = parse_gemini_credentials(json).unwrap();
assert_eq!(info.plan.as_deref(), Some("enterprise"));
assert!(info.email.is_none());
}
#[test]
fn test_parse_claude_oauth_with_non_string_fields() {
let json = r#"{"claudeAiOauth": {"subscriptionType": 42, "email": "user@test.com"}}"#;
let info = parse_claude_credentials(json).unwrap();
assert!(info.plan.is_none());
assert_eq!(info.email.as_deref(), Some("user@test.com"));
}
}