Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
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
12 changes: 12 additions & 0 deletions crates/tui/assets/plugins/rust-toolkit/plugin.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
[plugin]
name = "rust-toolkit"
description = "Rust development tools: cargo check, clippy, and test runner"
version = "1.0.0"
default_enabled = true

[skills]
paths = ["skills/rust-check/"]

[when]
os = ["linux", "windows", "macos"]
required_binaries = ["cargo"]
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
---
name: rust-check
type: check
when_to_use: Check Rust code compilation and clippy linting
argument_hint: "[file or directory to check]"
allowed_tools: ["read_file", "exec_shell:check-only"]
---

# rust-check — Rust code quality check

Run `cargo check` and `cargo clippy` on the specified Rust project or file.

1. If given a file path, run `cargo check` in the crate containing that file
2. If given a directory, run `cargo check` in that directory
3. If no path is given, check the current project
4. Report any compilation errors or clippy warnings
5 changes: 3 additions & 2 deletions crates/tui/src/commands/groups/mod.rs
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
//! Group-owned built-in command areas.
//! Group-owned built-in command areas.
//!
//! Each group module registers command objects into the central command
//! registry. Command implementation functions still live with their owning
Expand All @@ -8,7 +8,7 @@
pub mod config;
pub mod core;
pub mod debug;
pub mod memory;
pub mod memory;pub mod plugins;
pub mod project;
pub mod session;
pub mod skills;
Expand All @@ -28,3 +28,4 @@ pub fn all_command_groups() -> Vec<&'static dyn CommandGroup> {
&utility::UtilityCommands,
]
}

113 changes: 113 additions & 0 deletions crates/tui/src/commands/groups/plugins/mod.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
//! `/plugin` slash commands — manage the new plugin registry.
//!
//! Subcommands:
//! - `/plugin list` — list all plugins with enable/disable status
//! - `/plugin enable <name>` — enable a plugin
//! - `/plugin disable <name>` — disable a plugin
//! - `/plugin info <name>` — show plugin details

use crate::commands::CommandResult;
use crate::plugins;
use crate::tui::app::App;

pub fn plugin_command(_app: &mut App, arg: Option<&str>) -> CommandResult {
let parts: Vec<&str> = arg
.unwrap_or("")
.split_whitespace()
.filter(|s| !s.is_empty())
.collect();

if parts.is_empty() || parts[0] == "list" {
return cmd_list();
}

match parts[0] {
"list" => cmd_list(),
"enable" => {
if parts.len() < 2 {
return CommandResult::error("Usage: /plugin enable <name>".to_string());
}
cmd_enable(parts[1])
}
"disable" => {
if parts.len() < 2 {
return CommandResult::error("Usage: /plugin disable <name>".to_string());
}
cmd_disable(parts[1])
}
"info" => {
if parts.len() < 2 {
return CommandResult::error("Usage: /plugin info <name>".to_string());
}
cmd_info(parts[1])
}
_ => CommandResult::error(format!(
"Unknown subcommand '{}'. Available: list, enable <name>, disable <name>, info <name>",
parts[0]
)),
}
}

fn cmd_list() -> CommandResult {
let summary = plugins::with_registry(|r| {
let list = r.list();
if list.is_empty() {
return "No plugins found.".to_string();
}
let mut out = String::new();
out.push_str(&format!("Plugins ({}):\n", list.len()));
for p in &list {
let status = if p.enabled { "✅" } else { "⬜" };
let source = if p.source == "builtin" { "📦" } else { "👤" };
let skills_info = if p.skill_count > 0 {
format!(" ({} skills)", p.skill_count)
} else {
String::new()
};
out.push_str(&format!(" {status} {source} **{}**{} — {}\n", p.name, skills_info, p.description));
}
out
});
CommandResult::message(summary)
}

fn cmd_enable(name: &str) -> CommandResult {
let result = plugins::with_registry_mut(|r| r.enable(name));
match result {
Ok(()) => CommandResult::message(format!("Plugin '{name}' enabled ✅")),
Err(e) => CommandResult::error(e),
}
}

