1use crate::skills::types::{Skill, SkillManifest, SkillResource};
22use crate::tool_policy::ToolPolicy;
23use crate::tools::traits::Tool;
24use crate::utils::async_utils;
25use crate::utils::file_utils::{read_file_with_context_sync, read_json_file_sync};
26use anyhow::{Result, anyhow};
27use async_trait::async_trait;
28use serde::{Deserialize, Serialize};
29use serde_json::Value;
30use std::path::{Path, PathBuf};
31use std::process::Stdio;
32use std::time::Duration;
33use tokio::process::Command;
34use tracing::{debug, info, warn};
35
36#[derive(Debug, Clone, Serialize, Deserialize)]
38pub struct CliToolConfig {
39 pub name: String,
41
42 pub description: String,
44
45 pub executable_path: PathBuf,
47
48 pub readme_path: Option<PathBuf>,
50
51 pub schema_path: Option<PathBuf>,
53
54 pub timeout_seconds: Option<u64>,
56
57 pub supports_json: bool,
59
60 pub environment: Option<hashbrown::HashMap<String, String>>,
62
63 pub working_dir: Option<PathBuf>,
65}
66
67#[derive(Debug, Clone, Serialize, Deserialize)]
69pub struct CliToolResult {
70 pub exit_code: i32,
72
73 pub stdout: String,
75
76 pub stderr: String,
78
79 pub json_output: Option<Value>,
81
82 pub execution_time_ms: u64,
84}
85
86#[derive(Debug, Clone)]
88pub struct CliToolBridge {
89 pub config: CliToolConfig,
90 instructions: String,
91 schema: Option<Value>,
92}
93
94impl CliToolBridge {
95 pub fn new(config: CliToolConfig) -> Result<Self> {
97 let instructions = Self::load_readme(&config)?;
98 let schema = Self::load_schema(&config)?;
99
100 Ok(CliToolBridge {
101 config,
102 instructions,
103 schema,
104 })
105 }
106
107 pub fn from_directory(tool_dir: &Path) -> Result<Self> {
109 let config_path = tool_dir.join("tool.json");
110 let config: CliToolConfig = if config_path.exists() {
111 read_json_file_sync(&config_path)?
112 } else {
113 Self::auto_discover_config(tool_dir)?
115 };
116
117 Self::new(config)
118 }
119
120 fn auto_discover_config(tool_dir: &Path) -> Result<CliToolConfig> {
122 let executables = Self::find_executables(tool_dir)?;
124 if executables.is_empty() {
125 return Err(anyhow!(
126 "No executable files found in {}",
127 tool_dir.display()
128 ));
129 }
130
131 let readme_files = Self::find_readmes(tool_dir)?;
133
134 let executable_path = executables[0].clone();
136 let readme_path = readme_files.first().cloned();
137
138 let name = executable_path
140 .file_stem()
141 .and_then(|s| s.to_str())
142 .ok_or_else(|| anyhow!("Invalid executable filename"))?
143 .to_string();
144
145 Ok(CliToolConfig {
146 name: name.clone(),
147 description: format!("CLI tool: {}", name),
148 executable_path,
149 readme_path,
150 schema_path: None,
151 timeout_seconds: Some(30),
152 supports_json: false, environment: None,
154 working_dir: Some(tool_dir.to_path_buf()),
155 })
156 }
157
158 fn find_executables(dir: &Path) -> Result<Vec<PathBuf>> {
160 let mut executables = vec![];
161
162 for entry in std::fs::read_dir(dir)? {
163 let entry = entry?;
164 let path = entry.path();
165
166 if path.is_file() {
167 #[cfg(unix)]
168 {
169 use std::os::unix::fs::PermissionsExt;
170 let metadata = entry.metadata()?;
171 let permissions = metadata.permissions();
172 if permissions.mode() & 0o111 != 0 {
173 executables.push(path);
174 }
175 }
176
177 #[cfg(windows)]
178 {
179 if let Some(ext) = path.extension() {
180 if ext == "exe" || ext == "bat" || ext == "cmd" {
181 executables.push(path);
182 }
183 }
184 }
185 }
186 }
187
188 Ok(executables)
189 }
190
191 fn find_readmes(dir: &Path) -> Result<Vec<PathBuf>> {
193 let mut readmes = vec![];
194
195 for entry in std::fs::read_dir(dir)? {
196 let entry = entry?;
197 let path = entry.path();
198
199 if let Some(_name) = path.file_name().and_then(|n| n.to_str()).filter(|n| {
200 path.is_file() && n.to_lowercase().starts_with("readme") && n.ends_with(".md")
201 }) {
202 readmes.push(path);
203 }
204 }
205
206 Ok(readmes)
207 }
208
209 fn load_readme(config: &CliToolConfig) -> Result<String> {
211 if let Some(readme_path) = config.readme_path.as_ref().filter(|p| p.exists()) {
212 return read_file_with_context_sync(readme_path, "README file");
213 }
214
215 Ok(format!(
217 "# {}\n\nCLI tool: {}\n\nExecute with provided arguments.\n",
218 config.name,
219 config.executable_path.display()
220 ))
221 }
222
223 fn load_schema(config: &CliToolConfig) -> Result<Option<Value>> {
225 if let Some(schema_path) = config.schema_path.as_ref().filter(|p| p.exists()) {
226 return Ok(Some(read_json_file_sync(schema_path)?));
227 }
228
229 Ok(None)
230 }
231
232 pub async fn execute_internal(&self, args: Value) -> Result<CliToolResult> {
234 info!(
235 "Executing CLI tool: {} with args: {:?}",
236 self.config.name, args
237 );
238
239 let start_time = std::time::Instant::now();
240
241 if let Some(schema) = &self.schema {
243 self.validate_args(&args, schema)?;
244 }
245
246 let mut cmd = Command::new(&self.config.executable_path);
248
249 if let Some(working_dir) = &self.config.working_dir {
251 cmd.current_dir(working_dir);
252 }
253
254 if let Some(env) = &self.config.environment {
256 for (key, value) in env {
257 cmd.env(key, value);
258 }
259 }
260
261 cmd.stdin(Stdio::piped())
263 .stdout(Stdio::piped())
264 .stderr(Stdio::piped());
265
266 self.configure_arguments(&mut cmd, &args)?;
268
269 let timeout_duration = Duration::from_secs(self.config.timeout_seconds.unwrap_or(30));
271 let output_result =
272 async_utils::with_timeout(cmd.output(), timeout_duration, "CLI tool execution")
273 .await??;
274
275 let execution_time_ms = start_time.elapsed().as_millis() as u64;
276
277 let stdout = String::from_utf8_lossy(&output_result.stdout).to_string();
279 let stderr = String::from_utf8_lossy(&output_result.stderr).to_string();
280
281 let json_output = if self.config.supports_json {
283 serde_json::from_str(&stdout).ok()
284 } else {
285 None
286 };
287
288 Ok(CliToolResult {
289 exit_code: output_result.status.code().unwrap_or(-1),
290 stdout,
291 stderr,
292 json_output,
293 execution_time_ms,
294 })
295 }
296
297 fn configure_arguments(&self, cmd: &mut Command, args: &Value) -> Result<()> {
299 if args.is_null() || args == &Value::Null {
300 return Ok(());
301 }
302
303 match args {
305 Value::String(s) => {
306 cmd.arg(s);
308 }
309 Value::Array(arr) => {
310 for arg in arr {
312 if let Some(s) = arg.as_str() {
313 cmd.arg(s);
314 }
315 }
316 }
317 Value::Object(map) => {
318 for (key, value) in map {
320 if let Some(s) = value.as_str() {
321 cmd.arg(format!("--{}", key));
322 cmd.arg(s);
323 } else if value.as_bool().is_some_and(|flag| flag) {
324 cmd.arg(format!("--{}", key));
325 }
326 }
327 }
328 _ => {
329 let json_str = serde_json::to_string(args)?;
331 cmd.arg(json_str);
332 }
333 }
334
335 Ok(())
336 }
337
338 fn validate_args(&self, args: &Value, schema: &Value) -> Result<()> {
340 debug!("Validating args against schema: {:?}", schema);
342
343 if let Some(required) = schema.get("required").and_then(|v| v.as_array()) {
345 for field in required {
346 if let Some(field_name) = field.as_str().filter(|f| args.get(*f).is_none()) {
347 return Err(anyhow!("Missing required field: {}", field_name));
348 }
349 }
350 }
351
352 Ok(())
353 }
354
355 pub async fn test_json_support(&self) -> Result<bool> {
357 debug!("Testing JSON support for tool: {}", self.config.name);
358
359 let mut cmd = Command::new(&self.config.executable_path);
361 cmd.arg("--help-json")
362 .stdout(Stdio::piped())
363 .stderr(Stdio::piped());
364
365 let result = cmd.output().await;
366
367 match result {
368 Ok(output) => {
369 let stdout = String::from_utf8_lossy(&output.stdout);
370 Ok(serde_json::from_str::<Value>(&stdout).is_ok())
372 }
373 Err(_) => Ok(false),
374 }
375 }
376
377 pub fn to_skill(&self) -> Result<Skill> {
379 let manifest = SkillManifest {
380 name: self.config.name.clone(),
381 description: self.config.description.clone(),
382 version: Some("1.0.0".to_string()),
383 author: Some("VT Code CLI Bridge".to_string()),
384 variety: crate::skills::types::SkillVariety::SystemUtility,
385 ..Default::default()
386 };
387
388 let mut skill = Skill::new(
389 manifest,
390 self.config
391 .executable_path
392 .parent()
393 .unwrap_or_else(|| Path::new("."))
394 .to_path_buf(),
395 self.instructions.clone(),
396 )?;
397
398 if let Some(schema) = &self.schema {
400 skill.add_resource(
401 "schema.json".to_string(),
402 SkillResource {
403 path: "schema.json".to_string(),
404 resource_type: crate::skills::types::ResourceType::Reference,
405 content: Some(schema.to_string().into_bytes()),
406 },
407 );
408 }
409
410 Ok(skill)
411 }
412}
413
414#[async_trait]
415impl Tool for CliToolBridge {
416 fn name(&self) -> &str {
417 &self.config.name
418 }
419
420 fn description(&self) -> &str {
421 &self.config.description
422 }
423
424 fn parameter_schema(&self) -> Option<Value> {
425 self.schema.clone()
426 }
427
428 fn default_permission(&self) -> ToolPolicy {
429 ToolPolicy::Prompt
430 }
431
432 async fn execute(&self, args: Value) -> Result<Value> {
433 let result = self.execute_internal(args).await?;
434 Ok(serde_json::to_value(result)?)
435 }
436}
437
438pub fn discover_cli_tools() -> Result<Vec<CliToolConfig>> {
440 let mut tools = vec![];
441
442 let search_paths = vec![
444 PathBuf::from("/usr/local/bin"),
445 PathBuf::from("/usr/bin"),
446 PathBuf::from("~/.local/bin").expand_home()?,
447 PathBuf::from("./tools"),
448 PathBuf::from("./vendor/tools"),
449 ];
450
451 for path in search_paths {
452 if path.exists() && path.is_dir() {
453 match discover_tools_in_directory(&path) {
454 Ok(dir_tools) => tools.extend(dir_tools),
455 Err(e) => warn!("Failed to discover tools in {}: {}", path.display(), e),
456 }
457 }
458 }
459
460 info!("Discovered {} CLI tools", tools.len());
461 Ok(tools)
462}
463
464fn discover_tools_in_directory(dir: &Path) -> Result<Vec<CliToolConfig>> {
466 let mut tools = vec![];
467
468 for entry in std::fs::read_dir(dir)? {
469 let entry = entry?;
470 let path = entry.path();
471
472 if path.is_file() {
473 #[cfg(unix)]
475 {
476 use std::os::unix::fs::PermissionsExt;
477 let metadata = entry.metadata()?;
478 let permissions = metadata.permissions();
479 if permissions.mode() & 0o111 == 0 {
480 continue;
481 }
482 }
483
484 #[cfg(windows)]
485 {
486 if let Some(ext) = path.extension() {
487 if ext != "exe" && ext != "bat" && ext != "cmd" {
488 continue;
489 }
490 } else {
491 continue;
492 }
493 }
494
495 let Some(stem) = path.file_stem().and_then(|stem| stem.to_str()) else {
497 continue;
498 };
499 let readme_path = dir.join(format!("{stem}.md"));
500
501 let config = CliToolConfig {
502 name: stem.to_string(),
503 description: format!("CLI tool: {}", path.display()),
504 executable_path: path.clone(),
505 readme_path: if readme_path.exists() {
506 Some(readme_path)
507 } else {
508 None
509 },
510 schema_path: None,
511 timeout_seconds: Some(30),
512 supports_json: false,
513 environment: None,
514 working_dir: Some(dir.to_path_buf()),
515 };
516
517 tools.push(config);
518 }
519 }
520
521 Ok(tools)
522}
523
524trait PathExt {
526 fn expand_home(&self) -> Result<PathBuf>;
527}
528
529impl PathExt for PathBuf {
530 fn expand_home(&self) -> Result<PathBuf> {
531 if let Some(home) = std::env::var("HOME").ok().filter(|_| self.starts_with("~")) {
532 let stripped = self.strip_prefix("~").unwrap_or(self);
533 return Ok(PathBuf::from(home).join(stripped));
534 }
535 Ok(self.clone())
536 }
537}
538
539#[cfg(test)]
540mod tests {
541 use super::*;
542 #[expect(unused_imports)]
543 use tempfile::TempDir;
544
545 #[test]
546 fn test_cli_tool_config_creation() {
547 let config = CliToolConfig {
548 name: "test-tool".to_string(),
549 description: "Test tool".to_string(),
550 executable_path: PathBuf::from("/bin/echo"),
551 readme_path: None,
552 schema_path: None,
553 timeout_seconds: Some(10),
554 supports_json: false,
555 environment: None,
556 working_dir: None,
557 };
558
559 assert_eq!(config.name, "test-tool");
560 assert_eq!(config.timeout_seconds, Some(10));
561 }
562
563 #[tokio::test]
564 async fn test_simple_tool_execution() {
565 let config = CliToolConfig {
566 name: "echo".to_string(),
567 description: "Echo command".to_string(),
568 executable_path: PathBuf::from("/bin/echo"),
569 readme_path: None,
570 schema_path: None,
571 timeout_seconds: Some(5),
572 supports_json: false,
573 environment: None,
574 working_dir: None,
575 };
576
577 let bridge = CliToolBridge::new(config).unwrap();
578 let result = bridge
579 .execute_internal(Value::String("hello world".to_string()))
580 .await
581 .unwrap();
582
583 assert_eq!(result.exit_code, 0);
584 assert!(result.stdout.contains("hello world"));
585 }
586}