Skip to main content

agentzero_tools/
cli_discovery.rs

1use agentzero_core::{Tool, ToolContext, ToolResult};
2use anyhow::Context;
3use async_trait::async_trait;
4use serde::Deserialize;
5use serde_json::json;
6
7#[derive(Debug, Deserialize)]
8struct Input {
9    op: String,
10    #[serde(default)]
11    command: Option<String>,
12}
13
14/// Tool that discovers available CLI tools and capabilities at runtime.
15///
16/// Operations:
17/// - `check_command`: Check if a shell command is available on PATH
18/// - `runtime_info`: Return runtime environment information
19#[derive(Debug, Default, Clone, Copy)]
20pub struct CliDiscoveryTool;
21
22#[async_trait]
23impl Tool for CliDiscoveryTool {
24    fn name(&self) -> &'static str {
25        "cli_discovery"
26    }
27
28    fn description(&self) -> &'static str {
29        "Discover available CLI tools and runtime environment: check command availability or get runtime info."
30    }
31
32    fn input_schema(&self) -> Option<serde_json::Value> {
33        Some(json!({
34            "type": "object",
35            "required": ["op"],
36            "properties": {
37                "op": {"type": "string", "description": "Operation: check_command or runtime_info"},
38                "command": {"type": "string", "description": "Command name to check (for check_command op)"}
39            }
40        }))
41    }
42
43    async fn execute(&self, input: &str, ctx: &ToolContext) -> anyhow::Result<ToolResult> {
44        let parsed: Input =
45            serde_json::from_str(input).context("cli_discovery expects JSON: {\"op\", ...}")?;
46
47        let output = match parsed.op.as_str() {
48            "check_command" => {
49                let cmd = parsed
50                    .command
51                    .as_deref()
52                    .ok_or_else(|| anyhow::anyhow!("check_command requires a `command` field"))?;
53
54                if cmd.trim().is_empty() {
55                    return Err(anyhow::anyhow!("command must not be empty"));
56                }
57
58                let available = which_exists(cmd).await;
59                json!({
60                    "command": cmd,
61                    "available": available,
62                })
63                .to_string()
64            }
65            "runtime_info" => json!({
66                "workspace_root": ctx.workspace_root,
67                "os": std::env::consts::OS,
68                "arch": std::env::consts::ARCH,
69                "family": std::env::consts::FAMILY,
70            })
71            .to_string(),
72            other => json!({ "error": format!("unknown op: {other}") }).to_string(),
73        };
74
75        Ok(ToolResult { output })
76    }
77}
78
79async fn which_exists(command: &str) -> bool {
80    tokio::process::Command::new("which")
81        .arg(command)
82        .stdout(std::process::Stdio::null())
83        .stderr(std::process::Stdio::null())
84        .status()
85        .await
86        .map(|s| s.success())
87        .unwrap_or(false)
88}
89
90#[cfg(test)]
91mod tests {
92    use super::*;
93    use agentzero_core::ToolContext;
94
95    fn test_ctx() -> ToolContext {
96        ToolContext::new("/tmp".to_string())
97    }
98
99    #[tokio::test]
100    async fn check_command_finds_sh() {
101        let tool = CliDiscoveryTool;
102        let result = tool
103            .execute(r#"{"op": "check_command", "command": "sh"}"#, &test_ctx())
104            .await
105            .expect("should succeed");
106        let v: serde_json::Value = serde_json::from_str(&result.output).unwrap();
107        assert_eq!(v["command"], "sh");
108        assert_eq!(v["available"], true);
109    }
110
111    #[tokio::test]
112    async fn check_command_missing_binary() {
113        let tool = CliDiscoveryTool;
114        let result = tool
115            .execute(
116                r#"{"op": "check_command", "command": "nonexistent_binary_xyz_123"}"#,
117                &test_ctx(),
118            )
119            .await
120            .expect("should succeed");
121        let v: serde_json::Value = serde_json::from_str(&result.output).unwrap();
122        assert_eq!(v["available"], false);
123    }
124
125    #[tokio::test]
126    async fn runtime_info_returns_os() {
127        let tool = CliDiscoveryTool;
128        let result = tool
129            .execute(r#"{"op": "runtime_info"}"#, &test_ctx())
130            .await
131            .expect("should succeed");
132        let v: serde_json::Value = serde_json::from_str(&result.output).unwrap();
133        assert!(v["os"].as_str().is_some());
134        assert!(v["arch"].as_str().is_some());
135        assert_eq!(v["workspace_root"], "/tmp");
136    }
137
138    #[tokio::test]
139    async fn unknown_op_returns_error() {
140        let tool = CliDiscoveryTool;
141        let result = tool
142            .execute(r#"{"op": "bad_op"}"#, &test_ctx())
143            .await
144            .expect("should succeed");
145        let v: serde_json::Value = serde_json::from_str(&result.output).unwrap();
146        assert!(v["error"].as_str().unwrap().contains("unknown op"));
147    }
148
149    #[tokio::test]
150    async fn check_command_empty_fails() {
151        let tool = CliDiscoveryTool;
152        let err = tool
153            .execute(r#"{"op": "check_command", "command": ""}"#, &test_ctx())
154            .await
155            .expect_err("empty command should fail");
156        assert!(err.to_string().contains("command must not be empty"));
157    }
158}