Skip to main content

vtcode_core/skills/
cli_bridge.rs

1//! CLI Tool Bridge for External Tool Integration
2//!
3//! Bridges external CLI tools to VT Code's skill system, enabling integration
4//! of any command-line tool with proper documentation into the agent harness.
5//!
6//! ## Features
7//!
8//! - **Progressive Disclosure**: Load tool documentation only when needed
9//! - **JSON I/O**: Structured input/output via JSON when available
10//! - **Fallback Support**: Graceful degradation to text output
11//! - **Validation**: Schema-based validation for tool arguments
12//! - **Streaming**: Support for long-running operations
13//!
14//! ## Tool Discovery
15//!
16//! Tools are discovered by scanning for:
17//! - Executable files with accompanying README.md
18//! - tool.json metadata files
19//! - Standard installation paths (/usr/local/bin, ~/.local/bin, etc.)
20
21use 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/// Configuration for a CLI tool skill
37#[derive(Debug, Clone, Serialize, Deserialize)]
38pub struct CliToolConfig {
39    /// Tool name (must be unique)
40    pub name: String,
41
42    /// Brief description
43    pub description: String,
44
45    /// Path to the executable
46    pub executable_path: PathBuf,
47
48    /// Path to README/documentation
49    pub readme_path: Option<PathBuf>,
50
51    /// Path to JSON schema for arguments
52    pub schema_path: Option<PathBuf>,
53
54    /// Timeout for execution (seconds)
55    pub timeout_seconds: Option<u64>,
56
57    /// Whether tool supports JSON I/O
58    pub supports_json: bool,
59
60    /// Environment variables to set
61    pub environment: Option<hashbrown::HashMap<String, String>>,
62
63    /// Working directory for execution
64    pub working_dir: Option<PathBuf>,
65}
66
67/// Tool execution result
68#[derive(Debug, Clone, Serialize, Deserialize)]
69pub struct CliToolResult {
70    /// Exit code
71    pub exit_code: i32,
72
73    /// Standard output
74    pub stdout: String,
75
76    /// Standard error
77    pub stderr: String,
78
79    /// Parsed JSON output (if available)
80    pub json_output: Option<Value>,
81
82    /// Execution time in milliseconds
83    pub execution_time_ms: u64,
84}
85
86/// Bridge between CLI tools and VT Code skills
87#[derive(Debug, Clone)]
88pub struct CliToolBridge {
89    pub config: CliToolConfig,
90    instructions: String,
91    schema: Option<Value>,
92}
93
94impl CliToolBridge {
95    /// Create a new CLI tool bridge from configuration
96    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    /// Create a bridge from a tool directory
104    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            // Auto-discover tool configuration
110            Self::auto_discover_config(tool_dir)?
111        };
112
113        Self::new(config)
114    }
115
116    /// Auto-discover tool configuration from directory
117    fn auto_discover_config(tool_dir: &Path) -> Result<CliToolConfig> {
118        // Look for executable files
119        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        // Look for README files
125        let readme_files = Self::find_readmes(tool_dir)?;
126
127        // Use first executable and README (if found)
128        let executable_path = executables[0].clone();
129        let readme_path = readme_files.first().cloned();
130
131        // Try to determine tool name from executable
132        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, // Will be tested during execution
146            environment: None,
147            working_dir: Some(tool_dir.to_path_buf()),
148        })
149    }
150
151    /// Find executable files in directory
152    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    /// Find README files in directory
185    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    /// Load README/documentation content
205    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        // Generate basic instructions if no README
211        Ok(format!(
212            "# {}\n\nCLI tool: {}\n\nExecute with provided arguments.\n",
213            config.name,
214            config.executable_path.display()
215        ))
216    }
217
218    /// Load JSON schema for validation
219    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    /// Execute the CLI tool with given arguments
228    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        // Validate arguments against schema if available
234        if let Some(schema) = &self.schema {
235            self.validate_args(&args, schema)?;
236        }
237
238        // Build command
239        let mut cmd = Command::new(&self.config.executable_path);
240
241        // Set working directory
242        if let Some(working_dir) = &self.config.working_dir {
243            cmd.current_dir(working_dir);
244        }
245
246        // Set environment variables
247        if let Some(env) = &self.config.environment {
248            for (key, value) in env {
249                cmd.env(key, value);
250            }
251        }
252
253        // Configure I/O
254        cmd.stdin(Stdio::piped()).stdout(Stdio::piped()).stderr(Stdio::piped());
255
256        // Add arguments based on configuration and input
257        self.configure_arguments(&mut cmd, &args)?;
258
259        // Execute with timeout
260        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        // Parse output
266        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        // Try to parse JSON output if supported
270        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    /// Configure command arguments based on input
286    fn configure_arguments(&self, cmd: &mut Command, args: &Value) -> Result<()> {
287        if args.is_null() || args == &Value::Null {
288            return Ok(());
289        }
290
291        // Handle different argument formats
292        match args {
293            Value::String(s) => {
294                // Single string argument
295                cmd.arg(s);
296            }
297            Value::Array(arr) => {
298                // Array of arguments
299                for arg in arr {
300                    if let Some(s) = arg.as_str() {
301                        cmd.arg(s);
302                    }
303                }
304            }
305            Value::Object(map) => {
306                // Named arguments - convert to command-line flags
307                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                // Fallback: serialize to JSON and pass as single argument
318                let json_str = serde_json::to_string(args)?;
319                cmd.arg(json_str);
320            }
321        }
322
323        Ok(())
324    }
325
326    /// Validate arguments against JSON schema
327    fn validate_args(&self, args: &Value, schema: &Value) -> Result<()> {
328        // Basic validation - in production, use jsonschema crate
329        debug!("Validating args against schema: {:?}", schema);
330
331        // For now, just check required fields
332        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    /// Test if tool supports JSON I/O
344    pub async fn test_json_support(&self) -> Result<bool> {
345        debug!("Testing JSON support for tool: {}", self.config.name);
346
347        // Try to execute with --help-json or similar flag
348        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                // Check if output is valid JSON
357                Ok(serde_json::from_str::<Value>(&stdout).is_ok())
358            }
359            Err(_) => Ok(false),
360        }
361    }
362
363    /// Convert to VT Code Skill
364    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        // Add schema as resource if available
385        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
424/// Discover CLI tools in project-local locations.
425///
426/// Only project-scoped directories are scanned (`~/.local/bin`, `./tools`,
427/// `./vendor/tools`). System directories such as `/usr/bin` and
428/// `/usr/local/bin` are intentionally excluded: treating every system
429/// executable as a skill pollutes the catalog with hundreds of unrelated
430/// binaries (`kubectl`, `binhex`, versioned `python3.12`/`python3.15` pairs
431/// that collapse to the same `file_stem` name), slows startup, and exposes
432/// system binaries as model-invocable skills.
433pub fn discover_cli_tools() -> Result<Vec<CliToolConfig>> {
434    let mut tools = vec![];
435
436    // Project-local locations to search (no system directories).
437    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
456/// Build a CLI tool config from an executable path.
457///
458/// This is the single shared predicate for "is this binary a skill": the
459/// path must be an executable file shipping with explicit per-tool metadata
460/// (`<stem>.md`, `<stem>.json`, or a sibling `tool.json`). Bare binaries
461/// without docs return `None` so system executables never pollute the skill
462/// catalog. Both directory scanners delegate here so the rule cannot drift.
463pub(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
512/// Discover tools in a specific directory
513fn 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
528/// Extension trait for PathBuf to expand home directory
529trait 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        // Versioned system-style binaries without docs must not collide either.
604        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}