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>> {
426 let mut tools = vec![];
427
428 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
450fn 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 #[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 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
506trait 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}