1use std::collections::BTreeSet;
7use std::path::{Path, PathBuf};
8use std::process::Stdio;
9use std::time::Duration;
10
11use agent_call::Provider;
12use tokio::process::Command;
13
14use crate::{PROVIDERS, provider_name};
15
16#[derive(Debug, Clone, Copy, PartialEq, Eq)]
18pub enum Login {
19 LoggedIn,
21 LoggedOut,
23 Unknown,
25}
26
27#[derive(Debug, Clone, PartialEq, Eq)]
29pub struct DetectedCli {
30 pub provider: Provider,
32 pub path: PathBuf,
34 pub version: String,
36 pub login: Login,
38}
39
40impl DetectedCli {
41 pub fn is_ready(&self) -> bool {
43 self.login == Login::LoggedIn
44 }
45}
46
47#[derive(Debug, Clone)]
49pub struct SearchPaths {
50 pub dirs: Vec<PathBuf>,
52}
53
54impl SearchPaths {
55 pub fn standard(path_var: Option<&std::ffi::OsStr>, home: Option<&Path>) -> Self {
57 let mut dirs: Vec<PathBuf> = path_var
58 .map(|p| std::env::split_paths(p).collect())
59 .unwrap_or_default();
60 if let Some(home) = home {
61 for rel in [
62 ".local/bin",
63 ".npm-global/bin",
64 ".bun/bin",
65 ".claude/local",
66 ".cargo/bin",
67 ".opencode/bin",
68 ] {
69 dirs.push(home.join(rel));
70 }
71 }
72 dirs.push(PathBuf::from("/usr/local/bin"));
73 dirs.push(PathBuf::from("/opt/homebrew/bin"));
74 Self { dirs }
75 }
76
77 pub fn from_env() -> Self {
79 let home = std::env::var_os("HOME").map(PathBuf::from);
80 Self::standard(std::env::var_os("PATH").as_deref(), home.as_deref())
81 }
82
83 pub fn find(&self, name: &str) -> Option<PathBuf> {
85 self.dirs
86 .iter()
87 .map(|dir| dir.join(name))
88 .find(|candidate| is_executable(candidate))
89 .map(|p| std::fs::canonicalize(&p).unwrap_or(p))
90 }
91}
92
93fn is_executable(path: &Path) -> bool {
94 let Ok(meta) = std::fs::metadata(path) else {
95 return false;
96 };
97 if !meta.is_file() {
98 return false;
99 }
100 #[cfg(unix)]
101 {
102 use std::os::unix::fs::PermissionsExt;
103 meta.permissions().mode() & 0o111 != 0
104 }
105 #[cfg(not(unix))]
106 {
107 true
108 }
109}
110
111const CHECK_TIMEOUT: Duration = Duration::from_secs(15);
112
113async fn output(
114 path: &Path,
115 args: &[&str],
116 env: &[(String, String)],
117) -> Option<std::process::Output> {
118 let mut command = Command::new(path);
119 command
120 .args(args)
121 .envs(env.iter().map(|(k, v)| (k, v)))
122 .stdin(Stdio::null())
123 .stdout(Stdio::piped())
124 .stderr(Stdio::piped())
125 .kill_on_drop(true);
126 tokio::time::timeout(CHECK_TIMEOUT, command.output())
127 .await
128 .ok()?
129 .ok()
130}
131
132async fn login(provider: Provider, path: &Path, env: &[(String, String)]) -> Login {
133 let args: &[&str] = match provider {
134 Provider::Claude | Provider::Devin => &["auth", "status"],
135 Provider::Codex => &["login", "status"],
136 Provider::OpenCodeV2 => return Login::Unknown,
137 };
138 let Some(out) = output(path, args, env).await else {
139 return Login::Unknown;
140 };
141 if provider == Provider::Claude {
142 return match serde_json::from_slice::<serde_json::Value>(&out.stdout)
144 .ok()
145 .and_then(|v| v["loggedIn"].as_bool())
146 {
147 Some(true) => Login::LoggedIn,
148 Some(false) => Login::LoggedOut,
149 None => Login::Unknown,
150 };
151 }
152 if out.status.success() {
153 Login::LoggedIn
154 } else {
155 Login::LoggedOut
156 }
157}
158
159pub async fn detect_one(provider: Provider, paths: &SearchPaths) -> Option<DetectedCli> {
161 detect_one_with_env(provider, paths, &[]).await
162}
163
164pub async fn detect_one_with_env(
166 provider: Provider,
167 paths: &SearchPaths,
168 env: &[(String, String)],
169) -> Option<DetectedCli> {
170 let path = paths.find(provider.executable())?;
171 detect_path(provider, &path, env).await
172}
173
174pub async fn detect_path(
176 provider: Provider,
177 path: &Path,
178 env: &[(String, String)],
179) -> Option<DetectedCli> {
180 if !is_executable(path) {
181 return None;
182 }
183 let path = std::fs::canonicalize(path).ok()?;
184 let out = output(&path, &["--version"], env).await?;
185 if !out.status.success() {
186 return None;
187 }
188 let version = String::from_utf8_lossy(&out.stdout)
189 .lines()
190 .next()
191 .unwrap_or("")
192 .trim()
193 .to_string();
194 let login = login(provider, &path, env).await;
195 Some(DetectedCli {
196 provider,
197 path,
198 version,
199 login,
200 })
201}
202
203pub async fn detect(paths: &SearchPaths) -> Vec<DetectedCli> {
205 let mut found = Vec::new();
206 let mut seen = BTreeSet::new();
207 for provider in PROVIDERS {
208 if let Some(cli) = detect_one(provider, paths).await
209 && seen.insert(provider_name(provider))
210 {
211 found.push(cli);
212 }
213 }
214 found.sort_by_key(|c| !c.is_ready());
215 found
216}
217
218#[cfg(all(test, unix))]
219mod tests {
220 use super::*;
221 use okbot_testkit::fake_cli;
222
223 #[tokio::test]
224 async fn finds_cli_outside_path_and_reads_login() {
225 let home = tempfile::tempdir().unwrap();
226 let bin = home.path().join(".local/bin");
227 std::fs::create_dir_all(&bin).unwrap();
228 fake_cli(
229 &bin,
230 "claude",
231 r#"if [ "$1" = auth ]; then echo '{"loggedIn": true}'; fi"#,
232 );
233 fake_cli(&bin, "codex", r#"if [ "$1" = login ]; then exit 1; fi"#);
234 let paths = SearchPaths::standard(Some("/nonexistent".as_ref()), Some(home.path()));
235 let found = detect(&paths).await;
236 let summary: Vec<(&str, Login)> = found
237 .iter()
238 .map(|c| (provider_name(c.provider), c.login))
239 .collect();
240 assert_eq!(
241 summary,
242 [("claude", Login::LoggedIn), ("codex", Login::LoggedOut)]
243 );
244 assert_eq!(found[0].version, "1.0.0");
245 assert!(found[0].path.is_absolute());
246 }
247
248 #[tokio::test]
249 async fn nothing_found() {
250 let empty = tempfile::tempdir().unwrap();
251 let paths = SearchPaths {
252 dirs: vec![empty.path().to_path_buf()],
253 };
254 assert_eq!(detect(&paths).await, []);
255 }
256}