agentzero_tools/
cli_discovery.rs1use 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#[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}