From fab407b1813ef819d9297645424f18518ae0de4e Mon Sep 17 00:00:00 2001 From: Paulo Aboim Pinto Date: Thu, 21 May 2026 00:57:48 +0200 Subject: [PATCH] feat: Pluggable Tool Registry -- runtime registration, config.toml overrides, and plugin tools (#1847) --- config.example.toml | 55 ++- crates/tui/src/config.rs | 40 ++ crates/tui/src/core/engine.rs | 66 ++- crates/tui/src/main.rs | 7 +- crates/tui/src/runtime_threads.rs | 1 + crates/tui/src/tools/mod.rs | 1 + crates/tui/src/tools/plugin.rs | 684 ++++++++++++++++++++++++++++++ crates/tui/src/tools/registry.rs | 73 ++++ crates/tui/src/tui/ui.rs | 1 + 9 files changed, 923 insertions(+), 5 deletions(-) create mode 100644 crates/tui/src/tools/plugin.rs diff --git a/config.example.toml b/config.example.toml index c8a7155bc..5f78657cd 100644 --- a/config.example.toml +++ b/config.example.toml @@ -599,8 +599,61 @@ default_text_model = "deepseek-ai/deepseek-v4-pro" # [runtime_api] # cors_origins = ["http://localhost:5173", "http://127.0.0.1:5173"] +# ───────────────────────────────────────────────────────────────────────────────── +# Tool Overrides & Plugins ([tools]) +# ───────────────────────────────────────────────────────────────────────────────── +# The `[tools]` table lets you replace any built-in tool with a custom +# implementation (script or command) or disable it entirely — without +# forking or recompiling the binary. +# +# Plugin scripts dropped in the plugin directory are auto-discovered and +# registered as model-visible tools alongside the built-in ones. +# +# Scripts receive the tool's JSON input on **stdin** and must return a +# JSON `ToolResult` (`{"content": "...", "success": true}`) on **stdout**. +# +# [tools] +# # Custom plugin directory (defaults to `~/.deepseek/tools/`) +# plugin_dir = "~/.deepseek/tools" +# +# [tools.overrides] +# # Disable a tool entirely — removes it from the model-visible catalog. +# "code_execution" = { type = "disabled" } +# +# # Replace a tool with a script. Relative paths resolve against plugin_dir. +# "exec_shell" = { type = "script", path = "audit-exec-shell.sh" } +# +# # Replace a tool with a command (binary on PATH or absolute path). +# "read_file" = { type = "command", command = "bat", args = ["--paging=never"] } +# +# # Scripts can also accept static arguments before the JSON input: +# "fetch_url" = { type = "script", path = "cached-fetch.sh", args = ["--ttl", "300"] } + +# ──────────── Enterprise example: audit-logging exec_shell wrapper ────────────── +# Drop `audit-exec-shell.sh` in `~/.deepseek/tools/` and enable with: +# +# [tools.overrides] +# "exec_shell" = { type = "script", path = "audit-exec-shell.sh" } +# +# The wrapper logs every command to `~/.deepseek/audit/exec_shell.log` before +# executing it, then runs the real `exec_shell` tool logic via stdin/stdout +# passthrough. No code changes, no fork, no recompile. +# +# ```sh +# #!/usr/bin/env sh +# # name: exec_shell +# # description: Audit-logging wrapper for exec_shell +# # approval: required +# LOGDIR="${HOME}/.deepseek/audit" +# mkdir -p "$LOGDIR" +# LOGFILE="$LOGDIR/exec_shell.log" +# input=$(cat) +# echo "[$(date -Iseconds)] $input" >> "$LOGFILE" +# echo "$input" | exec /bin/sh -s +# ``` + # ───────────────────────────────────────────────────────────────────────────────── # Requirements (admin constraints) example file # ───────────────────────────────────────────────────────────────────────────────── # allowed_approval_policies = ["on-request", "untrusted", "never"] -# allowed_sandbox_modes = ["read-only", "workspace-write"] +# allowed_sandbox_modes = ["read-only", "workspace-write"] \ No newline at end of file diff --git a/crates/tui/src/config.rs b/crates/tui/src/config.rs index 282d2023e..6fc15efc6 100644 --- a/crates/tui/src/config.rs +++ b/crates/tui/src/config.rs @@ -730,6 +730,19 @@ pub struct ToolsConfig { /// default core catalog. Unknown names are harmless and simply never match. #[serde(default)] pub always_load: Vec, + + /// Optional directory to scan for plugin tool scripts. Scripts with a + /// frontmatter header (`# name:`, `# description:`, `# schema:`) are + /// auto-discovered and registered as tools. + /// + /// Defaults to `~/.deepseek/tools/` when `None`. + #[serde(default)] + pub plugin_dir: Option, + + /// Per-tool overrides keyed by built-in tool name. + /// Each override replaces or disables the named tool. + #[serde(default)] + pub overrides: Option>, } /// One configurable footer item. @@ -1152,6 +1165,33 @@ pub struct Config { pub vision_model: Option, } +/// How a user wants to replace or disable a built-in tool. +#[derive(Debug, Clone, Deserialize)] +#[serde(tag = "type", rename_all = "snake_case")] +pub enum ToolOverride { + /// Run a local script file. The script receives the tool's JSON input + /// on stdin and must return a JSON `ToolResult` on stdout. + Script { + /// Path to the script (absolute, or relative to `~/.deepseek/tools/`). + path: String, + /// Optional static arguments prepended before the tool's JSON input. + #[serde(default)] + args: Option>, + }, + /// Run an external command. The command receives the tool's JSON input + /// on stdin and must return a JSON `ToolResult` on stdout. + Command { + /// The command to run (binary name or absolute path). + command: String, + /// Optional static arguments prepended before the tool's JSON input. + #[serde(default)] + args: Option>, + }, + /// Completely disable a built-in tool. The tool will not appear in the + /// model-visible catalog and cannot be called. + Disabled, +} + /// Vision model configuration for the `image_analyze` tool. /// Uses an OpenAI-compatible vision model API. #[derive(Debug, Clone, Deserialize)] diff --git a/crates/tui/src/core/engine.rs b/crates/tui/src/core/engine.rs index 02737eb7a..6b193d0c3 100644 --- a/crates/tui/src/core/engine.rs +++ b/crates/tui/src/core/engine.rs @@ -185,6 +185,10 @@ pub struct EngineConfig { /// through bubblewrap instead of relying solely on Landlock (#2184). #[allow(dead_code)] // Wired through ShellManager in follow-up PR pub prefer_bwrap: bool, + /// Tool override and plugin configuration (`[tools]` table in config.toml). + /// Applied to the per-turn tool registry after built-in tools are registered. + /// When `None`, no overrides or plugin loading occurs. + pub tools: Option, } impl Default for EngineConfig { @@ -232,6 +236,7 @@ impl Default for EngineConfig { ), tools_always_load: HashSet::new(), prefer_bwrap: false, + tools: None, } } } @@ -1113,7 +1118,7 @@ impl Engine { None }; - let tool_registry = match mode { + let mut tool_registry = match mode { AppMode::Agent | AppMode::Yolo => { if self.config.features.enabled(Feature::Subagents) { let runtime = if let Some(client) = self.deepseek_client.clone() { @@ -1161,18 +1166,73 @@ impl Engine { _ => Some(builder.build(tool_context)), }; + // Load plugin tools from the user's tools directory and apply any + // config.toml overrides. Plugin scripts are auto-discovered and + // registered without requiring a `[tools]` config section — the + // default `~/.deepseek/tools/` directory is always checked. + let mut plugin_tool_names: std::collections::HashSet = + std::collections::HashSet::new(); + if let Some(ref mut tool_registry) = tool_registry { + let names_before: std::collections::HashSet = tool_registry + .names() + .into_iter() + .map(|s| s.to_string()) + .collect(); + + let default_dir = dirs::home_dir() + .map(|h| h.join(".deepseek").join("tools")) + .unwrap_or_else(|| PathBuf::from(".deepseek/tools")); + let plugin_dir = if let Some(ref tools_config) = self.config.tools + && let Some(ref custom_dir) = tools_config.plugin_dir + { + let p = PathBuf::from(shellexpand::tilde(custom_dir).as_ref()); + if !p.exists() { + tracing::warn!( + "Configured plugin directory {} does not exist, falling back to default", + p.display() + ); + default_dir + } else { + p + } + } else { + default_dir + }; + + if let Some(ref tools_config) = self.config.tools + && let Some(ref overrides) = tools_config.overrides + { + tool_registry.apply_overrides(overrides, &plugin_dir); + } + + tool_registry.load_plugins(&plugin_dir); + + let names_after: std::collections::HashSet = tool_registry + .names() + .into_iter() + .map(|s| s.to_string()) + .collect(); + plugin_tool_names = &names_after - &names_before; + } + let mcp_tools = if self.config.features.enabled(Feature::Mcp) { self.mcp_tools().await } else { Vec::new() }; let tools = tool_registry.as_ref().map(|registry| { - build_model_tool_catalog( + let mut catalog = build_model_tool_catalog( registry.to_api_tools_with_cache(true), mcp_tools, mode, &self.config.tools_always_load, - ) + ); + for tool in &mut catalog { + if plugin_tool_names.contains(&tool.name) { + tool.defer_loading = Some(false); + } + } + catalog }); // Main turn loop diff --git a/crates/tui/src/main.rs b/crates/tui/src/main.rs index f46456918..90ad6cdec 100644 --- a/crates/tui/src/main.rs +++ b/crates/tui/src/main.rs @@ -1472,8 +1472,12 @@ fn tools_readme_template() -> &'static str { "# Local tools\n\n\ Drop self-describing scripts here so they can be discovered by\n\ `codewhale-tui setup --status` and surfaced in `codewhale-tui doctor`.\n\n\ + When `[tools.plugin_dir]` is set in config.toml (or when the default\n\ + `~/.deepseek/tools/` directory exists), they are auto-discovered and\n\ + registered as model-visible tools.\n\n\ Each script should start with a frontmatter-style header so the\n\ - description is visible without executing the file:\n\n\ + description is visible without executing the file and the agent knows\n\ + the tool name, description, and input schema:\n\n\ ```\n\ # name: my-tool\n\ # description: One-line summary of what this tool does\n\ @@ -5228,6 +5232,7 @@ async fn run_exec_agent( search_provider: config.search_provider(), search_api_key: config.search.as_ref().and_then(|s| s.api_key.clone()), tools_always_load: config.tools_always_load(), + tools: config.tools.clone(), }; let engine_handle = spawn_engine(engine_config, config); diff --git a/crates/tui/src/runtime_threads.rs b/crates/tui/src/runtime_threads.rs index 25196f046..3d1de78d2 100644 --- a/crates/tui/src/runtime_threads.rs +++ b/crates/tui/src/runtime_threads.rs @@ -1996,6 +1996,7 @@ impl RuntimeThreadManager { search_provider: self.config.search_provider(), search_api_key: self.config.search.as_ref().and_then(|s| s.api_key.clone()), tools_always_load: self.config.tools_always_load(), + tools: self.config.tools.clone(), }; let engine = spawn_engine(engine_cfg, &self.config); diff --git a/crates/tui/src/tools/mod.rs b/crates/tui/src/tools/mod.rs index e54270654..db1e0f707 100644 --- a/crates/tui/src/tools/mod.rs +++ b/crates/tui/src/tools/mod.rs @@ -33,6 +33,7 @@ pub mod notify; pub mod pandoc; pub mod parallel; pub mod plan; +pub mod plugin; pub mod project; pub mod recall_archive; pub mod registry; diff --git a/crates/tui/src/tools/plugin.rs b/crates/tui/src/tools/plugin.rs new file mode 100644 index 000000000..b9ba725fe --- /dev/null +++ b/crates/tui/src/tools/plugin.rs @@ -0,0 +1,684 @@ +//! Plugin tool system — scripts and commands as first-class tools. +//! +//! Users can drop self-describing scripts in `~/.deepseek/tools/` and they +//! are auto-discovered, parsed for frontmatter, and registered as model-visible +//! tools alongside built-in implementations. +//! +//! # Script frontmatter format +//! +//! Every plugin script must have a frontmatter header in its first 20 lines: +//! +//! ```sh +//! # name: my-tool +//! # description: Does something useful +//! # schema: {"type":"object","properties":{"input":{"type":"string"}}} +//! # approval: auto +//! ``` +//! +//! The script receives the tool's JSON input on **stdin** and must return +//! a JSON `ToolResult` (`{"content": "...", "success": true}`) on **stdout**. +//! Non-JSON output is wrapped in a `ToolResult` with `success: false`. + +use std::path::{Path, PathBuf}; +use std::sync::Arc; +use std::time::Duration; + +use async_trait::async_trait; +use serde_json::Value; +use tokio::io::AsyncWriteExt; + +use super::spec::{ + ApprovalRequirement, ToolCapability, ToolContext, ToolError, ToolResult, ToolSpec, +}; + +use crate::config::ToolOverride; + +/// Timeout for plugin script execution (120 seconds). +const PLUGIN_EXECUTION_TIMEOUT: Duration = Duration::from_secs(120); + +/// Metadata extracted from a plugin script's frontmatter header. +#[derive(Debug, Clone)] +pub struct PluginMetadata { + /// Tool name (from `# name:`). + pub name: String, + /// Human-readable description (from `# description:`). + pub description: String, + /// JSON Schema for the tool's input (from `# schema:`). + /// Defaults to a permissive `{"type": "object"}` when absent. + pub input_schema: Value, + /// Approval requirement (from `# approval:`). + /// Defaults to `Suggest`. + pub approval: ApprovalRequirement, +} + +/// A tool backed by an external script or executable dropped into the +/// plugins directory. The script receives JSON input on stdin and writes +/// a JSON `ToolResult` to stdout. +struct ScriptPluginTool { + metadata: PluginMetadata, + /// Absolute path to the script. + script_path: PathBuf, + /// Optional static arguments passed before the JSON input. + args: Vec, +} + +impl std::fmt::Debug for ScriptPluginTool { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("ScriptPluginTool") + .field("name", &self.metadata.name) + .field("script_path", &self.script_path) + .finish() + } +} + +#[async_trait] +impl ToolSpec for ScriptPluginTool { + fn name(&self) -> &str { + &self.metadata.name + } + + fn description(&self) -> &str { + &self.metadata.description + } + + fn input_schema(&self) -> Value { + self.metadata.input_schema.clone() + } + + fn capabilities(&self) -> Vec { + // Unknown plugin — conservative: mark as requiring execution + approval. + vec![ + ToolCapability::ExecutesCode, + ToolCapability::RequiresApproval, + ] + } + + fn approval_requirement(&self) -> ApprovalRequirement { + self.metadata.approval + } + + async fn execute(&self, input: Value, _context: &ToolContext) -> Result { + // Resolve the interpreter: parse shebang, then fall back to extension. + let (interpreter, mut script_args) = resolve_interpreter(&self.script_path); + script_args.push(self.script_path.to_string_lossy().to_string()); + script_args.extend(self.args.iter().cloned()); + let label = self.script_path.display().to_string(); + run_plugin_child(&interpreter, &script_args, &label, input).await + } +} + +/// A tool backed by an arbitrary shell command from config.toml overrides. +/// Behaves like `ScriptPluginTool` but uses the user-specified command string. +struct CommandPluginTool { + name: String, + description: String, + input_schema: Value, + command: String, + args: Vec, + approval: ApprovalRequirement, +} + +impl std::fmt::Debug for CommandPluginTool { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("CommandPluginTool") + .field("name", &self.name) + .field("command", &self.command) + .finish() + } +} + +#[async_trait] +impl ToolSpec for CommandPluginTool { + fn name(&self) -> &str { + &self.name + } + + fn description(&self) -> &str { + &self.description + } + + fn input_schema(&self) -> Value { + self.input_schema.clone() + } + + fn capabilities(&self) -> Vec { + vec![ + ToolCapability::ExecutesCode, + ToolCapability::RequiresApproval, + ] + } + + fn approval_requirement(&self) -> ApprovalRequirement { + self.approval + } + + async fn execute(&self, input: Value, _context: &ToolContext) -> Result { + // On Windows, if the command doesn't have an extension, try wrapping + // in `cmd /c` or use `powershell` for `.ps1` files. For portability + // we let tokio::process::Command resolve via PATH. + let mut cmd = if cfg!(windows) && !self.command.contains('.') { + let mut c = tokio::process::Command::new("cmd"); + c.arg("/c").arg(&self.command); + c + } else { + tokio::process::Command::new(&self.command) + }; + cmd.args(&self.args); + let label = format!("command '{}'", self.command); + run_plugin_child_raw(&mut cmd, &label, input).await + } +} + +// --------------------------------------------------------------------------- +// Script interpreter resolution +// --------------------------------------------------------------------------- + +/// Parse a shebang line (`#!/usr/bin/env node`) to extract the interpreter. +fn parse_shebang(path: &Path) -> Option<(String, Vec)> { + use std::io::Read; + let mut file = std::fs::File::open(path).ok()?; + let mut buf = [0u8; 256]; + let n = file.read(&mut buf).ok()?; + let content = String::from_utf8_lossy(&buf[..n]); + let first_line = content.lines().next()?; + let rest = first_line.strip_prefix("#!")?; + let parts: Vec<&str> = rest.split_whitespace().collect(); + if parts.is_empty() { + return None; + } + let interpreter = parts[0].to_string(); + let args: Vec = parts[1..].iter().map(|s| s.to_string()).collect(); + Some((interpreter, args)) +} + +/// Resolve the interpreter binary and pre-args for a script file. +/// +/// Priority: +/// 1. Shebang line from the script itself (`#!/usr/bin/env node`) +/// 2. Extension-based fallback for known script types +/// 3. Direct execution (assumes the OS knows how to run it) +fn resolve_interpreter(path: &Path) -> (String, Vec) { + // 1. Try shebang + if let Some((interp, shebang_args)) = parse_shebang(path) { + let bin_name = interp.rsplit('/').next().unwrap_or(&interp); + // `env` is a special case: `#!/usr/bin/env node` → `node` + // On Windows, `env` is not available, so extract the intended binary. + if bin_name == "env" && !shebang_args.is_empty() { + return (shebang_args[0].clone(), shebang_args[1..].to_vec()); + } + return (bin_name.to_string(), shebang_args); + } + + // 2. Extension-based fallback for common script types + let ext = path + .extension() + .and_then(|e| e.to_str()) + .unwrap_or("") + .to_lowercase(); + match ext.as_str() { + "ps1" => ("powershell".into(), vec!["-File".into()]), + "py" => ("python".into(), vec![]), + "js" | "mjs" => ("node".into(), vec![]), + "ts" => ("npx".into(), vec!["tsx".into()]), + "rb" => ("ruby".into(), vec![]), + "sh" | "bash" | "zsh" => { + // On Windows, route shell scripts through sh if available + if cfg!(windows) { + ("sh".into(), vec![]) + } else { + (path.to_string_lossy().into(), vec![]) + } + } + _ => (path.to_string_lossy().into(), vec![]), + } +} + +// --------------------------------------------------------------------------- +// Shared child process helpers +// --------------------------------------------------------------------------- + +/// Spawn a command, pipe JSON input to stdin, collect ToolResult from stdout. +async fn run_plugin_child( + command: &str, + args: &[String], + label: &str, + input: Value, +) -> Result { + let mut cmd = tokio::process::Command::new(command); + cmd.args(args); + run_plugin_child_raw(&mut cmd, label, input).await +} + +/// Run a pre-configured tokio Command, pipe JSON input, collect ToolResult. +async fn run_plugin_child_raw( + cmd: &mut tokio::process::Command, + label: &str, + input: Value, +) -> Result { + let input_bytes = serde_json::to_vec(&input) + .map_err(|e| ToolError::invalid_input(format!("failed to serialize input: {e}")))?; + + cmd.stdin(std::process::Stdio::piped()); + cmd.stdout(std::process::Stdio::piped()); + cmd.stderr(std::process::Stdio::piped()); + + let mut child = cmd + .spawn() + .map_err(|e| ToolError::execution_failed(format!("failed to spawn {label}: {e}")))?; + + if let Some(ref mut stdin) = child.stdin { + stdin + .write_all(&input_bytes) + .await + .map_err(|e| ToolError::execution_failed(format!("failed to write stdin: {e}")))?; + stdin + .shutdown() + .await + .map_err(|e| ToolError::execution_failed(format!("failed to close stdin: {e}")))?; + } + + let output = tokio::time::timeout(PLUGIN_EXECUTION_TIMEOUT, child.wait_with_output()) + .await + .map_err(|_| ToolError::Timeout { + seconds: PLUGIN_EXECUTION_TIMEOUT.as_secs(), + })? + .map_err(|e| ToolError::execution_failed(format!("process error: {e}")))?; + + if output.status.success() { + let stdout = String::from_utf8_lossy(&output.stdout).to_string(); + if let Ok(parsed) = serde_json::from_str::(&stdout) { + Ok(parsed) + } else { + Ok(ToolResult::success(stdout)) + } + } else { + let stderr = String::from_utf8_lossy(&output.stderr).to_string(); + let stdout = String::from_utf8_lossy(&output.stdout).to_string(); + let combined = if stderr.is_empty() { + stdout + } else if stdout.is_empty() { + stderr + } else { + format!("{stdout}\n{stderr}") + }; + Err(ToolError::execution_failed(combined)) + } +} + +// --------------------------------------------------------------------------- +// Frontmatter parsing +// --------------------------------------------------------------------------- + +/// Parse frontmatter header from the first `max_lines` lines of a text file. +/// +/// Expected format (one `# key: value` per line): +/// ```text +/// # name: my-tool +/// # description: Does something +/// # schema: {"type":"object"} +/// # approval: auto +/// ``` +/// +/// Also supports `// ` prefix for JavaScript/TypeScript scripts and `-- ` for Lua. +pub fn parse_frontmatter(content: &str) -> PluginMetadata { + let mut name = String::new(); + let mut description = String::new(); + let mut schema_str = String::new(); + let mut approval_str = String::new(); + + for line in content.lines().take(20) { + let line = line.trim(); + // Strip leading comment markers: `# `, `// `, `-- ` + let rest = line + .strip_prefix("# ") + .or_else(|| line.strip_prefix("// ")) + .or_else(|| line.strip_prefix("-- ")); + let Some(rest) = rest else { continue }; + if let Some((key, value)) = rest.split_once(": ") { + let key = key.trim().to_lowercase(); + let value = value.trim(); + match key.as_str() { + "name" => name = value.to_string(), + "description" => description = value.to_string(), + "schema" => schema_str = value.to_string(), + "approval" => approval_str = value.to_string(), + _ => {} + } + } + } + + let input_schema = if schema_str.is_empty() { + // Default: accept any object payload + serde_json::json!({"type": "object"}) + } else { + serde_json::from_str(&schema_str).unwrap_or_else(|_| serde_json::json!({"type": "object"})) + }; + + let approval = match approval_str.to_lowercase().as_str() { + "auto" => ApprovalRequirement::Auto, + "required" => ApprovalRequirement::Required, + _ => ApprovalRequirement::Suggest, + }; + + PluginMetadata { + name: if name.is_empty() { + "unnamed-plugin".to_string() + } else { + name + }, + description: if description.is_empty() { + "User-provided plugin tool".to_string() + } else { + description + }, + input_schema, + approval, + } +} + +/// Read the first 4 KB of a file and parse its frontmatter. +fn read_script_metadata(path: &Path) -> Option { + use std::io::Read; + let mut file = std::fs::File::open(path).ok()?; + let mut buf = [0u8; 4096]; + let n = file.read(&mut buf).ok()?; + let content = String::from_utf8_lossy(&buf[..n]); + let meta = parse_frontmatter(&content); + // Require at least the `name` field to consider it a valid plugin. + if meta.name == "unnamed-plugin" { + return None; + } + Some(meta) +} + +// --------------------------------------------------------------------------- +// Directory scanning +// --------------------------------------------------------------------------- + +/// Scan a directory for plugin script files with frontmatter headers. +/// +/// Files are considered eligible when: +/// - They are regular files (not directories, not symlinks) +/// - They don't start with `.` (hidden files) +/// - They are not `README.md` +/// - Their first 20 lines contain `# name:` frontmatter +pub fn scan_plugin_dir(dir: &Path) -> Vec<(PathBuf, PluginMetadata)> { + let mut results = Vec::new(); + + let entries = match std::fs::read_dir(dir) { + Ok(entries) => entries, + Err(e) => { + tracing::warn!("Failed to read plugin directory {}: {e}", dir.display()); + return results; + } + }; + + for entry in entries.flatten() { + let path = entry.path(); + + // Skip directories and hidden files + if path.is_dir() { + continue; + } + if let Some(name) = path.file_name().and_then(|n| n.to_str()) { + if name.starts_with('.') || name == "README.md" { + continue; + } + } + + // Try to parse frontmatter + if let Some(meta) = read_script_metadata(&path) { + results.push((path, meta)); + } + } + + results +} + +/// Load all plugin tools from a directory. Each eligible script becomes +/// a registered `ScriptPluginTool`. +pub fn load_plugin_tools(plugin_dir: &Path) -> Vec> { + let discovered = scan_plugin_dir(plugin_dir); + let mut tools: Vec> = Vec::with_capacity(discovered.len()); + + for (path, meta) in discovered { + tracing::info!( + "Discovered plugin tool '{}' at {}", + meta.name, + path.display() + ); + tools.push(Arc::new(ScriptPluginTool { + metadata: meta, + script_path: path, + args: Vec::new(), + })); + } + + tools +} + +/// Create a single tool from a `ToolOverride` config entry. +/// +/// Returns `None` for `Disabled` (the caller handles removal separately). +pub fn tool_from_override( + tool_name: &str, + override_cfg: &ToolOverride, + plugin_dir: &Path, +) -> Option> { + match override_cfg { + ToolOverride::Disabled => None, + ToolOverride::Script { path, args } => { + let script_path = if Path::new(path).is_absolute() { + PathBuf::from(path) + } else { + // Relative paths resolve relative to the plugin directory. + plugin_dir.join(path) + }; + + if !script_path.exists() { + tracing::warn!( + "Override script for '{}' not found at {}", + tool_name, + script_path.display() + ); + return None; + } + + // Read the script's own frontmatter for metadata, or provide + // defaults if it has none. + let meta = read_script_metadata(&script_path).unwrap_or_else(|| PluginMetadata { + name: tool_name.to_string(), + description: format!("Override for built-in tool '{tool_name}'"), + input_schema: serde_json::json!({"type": "object"}), + approval: ApprovalRequirement::Suggest, + }); + + Some(Arc::new(ScriptPluginTool { + metadata: meta, + script_path, + args: args.clone().unwrap_or_default(), + }) as Arc) + } + ToolOverride::Command { command, args } => { + // Build a description that includes the command. + let description = format!("Override for '{tool_name}' — runs: {command}"); + let cmd_args = args.clone().unwrap_or_default(); + + Some(Arc::new(CommandPluginTool { + name: tool_name.to_string(), + description, + input_schema: serde_json::json!({"type": "object"}), + command: command.clone(), + args: cmd_args, + approval: ApprovalRequirement::Suggest, + }) as Arc) + } + } +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +#[cfg(test)] +mod tests { + use super::*; + use tempfile::TempDir; + + #[test] + fn test_parse_frontmatter_full() { + let content = "\ +#!/usr/bin/env sh +# name: my-tool +# description: A useful custom tool +# schema: {\"type\":\"object\",\"properties\":{\"input\":{\"type\":\"string\"}}} +# approval: required +echo hello +"; + let meta = parse_frontmatter(content); + assert_eq!(meta.name, "my-tool"); + assert_eq!(meta.description, "A useful custom tool"); + assert_eq!(meta.approval, ApprovalRequirement::Required); + assert_eq!( + meta.input_schema, + serde_json::json!({"type":"object","properties":{"input":{"type":"string"}}}) + ); + } + + #[test] + fn test_parse_frontmatter_minimal() { + let content = "# name: mini"; + let meta = parse_frontmatter(content); + assert_eq!(meta.name, "mini"); + assert_eq!(meta.description, "User-provided plugin tool"); + assert_eq!(meta.approval, ApprovalRequirement::Suggest); + } + + #[test] + fn test_parse_frontmatter_missing_name() { + let content = "# description: no name here"; + let meta = parse_frontmatter(content); + assert_eq!(meta.name, "unnamed-plugin"); + // read_script_metadata would return None for this. + } + + #[test] + fn test_scan_plugin_dir_finds_scripts() { + let dir = TempDir::new().unwrap(); + + // Valid plugin + std::fs::write( + dir.path().join("my-plugin.sh"), + "# name: my-plugin\n# description: test\n", + ) + .unwrap(); + + // Hidden file — should be skipped + std::fs::write( + dir.path().join(".hidden.sh"), + "# name: hidden\n# description: should skip\n", + ) + .unwrap(); + + // README — should be skipped + std::fs::write(dir.path().join("README.md"), "# Tools\n").unwrap(); + + // No frontmatter — should be skipped + std::fs::write(dir.path().join("random.sh"), "echo hi\n").unwrap(); + + let discovered = scan_plugin_dir(dir.path()); + assert_eq!(discovered.len(), 1); + assert_eq!(discovered[0].1.name, "my-plugin"); + } + + #[test] + fn test_load_plugin_tools_creates_tools() { + let dir = TempDir::new().unwrap(); + std::fs::write( + dir.path().join("greet.sh"), + "# name: greet\n# description: Say hello\n# schema: {\"type\":\"object\",\"properties\":{\"name\":{\"type\":\"string\"}},\"required\":[\"name\"]}\n", + ) + .unwrap(); + + let tools = load_plugin_tools(dir.path()); + assert_eq!(tools.len(), 1); + assert_eq!(tools[0].name(), "greet"); + assert_eq!(tools[0].description(), "Say hello"); + } + + #[test] + fn test_tool_from_override_script() { + let dir = TempDir::new().unwrap(); + std::fs::write( + dir.path().join("wrapper.sh"), + "# name: exec_shell\n# description: Audit wrapper for exec_shell\n", + ) + .unwrap(); + + let override_cfg = ToolOverride::Script { + path: "wrapper.sh".to_string(), + args: None, + }; + + let tool = tool_from_override("exec_shell", &override_cfg, dir.path()); + assert!(tool.is_some()); + assert_eq!(tool.unwrap().name(), "exec_shell"); + } + + #[test] + fn test_tool_from_override_disabled() { + let dir = TempDir::new().unwrap(); + let override_cfg = ToolOverride::Disabled; + let tool = tool_from_override("code_execution", &override_cfg, dir.path()); + assert!(tool.is_none()); + } + + #[test] + fn test_tool_from_override_command() { + let dir = TempDir::new().unwrap(); + let override_cfg = ToolOverride::Command { + command: "my-custom-reader".to_string(), + args: Some(vec!["--format".to_string(), "json".to_string()]), + }; + let tool = tool_from_override("read_file", &override_cfg, dir.path()); + assert!(tool.is_some()); + assert_eq!(tool.unwrap().name(), "read_file"); + } + + #[test] + fn test_tool_from_override_script_absolute_path() { + let dir = TempDir::new().unwrap(); + let script_path = dir.path().join("audit.sh"); + std::fs::write(&script_path, "# name: exec_shell\n# description: Audit\n").unwrap(); + + let override_cfg = ToolOverride::Script { + path: script_path.to_str().unwrap().to_string(), + args: None, + }; + + let tool = tool_from_override("exec_shell", &override_cfg, dir.path()); + assert!(tool.is_some()); + } + + #[test] + fn test_approval_variants() { + let check = |content: &str, expected: ApprovalRequirement| { + assert_eq!(parse_frontmatter(content).approval, expected); + }; + + check("# name: x\n# approval: auto", ApprovalRequirement::Auto); + check( + "# name: x\n# approval: required", + ApprovalRequirement::Required, + ); + check( + "# name: x\n# approval: suggest", + ApprovalRequirement::Suggest, + ); + check( + "# name: x\n# approval: unknown", + ApprovalRequirement::Suggest, + ); + check("# name: x", ApprovalRequirement::Suggest); + } +} diff --git a/crates/tui/src/tools/registry.rs b/crates/tui/src/tools/registry.rs index 5a437abfb..630330d27 100644 --- a/crates/tui/src/tools/registry.rs +++ b/crates/tui/src/tools/registry.rs @@ -9,6 +9,8 @@ use std::collections::HashMap; use std::sync::{Arc, OnceLock}; +use std::path::Path; + use serde_json::Value; use crate::client::DeepSeekClient; @@ -388,6 +390,77 @@ impl ToolRegistry { self.tools.clear(); self.invalidate_api_cache(); } + + /// Remove a tool from the registry by name. Returns `true` if the tool + /// was present and removed, `false` if no tool with that name existed. + pub fn remove_tool(&mut self, name: &str) -> bool { + let existed = self.tools.remove(name).is_some(); + if existed { + self.invalidate_api_cache(); + } + existed + } + + /// Apply config.toml tool overrides to this registry. + /// + /// For each entry in `overrides`: + /// - `Disabled` removes the tool. + /// - `Script` / `Command` replaces the tool with the user's implementation. + /// + /// `plugin_dir` is used as the base for relative script paths. + pub fn apply_overrides( + &mut self, + overrides: &std::collections::HashMap, + plugin_dir: &Path, + ) { + for (tool_name, override_cfg) in overrides { + match override_cfg { + crate::config::ToolOverride::Disabled => { + if self.remove_tool(tool_name) { + tracing::info!("Tool '{}' disabled via config override", tool_name); + } else { + tracing::warn!("Cannot disable tool '{}': not registered", tool_name); + } + } + _ => { + // Script and Command overrides create replacement tools. + use crate::tools::plugin::tool_from_override; + if let Some(replacement) = + tool_from_override(tool_name, override_cfg, plugin_dir) + { + self.register(replacement); + tracing::info!("Tool '{}' replaced via config override", tool_name); + } + } + } + } + } + + /// Load and register plugin tools from a directory. + /// + /// Each script with valid frontmatter (`# name:`, `# description:`, etc.) + /// becomes a registered `ScriptPluginTool`. Tools whose name matches an + /// already-registered tool will overwrite it. + pub fn load_plugins(&mut self, plugin_dir: &Path) { + if !plugin_dir.exists() { + tracing::debug!( + "Plugin directory {} does not exist, skipping", + plugin_dir.display() + ); + return; + } + let plugins = crate::tools::plugin::load_plugin_tools(plugin_dir); + let count = plugins.len(); + for tool in plugins { + self.register(tool); + } + if count > 0 { + tracing::info!( + "Loaded {count} plugin tool(s) from {}", + plugin_dir.display() + ); + } + } } /// Builder for constructing a `ToolRegistry` with common tools. diff --git a/crates/tui/src/tui/ui.rs b/crates/tui/src/tui/ui.rs index c90caeda7..6b460aab9 100644 --- a/crates/tui/src/tui/ui.rs +++ b/crates/tui/src/tui/ui.rs @@ -731,6 +731,7 @@ fn build_engine_config(app: &App, config: &Config) -> EngineConfig { search_provider: config.search_provider(), search_api_key: config.search.as_ref().and_then(|s| s.api_key.clone()), tools_always_load: config.tools_always_load(), + tools: config.tools.clone(), } }