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    let out = output(&path, &["--version"], env).await?;
172    if !out.status.success() {
173        return None;
174    }
175    let version = String::from_utf8_lossy(&out.stdout)
176        .lines()
177        .next()
178        .unwrap_or("")
179        .trim()
180        .to_string();
181    let login = login(provider, &path, env).await;
182    Some(DetectedCli {
183        provider,
184        path,
185        version,
186        login,
187    })
188}
189
190/// Detects every known CLI, ready ones first, otherwise in [`PROVIDERS`] order.
191pub async fn detect(paths: &SearchPaths) -> Vec<DetectedCli> {
192    let mut found = Vec::new();
193    let mut seen = BTreeSet::new();
194    for provider in PROVIDERS {
195        if let Some(cli) = detect_one(provider, paths).await
196            && seen.insert(provider_name(provider))
197        {
198            found.push(cli);
199        }
200    }
201    found.sort_by_key(|c| !c.is_ready());
202    found
203}
204
205#[cfg(all(test, unix))]
206mod tests {
207    use super::*;
208    use okbot_testkit::fake_cli;
209
210    #[tokio::test]
211    async fn finds_cli_outside_path_and_reads_login() {
212        let home = tempfile::tempdir().unwrap();
213        let bin = home.path().join(".local/bin");
214        std::fs::create_dir_all(&bin).unwrap();
215        fake_cli(
216            &bin,
217            "claude",
218            r#"if [ "$1" = auth ]; then echo '{"loggedIn": true}'; fi"#,
219        );
220        fake_cli(&bin, "codex", r#"if [ "$1" = login ]; then exit 1; fi"#);
221        let paths = SearchPaths::standard(Some("/nonexistent".as_ref()), Some(home.path()));
222        let found = detect(&paths).await;
223        let summary: Vec<(&str, Login)> = found
224            .iter()
225            .map(|c| (provider_name(c.provider), c.login))
226            .collect();
227        assert_eq!(
228            summary,
229            [("claude", Login::LoggedIn), ("codex", Login::LoggedOut)]
230        );
231        assert_eq!(found[0].version, "1.0.0");
232        assert!(found[0].path.is_absolute());
233    }
234
235    #[tokio::test]
236    async fn nothing_found() {
237        let empty = tempfile::tempdir().unwrap();
238        let paths = SearchPaths {
239            dirs: vec![empty.path().to_path_buf()],
240        };
241        assert_eq!(detect(&paths).await, []);
242    }
243}