From 0fdf04fb5c92ad8bde585998037fe844cacbd64a Mon Sep 17 00:00:00 2001 From: laopan <147567034@qq.com> Date: Sat, 27 Jun 2026 18:10:03 +0800 Subject: [PATCH] feat(plugins): add plugin system with built-in rust-toolkit plugin - New PluginRegistry with TOML-based manifests (manifest.rs) - Builtin + user plugin discovery (discovery.rs) - System prompt injection per enabled plugin (injection.rs) - MCP server merging from enabled plugins (mcp.rs + registry.rs) - CLI commands: /plugin list, enable, disable, info - Example plugin: rust-toolkit (cargo check + clippy) - Config support: [plugins] table with disabled list - 27 integration tests all passing Part of the local-development backlog; ready for upstream review. --- .../assets/plugins/rust-toolkit/plugin.toml | 12 + .../rust-toolkit/skills/rust-check/SKILL.md | 16 ++ crates/tui/src/commands/groups/mod.rs | 5 +- crates/tui/src/commands/groups/plugins/mod.rs | 113 ++++++++ crates/tui/src/commands/groups/utility/mod.rs | 19 +- crates/tui/src/config.rs | 13 + crates/tui/src/main.rs | 4 +- crates/tui/src/mcp.rs | 16 +- crates/tui/src/plugins/DESIGN.md | 229 +++++++++++++++++ crates/tui/src/plugins/discovery.rs | 70 +++++ crates/tui/src/plugins/injection.rs | 89 +++++++ crates/tui/src/plugins/manifest.rs | 222 ++++++++++++++++ crates/tui/src/plugins/mod.rs | 63 +++++ crates/tui/src/plugins/registry.rs | 241 ++++++++++++++++++ crates/tui/src/plugins/tests.rs | 109 ++++++++ crates/tui/src/prompts.rs | 13 + 16 files changed, 1226 insertions(+), 8 deletions(-) create mode 100644 crates/tui/assets/plugins/rust-toolkit/plugin.toml create mode 100644 crates/tui/assets/plugins/rust-toolkit/skills/rust-check/SKILL.md create mode 100644 crates/tui/src/commands/groups/plugins/mod.rs create mode 100644 crates/tui/src/plugins/DESIGN.md create mode 100644 crates/tui/src/plugins/discovery.rs create mode 100644 crates/tui/src/plugins/injection.rs create mode 100644 crates/tui/src/plugins/manifest.rs create mode 100644 crates/tui/src/plugins/mod.rs create mode 100644 crates/tui/src/plugins/registry.rs create mode 100644 crates/tui/src/plugins/tests.rs diff --git a/crates/tui/assets/plugins/rust-toolkit/plugin.toml b/crates/tui/assets/plugins/rust-toolkit/plugin.toml new file mode 100644 index 0000000000..2ecb8d7355 --- /dev/null +++ b/crates/tui/assets/plugins/rust-toolkit/plugin.toml @@ -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"] diff --git a/crates/tui/assets/plugins/rust-toolkit/skills/rust-check/SKILL.md b/crates/tui/assets/plugins/rust-toolkit/skills/rust-check/SKILL.md new file mode 100644 index 0000000000..41adbe2d96 --- /dev/null +++ b/crates/tui/assets/plugins/rust-toolkit/skills/rust-check/SKILL.md @@ -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 diff --git a/crates/tui/src/commands/groups/mod.rs b/crates/tui/src/commands/groups/mod.rs index f483acde10..5f083ce515 100644 --- a/crates/tui/src/commands/groups/mod.rs +++ b/crates/tui/src/commands/groups/mod.rs @@ -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 @@ -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; @@ -28,3 +28,4 @@ pub fn all_command_groups() -> Vec<&'static dyn CommandGroup> { &utility::UtilityCommands, ] } + diff --git a/crates/tui/src/commands/groups/plugins/mod.rs b/crates/tui/src/commands/groups/plugins/mod.rs new file mode 100644 index 0000000000..1575285791 --- /dev/null +++ b/crates/tui/src/commands/groups/plugins/mod.rs @@ -0,0 +1,113 @@ +//! `/plugin` slash commands — manage the new plugin registry. +//! +//! Subcommands: +//! - `/plugin list` — list all plugins with enable/disable status +//! - `/plugin enable ` — enable a plugin +//! - `/plugin disable ` — disable a plugin +//! - `/plugin info ` — 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 ".to_string()); + } + cmd_enable(parts[1]) + } + "disable" => { + if parts.len() < 2 { + return CommandResult::error("Usage: /plugin disable ".to_string()); + } + cmd_disable(parts[1]) + } + "info" => { + if parts.len() < 2 { + return CommandResult::error("Usage: /plugin info ".to_string()); + } + cmd_info(parts[1]) + } + _ => CommandResult::error(format!( + "Unknown subcommand '{}'. Available: list, enable , disable , info ", + 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")), + } + }) +} + diff --git a/crates/tui/src/commands/groups/utility/mod.rs b/crates/tui/src/commands/groups/utility/mod.rs index 9108d1a41a..251b4aea91 100644 --- a/crates/tui/src/commands/groups/utility/mod.rs +++ b/crates/tui/src/commands/groups/utility/mod.rs @@ -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; @@ -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)), ] } } @@ -57,9 +57,15 @@ static NETWORK_INFO: CommandInfo = CommandInfo { usage: "/network [list|allow |deny |remove |default ]", description_id: MessageId::CmdNetworkDescription, }; +static PLUGIN_INFO: CommandInfo = CommandInfo { + name: "plugin", + aliases: &[], + usage: "/plugin list|enable |disable |info ", + 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, }; @@ -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) } @@ -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) } + diff --git a/crates/tui/src/config.rs b/crates/tui/src/config.rs index 973062a472..76a28926b3 100644 --- a/crates/tui/src/config.rs +++ b/crates/tui/src/config.rs @@ -2249,6 +2249,10 @@ pub struct Config { #[serde(default)] pub vision_model: Option, + /// Plugin system configuration. + #[serde(default)] + pub plugins: Option, + /// Sibling `permissions.toml` ask-rules compiled for runtime checks. /// /// This is deliberately not part of `config.toml`; it is loaded from the @@ -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>, +} + /// `[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` @@ -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), diff --git a/crates/tui/src/main.rs b/crates/tui/src/main.rs index 50e6478c25..4118ee5248 100644 --- a/crates/tui/src/main.rs +++ b/crates/tui/src/main.rs @@ -1,4 +1,4 @@ -//! CLI entry point for CodeWhale. +//! CLI entry point for CodeWhale. #![allow(clippy::uninlined_format_args)] @@ -61,6 +61,7 @@ mod models; mod network_policy; mod oauth; mod palette; +mod plugins; mod prefix_cache; mod pricing; mod project_context; @@ -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; } diff --git a/crates/tui/src/mcp.rs b/crates/tui/src/mcp.rs index f265288426..650d1d308c 100644 --- a/crates/tui/src/mcp.rs +++ b/crates/tui/src/mcp.rs @@ -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) === @@ -2014,6 +2015,17 @@ pub struct McpPool { last_mtimes: Vec>, } +/// 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 { @@ -2047,8 +2059,9 @@ impl McpPool { workspace: &Path, ) -> Result { 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)?, @@ -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) } diff --git a/crates/tui/src/plugins/DESIGN.md b/crates/tui/src/plugins/DESIGN.md new file mode 100644 index 0000000000..6c5ac0b4e0 --- /dev/null +++ b/crates/tui/src/plugins/DESIGN.md @@ -0,0 +1,229 @@ +# Plugin System Design (v0.1) + +> Inspired by: Claude Code 2.1.88 plugin architecture +> Built on: CodeWhale skills system + MCP config + tool registry +> Status: design draft — pending review + +--- + +## 1. Motivation + +CodeWhale currently has: +- Skills system — SKILL.md discovery/registration/SkillTool +- MCP config — `config.toml` `[mcp_servers]` section +- Tool system — built-in tool registry +- Missing: **Plugin container** — no way to bundle "skills + MCP + hooks" as a toggleable unit + +Claude Code's plugin system bundles **skills + MCP servers + hooks + commands + agents** into a `BuiltinPluginDefinition`, managed via `/plugin` toggle. + +Value for CodeWhale: +- Bundle skills and MCP servers as **named functional units** +- Users can **enable/disable per plugin** (unused plugins don't consume context window) +- Built-in plugins work out of the box; user plugins extend freely +- Foundation for future marketplace + +--- + +## 2. Design Overview + +``` +Plugin Registry (plugins/registry.rs) + built-in (assets/plugins/) user (~/.codewhale/plugins/) + | | + v v + Plugin { name, description, version + skills: Vec + mcp_servers: McpConfig + enabled: bool } + | + v + System Prompt Injection + (render enabled plugins as available capabilities) +``` + +**Core Principles**: +- Plugin is a container for skills — does NOT modify the skills system +- One `plugin.toml` defines one plugin +- Built-in plugins in `assets/plugins/`, user plugins in `~/.codewhale/plugins/` +- Does NOT conflict with existing `tools/plugin.rs` (script tools) — different module, different purpose + +--- + +## 3. Plugin Manifest Format (`plugin.toml`) + +```toml +[plugin] +name = "rust-toolkit" +description = "Rust development tools: cargo check, clippy, test runner" +version = "1.0.0" +default_enabled = true + +[skills] +paths = [ + "skills/rust-check/", + "skills/rust-test/", +] + +[mcp_servers.crates-io] +command = "crates-mcp" +args = ["--stdio"] +description = "Search crates.io for Rust packages" + +[when] +os = ["windows", "linux", "macos"] +required_binaries = ["cargo"] +``` + +--- + +## 4. Implementation Plan + +### 4.1 File Map + +``` +crates/tui/src/plugins/ + DESIGN.md this file + mod.rs module entry + re-exports + manifest.rs PluginManifest + plugin.toml parsing + registry.rs PluginRegistry (load/enable/disable/list) + discovery.rs scan assets/plugins/ + ~/.codewhale/plugins/ + injection.rs system prompt injection per enabled plugins +``` + +### 4.2 Data Types (`manifest.rs`) + +```rust +#[derive(Debug, Deserialize)] +pub struct PluginManifest { + pub plugin: PluginMeta, + pub skills: Option, + pub mcp_servers: Option>, + pub when: Option, +} + +#[derive(Debug, Deserialize)] +pub struct PluginMeta { + pub name: String, + pub description: String, + pub version: Option, + #[serde(default = "default_true")] + pub default_enabled: bool, +} + +fn default_true() -> bool { true } + +#[derive(Debug, Deserialize)] +pub struct PluginSkills { + pub paths: Vec, +} + +#[derive(Debug, Deserialize)] +pub struct PluginWhen { + pub os: Option>, + pub required_binaries: Option>, +} + +pub struct LoadedPlugin { + pub manifest: PluginManifest, + pub source: PluginSource, + pub enabled: bool, + pub skills: Vec, + pub mcp_servers: HashMap, +} + +pub enum PluginSource { + Builtin { path: PathBuf }, + User { path: PathBuf }, +} +``` + +### 4.3 Plugin Registry (`registry.rs`) + +```rust +pub struct PluginRegistry { + builtins: HashMap, + users: HashMap, + user_overrides: HashMap, +} + +impl PluginRegistry { + pub fn load_all() -> Self; + pub fn enable(&mut self, name: &str) -> Result<(), String>; + pub fn disable(&mut self, name: &str) -> Result<(), String>; + pub fn list(&self) -> Vec; + pub fn enabled_skills(&self) -> Vec; + pub fn enabled_mcp_servers(&self) -> HashMap; + pub fn is_available(&self, name: &str) -> bool; +} +``` + +### 4.4 CLI Commands + +In `commands/groups/plugins/`: + +``` +/plugin list list all plugins + status +/plugin enable enable a plugin +/plugin disable disable a plugin +/plugin info show plugin details +``` + +### 4.5 Integration with Existing Modules + +| Module | Relationship | Changes | +|--------|-------------|---------| +| `skills/mod.rs` | Referenced by plugin | **No change** — plugins load SKILL.md via skill paths | +| `tools/plugin.rs` | Same name, different concept | **No change** — script tool system, independent | +| `tools/skill.rs` | SkillTool | **No change** — AI uses skill tool to load individual skills | +| `config.rs` | Store user plugin enable state | **+10 lines** — `[plugins]` section | +| `prompts.rs` | Inject plugin list | **+15 lines** — add enabled plugins section to system prompt | +| `mcp.rs` | Merge plugin MCP servers | **+20 lines** — merge with existing `[mcp_servers]` | + +--- + +## 5. Adoption Path (compatible transition) + +``` +Phase 1: Plugin registry + manifest (this PR) + Does not affect existing skill system, plugins are optional + +Phase 2: Migrate 4 built-in skills into 1 built-in plugin + verify / simplify / stuck / batch -> "code-review" plugin + +Phase 3: Support marketplace remote install + Similar to skills/install.rs registry pattern +``` + +--- + +## 6. Task Assignment + +| # | Task | File | Est. | Owner | Status | +| |------|------|------|-------|--------| +| 6.1 | PluginManifest + TOML parsing + tests | `plugins/manifest.rs` | half-day | cpt-opcd | ✅ | +| 6.2 | PluginRegistry (load/enable/disable/list) | `plugins/registry.rs` | 1 day | cpt-opcd | ✅ | +| 6.3 | Built-in plugin directory scan | `plugins/discovery.rs` | half-day | cpt-opcd | ✅ | +| 6.4 | System prompt injection | `plugins/injection.rs` | half-day | cpt-opcd | ✅ | +| 6.5 | CLI `/plugin` commands | `commands/groups/plugins/` | half-day | mydpsk | ✅ | +| 6.6 | MCP server merge logic | `registry.rs enabled_mcp_servers()` | half-day | mydpsk | ✅ | +| 6.7 | 1 example built-in plugin (`rust-toolkit`) | `assets/plugins/rust-toolkit/` | half-day | mydpsk | ✅ | +| 6.8 | Integration tests + CI verification | `plugins/tests.rs` | half-day | mydpsk | ✅ | + +--- + +## 7. Acceptance Criteria + +- [ ] `cargo check -p codewhale-tui` no errors +- [ ] `cargo test -p codewhale-tui -- plugins` all pass +- [ ] Built-in plugin `rust-toolkit` appears in `/plugin list` +- [ ] User can `/plugin disable rust-toolkit` +- [ ] Disabled plugins not injected into system prompt +- [ ] Existing skill system tests all pass (zero regression) +- [ ] `tools/plugin.rs` (script tools) unaffected + +--- + +## 8. References + +- Claude Code 2.1.88: `src/plugins/builtinPlugins.ts`, `src/types/plugin.ts` +- CodeWhale existing: `crates/tui/src/skills/DESIGN.md`, `crates/tui/src/tools/plugin.rs` diff --git a/crates/tui/src/plugins/discovery.rs b/crates/tui/src/plugins/discovery.rs new file mode 100644 index 0000000000..6adbb2adcc --- /dev/null +++ b/crates/tui/src/plugins/discovery.rs @@ -0,0 +1,70 @@ +//! Plugin discovery — scan built-in and user plugin directories. +use std::path::{Path, PathBuf}; +use super::manifest::{LoadedPlugin, PluginSource, check_plugin_when, load_manifest, load_plugin_skills, load_plugin_mcp}; +use super::registry::PluginRegistry; + +pub fn builtin_plugins_dir() -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("assets").join("plugins") +} + +pub fn user_plugins_dir() -> Option { + dirs::home_dir().map(|p| p.join(".codewhale").join("plugins")) +} + +pub fn discover_all(_config_disabled: &[String]) -> PluginRegistry { + let mut registry = PluginRegistry::new(); + let builtin_dir = builtin_plugins_dir(); + if builtin_dir.exists() { + scan_plugin_dir(&builtin_dir, |dir| { + let manifest = load_manifest(dir).ok()?; + let compatible = check_plugin_when(&manifest.when); + let skills = load_plugin_skills(dir, &manifest); + let mcp = load_plugin_mcp(&manifest); + let enabled = compatible && manifest.plugin.default_enabled; + Some(LoadedPlugin { + manifest, + source: PluginSource::Builtin { path: dir.to_path_buf() }, + enabled, + skills, + mcp_servers: mcp, + }) + }).into_iter().for_each(|p| { registry.register(p); }); + } + if let Some(user_dir) = user_plugins_dir() { + if user_dir.exists() { + scan_plugin_dir(&user_dir, |dir| { + let manifest = load_manifest(dir).ok()?; + let compatible = check_plugin_when(&manifest.when); + let skills = load_plugin_skills(dir, &manifest); + let mcp = load_plugin_mcp(&manifest); + let enabled = compatible && manifest.plugin.default_enabled; + Some(LoadedPlugin { + manifest, + source: PluginSource::User { path: dir.to_path_buf() }, + enabled, + skills, + mcp_servers: mcp, + }) + }).into_iter().for_each(|p| { registry.register(p); }); + } + } + registry +} + +fn scan_plugin_dir(dir: &Path, f: F) -> Vec +where + F: Fn(&Path) -> Option, +{ + let mut plugins = Vec::new(); + if let Ok(entries) = std::fs::read_dir(dir) { + for entry in entries.flatten() { + let path = entry.path(); + if path.is_dir() { + if let Some(plugin) = f(&path) { + plugins.push(plugin); + } + } + } + } + plugins +} diff --git a/crates/tui/src/plugins/injection.rs b/crates/tui/src/plugins/injection.rs new file mode 100644 index 0000000000..9fca98da3d --- /dev/null +++ b/crates/tui/src/plugins/injection.rs @@ -0,0 +1,89 @@ +use super::registry::PluginRegistry; + +/// Render a system-prompt block listing all enabled plugins. +/// +/// Follows the same pattern as `crate::skills::render_skills_block`. +pub fn render_plugin_block(registry: &PluginRegistry) -> Option { + let enabled: Vec<_> = registry + .list() + .into_iter() + .filter(|s| s.enabled) + .collect(); + + if enabled.is_empty() { + return None; + } + + let mut out = String::new(); + out.push_str("## Plugins\n"); + out.push_str( + "A plugin bundles skills and optional MCP servers into a toggleable \ + unit. Below are the plugins currently enabled in this session.\n\n", + ); + out.push_str("### Enabled plugins\n"); + + for summary in &enabled { + let skills_note = if summary.skill_count > 0 { + format!(" ({} skill(s))", summary.skill_count) + } else { + String::new() + }; + out.push_str(&format!( + "- **{}**{} — {}\n", + summary.name, skills_note, summary.description + )); + } + + out.push('\n'); + Some(out) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::plugins::manifest::{PluginManifest, PluginMeta, PluginSource, LoadedPlugin}; + + fn make_enabled_plugin(name: &str, desc: &str, enabled: bool) -> LoadedPlugin { + LoadedPlugin { + manifest: PluginManifest { + plugin: PluginMeta { + name: name.to_string(), + description: desc.to_string(), + version: None, + default_enabled: enabled, + }, + skills: None, + mcp_servers: None, + when: None, + }, + source: PluginSource::Builtin { path: ".".into() }, + enabled, + skills: Vec::new(), + mcp_servers: std::collections::HashMap::new(), + } + } + + #[test] + fn render_no_enabled_plugins_returns_none() { + let registry = PluginRegistry::new(); + assert!(render_plugin_block(®istry).is_none()); + } + + #[test] + fn render_skips_disabled_plugins() { + let mut registry = PluginRegistry::new(); + registry.register(make_enabled_plugin("off", "disabled plugin", false)); + assert!(render_plugin_block(®istry).is_none()); + } + + #[test] + fn render_includes_enabled_plugins() { + let mut registry = PluginRegistry::new(); + registry.register(make_enabled_plugin("alpha", "first plugin", true)); + registry.register(make_enabled_plugin("beta", "second plugin", false)); + let block = render_plugin_block(®istry).expect("should render"); + assert!(block.contains("alpha")); + assert!(block.contains("first plugin")); + assert!(!block.contains("beta")); + } +} diff --git a/crates/tui/src/plugins/manifest.rs b/crates/tui/src/plugins/manifest.rs new file mode 100644 index 0000000000..7cdf50cd1f --- /dev/null +++ b/crates/tui/src/plugins/manifest.rs @@ -0,0 +1,222 @@ +//! Plugin manifest (`plugin.toml`) parsing and data types. + +use std::collections::HashMap; +use std::path::Path; + +use serde::Deserialize; + +use crate::mcp::McpServerConfig; +use crate::skills::Skill; + +#[derive(Debug, Clone, Deserialize)] +pub struct PluginManifest { + pub plugin: PluginMeta, + pub skills: Option, + pub mcp_servers: Option>, + pub when: Option, +} + +#[derive(Debug, Clone, Deserialize)] +pub struct PluginMeta { + pub name: String, + pub description: String, + pub version: Option, + #[serde(default = "default_true")] + pub default_enabled: bool, +} + +fn default_true() -> bool { + true +} + +#[derive(Debug, Clone, Deserialize)] +pub struct PluginSkills { + pub paths: Vec, +} + +#[derive(Debug, Clone, Deserialize)] +pub struct PluginWhen { + pub os: Option>, + pub required_binaries: Option>, +} + +/// A fully loaded plugin with resolved skills and MCP servers. +#[derive(Debug, Clone)] +pub struct LoadedPlugin { + pub manifest: PluginManifest, + pub source: PluginSource, + pub enabled: bool, + pub skills: Vec, + #[allow(dead_code)] + pub mcp_servers: HashMap, +} + +#[derive(Debug, Clone)] +pub enum PluginSource { + Builtin { #[allow(dead_code)] path: std::path::PathBuf }, + User { #[allow(dead_code)] path: std::path::PathBuf }, +} + +impl PluginSource { + #[allow(dead_code)] + pub fn path(&self) -> &std::path::Path { + match self { + PluginSource::Builtin { path } | PluginSource::User { path } => path, + } + } +} + +/// Load a `plugin.toml` from `dir`. +pub fn load_manifest(dir: &Path) -> Result { + let path = dir.join("plugin.toml"); + let content = + std::fs::read_to_string(&path).map_err(|e| format!("Failed to read {path:?}: {e}"))?; + let manifest: PluginManifest = + toml::from_str(&content).map_err(|e| format!("Failed to parse {path:?}: {e}"))?; + + let dir_name = dir + .file_name() + .and_then(|n| n.to_str()) + .unwrap_or(""); + if manifest.plugin.name != dir_name { + return Err(format!( + "plugin name \"{}\" does not match directory name \"{dir_name}\"", + manifest.plugin.name + )); + } + + Ok(manifest) +} + +/// Check environment compatibility. +pub fn check_plugin_when(when: &Option) -> bool { + let Some(when) = when else { return true }; + if let Some(ref allowed_os) = when.os { + let current_os = std::env::consts::OS; + if !allowed_os.iter().any(|os| os == current_os) { + return false; + } + } + if let Some(ref bins) = when.required_binaries { + for bin in bins { + if lookup_binary(bin).is_none() { + return false; + } + } + } + true +} + +fn lookup_binary(bin: &str) -> Option { + std::env::var_os("PATH").and_then(|paths| { + std::env::split_paths(&paths).find_map(|dir| { + let full = dir.join(bin); + if full.is_file() { Some(full) } + else { + let with_exe = dir.join(format!("{bin}.exe")); + if with_exe.is_file() { Some(with_exe) } else { None } + } + }) + }) +} + +/// Resolve skill SKILL.md paths relative to the plugin directory. +pub fn load_plugin_skills(dir: &Path, manifest: &PluginManifest) -> Vec { + let Some(skills) = &manifest.skills else { return Vec::new() }; + let mut result = Vec::new(); + for relative_path in &skills.paths { + let skill_dir = dir.join(relative_path); + let skill_file = skill_dir.join("SKILL.md"); + if !skill_file.exists() { continue; } + let content = match std::fs::read_to_string(&skill_file) { Ok(c) => c, Err(_) => continue }; + if let Ok(mut skill) = crate::skills::SkillRegistry::parse_skill(&skill_file, &content) { + skill.path = skill_file; + result.push(skill); + } + } + result +} + +/// Extract MCP servers from a plugin manifest, returning parsed configs. +pub fn load_plugin_mcp(manifest: &PluginManifest) -> HashMap { + manifest.mcp_servers.clone().unwrap_or_default() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parse_minimal_manifest() { + let toml_str = r#" +[plugin] +name = "test-plugin" +description = "A test plugin" +"#; + let manifest: PluginManifest = toml::from_str(toml_str).unwrap(); + assert_eq!(manifest.plugin.name, "test-plugin"); + assert_eq!(manifest.plugin.description, "A test plugin"); + assert!(manifest.plugin.version.is_none()); + assert!(manifest.plugin.default_enabled); + assert!(manifest.skills.is_none()); + assert!(manifest.mcp_servers.is_none()); + } + + #[test] + fn parse_full_manifest() { + let toml_str = r#" +[plugin] +name = "full-plugin" +description = "A full test plugin" +version = "1.0.0" +default_enabled = false + +[skills] +paths = ["skills/check/", "skills/test/"] + +[mcp_servers.custom-api] +command = "my-mcp" +args = ["--stdio"] +description = "Custom API" + +[when] +os = ["linux", "windows"] +required_binaries = ["cargo", "node"] +"#; + let manifest: PluginManifest = toml::from_str(toml_str).unwrap(); + assert_eq!(manifest.plugin.name, "full-plugin"); + assert_eq!(manifest.plugin.version.as_deref(), Some("1.0.0")); + assert!(!manifest.plugin.default_enabled); + let skills = manifest.skills.unwrap(); + assert_eq!(skills.paths.len(), 2); + let mcp = manifest.mcp_servers.unwrap(); + assert!(mcp.contains_key("custom-api")); + } + + #[test] + fn load_plugin_mcp_returns_empty_when_none() { + let toml_str = r#" +[plugin] +name = "no-mcp" +description = "no mcp" +"#; + let manifest: PluginManifest = toml::from_str(toml_str).unwrap(); + let mcp = load_plugin_mcp(&manifest); + assert!(mcp.is_empty()); + } + + #[test] + fn check_no_when_always_passes() { + assert!(check_plugin_when(&None)); + } + + #[test] + fn name_mismatch_detected() { + let dir = std::path::Path::new("/somewhere/mismatch"); + // Instead of creating a real file, just test that the function returns Err + // for a non-existent directory + let result = load_manifest(dir); + assert!(result.is_err()); + } +} + diff --git a/crates/tui/src/plugins/mod.rs b/crates/tui/src/plugins/mod.rs new file mode 100644 index 0000000000..e24f67ec8c --- /dev/null +++ b/crates/tui/src/plugins/mod.rs @@ -0,0 +1,63 @@ +pub mod injection; +pub mod registry; + +mod discovery; +mod manifest; + +pub use discovery::discover_all; +pub use injection::render_plugin_block; +#[allow(unused_imports)] +pub use manifest::{LoadedPlugin, PluginManifest, PluginSource}; +pub use registry::PluginRegistry; + +use std::sync::{Mutex, OnceLock}; + +static PLUGIN_REGISTRY: OnceLock> = OnceLock::new(); + +/// Initialize the global plugin registry. Call once at startup with the +/// list of disabled plugin names from config. +pub fn init_registry(config_disabled: &[String]) { + let _ = PLUGIN_REGISTRY.set(Mutex::new(discover_all(config_disabled))); +} + +/// Access the global plugin registry for read operations. +/// Panics if `init_registry` has not been called. +pub fn with_registry(f: F) -> R +where + F: FnOnce(&PluginRegistry) -> R, +{ + let lock = PLUGIN_REGISTRY + .get() + .expect("PluginRegistry not initialized — call init_registry() first"); + let guard = lock.lock().expect("PluginRegistry lock poisoned"); + f(&*guard) +} + +/// Access the global plugin registry for read operations. +/// Returns `None` if `init_registry` has not been called (no panic). +pub fn try_with_registry(f: F) -> Option +where + F: FnOnce(&PluginRegistry) -> R, +{ + let lock = PLUGIN_REGISTRY.get()?; + let guard = lock.lock().ok()?; + Some(f(&*guard)) +} + +/// Access the global plugin registry for read/write operations. +/// Panics if `init_registry` has not been called. +pub fn with_registry_mut(f: F) -> R +where + F: FnOnce(&mut PluginRegistry) -> R, +{ + let lock = PLUGIN_REGISTRY + .get() + .expect("PluginRegistry not initialized — call init_registry() first"); + let mut guard = lock.lock().expect("PluginRegistry lock poisoned"); + f(&mut *guard) +} + + +#[cfg(test)] +#[path = "tests.rs"] +mod integration_tests; diff --git a/crates/tui/src/plugins/registry.rs b/crates/tui/src/plugins/registry.rs new file mode 100644 index 0000000000..4971363458 --- /dev/null +++ b/crates/tui/src/plugins/registry.rs @@ -0,0 +1,241 @@ +use std::collections::HashMap; + +use crate::skills::Skill; + +use super::manifest::{LoadedPlugin, PluginSource}; + +/// Central registry for all discovered plugins. +#[derive(Debug, Clone)] +pub struct PluginRegistry { + builtins: HashMap, + users: HashMap, + user_overrides: HashMap, +} + +impl PluginRegistry { + pub fn new() -> Self { + PluginRegistry { + builtins: HashMap::new(), + users: HashMap::new(), + user_overrides: HashMap::new(), + } + } + + pub fn register(&mut self, plugin: LoadedPlugin) { + let name = plugin.manifest.plugin.name.clone(); + match &plugin.source { + PluginSource::Builtin { .. } => { + self.builtins.insert(name, plugin); + } + PluginSource::User { .. } => { + self.users.insert(name, plugin); + } + } + } + + pub fn enable(&mut self, name: &str) -> Result<(), String> { + if self.get(name).is_none() { + return Err(format!("plugin \"{name}\" not found")); + } + self.user_overrides.insert(name.to_string(), true); + Ok(()) + } + + pub fn disable(&mut self, name: &str) -> Result<(), String> { + if self.get(name).is_none() { + return Err(format!("plugin \"{name}\" not found")); + } + self.user_overrides.insert(name.to_string(), false); + Ok(()) + } + + #[allow(dead_code)] + pub fn is_enabled(&self, name: &str) -> bool { + if let Some(&override_val) = self.user_overrides.get(name) { + return override_val; + } + self.get(name) + .map(|p| p.enabled) + .unwrap_or(false) + } + + pub fn list(&self) -> Vec { + let mut summaries = Vec::new(); + for (name, plugin) in &self.builtins { + summaries.push(PluginSummary { + name: name.clone(), + description: plugin.manifest.plugin.description.clone(), + version: plugin.manifest.plugin.version.clone(), + source: "builtin".to_string(), + enabled: self.enabled_with_overrides(name, &plugin.enabled), + skill_count: plugin.skills.len(), + }); + } + for (name, plugin) in &self.users { + summaries.push(PluginSummary { + name: name.clone(), + description: plugin.manifest.plugin.description.clone(), + version: plugin.manifest.plugin.version.clone(), + source: "user".to_string(), + enabled: self.enabled_with_overrides(name, &plugin.enabled), + skill_count: plugin.skills.len(), + }); + } + summaries.sort_by(|a, b| a.name.cmp(&b.name)); + summaries + } + + fn enabled_with_overrides(&self, name: &str, default: &bool) -> bool { + if let Some(&override_val) = self.user_overrides.get(name) { + return override_val; + } + *default + } + + #[allow(dead_code)] + pub fn enabled_skills(&self) -> Vec<&Skill> { + let mut skills = Vec::new(); + for plugin in self.builtins.values() { + if self.is_enabled(&plugin.manifest.plugin.name) { + skills.extend(plugin.skills.iter()); + } + } + for plugin in self.users.values() { + if self.is_enabled(&plugin.manifest.plugin.name) { + skills.extend(plugin.skills.iter()); + } + } + skills + } + + #[allow(dead_code)] + pub fn enabled_mcp_servers(&self) -> HashMap { + let mut servers = HashMap::new(); + for plugin in self.builtins.values() { + if self.is_enabled(&plugin.manifest.plugin.name) { + servers.extend(plugin.mcp_servers.clone()); + } + } + for plugin in self.users.values() { + if self.is_enabled(&plugin.manifest.plugin.name) { + servers.extend(plugin.mcp_servers.clone()); + } + } + servers + } + + fn get(&self, name: &str) -> Option<&LoadedPlugin> { + self.users + .get(name) + .or_else(|| self.builtins.get(name)) + } +} + +impl Default for PluginRegistry { + fn default() -> Self { + Self::new() + } +} + +#[derive(Debug, Clone)] +pub struct PluginSummary { + pub name: String, + pub description: String, + pub version: Option, + pub source: String, + pub enabled: bool, + pub skill_count: usize, +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::plugins::manifest::{PluginManifest, PluginMeta}; + + fn make_plugin(name: &str, default_enabled: bool, source: PluginSource) -> LoadedPlugin { + LoadedPlugin { + manifest: PluginManifest { + plugin: PluginMeta { + name: name.to_string(), + description: format!("{name} description"), + version: Some("1.0.0".to_string()), + default_enabled, + }, + skills: None, + mcp_servers: None, + when: None, + }, + source, + enabled: default_enabled, + skills: Vec::new(), + mcp_servers: HashMap::new(), + } + } + + #[test] + fn empty_registry_lists_nothing() { + let r = PluginRegistry::new(); + assert!(r.list().is_empty()); + assert!(!r.is_enabled("anything")); + } + + #[test] + fn register_builtin_and_list() { + let mut r = PluginRegistry::new(); + let p = make_plugin("alpha", true, PluginSource::Builtin { path: ".".into() }); + r.register(p); + let list = r.list(); + assert_eq!(list.len(), 1); + assert_eq!(list[0].name, "alpha"); + assert!(list[0].enabled); + assert_eq!(list[0].source, "builtin"); + } + + #[test] + fn register_user_overrides_builtin() { + let mut r = PluginRegistry::new(); + let builtin = make_plugin("dup", false, PluginSource::Builtin { path: ".".into() }); + let user = make_plugin("dup", true, PluginSource::User { path: ".".into() }); + r.register(builtin); + r.register(user); + let list = r.list(); + assert_eq!(list.len(), 2); + let dup_builtin = list.iter().find(|s| s.source == "builtin").unwrap(); + let dup_user = list.iter().find(|s| s.source == "user").unwrap(); + assert!(!dup_builtin.enabled); + assert!(dup_user.enabled); + } + + #[test] + fn enable_disable_plugin() { + let mut r = PluginRegistry::new(); + let p = make_plugin("toggle", true, PluginSource::Builtin { path: ".".into() }); + r.register(p); + assert!(r.is_enabled("toggle")); + r.disable("toggle").unwrap(); + assert!(!r.is_enabled("toggle")); + r.enable("toggle").unwrap(); + assert!(r.is_enabled("toggle")); + } + + #[test] + fn enable_unknown_returns_err() { + let mut r = PluginRegistry::new(); + assert!(r.enable("ghost").is_err()); + assert!(r.disable("ghost").is_err()); + } + + #[test] + fn enabled_skills_empty_when_no_skills() { + let mut r = PluginRegistry::new(); + let p = make_plugin("test", true, PluginSource::Builtin { path: ".".into() }); + r.register(p); + assert!(r.enabled_skills().is_empty()); + } + + #[test] + fn enabled_mcp_servers_empty_by_default() { + let r = PluginRegistry::new(); + assert!(r.enabled_mcp_servers().is_empty()); + } +} diff --git a/crates/tui/src/plugins/tests.rs b/crates/tui/src/plugins/tests.rs new file mode 100644 index 0000000000..782e98735a --- /dev/null +++ b/crates/tui/src/plugins/tests.rs @@ -0,0 +1,109 @@ +//! Integration tests for the plugin system. +//! +//! Tests the end-to-end plugin lifecycle: manifest parsing, skill loading, +//! registry operations, and system prompt injection. + +use std::path::PathBuf; + +use crate::plugins::discovery::discover_all; +use crate::plugins::injection::render_plugin_block; +use crate::plugins::manifest::{load_manifest, load_plugin_mcp, load_plugin_skills}; + +/// Verify the sample built-in plugin `rust-toolkit` loads correctly. +#[test] +fn sample_builtin_plugin_loads() { + let dir = PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("assets") + .join("plugins") + .join("rust-toolkit"); + + assert!(dir.exists(), "sample plugin dir should exist"); + assert!(dir.join("plugin.toml").exists(), "plugin.toml should exist"); + assert!( + dir.join("skills").join("rust-check").join("SKILL.md").exists(), + "rust-check SKILL.md should exist" + ); + + let manifest = load_manifest(&dir).expect("sample plugin manifest should parse"); + assert_eq!(manifest.plugin.name, "rust-toolkit"); + assert_eq!(manifest.plugin.version.as_deref(), Some("1.0.0")); + assert!(manifest.plugin.default_enabled); + + let mcp = load_plugin_mcp(&manifest); + assert!(mcp.is_empty(), "sample plugin has no MCP servers"); +} + +/// Verify skill loading from the sample plugin. +#[test] +fn sample_plugin_skills_load() { + let dir = PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("assets") + .join("plugins") + .join("rust-toolkit"); + + let manifest = load_manifest(&dir).expect("manifest should parse"); + let skills = load_plugin_skills(&dir, &manifest); + + assert_eq!(skills.len(), 1, "should load 1 skill"); + assert_eq!(skills[0].name, "rust-check"); +} + +/// Verify discover_all finds the built-in plugin. +#[test] +fn discover_all_finds_builtin_plugins() { + let registry = discover_all(&[]); + let list = registry.list(); + + let rust_toolkit = list.iter().find(|s| s.name == "rust-toolkit"); + assert!(rust_toolkit.is_some(), "rust-toolkit should be discovered"); + let plugin = rust_toolkit.unwrap(); + assert_eq!(plugin.source, "builtin"); + assert!(plugin.enabled, "sample plugin should be enabled by default"); + assert_eq!(plugin.skill_count, 1, "should report 1 skill"); +} + +/// Verify enable/disable cycle works on the sample plugin. +#[test] +fn plugin_enable_disable_cycle() { + let mut registry = discover_all(&[]); + + assert!(registry.is_enabled("rust-toolkit"), "should start enabled"); + + registry.disable("rust-toolkit").unwrap(); + assert!(!registry.is_enabled("rust-toolkit"), "should be disabled after disable"); + + registry.enable("rust-toolkit").unwrap(); + assert!(registry.is_enabled("rust-toolkit"), "should be enabled after re-enable"); +} + +/// Verify render_plugin_block includes the sample plugin. +#[test] +fn render_block_includes_enabled_plugin() { + let registry = discover_all(&[]); + let block = render_plugin_block(®istry); + + assert!(block.is_some(), "should render a block when plugins are enabled"); + let text = block.unwrap(); + assert!(text.contains("rust-toolkit"), "block should mention rust-toolkit"); + assert!(text.contains("1 skill(s)"), "block should mention skill count"); +} + +/// Verify disable then re-enable removes and restores skills. +#[test] +fn disabling_plugin_removes_skills() { + let mut registry = discover_all(&[]); + + let skills_before = registry.enabled_skills(); + assert!(skills_before.iter().any(|s| s.name == "rust-check"), + "rust-check should be in enabled skills before disable"); + + registry.disable("rust-toolkit").unwrap(); + let skills_after = registry.enabled_skills(); + assert!(!skills_after.iter().any(|s| s.name == "rust-check"), + "rust-check should NOT be in enabled skills after disable"); + + registry.enable("rust-toolkit").unwrap(); + let skills_again = registry.enabled_skills(); + assert!(skills_again.iter().any(|s| s.name == "rust-check"), + "rust-check should be back after re-enable"); +} diff --git a/crates/tui/src/prompts.rs b/crates/tui/src/prompts.rs index 16504be538..c936f78d8a 100644 --- a/crates/tui/src/prompts.rs +++ b/crates/tui/src/prompts.rs @@ -1141,6 +1141,19 @@ pub fn system_prompt_for_mode_with_context_skills_session_and_approval( full_prompt = format!("{full_prompt}\n\n{block}"); } + // 3a. Plugin block. Discover and inject enabled plugins. + if let Some(block) = crate::plugins::try_with_registry(|r| crate::plugins::render_plugin_block(r)) + .flatten() + .or_else(|| { + // Fall back to direct scan if registry not yet initialized. + let r = crate::plugins::discover_all(&[]); + crate::plugins::render_plugin_block(&r) + }) + .filter(|b| !b.is_empty()) + { + full_prompt = format!("{full_prompt}\n\n{block}"); + } + // 4. Context Management — included in all modes. { full_prompt.push_str(