1use serde_json::Value;
20
21use crate::agent::Agent;
22use crate::error::{Error, Result};
23
24#[derive(Debug, Clone, Copy, PartialEq, Eq)]
26#[non_exhaustive]
27pub enum AuthState {
28 LoggedIn,
30 LoggedOut,
32 Unknown,
39}
40
41#[derive(Debug, Clone)]
43#[non_exhaustive]
44pub struct AuthStatus {
45 pub agent: Agent,
47 pub state: AuthState,
49 pub method: Option<String>,
52 pub account: Option<String>,
54 pub plan: Option<String>,
56 pub detail: String,
58 pub login_hint: &'static str,
60}
61
62impl AuthStatus {
63 pub async fn check(agent: Agent) -> Result<AuthStatus> {
70 AuthStatus::check_bin(agent, agent.bin()).await
71 }
72
73 pub async fn check_bin(agent: Agent, bin: &str) -> Result<AuthStatus> {
80 let Some(args) = agent.auth_status_argv() else {
81 return Ok(AuthStatus::uncheckable(agent));
82 };
83
84 let output = tokio::process::Command::new(bin)
85 .args(args)
86 .output()
87 .await
88 .map_err(|source| {
89 if source.kind() == std::io::ErrorKind::NotFound {
90 Error::NotInstalled {
91 agent,
92 bin: bin.to_string(),
93 hint: agent.install_hint(),
94 }
95 } else {
96 Error::Spawn {
97 bin: bin.to_string(),
98 source,
99 }
100 }
101 })?;
102
103 let mut text = String::from_utf8_lossy(&output.stdout).trim().to_string();
104 if text.is_empty() {
105 text = String::from_utf8_lossy(&output.stderr).trim().to_string();
106 }
107 Ok(AuthStatus::read(agent, &text, output.status.success()))
108 }
109
110 #[must_use]
115 pub(crate) fn read(agent: Agent, text: &str, exit_ok: bool) -> AuthStatus {
116 let mut status = AuthStatus {
117 agent,
118 state: AuthState::Unknown,
119 method: None,
120 account: None,
121 plan: None,
122 detail: text.to_string(),
123 login_hint: agent.login_hint(),
124 };
125
126 match agent {
127 Agent::Claude => {
130 if let Ok(Value::Object(map)) = serde_json::from_str::<Value>(text) {
131 status.state = match map.get("loggedIn").and_then(Value::as_bool) {
132 Some(true) => AuthState::LoggedIn,
133 Some(false) => AuthState::LoggedOut,
134 None => AuthState::Unknown,
135 };
136 let field = |key: &str| {
137 map.get(key)
138 .and_then(Value::as_str)
139 .map(str::to_string)
140 .filter(|v| !v.is_empty())
141 };
142 status.method = field("authMethod");
143 status.account = field("email");
144 status.plan = field("subscriptionType");
145 }
146 }
147 Agent::Codex => {
151 let lower = text.to_ascii_lowercase();
152 status.state = if lower.contains("not logged in") || lower.contains("logged out") {
153 AuthState::LoggedOut
154 } else if exit_ok && lower.contains("logged in") {
155 status.method = text
157 .rsplit_once(" using ")
158 .map(|(_, method)| method.trim().to_string());
159 AuthState::LoggedIn
160 } else {
161 AuthState::Unknown
162 };
163 }
164 Agent::Copilot => {}
167 }
168 status
169 }
170
171 fn uncheckable(agent: Agent) -> AuthStatus {
173 let env_token = agent
177 .auth_env_vars()
178 .iter()
179 .find(|name| std::env::var_os(name).is_some_and(|v| !v.is_empty()));
180
181 AuthStatus {
182 agent,
183 state: AuthState::Unknown,
184 method: env_token.map(|name| format!("token in {name}")),
185 account: None,
186 plan: None,
187 detail: match env_token {
188 Some(name) => format!(
189 "{agent} exposes no status command, so this cannot be confirmed without \
190 spending a request. {name} is set, but its validity is unverified."
191 ),
192 None => format!(
193 "{agent} exposes no status command, so this cannot be confirmed without \
194 spending a request, and no credential environment variable is set."
195 ),
196 },
197 login_hint: agent.login_hint(),
198 }
199 }
200
201 #[must_use]
207 pub fn is_logged_in(&self) -> bool {
208 self.state == AuthState::LoggedIn
209 }
210
211 #[must_use]
216 pub fn needs_login(&self) -> bool {
217 self.state == AuthState::LoggedOut
218 }
219
220 #[must_use]
222 pub fn summary(&self) -> String {
223 match self.state {
224 AuthState::LoggedIn => {
225 let who = self
226 .account
227 .as_deref()
228 .or(self.method.as_deref())
229 .unwrap_or("logged in");
230 match &self.plan {
231 Some(plan) => format!("logged in as {who} ({plan})"),
232 None => format!("logged in as {who}"),
233 }
234 }
235 AuthState::LoggedOut => format!("not logged in: {}", self.login_hint),
236 AuthState::Unknown => format!("unknown: {}", self.detail),
237 }
238 }
239}
240
241#[cfg(test)]
242mod tests {
243 use super::*;
244
245 #[test]
247 fn claude_json_is_read_into_a_status() {
248 let text = r#"{
249 "loggedIn": true,
250 "authMethod": "claude.ai",
251 "apiProvider": "firstParty",
252 "email": "claude@pathscale.com",
253 "orgId": "18b5d0a6",
254 "subscriptionType": "max"
255 }"#;
256 let status = AuthStatus::read(Agent::Claude, text, true);
257 assert_eq!(status.state, AuthState::LoggedIn);
258 assert!(status.is_logged_in());
259 assert!(!status.needs_login());
260 assert_eq!(status.account.as_deref(), Some("claude@pathscale.com"));
261 assert_eq!(status.method.as_deref(), Some("claude.ai"));
262 assert_eq!(status.plan.as_deref(), Some("max"));
263 assert!(status.summary().contains("claude@pathscale.com"));
264 }
265
266 #[test]
267 fn claude_reports_a_logout_as_one() {
268 let status = AuthStatus::read(Agent::Claude, r#"{"loggedIn": false}"#, true);
269 assert_eq!(status.state, AuthState::LoggedOut);
270 assert!(status.needs_login());
271 assert!(status.summary().contains("/login"), "{}", status.summary());
272 }
273
274 #[test]
276 fn codex_prose_is_read_into_a_status() {
277 let status = AuthStatus::read(Agent::Codex, "Logged in using ChatGPT", true);
278 assert_eq!(status.state, AuthState::LoggedIn);
279 assert_eq!(status.method.as_deref(), Some("ChatGPT"));
280 }
281
282 #[test]
284 fn codex_negatives_are_not_read_as_positives() {
285 for text in ["Not logged in", "You are not logged in.", "Logged out"] {
286 let status = AuthStatus::read(Agent::Codex, text, true);
287 assert_eq!(status.state, AuthState::LoggedOut, "{text:?}");
288 assert!(status.summary().contains("codex login"));
289 }
290 }
291
292 #[test]
293 fn unrecognized_output_is_unknown_rather_than_a_guess() {
294 for (agent, text) in [
295 (Agent::Claude, "not json at all"),
296 (Agent::Codex, "something else entirely"),
297 ] {
298 let status = AuthStatus::read(agent, text, true);
299 assert_eq!(status.state, AuthState::Unknown, "{agent}");
300 assert!(!status.is_logged_in());
301 assert!(!status.needs_login(), "{agent}");
304 }
305 }
306
307 #[tokio::test]
310 async fn copilot_reports_that_it_cannot_be_checked() {
311 let status = AuthStatus::check_bin(Agent::Copilot, "copilot")
312 .await
313 .expect("an uncheckable agent is not an error");
314 assert_eq!(status.state, AuthState::Unknown);
315 assert!(!status.needs_login());
316 assert!(
317 status.detail.contains("no status command"),
318 "{}",
319 status.detail
320 );
321 }
322
323 #[test]
324 fn only_claude_and_codex_can_be_asked() {
325 assert!(Agent::Claude.auth_status_argv().is_some());
326 assert!(Agent::Codex.auth_status_argv().is_some());
327 assert!(Agent::Copilot.auth_status_argv().is_none());
328 }
329}