Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
e1a44eb
feat: add ShellDispatcher for shell-agnostic command execution
aboimpinto May 18, 2026
e13fc25
feat(shell_dispatcher): harden PowerShell detection and add execution…
aboimpinto May 19, 2026
9a10c72
style: cargo fmt after rebase
aboimpinto May 24, 2026
4ec1274
fix: add #[allow(dead_code)] to ShellDispatcher items not yet wired
aboimpinto May 24, 2026
a91b2c5
fix: make raw-mode guard conditional and remove unused_variables allow
aboimpinto May 24, 2026
0af5ae0
fix: restore #[allow(unused_variables)] for dead code in ShellDispatcher
aboimpinto May 24, 2026
b62843b
fix: prefix unused kind with _kind to suppress unused_variables warning
aboimpinto May 24, 2026
1e4d94e
fix: move kind variable inside #[cfg(windows)] block to avoid unused …
aboimpinto May 24, 2026
9531ea9
fix: update tests for cross-platform ShellDispatcher
aboimpinto May 24, 2026
a483e95
style: cargo fmt after test fixes
aboimpinto May 24, 2026
cf708a5
fix: make ShellDispatcher::detect_shell() pub for test helpers
aboimpinto May 24, 2026
6ac49d5
fix: relax Windows test expectations for dynamic shell detection
aboimpinto May 24, 2026
efc60cd
style: cargo fmt
aboimpinto May 24, 2026
69deada
fix: handle pwsh multi-arg format in test helpers
aboimpinto May 24, 2026
f6541f8
fix: relax prepare_unsandboxed assertion for pwsh UTF8 prefix
aboimpinto May 24, 2026
1ab9a9c
fix: replace field-access format string with explicit arg
aboimpinto May 24, 2026
3b10808
style: cargo fmt
aboimpinto May 24, 2026
df527ba
fix: replace second field-access format string with explicit arg
aboimpinto May 24, 2026
9354d64
style: cargo fmt sandbox/mod.rs
aboimpinto May 24, 2026
30ccac9
feat: replace hardcoded Command::new(git/gh/rustc) with ExternalTool …
aboimpinto May 24, 2026
d4bd11a
fix: refactor resolve() to iterate candidates; fix Go/Windows .cmd de…
aboimpinto May 20, 2026
9abf3c9
refactor: extract emit_tool_outcome helper to eliminate duplicate boi…
aboimpinto May 20, 2026
ac34c7f
refactor: replace spawn_blocking with tokio_command in share.rs and t…
aboimpinto May 20, 2026
bb7e100
feat: Pluggable Tool Registry -- runtime registration, config.toml ov…
aboimpinto May 20, 2026
6adc99e
fix: resolve pluggable registry rebase conflicts
aboimpinto May 24, 2026
ac1d05d
fix: silence intentional dead code for strict CI
aboimpinto May 24, 2026
349c1f1
fix: restore CodeWhale UI state after rebase
aboimpinto May 24, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
55 changes: 54 additions & 1 deletion config.example.toml
Original file line number Diff line number Diff line change
Expand Up @@ -576,8 +576,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"]
20 changes: 11 additions & 9 deletions crates/tui/src/commands/share.rs
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ use std::io::Write;
use std::path::Path;

use super::CommandResult;
use crate::dependencies::ExternalTool;
use crate::tui::app::{App, AppAction};