fn cmd_disable(name: &str) -> CommandResult {
let result = plugins::with_registry_mut(|r| r.disable(name));
match result {
Ok(()) => CommandResult::message(format!("Plugin '{name}' disabled ⬜")),
Err(e) => CommandResult::error(e),
}
}

fn cmd_info(name: &str) -> CommandResult {
plugins::with_registry(|r| {
let list = r.list();
let plugin = list.into_iter().find(|s| s.name == name);
match plugin {
Some(p) => {
let status = if p.enabled { "✅ enabled" } else { "⬜ disabled" };
let mut out = format!(
"**{}** — {}\n- Status: {}\n- Source: {}\n",
p.name, p.description, status, p.source
);
if let Some(ref v) = p.version {
out.push_str(&format!("- Version: {v}\n"));
}
if p.skill_count > 0 {
out.push_str(&format!("- Skills: {} skill(s)\n", p.skill_count));
}
CommandResult::message(out)
}
None => CommandResult::error(format!("Plugin '{name}' not found")),
}
})
}

19 changes: 15 additions & 4 deletions crates/tui/src/commands/groups/utility/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ mod mcp;
mod network;
mod task;

use crate::commands::CommandResult;
use crate::commands::CommandResult;use crate::commands::groups::plugins::plugin_command as plugin_cmd;
use crate::commands::traits::{Command, CommandGroup, CommandInfo, FunctionCommand};
use crate::localization::MessageId;
use crate::tui::app::App;
Expand All @@ -22,7 +22,7 @@ impl CommandGroup for UtilityCommands {
Box::new(FunctionCommand::new(&JOBS_INFO, run_jobs)),
Box::new(FunctionCommand::new(&MCP_INFO, run_mcp)),
Box::new(FunctionCommand::new(&NETWORK_INFO, run_network)),
Box::new(FunctionCommand::new(&PLUGINS_INFO, run_plugins)),
Box::new(FunctionCommand::new(&PLUGINS_INFO, run_plugins)), Box::new(FunctionCommand::new(&PLUGIN_INFO, run_plugin)),
]
}
}
Expand Down Expand Up @@ -57,9 +57,15 @@ static NETWORK_INFO: CommandInfo = CommandInfo {
usage: "/network [list|allow <host>|deny <host>|remove <host>|default <allow|deny|prompt>]",
description_id: MessageId::CmdNetworkDescription,
};
static PLUGIN_INFO: CommandInfo = CommandInfo {
name: "plugin",
aliases: &[],
usage: "/plugin list|enable <name>|disable <name>|info <name>",
description_id: MessageId::CmdPluginDescription,
};
static PLUGINS_INFO: CommandInfo = CommandInfo {
name: "plugins",
aliases: &["plugin"],
aliases: &[], // "plugin" is now a separate command
usage: "/plugins [name]",
description_id: MessageId::CmdPluginDescription,
};
Expand All @@ -83,6 +89,9 @@ fn run_mcp(app: &mut App, arg: Option<&str>) -> CommandResult {
fn run_network(app: &mut App, arg: Option<&str>) -> CommandResult {
run_registered(app, "network", arg)
}
fn run_plugin(app: &mut App, arg: Option<&str>) -> CommandResult {
plugin_cmd(app, arg)
}
fn run_plugins(app: &mut App, arg: Option<&str>) -> CommandResult {
run_registered(app, "plugins", arg)
}
Expand All @@ -98,8 +107,10 @@ pub(in crate::commands) fn dispatch(
"jobs" | "job" | "zuoye" => jobs::jobs(app, arg),
"mcp" => mcp::mcp(app, arg),
"network" => network::network(app, arg),
"plugins" | "plugin" => crate::commands::plugins::plugins(app, arg),
"plugins" => crate::commands::plugins::plugins(app, arg),
"plugin" => plugin_cmd(app, arg),
_ => return None,
};
Some(result)
}

13 changes: 13 additions & 0 deletions crates/tui/src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2249,6 +2249,10 @@ pub struct Config {
#[serde(default)]
pub vision_model: Option<VisionModelConfig>,

/// Plugin system configuration.
#[serde(default)]
pub plugins: Option<PluginsConfig>,

/// Sibling `permissions.toml` ask-rules compiled for runtime checks.
///
/// This is deliberately not part of `config.toml`; it is loaded from the
Expand Down Expand Up @@ -2529,6 +2533,14 @@ impl SkillsConfig {
}
}

