Skip to main content

okbot_agent/
detect.rs

1//! Finding installed agent CLIs (design ยง6.3.1).
2//!
3//! okbot usually runs on a VPS where CLIs live in user-level directories that a service's `PATH`
4//! misses, so detection also looks in the usual install locations and records absolute paths.
5
6use 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/// Whether a CLI is logged in.
17#[derive(Debug, Clone, Copy, PartialEq, Eq)]
18pub enum Login {
19    /// The CLI reports a login.
20    LoggedIn,
21    /// The CLI reports no login.
22    LoggedOut,
23    /// The CLI has no reliable status command (OpenCode) or the check failed.
24    Unknown,
25}
26
27/// One installed CLI.
28#[derive(Debug, Clone, PartialEq, Eq)]
29pub struct DetectedCli {
30    /// Which CLI.
31    pub provider: Provider,
32    /// Absolute, symlink-resolved path.
33    pub path: PathBuf,
34    /// `--version` output (first line).
35    pub version: String,
36    /// Login state.
37    pub login: Login,
38}
39
40impl DetectedCli {
41    /// Ready to use without asking the operator anything.
42    pub fn is_ready(&self) -> bool {
43        self.login == Login::LoggedIn
44    }
45}
46
47/// Where to look.
48#[derive(Debug, Clone)]
49pub struct SearchPaths {
50    /// Directories searched in order.
51    pub dirs: Vec<PathBuf>,
52}
53
54impl SearchPaths {
55    /// `PATH`, then common install locations under `home`.
56    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    /// The current process's `PATH` and `HOME`.
78    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    /// The first executable named `name`.
84    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        // `claude auth status` prints JSON with `loggedIn`.
143        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
159/// Detects one CLI.
160pub async fn detect_one(provider: Provider, paths: &SearchPaths) -> Option<DetectedCli> {
161    detect_one_with_env(provider, paths, &[]).await
162}
163
164/// Detects one CLI, checking its login with extra environment (a bot's separate agent home).
165pub 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
174/// Checks exactly this executable, including custom names and paths outside `PATH`.
175pub 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
203/// Detects every known CLI, ready ones first, otherwise in [`PROVIDERS`] order.
204pub 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}