/// Share the current session as a web URL.
Expand Down Expand Up @@ -101,7 +102,7 @@ fn render_session_html(history_json: &str, model: &str, mode: &str) -> String {
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>codewhale Session Export</title>
<title>DeepSeek TUI Session Export</title>
<style>
body {{
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
Expand All @@ -119,14 +120,14 @@ fn render_session_html(history_json: &str, model: &str, mode: &str) -> String {
</style>
</head>
<body>
<h1>codewhale Session</h1>
<h1>DeepSeek TUI Session</h1>
<div class="meta">
<strong>Model:</strong> {escaped_model} · <strong>Mode:</strong> {escaped_mode}<br>
<strong>Exported:</strong> {timestamp}
</div>
<pre>{escaped_body}</pre>
<div class="footer">
Generated by codewhale · https://github.com/Hmbown/CodeWhale
Generated by DeepSeek TUI · https://github.com/Hmbown/DeepSeek-TUI
</div>
</body>
</html>"#,
Expand All @@ -145,7 +146,7 @@ fn html_escape(s: &str) -> String {
/// Write HTML to a secure temp file and keep it alive for upload.
fn write_temp_html(html: &str) -> Result<tempfile::NamedTempFile, String> {
let mut tmp = tempfile::Builder::new()
.prefix("codewhale-share-")
.prefix("deepseek-share-")
.suffix(".html")
.tempfile()
.map_err(|e| format!("{e}"))?;
Expand All @@ -155,16 +156,17 @@ fn write_temp_html(html: &str) -> Result<tempfile::NamedTempFile, String> {

/// Upload a file as a GitHub Gist using the `gh` CLI.
async fn upload_gist(path: &Path) -> Result<String, String> {
let output = tokio::process::Command::new("gh")
let output = crate::dependencies::Gh::tokio_command()
.ok_or_else(|| "gh not found on PATH".to_string())?
.args([
"gist",
"create",
"--public",
&path.to_string_lossy(),
&path.to_string_lossy().to_string(),
"--filename",
"session-export.html",
"--desc",
"codewhale Session Export",
"DeepSeek TUI Session Export",
])
.output()
.await
Expand Down Expand Up @@ -194,7 +196,7 @@ mod tests {
assert!(html.contains("deepseek-v4-pro"));
assert!(html.contains("agent"));
assert!(html.contains("[{}]"));
assert!(html.contains("codewhale"));
assert!(html.contains("DeepSeek TUI"));
}

#[test]
Expand All @@ -219,6 +221,6 @@ mod tests {
assert!(html.contains("plan"));
assert!(html.contains("test data"));
assert!(html.contains("Exported:"));
assert!(html.contains("https://github.com/Hmbown/CodeWhale"));
assert!(html.contains("https://github.com/Hmbown/DeepSeek-TUI"));
}
}
54 changes: 54 additions & 0 deletions crates/tui/src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1039,6 +1039,59 @@ pub struct Config {
/// Vision model configuration for the `image_analyze` tool.
#[serde(default)]
pub vision_model: Option<VisionModelConfig>,

/// Tool override and plugin configuration (`[tools]` table in config.toml).
/// When absent, all built-in tools remain as-is and no plugin directory
/// is scanned.
#[serde(default)]
pub tools: Option<ToolsConfig>,
}

/// Runtime tool override configuration loaded from `[tools]` in config.toml.
///
/// Users can replace any built-in tool with a script or external command,
/// or disable a tool entirely — without forking or recompiling.
#[derive(Debug, Clone, Default, Deserialize)]
#[serde(default)]
pub struct ToolsConfig {
/// 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`.
pub plugin_dir: Option<String>,

/// Per-tool overrides keyed by built-in tool name.
/// Each override replaces or disables the named tool.
#[serde(default)]
pub overrides: Option<HashMap<String, ToolOverride>>,
}

/// 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<Vec<String>>,
},
/// 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<Vec<String>>,
},
/// 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.
Expand Down Expand Up @@ -2971,6 +3024,7 @@ fn merge_config(base: Config, override_cfg: Config) -> Config {
strict_tool_mode: override_cfg.strict_tool_mode.or(base.strict_tool_mode),
runtime_api: override_cfg.runtime_api.or(base.runtime_api),
workshop: override_cfg.workshop.or(base.workshop),
tools: override_cfg.tools.or(base.tools),
}
}

Expand Down
87 changes: 82 additions & 5 deletions crates/tui/src/core/engine.rs
Original file line number Diff line number Diff line change
Expand Up @@ -171,6 +171,11 @@ pub struct EngineConfig {
/// once at engine construction, then threaded onto every
/// `SubAgentRuntime` the engine builds (#1806, #1808).
pub subagent_api_timeout: Duration,

/// 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<crate::config::ToolsConfig>,
}

impl Default for EngineConfig {
Expand Down Expand Up @@ -214,6 +219,7 @@ impl Default for EngineConfig {
subagent_api_timeout: Duration::from_secs(
crate::config::DEFAULT_SUBAGENT_API_TIMEOUT_SECS,
),
tools: None,
}
}
}
Expand Down Expand Up @@ -298,7 +304,7 @@ pub struct Engine {
/// can fan completion events back into the engine.
tx_subagent_completion: mpsc::UnboundedSender<SubAgentCompletion>,
/// Receiver paired with `tx_subagent_completion`. Drained at the
/// turn-loop's empty-tool_uses branch to surface `<codewhale:subagent.done>`
/// turn-loop's empty-tool_uses branch to surface `<deepseek:subagent.done>`
/// sentinels into the parent's transcript before deciding to end the turn.
pub(super) rx_subagent_completion: mpsc::UnboundedReceiver<SubAgentCompletion>,
cancel_token: CancellationToken,
Expand Down Expand Up @@ -1060,7 +1066,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() {
Expand Down Expand Up @@ -1108,13 +1114,83 @@ impl Engine {
_ => Some(builder.build(tool_context)),
};

// Track names added by plugin loading so we never defer them.
let mut plugin_tool_names: std::collections::HashSet<String> =
std::collections::HashSet::new();

// 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.
if let Some(ref mut tool_registry) = tool_registry {
// Snapshot built-in tool names before any modifications.
let names_before: std::collections::HashSet<String> = tool_registry
.names()
.into_iter()
.map(|s| s.to_string())
.collect();

// Resolve the plugin directory. Defaults to `~/.deepseek/tools/`.
let default_dir = {
let home = dirs::home_dir()
.map(|h| h.join(".deepseek").join("tools"))
.unwrap_or_else(|| PathBuf::from(".deepseek/tools"));
home
};
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
};

// Apply per-tool overrides from config.toml (disable / replace).
if let Some(ref tools_config) = self.config.tools
&& let Some(ref overrides) = tools_config.overrides
{
tool_registry.apply_overrides(overrides, &plugin_dir);
}

// Load auto-discovered plugin scripts from the tools directory.
tool_registry.load_plugins(&plugin_dir);

// Diff: any tool name that didn't exist before overrides/plugins
// is a user-registered tool. These should never be deferred.
let names_after: std::collections::HashSet<String> = 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(registry.to_api_tools_with_cache(true), mcp_tools, mode)
let mut catalog =
build_model_tool_catalog(registry.to_api_tools_with_cache(true), mcp_tools, mode);
// Ensure plugin/override tools are NOT deferred — they should be
// immediately visible since the user explicitly opted into them.
for tool in &mut catalog {
if plugin_tool_names.contains(&tool.name) {
tool.defer_loading = Some(false);
}
}
catalog
});

// Main turn loop
Expand Down Expand Up @@ -1364,7 +1440,7 @@ impl Engine {
"Emergency compaction complete: {before_count} → {after_count} messages ({removed} removed), ~{before_tokens} → ~{after_tokens} tokens"
);
if retries_used > 0 {
details.push_str(&format!(" ({retries_used} retries)"));
details.push_str(&format!(" ({} retries)", retries_used));
}
if trimmed > 0 {
details.push_str(&format!(", trimmed {trimmed} oldest"));
Expand All @@ -1383,7 +1459,8 @@ impl Engine {

let message = format!(
"Emergency context compaction failed to reduce request below model limit \
(estimate ~{after_tokens} tokens, budget ~{target_budget})."
(estimate ~{} tokens, budget ~{}).",
after_tokens, target_budget
);
self.emit_compaction_failed(id, true, message.clone()).await;
let _ = self.tx_event.send(Event::status(message)).await;
Expand Down
Loading
Loading