/// `[plugins]` table — user-level plugin configuration.
#[derive(Debug, Clone, Deserialize)]
pub struct PluginsConfig {
/// List of plugin names to forcibly disable.
#[serde(default)]
pub disabled: Option<Vec<String>>,
}

/// `[network]` table — mirrors `codewhale_config::NetworkPolicyToml` so the live
/// TUI runtime can construct a [`crate::network_policy::NetworkPolicy`]
/// without reaching into the workspace config crate. See `config.example.toml`
Expand Down Expand Up @@ -5527,6 +5539,7 @@ fn merge_config(base: Config, override_cfg: Config) -> Config {
provider: override_cfg.provider.or(base.provider),
api_key: override_cfg.api_key.or(base.api_key),
base_url: override_cfg.base_url.or(base.base_url),
plugins: override_cfg.plugins.or(base.plugins),
http_headers: override_cfg.http_headers.or(base.http_headers),
default_text_model: override_cfg.default_text_model.or(base.default_text_model),
auth_mode: override_cfg.auth_mode.or(base.auth_mode),
Expand Down
4 changes: 3 additions & 1 deletion crates/tui/src/main.rs
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
//! CLI entry point for CodeWhale.
//! CLI entry point for CodeWhale.

#![allow(clippy::uninlined_format_args)]

Expand Down Expand Up @@ -61,6 +61,7 @@ mod models;
mod network_policy;
mod oauth;
mod palette;
mod plugins;
mod prefix_cache;
mod pricing;
mod project_context;
Expand Down Expand Up @@ -1289,6 +1290,7 @@ async fn main() -> Result<()> {
// for follow-up messages. Use `codewhale exec` for explicit non-interactive
// one-shot behavior (#2370).
let config = load_config_from_cli(&cli)?;
crate::plugins::init_registry(&[]);
if let Some(initial_input) = top_level_prompt_initial_input(&cli.prompt) {
return run_interactive(&cli, &config, None, Some(initial_input)).await;
}
Expand Down
16 changes: 15 additions & 1 deletion crates/tui/src/mcp.rs
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ pub mod oauth;
use self::headers::{apply_safe_custom_headers, with_default_mcp_http_headers};
use crate::child_env;
use crate::network_policy::{Decision, NetworkPolicyDecider, host_from_url};
use crate::plugins;
use crate::utils::write_atomic;

// === Error diagnostics helpers (#71) ===
Expand Down Expand Up @@ -2014,6 +2015,17 @@ pub struct McpPool {
last_mtimes: Vec<Option<std::time::SystemTime>>,
}

/// Merge plugin-provided MCP servers into the pool config.
/// Plugin servers are added only when no user-configured server with the
/// same name exists, giving user config (global + workspace) priority.
fn merge_plugin_mcp_servers(config: &mut McpConfig) {
if let Some(servers) = plugins::try_with_registry(|r| r.enabled_mcp_servers()) {
for (name, server_config) in servers {
config.servers.entry(name).or_insert(server_config);
}
}
}

impl McpPool {
/// Create a new pool with the given configuration
pub fn new(config: McpConfig) -> Self {
Expand Down Expand Up @@ -2047,8 +2059,9 @@ impl McpPool {
workspace: &Path,
) -> Result<Self> {
let config = load_config_with_workspace(path, workspace)?;
let workspace = checked_workspace_path(workspace)?;
let mut pool = Self::new(config);
merge_plugin_mcp_servers(&mut pool.config);
let workspace = checked_workspace_path(workspace)?;
pool.config_sources = vec![
path.to_path_buf(),
checked_workspace_mcp_config_path(&workspace)?,
Expand Down Expand Up @@ -2142,6 +2155,7 @@ impl McpPool {
// get_or_connect picks up the new config (sandbox flags, env, args).
self.drop_all_connections("config reload");
self.config = new_config;
merge_plugin_mcp_servers(&mut self.config);
self.config_hash = new_hash;
Ok(true)
}
Expand Down
Loading