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 { config, instructions, schema })
101 }
102
103 pub fn from_directory(tool_dir: &Path) -> Result<Self> {
105 let config_path = tool_dir.join("tool.json");
106 let config: CliToolConfig = if config_path.exists() {
107 read_json_file_sync(&config_path)?
108 } else {
109 Self::auto_discover_config(tool_dir)?
111 };
112
113 Self::new(config)
114 }
115
116 fn auto_discover_config(tool_dir: &Path) -> Result<CliToolConfig> {
118 let executables = Self::find_executables(tool_dir)?;
120 if executables.is_empty() {
121 return Err(anyhow!("No executable files found in {}", tool_dir.display()));
122 }
123
124 let readme_files = Self::find_readmes(tool_dir)?;
126
127 let executable_path = executables[0].clone();
129 let readme_path = readme_files.first().cloned();
130
131 let name = executable_path
133 .file_stem()
134 .and_then(|s| s.to_str())
135 .ok_or_else(|| anyhow!("Invalid executable filename"))?
136 .to_string();
137
138 Ok(CliToolConfig {
139 name: name.clone(),
140 description: format!("CLI tool: {name}"),
141 executable_path,
142 readme_path,
143 schema_path: None,
144 timeout_seconds: Some(30),
145 supports_json: false, environment: None,
147 working_dir: Some(tool_dir.to_path_buf()),
148 })
149 }
150
151 fn find_executables(dir: &Path) -> Result<Vec<PathBuf>> {
153 let mut executables = vec![];
154
155 for entry in std::fs::read_dir(dir)? {
156 let entry = entry?;
157 let path = entry.path();
158
159 if path.is_file() {
160 #[cfg(unix)]
161 {
162 use std::os::unix::fs::PermissionsExt;
163 let metadata = entry.metadata()?;
164 let permissions = metadata.permissions();
165 if permissions.mode() & 0o111 != 0 {
166 executables.push(path);
167 }
168 }
169
170 #[cfg(windows)]
171 {
172 if let Some(ext) = path.extension() {
173 if ext == "exe" || ext == "bat" || ext == "cmd" {
174 executables.push(path);
175 }
176 }
177 }
178 }
179 }
180
181 Ok(executables)
182 }
183
184 fn find_readmes(dir: &Path) -> Result<Vec<PathBuf>> {
186 let mut readmes = vec![];
187
188 for entry in std::fs::read_dir(dir)? {
189 let entry = entry?;
190 let path = entry.path();
191
192 if let Some(_name) = path
193 .file_name()
194 .and_then(|n| n.to_str())
195 .filter(|n| path.is_file() && n.to_lowercase().starts_with("readme") && n.ends_with(".md"))
196 {
197 readmes.push(path);
198 }
199 }
200
201 Ok(readmes)
202 }
203
204 fn load_readme(config: &CliToolConfig) -> Result<String> {
206 if let Some(readme_path) = config.readme_path.as_ref().filter(|p| p.exists()) {
207 return read_file_with_context_sync(readme_path, "README file");
208 }
209
210 Ok(format!(
212 "# {}\n\nCLI tool: {}\n\nExecute with provided arguments.\n",
213 config.name,
214 config.executable_path.display()
215 ))
216 }
217
218 fn load_schema(config: &CliToolConfig) -> Result<Option<Value>> {
220 if let Some(schema_path) = config.schema_path.as_ref().filter(|p| p.exists()) {
221 return Ok(Some(read_json_file_sync(schema_path)?));
222 }
223
224 Ok(None)
225 }
226
227 pub async fn execute_internal(&self, args: Value) -> Result<CliToolResult> {
229 info!("Executing CLI tool: {} with args: {:?}", self.config.name, args);
230
231 let start_time = std::time::Instant::now();
232
233 if let Some(schema) = &self.schema {
235 self.validate_args(&args, schema)?;
236 }
237
238 let mut cmd = Command::new(&self.config.executable_path);
240
241 if let Some(working_dir) = &self.config.working_dir {
243 cmd.current_dir(working_dir);
244 }
245
246 if let Some(env) = &self.config.environment {
248 for (key, value) in env {
249 cmd.env(key, value);
250 }
251 }
252
253 cmd.stdin(Stdio::piped()).stdout(Stdio::piped()).stderr(Stdio::piped());
255
256 self.configure_arguments(&mut cmd, &args)?;
258
259 let timeout_duration = Duration::from_secs(self.config.timeout_seconds.unwrap_or(30));
261 let output_result = async_utils::with_timeout(cmd.output(), timeout_duration, "CLI tool execution").await??;
262
263 let execution_time_ms = start_time.elapsed().as_millis() as u64;
264
265 let stdout = String::from_utf8_lossy(&output_result.stdout).into_owned();
267 let stderr = String::from_utf8_lossy(&output_result.stderr).into_owned();
268
269 let json_output = if self.config.supports_json {
271 serde_json::from_str(&stdout).ok()
272 } else {
273 None
274 };
275
276 Ok(CliToolResult {
277 exit_code: output_result.status.code().unwrap_or(-1),
278 stdout,
279 stderr,
280 json_output,
281 execution_time_ms,
282 })
283 }
284
285 fn configure_arguments(&self, cmd: &mut Command, args: &Value) -> Result<()> {
287 if args.is_null() || args == &Value::Null {
288 return Ok(());
289 }
290
291 match args {
293 Value::String(s) => {
294 cmd.arg(s);
296 }
297 Value::Array(arr) => {
298 for arg in arr {
300 if let Some(s) = arg.as_str() {
301 cmd.arg(s);
302 }
303 }
304 }
305 Value::Object(map) => {
306 for (key, value) in map {
308 if let Some(s) = value.as_str() {
309 cmd.arg(format!("--{key}"));
310 cmd.arg(s);
311 } else if value.as_bool().is_some_and(|flag| flag) {
312 cmd.arg(format!("--{key}"));
313 }
314 }
315 }
316 _ => {
317 let json_str = serde_json::to_string(args)?;
319 cmd.arg(json_str);
320 }
321 }
322
323 Ok(())
324 }
325
326 fn validate_args(&self, args: &Value, schema: &Value) -> Result<()> {
328 debug!("Validating args against schema: {:?}", schema);
330
331 if let Some(required) = schema.get("required").and_then(|v| v.as_array()) {
333 for field in required {
334 if let Some(field_name) = field.as_str().filter(|f| args.get(*f).is_none()) {
335 return Err(anyhow!("Missing required field: {field_name}"));
336 }
337 }
338 }
339
340 Ok(())
341 }
342
343 pub async fn test_json_support(&self) -> Result<bool> {
345 debug!("Testing JSON support for tool: {}", self.config.name);
346
347 let mut cmd = Command::new(&self.config.executable_path);
349 cmd.arg("--help-json").stdout(Stdio::piped()).stderr(Stdio::piped());
350
351 let result = cmd.output().await;
352
353 match result {
354 Ok(output) => {
355 let stdout = String::from_utf8_lossy(&output.stdout);
356 Ok(serde_json::from_str::<Value>(&stdout).is_ok())
358 }
359 Err(_) => Ok(false),
360 }
361 }
362
363 pub fn to_skill(&self) -> Result<Skill> {
365 let manifest = SkillManifest {
366 name: self.config.name.clone(),
367 description: self.config.description.clone(),
368 version: Some("1.0.0".to_string()),
369 author: Some("VT Code CLI Bridge".to_string()),
370 variety: crate::skills::types::SkillVariety::SystemUtility,
371 ..Default::default()
372 };
373
374 let mut skill = Skill::new(
375 manifest,
376 self.config
377 .executable_path
378 .parent()
379 .unwrap_or_else(|| Path::new("."))
380 .to_path_buf(),
381 self.instructions.clone(),
382 )?;
383
384 if let Some(schema) = &self.schema {
386 skill.add_resource(
387 "schema.json".to_string(),
388 SkillResource {
389 path: "schema.json".to_string(),
390 resource_type: crate::skills::types::ResourceType::Reference,
391 content: Some(schema.to_string().into_bytes()),
392 },
393 );
394 }
395
396 Ok(skill)
397 }
398}
399
400#[async_trait]
401impl Tool for CliToolBridge {
402 fn name(&self) -> &str {
403 &self.config.name
404 }
405
406 fn description(&self) -> &str {
407 &self.config.description
408 }
409
410 fn parameter_schema(&self) -> Option<Value> {
411 self.schema.clone()
412 }
413
414 fn default_permission(&self) -> ToolPolicy {
415 ToolPolicy::Prompt
416 }
417
418 async fn execute(&self, args: Value) -> Result<Value> {
419 let result = self.execute_internal(args).await?;
420 Ok(serde_json::to_value(result)?)
421 }
422}
423
424pub fn discover_cli_tools() -> Result<Vec<CliToolConfig>> {
434 let mut tools = vec![];
435
436 let search_paths = vec![
438 PathBuf::from("~/.local/bin").expand_home()?,
439 PathBuf::from("./tools"),
440 PathBuf::from("./vendor/tools"),
441 ];
442
443 for path in search_paths {
444 if path.exists() && path.is_dir() {
445 match discover_tools_in_directory(&path) {
446 Ok(dir_tools) => tools.extend(dir_tools),
447 Err(e) => warn!("Failed to discover tools in {}: {}", path.display(), e),
448 }
449 }
450 }
451
452 debug!("Discovered {} CLI tools", tools.len());
453 Ok(tools)
454}
455
456pub(crate) fn cli_tool_config_from_executable(dir: &Path, path: &Path) -> Option<CliToolConfig> {
464 if !path.is_file() {
465 return None;
466 }
467 #[cfg(unix)]
468 {
469 use std::os::unix::fs::PermissionsExt;
470 let Ok(metadata) = path.metadata() else {
471 return None;
472 };
473 if metadata.permissions().mode() & 0o111 == 0 {
474 return None;
475 }
476 }
477 #[cfg(windows)]
478 {
479 match path.extension().and_then(|ext| ext.to_str()) {
480 Some(ext) if matches!(ext.to_lowercase().as_str(), "exe" | "bat" | "cmd") => {}
481 _ => return None,
482 }
483 }
484
485 let stem = path.file_stem().and_then(|stem| stem.to_str())?;
486 let readme_path = dir.join(format!("{stem}.md"));
487 let schema_path = dir.join(format!("{stem}.json"));
488 let tool_json_path = dir.join("tool.json");
489 if !readme_path.exists() && !schema_path.exists() && !tool_json_path.exists() {
490 return None;
491 }
492
493 Some(CliToolConfig {
494 name: stem.to_string(),
495 description: format!("CLI tool: {}", path.display()),
496 executable_path: path.to_path_buf(),
497 readme_path: readme_path.exists().then(|| readme_path.clone()),
498 schema_path: if schema_path.exists() {
499 Some(schema_path)
500 } else if tool_json_path.exists() {
501 Some(tool_json_path)
502 } else {
503 None
504 },
505 timeout_seconds: Some(30),
506 supports_json: false,
507 environment: None,
508 working_dir: Some(dir.to_path_buf()),
509 })
510}
511
512fn discover_tools_in_directory(dir: &Path) -> Result<Vec<CliToolConfig>> {
514 let mut tools = vec![];
515
516 for entry in std::fs::read_dir(dir)? {
517 let entry = entry?;
518 let path = entry.path();
519
520 if let Some(config) = cli_tool_config_from_executable(dir, &path) {
521 tools.push(config);
522 }
523 }
524
525 Ok(tools)
526}
527
528trait PathExt {
530 fn expand_home(&self) -> Result<PathBuf>;
531}
532
533impl PathExt for PathBuf {
534 fn expand_home(&self) -> Result<PathBuf> {
535 if let Some(home) = std::env::var("HOME").ok().filter(|_| self.starts_with("~")) {
536 let stripped = self.strip_prefix("~").unwrap_or(self);
537 return Ok(PathBuf::from(home).join(stripped));
538 }
539 Ok(self.clone())
540 }
541}
542
543#[cfg(test)]
544mod tests {
545 use super::*;
546 use tempfile::TempDir;
547
548 #[test]
549 fn test_cli_tool_config_creation() {
550 let config = CliToolConfig {
551 name: "test-tool".to_string(),
552 description: "Test tool".to_string(),
553 executable_path: PathBuf::from("/bin/echo"),
554 readme_path: None,
555 schema_path: None,
556 timeout_seconds: Some(10),
557 supports_json: false,
558 environment: None,
559 working_dir: None,
560 };
561
562 assert_eq!(config.name, "test-tool");
563 assert_eq!(config.timeout_seconds, Some(10));
564 }
565
566 #[tokio::test]
567 async fn test_simple_tool_execution() {
568 let config = CliToolConfig {
569 name: "echo".to_string(),
570 description: "Echo command".to_string(),
571 executable_path: PathBuf::from("/bin/echo"),
572 readme_path: None,
573 schema_path: None,
574 timeout_seconds: Some(5),
575 supports_json: false,
576 environment: None,
577 working_dir: None,
578 };
579
580 let bridge = CliToolBridge::new(config).unwrap();
581 let result = bridge.execute_internal(Value::String("hello world".to_string())).await.unwrap();
582
583 assert_eq!(result.exit_code, 0);
584 assert!(result.stdout.contains("hello world"));
585 }
586
587 #[cfg(unix)]
588 fn write_executable(dir: &Path, name: &str) -> PathBuf {
589 use std::os::unix::fs::PermissionsExt;
590 let path = dir.join(name);
591 std::fs::write(&path, "#!/bin/sh\necho hi\n").expect("write executable");
592 let mut perms = std::fs::metadata(&path).expect("metadata").permissions();
593 perms.set_mode(0o755);
594 std::fs::set_permissions(&path, perms).expect("chmod");
595 path
596 }
597
598 #[cfg(unix)]
599 #[test]
600 fn bare_executables_without_docs_are_not_skills() {
601 let temp = TempDir::new().expect("tempdir");
602 write_executable(temp.path(), "bare-tool");
603 write_executable(temp.path(), "python3.12");
605 write_executable(temp.path(), "python3.15");
606
607 let tools = discover_tools_in_directory(temp.path()).expect("discover tools");
608 assert!(
609 tools.is_empty(),
610 "bare executables without <stem>.md/.json or tool.json must be skipped, got {:?}",
611 tools.iter().map(|t| &t.name).collect::<Vec<_>>()
612 );
613 }
614
615 #[cfg(unix)]
616 #[test]
617 fn documented_executables_are_discovered_as_skills() {
618 let temp = TempDir::new().expect("tempdir");
619 write_executable(temp.path(), "doc-tool");
620 std::fs::write(temp.path().join("doc-tool.md"), "# doc-tool\n").expect("write readme");
621 write_executable(temp.path(), "schema-tool");
622 std::fs::write(temp.path().join("schema-tool.json"), "{}").expect("write schema");
623
624 let tools = discover_tools_in_directory(temp.path()).expect("discover tools");
625 let names: Vec<&str> = tools.iter().map(|t| t.name.as_str()).collect();
626 assert!(names.contains(&"doc-tool"), "expected doc-tool, got {names:?}");
627 assert!(names.contains(&"schema-tool"), "expected schema-tool, got {names:?}");
628 assert_eq!(tools.len(), 2, "only documented tools should load, got {names:?}");
629 }
630
631 #[cfg(unix)]
632 #[test]
633 fn non_executable_with_docs_is_skipped() {
634 let temp = TempDir::new().expect("tempdir");
635 let script = temp.path().join("not-executable");
636 std::fs::write(&script, "#!/bin/sh\necho hi\n").expect("write script");
637 #[cfg(unix)]
638 {
639 use std::os::unix::fs::PermissionsExt;
640 let mut perms = std::fs::metadata(&script).expect("metadata").permissions();
641 perms.set_mode(0o644);
642 std::fs::set_permissions(&script, perms).expect("chmod");
643 }
644 std::fs::write(temp.path().join("not-executable.md"), "# docs\n").expect("write readme");
645
646 let tools = discover_tools_in_directory(temp.path()).expect("discover tools");
647 assert!(
648 tools.is_empty(),
649 "non-executable files must be skipped even with docs, got {:?}",
650 tools.iter().map(|t| &t.name).collect::<Vec<_>>()
651 );
652 }
653
654 #[cfg(unix)]
655 #[test]
656 fn sibling_tool_json_counts_as_docs() {
657 let temp = TempDir::new().expect("tempdir");
658 write_executable(temp.path(), "json-tool");
659 std::fs::write(temp.path().join("tool.json"), "{}").expect("write tool.json");
660
661 let tools = discover_tools_in_directory(temp.path()).expect("discover tools");
662 assert_eq!(tools.len(), 1, "sibling tool.json should qualify the executable");
663 assert_eq!(tools[0].name, "json-tool");
664 }
665}