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 standard locations
425pub fn discover_cli_tools() -> Result<Vec<CliToolConfig>> {
426    let mut tools = vec![];
427
428    // Standard locations to search
429    let search_paths = vec![
430        PathBuf::from("/usr/local/bin"),
431        PathBuf::from("/usr/bin"),
432        PathBuf::from("~/.local/bin").expand_home()?,
433        PathBuf::from("./tools"),
434        PathBuf::from("./vendor/tools"),
435    ];
436
437    for path in search_paths {
438        if path.exists() && path.is_dir() {
439            match discover_tools_in_directory(&path) {
440                Ok(dir_tools) => tools.extend(dir_tools),
441                Err(e) => warn!("Failed to discover tools in {}: {}", path.display(), e),
442            }
443        }
444    }
445
446    info!("Discovered {} CLI tools", tools.len());
447    Ok(tools)
448}
449
450/// Discover tools in a specific directory
451fn discover_tools_in_directory(dir: &Path) -> Result<Vec<CliToolConfig>> {
452    let mut tools = vec![];
453
454    for entry in std::fs::read_dir(dir)? {
455        let entry = entry?;
456        let path = entry.path();
457
458        if path.is_file() {
459            // Check if it's an executable
460            #[cfg(unix)]
461            {
462                use std::os::unix::fs::PermissionsExt;
463                let metadata = entry.metadata()?;
464                let permissions = metadata.permissions();
465                if permissions.mode() & 0o111 == 0 {
466                    continue;
467                }
468            }
469
470            #[cfg(windows)]
471            {
472                if let Some(ext) = path.extension() {
473                    if ext != "exe" && ext != "bat" && ext != "cmd" {
474                        continue;
475                    }
476                } else {
477                    continue;
478                }
479            }
480
481            // Look for accompanying README
482            let Some(stem) = path.file_stem().and_then(|stem| stem.to_str()) else {
483                continue;
484            };
485            let readme_path = dir.join(format!("{stem}.md"));
486
487            let config = CliToolConfig {
488                name: stem.to_string(),
489                description: format!("CLI tool: {}", path.display()),
490                executable_path: path.clone(),
491                readme_path: if readme_path.exists() { Some(readme_path) } else { None },
492                schema_path: None,
493                timeout_seconds: Some(30),
494                supports_json: false,
495                environment: None,
496                working_dir: Some(dir.to_path_buf()),
497            };
498
499            tools.push(config);
500        }
501    }
502
503    Ok(tools)
504}
505
506/// Extension trait for PathBuf to expand home directory
507trait PathExt {
508    fn expand_home(&self) -> Result<PathBuf>;
509}
510
511impl PathExt for PathBuf {
512    fn expand_home(&self) -> Result<PathBuf> {
513        if let Some(home) = std::env::var("HOME").ok().filter(|_| self.starts_with("~")) {
514            let stripped = self.strip_prefix("~").unwrap_or(self);
515            return Ok(PathBuf::from(home).join(stripped));
516        }
517        Ok(self.clone())
518    }
519}
520
521#[cfg(test)]
522mod tests {
523    use super::*;
524    #[expect(
525        unused_imports,
526        reason = "Intentional compatibility, platform, test, or API-shape suppression."
527    )]
528    use tempfile::TempDir;
529
530    #[test]
531    fn test_cli_tool_config_creation() {
532        let config = CliToolConfig {
533            name: "test-tool".to_string(),
534            description: "Test tool".to_string(),
535            executable_path: PathBuf::from("/bin/echo"),
536            readme_path: None,
537            schema_path: None,
538            timeout_seconds: Some(10),
539            supports_json: false,
540            environment: None,
541            working_dir: None,
542        };
543
544        assert_eq!(config.name, "test-tool");
545        assert_eq!(config.timeout_seconds, Some(10));
546    }
547
548    #[tokio::test]
549    async fn test_simple_tool_execution() {
550        let config = CliToolConfig {
551            name: "echo".to_string(),
552            description: "Echo command".to_string(),
553            executable_path: PathBuf::from("/bin/echo"),
554            readme_path: None,
555            schema_path: None,
556            timeout_seconds: Some(5),
557            supports_json: false,
558            environment: None,
559            working_dir: None,
560        };
561
562        let bridge = CliToolBridge::new(config).unwrap();
563        let result = bridge.execute_internal(Value::String("hello world".to_string())).await.unwrap();
564
565        assert_eq!(result.exit_code, 0);
566        assert!(result.stdout.contains("hello world"));
567    }
568}