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 {
101            config,
102            instructions,
103            schema,
104        })
105    }
106
107    /// Create a bridge from a tool directory
108    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            // Auto-discover tool configuration
114            Self::auto_discover_config(tool_dir)?
115        };
116
117        Self::new(config)
118    }
119
120    /// Auto-discover tool configuration from directory
121    fn auto_discover_config(tool_dir: &Path) -> Result<CliToolConfig> {
122        // Look for executable files
123        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        // Look for README files
132        let readme_files = Self::find_readmes(tool_dir)?;
133
134        // Use first executable and README (if found)
135        let executable_path = executables[0].clone();
136        let readme_path = readme_files.first().cloned();
137
138        // Try to determine tool name from executable
139        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, // Will be tested during execution
153            environment: None,
154            working_dir: Some(tool_dir.to_path_buf()),
155        })
156    }
157
158    /// Find executable files in directory
159    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    /// Find README files in directory
192    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    /// Load README/documentation content
210    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        // Generate basic instructions if no README
216        Ok(format!(
217            "# {}\n\nCLI tool: {}\n\nExecute with provided arguments.\n",
218            config.name,
219            config.executable_path.display()
220        ))
221    }
222
223    /// Load JSON schema for validation
224    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    /// Execute the CLI tool with given arguments
233    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        // Validate arguments against schema if available
242        if let Some(schema) = &self.schema {
243            self.validate_args(&args, schema)?;
244        }
245
246        // Build command
247        let mut cmd = Command::new(&self.config.executable_path);
248
249        // Set working directory
250        if let Some(working_dir) = &self.config.working_dir {
251            cmd.current_dir(working_dir);
252        }
253
254        // Set environment variables
255        if let Some(env) = &self.config.environment {
256            for (key, value) in env {
257                cmd.env(key, value);
258            }
259        }
260
261        // Configure I/O
262        cmd.stdin(Stdio::piped())
263            .stdout(Stdio::piped())
264            .stderr(Stdio::piped());
265
266        // Add arguments based on configuration and input
267        self.configure_arguments(&mut cmd, &args)?;
268
269        // Execute with timeout
270        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        // Parse output
278        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        // Try to parse JSON output if supported
282        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    /// Configure command arguments based on input
298    fn configure_arguments(&self, cmd: &mut Command, args: &Value) -> Result<()> {
299        if args.is_null() || args == &Value::Null {
300            return Ok(());
301        }
302
303        // Handle different argument formats
304        match args {
305            Value::String(s) => {
306                // Single string argument
307                cmd.arg(s);
308            }
309            Value::Array(arr) => {
310                // Array of arguments
311                for arg in arr {
312                    if let Some(s) = arg.as_str() {
313                        cmd.arg(s);
314                    }
315                }
316            }
317            Value::Object(map) => {
318                // Named arguments - convert to command-line flags
319                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                // Fallback: serialize to JSON and pass as single argument
330                let json_str = serde_json::to_string(args)?;
331                cmd.arg(json_str);
332            }
333        }
334
335        Ok(())
336    }
337
338    /// Validate arguments against JSON schema
339    fn validate_args(&self, args: &Value, schema: &Value) -> Result<()> {
340        // Basic validation - in production, use jsonschema crate
341        debug!("Validating args against schema: {:?}", schema);
342
343        // For now, just check required fields
344        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    /// Test if tool supports JSON I/O
356    pub async fn test_json_support(&self) -> Result<bool> {
357        debug!("Testing JSON support for tool: {}", self.config.name);
358
359        // Try to execute with --help-json or similar flag
360        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                // Check if output is valid JSON
371                Ok(serde_json::from_str::<Value>(&stdout).is_ok())
372            }
373            Err(_) => Ok(false),
374        }
375    }
376
377    /// Convert to VT Code Skill
378    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        // Add schema as resource if available
399        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
438/// Discover CLI tools in standard locations
439pub fn discover_cli_tools() -> Result<Vec<CliToolConfig>> {
440    let mut tools = vec![];
441
442    // Standard locations to search
443    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
464/// Discover tools in a specific directory
465fn 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            // Check if it's an executable
474            #[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            // Look for accompanying README
496            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
524/// Extension trait for PathBuf to expand home directory
525trait 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}