use crate::skills::types::{Skill, SkillManifest, SkillResource};
use crate::tool_policy::ToolPolicy;
use crate::tools::traits::Tool;
use crate::utils::async_utils;
use crate::utils::file_utils::{read_file_with_context_sync, read_json_file_sync};
use anyhow::{Result, anyhow};
use async_trait::async_trait;
use serde::{Deserialize, Serialize};
use serde_json::Value;
use std::path::{Path, PathBuf};
use std::process::Stdio;
use std::time::Duration;
use tokio::process::Command;
use tracing::{debug, info, warn};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CliToolConfig {
pub name: String,
pub description: String,
pub executable_path: PathBuf,
pub readme_path: Option<PathBuf>,
pub schema_path: Option<PathBuf>,
pub timeout_seconds: Option<u64>,
pub supports_json: bool,
pub environment: Option<hashbrown::HashMap<String, String>>,
pub working_dir: Option<PathBuf>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CliToolResult {
pub exit_code: i32,
pub stdout: String,
pub stderr: String,
pub json_output: Option<Value>,
pub execution_time_ms: u64,
}
#[derive(Debug, Clone)]
pub struct CliToolBridge {
pub config: CliToolConfig,
instructions: String,
schema: Option<Value>,
}
impl CliToolBridge {
pub fn new(config: CliToolConfig) -> Result<Self> {
let instructions = Self::load_readme(&config)?;
let schema = Self::load_schema(&config)?;
Ok(CliToolBridge {
config,
instructions,
schema,
})
}
pub fn from_directory(tool_dir: &Path) -> Result<Self> {
let config_path = tool_dir.join("tool.json");
let config: CliToolConfig = if config_path.exists() {
read_json_file_sync(&config_path)?
} else {
Self::auto_discover_config(tool_dir)?
};
Self::new(config)
}
fn auto_discover_config(tool_dir: &Path) -> Result<CliToolConfig> {
let executables = Self::find_executables(tool_dir)?;
if executables.is_empty() {
return Err(anyhow!(
"No executable files found in {}",
tool_dir.display()
));
}
let readme_files = Self::find_readmes(tool_dir)?;
let executable_path = executables[0].clone();
let readme_path = readme_files.first().cloned();
let name = executable_path
.file_stem()
.and_then(|s| s.to_str())
.ok_or_else(|| anyhow!("Invalid executable filename"))?
.to_string();
Ok(CliToolConfig {
name: name.clone(),
description: format!("CLI tool: {}", name),
executable_path,
readme_path,
schema_path: None,
timeout_seconds: Some(30),
supports_json: false, environment: None,
working_dir: Some(tool_dir.to_path_buf()),
})
}
fn find_executables(dir: &Path) -> Result<Vec<PathBuf>> {
let mut executables = vec![];
for entry in std::fs::read_dir(dir)? {
let entry = entry?;
let path = entry.path();
if path.is_file() {
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let metadata = entry.metadata()?;
let permissions = metadata.permissions();
if permissions.mode() & 0o111 != 0 {
executables.push(path);
}
}
#[cfg(windows)]
{
if let Some(ext) = path.extension() {
if ext == "exe" || ext == "bat" || ext == "cmd" {
executables.push(path);
}
}
}
}
}
Ok(executables)
}
fn find_readmes(dir: &Path) -> Result<Vec<PathBuf>> {
let mut readmes = vec![];
for entry in std::fs::read_dir(dir)? {
let entry = entry?;
let path = entry.path();
if let Some(_name) = path.file_name().and_then(|n| n.to_str()).filter(|n| {
path.is_file() && n.to_lowercase().starts_with("readme") && n.ends_with(".md")
}) {
readmes.push(path);
}
}
Ok(readmes)
}
fn load_readme(config: &CliToolConfig) -> Result<String> {
if let Some(readme_path) = config.readme_path.as_ref().filter(|p| p.exists()) {
return read_file_with_context_sync(readme_path, "README file");
}
Ok(format!(
"# {}\n\nCLI tool: {}\n\nExecute with provided arguments.\n",
config.name,
config.executable_path.display()
))
}
fn load_schema(config: &CliToolConfig) -> Result<Option<Value>> {
if let Some(schema_path) = config.schema_path.as_ref().filter(|p| p.exists()) {
return Ok(Some(read_json_file_sync(schema_path)?));
}
Ok(None)
}
pub async fn execute_internal(&self, args: Value) -> Result<CliToolResult> {
info!(
"Executing CLI tool: {} with args: {:?}",
self.config.name, args
);
let start_time = std::time::Instant::now();
if let Some(schema) = &self.schema {
self.validate_args(&args, schema)?;
}
let mut cmd = Command::new(&self.config.executable_path);
if let Some(working_dir) = &self.config.working_dir {
cmd.current_dir(working_dir);
}
if let Some(env) = &self.config.environment {
for (key, value) in env {
cmd.env(key, value);
}
}
cmd.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped());
self.configure_arguments(&mut cmd, &args)?;
let timeout_duration = Duration::from_secs(self.config.timeout_seconds.unwrap_or(30));
let output_result =
async_utils::with_timeout(cmd.output(), timeout_duration, "CLI tool execution")
.await??;
let execution_time_ms = start_time.elapsed().as_millis() as u64;
let stdout = String::from_utf8_lossy(&output_result.stdout).to_string();
let stderr = String::from_utf8_lossy(&output_result.stderr).to_string();
let json_output = if self.config.supports_json {
serde_json::from_str(&stdout).ok()
} else {
None
};
Ok(CliToolResult {
exit_code: output_result.status.code().unwrap_or(-1),
stdout,
stderr,
json_output,
execution_time_ms,
})
}
fn configure_arguments(&self, cmd: &mut Command, args: &Value) -> Result<()> {
if args.is_null() || args == &Value::Null {
return Ok(());
}
match args {
Value::String(s) => {
cmd.arg(s);
}
Value::Array(arr) => {
for arg in arr {
if let Some(s) = arg.as_str() {
cmd.arg(s);
}
}
}
Value::Object(map) => {
for (key, value) in map {
if let Some(s) = value.as_str() {
cmd.arg(format!("--{}", key));
cmd.arg(s);
} else if value.as_bool().is_some_and(|flag| flag) {
cmd.arg(format!("--{}", key));
}
}
}
_ => {
let json_str = serde_json::to_string(args)?;
cmd.arg(json_str);
}
}
Ok(())
}
fn validate_args(&self, args: &Value, schema: &Value) -> Result<()> {
debug!("Validating args against schema: {:?}", schema);
if let Some(required) = schema.get("required").and_then(|v| v.as_array()) {
for field in required {
if let Some(field_name) = field.as_str().filter(|f| args.get(*f).is_none()) {
return Err(anyhow!("Missing required field: {}", field_name));
}
}
}
Ok(())
}
pub async fn test_json_support(&self) -> Result<bool> {
debug!("Testing JSON support for tool: {}", self.config.name);
let mut cmd = Command::new(&self.config.executable_path);
cmd.arg("--help-json")
.stdout(Stdio::piped())
.stderr(Stdio::piped());
let result = cmd.output().await;
match result {
Ok(output) => {
let stdout = String::from_utf8_lossy(&output.stdout);
Ok(serde_json::from_str::<Value>(&stdout).is_ok())
}
Err(_) => Ok(false),
}
}
pub fn to_skill(&self) -> Result<Skill> {
let manifest = SkillManifest {
name: self.config.name.clone(),
description: self.config.description.clone(),
version: Some("1.0.0".to_string()),
author: Some("VT Code CLI Bridge".to_string()),
variety: crate::skills::types::SkillVariety::SystemUtility,
..Default::default()
};
let mut skill = Skill::new(
manifest,
self.config
.executable_path
.parent()
.unwrap_or_else(|| Path::new("."))
.to_path_buf(),
self.instructions.clone(),
)?;
if let Some(schema) = &self.schema {
skill.add_resource(
"schema.json".to_string(),
SkillResource {
path: "schema.json".to_string(),
resource_type: crate::skills::types::ResourceType::Reference,
content: Some(schema.to_string().into_bytes()),
},
);
}
Ok(skill)
}
}
#[async_trait]
impl Tool for CliToolBridge {
fn name(&self) -> &str {
&self.config.name
}
fn description(&self) -> &str {
&self.config.description
}
fn parameter_schema(&self) -> Option<Value> {
self.schema.clone()
}
fn default_permission(&self) -> ToolPolicy {
ToolPolicy::Prompt
}
async fn execute(&self, args: Value) -> Result<Value> {
let result = self.execute_internal(args).await?;
Ok(serde_json::to_value(result)?)
}
}
pub fn discover_cli_tools() -> Result<Vec<CliToolConfig>> {
let mut tools = vec![];
let search_paths = vec![
PathBuf::from("/usr/local/bin"),
PathBuf::from("/usr/bin"),
PathBuf::from("~/.local/bin").expand_home()?,
PathBuf::from("./tools"),
PathBuf::from("./vendor/tools"),
];
for path in search_paths {
if path.exists() && path.is_dir() {
match discover_tools_in_directory(&path) {
Ok(dir_tools) => tools.extend(dir_tools),
Err(e) => warn!("Failed to discover tools in {}: {}", path.display(), e),
}
}
}
info!("Discovered {} CLI tools", tools.len());
Ok(tools)
}
fn discover_tools_in_directory(dir: &Path) -> Result<Vec<CliToolConfig>> {
let mut tools = vec![];
for entry in std::fs::read_dir(dir)? {
let entry = entry?;
let path = entry.path();
if path.is_file() {
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let metadata = entry.metadata()?;
let permissions = metadata.permissions();
if permissions.mode() & 0o111 == 0 {
continue;
}
}
#[cfg(windows)]
{
if let Some(ext) = path.extension() {
if ext != "exe" && ext != "bat" && ext != "cmd" {
continue;
}
} else {
continue;
}
}
let Some(stem) = path.file_stem().and_then(|stem| stem.to_str()) else {
continue;
};
let readme_path = dir.join(format!("{stem}.md"));
let config = CliToolConfig {
name: stem.to_string(),
description: format!("CLI tool: {}", path.display()),
executable_path: path.clone(),
readme_path: if readme_path.exists() {
Some(readme_path)
} else {
None
},
schema_path: None,
timeout_seconds: Some(30),
supports_json: false,
environment: None,
working_dir: Some(dir.to_path_buf()),
};
tools.push(config);
}
}
Ok(tools)
}
trait PathExt {
fn expand_home(&self) -> Result<PathBuf>;
}
impl PathExt for PathBuf {
fn expand_home(&self) -> Result<PathBuf> {
if let Some(home) = std::env::var("HOME").ok().filter(|_| self.starts_with("~")) {
let stripped = self.strip_prefix("~").unwrap_or(self);
return Ok(PathBuf::from(home).join(stripped));
}
Ok(self.clone())
}
}
#[cfg(test)]
mod tests {
use super::*;
#[allow(unused_imports)]
use tempfile::TempDir;
#[test]
fn test_cli_tool_config_creation() {
let config = CliToolConfig {
name: "test-tool".to_string(),
description: "Test tool".to_string(),
executable_path: PathBuf::from("/bin/echo"),
readme_path: None,
schema_path: None,
timeout_seconds: Some(10),
supports_json: false,
environment: None,
working_dir: None,
};
assert_eq!(config.name, "test-tool");
assert_eq!(config.timeout_seconds, Some(10));
}
#[tokio::test]
async fn test_simple_tool_execution() {
let config = CliToolConfig {
name: "echo".to_string(),
description: "Echo command".to_string(),
executable_path: PathBuf::from("/bin/echo"),
readme_path: None,
schema_path: None,
timeout_seconds: Some(5),
supports_json: false,
environment: None,
working_dir: None,
};
let bridge = CliToolBridge::new(config).unwrap();
let result = bridge
.execute_internal(Value::String("hello world".to_string()))
.await
.unwrap();
assert_eq!(result.exit_code, 0);
assert!(result.stdout.contains("hello world"));
}
}