From f46479ee08cb81c6b3244a97f11645469a4b70d7 Mon Sep 17 00:00:00 2001 From: laopan <147567034@qq.com> Date: Sat, 27 Jun 2026 20:21:46 +0800 Subject: [PATCH 1/4] feat(plugins): core manifest + registry (core) Add plugin manifest parsing, PluginRegistry with enable/disable, and global registry access functions. --- crates/tui/src/plugins/manifest.rs | 212 +++++++++++++++++++++++++ crates/tui/src/plugins/mod.rs | 48 ++++++ crates/tui/src/plugins/registry.rs | 240 +++++++++++++++++++++++++++++ crates/tui/src/plugins/tests.rs | 32 ++++ 4 files changed, 532 insertions(+) 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/src/plugins/manifest.rs b/crates/tui/src/plugins/manifest.rs new file mode 100644 index 000000000..6e4e905ef --- /dev/null +++ b/crates/tui/src/plugins/manifest.rs @@ -0,0 +1,212 @@ +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>, +} + +#[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, + } + } +} + +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) +} + +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 } + } + }) + }) +} + +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 +} + +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"); + 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 000000000..06d2a0f73 --- /dev/null +++ b/crates/tui/src/plugins/mod.rs @@ -0,0 +1,48 @@ +mod manifest; +mod registry; + +pub use manifest::{LoadedPlugin, PluginManifest, PluginSource}; +pub use registry::PluginRegistry; + +use std::sync::{Mutex, OnceLock}; + +static PLUGIN_REGISTRY: OnceLock> = OnceLock::new(); + +pub fn init_registry(config_disabled: &[String]) { + let _ = PLUGIN_REGISTRY.set(Mutex::new(PluginRegistry::new())); +} + +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) +} + +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)) +} + +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 000000000..d7f55c63e --- /dev/null +++ b/crates/tui/src/plugins/registry.rs @@ -0,0 +1,240 @@ +use std::collections::HashMap; + +use crate::skills::Skill; + +use super::manifest::{LoadedPlugin, PluginSource}; + +#[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 000000000..51e50473a --- /dev/null +++ b/crates/tui/src/plugins/tests.rs @@ -0,0 +1,32 @@ +use crate::plugins::manifest::{load_manifest, load_plugin_mcp, PluginManifest}; + +#[test] +fn manifest_parsing_basics() { + 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"); +} + +#[test] +fn load_plugin_mcp_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 load_manifest_fails_for_missing_dir() { + let dir = std::path::Path::new("/nonexistent/plugin/dir"); + let result = load_manifest(dir); + assert!(result.is_err()); +} From e15fbfd87734431a7ff5c8f41210ed1c57a256df Mon Sep 17 00:00:00 2001 From: laopan <147567034@qq.com> Date: Sat, 27 Jun 2026 20:23:48 +0800 Subject: [PATCH 2/4] feat(plugins): builtin plugin discovery Add plugin discovery system that scans built-in and user plugin directories. Includes the rust-toolkit sample plugin with cargo check skill. Built-in plugins are disabled by default. --- .../assets/plugins/rust-toolkit/plugin.toml | 12 ++++ .../rust-toolkit/skills/rust-check/SKILL.md | 16 +++++ crates/tui/src/plugins/discovery.rs | 69 +++++++++++++++++++ crates/tui/src/plugins/mod.rs | 4 +- crates/tui/src/plugins/tests.rs | 30 ++++++++ 5 files changed, 130 insertions(+), 1 deletion(-) 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/plugins/discovery.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 000000000..9b3fd3a0e --- /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 = false + +[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 000000000..41adbe2d9 --- /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/plugins/discovery.rs b/crates/tui/src/plugins/discovery.rs new file mode 100644 index 000000000..1396aaa9c --- /dev/null +++ b/crates/tui/src/plugins/discovery.rs @@ -0,0 +1,69 @@ +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/mod.rs b/crates/tui/src/plugins/mod.rs index 06d2a0f73..242c4412d 100644 --- a/crates/tui/src/plugins/mod.rs +++ b/crates/tui/src/plugins/mod.rs @@ -1,6 +1,8 @@ +mod discovery; mod manifest; mod registry; +pub use discovery::discover_all; pub use manifest::{LoadedPlugin, PluginManifest, PluginSource}; pub use registry::PluginRegistry; @@ -9,7 +11,7 @@ use std::sync::{Mutex, OnceLock}; static PLUGIN_REGISTRY: OnceLock> = OnceLock::new(); pub fn init_registry(config_disabled: &[String]) { - let _ = PLUGIN_REGISTRY.set(Mutex::new(PluginRegistry::new())); + let _ = PLUGIN_REGISTRY.set(Mutex::new(discover_all(config_disabled))); } pub fn with_registry(f: F) -> R diff --git a/crates/tui/src/plugins/tests.rs b/crates/tui/src/plugins/tests.rs index 51e50473a..7dc98d8de 100644 --- a/crates/tui/src/plugins/tests.rs +++ b/crates/tui/src/plugins/tests.rs @@ -1,3 +1,6 @@ +use std::path::PathBuf; + +use crate::plugins::discovery::{builtin_plugins_dir, discover_all}; use crate::plugins::manifest::{load_manifest, load_plugin_mcp, PluginManifest}; #[test] @@ -30,3 +33,30 @@ fn load_manifest_fails_for_missing_dir() { let result = load_manifest(dir); assert!(result.is_err()); } + +#[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"); + + let manifest = load_manifest(&dir).expect("sample plugin manifest should parse"); + assert_eq!(manifest.plugin.name, "rust-toolkit"); + assert_eq!(manifest.plugin.description, "Rust development tools: cargo check, clippy, and test runner"); +} + +#[test] +fn builtin_plugins_dir_exists() { + let dir = builtin_plugins_dir(); + assert!(dir.exists()); +} + +#[test] +fn discover_all_returns_registry() { + let registry = discover_all(&[]); + assert!(registry.list().is_empty() || registry.list().len() >= 1); +} From 8e5b1f922e5a160b223bff63fe7578aec04f94ca Mon Sep 17 00:00:00 2001 From: laopan <147567034@qq.com> Date: Sat, 27 Jun 2026 21:40:14 +0800 Subject: [PATCH 3/4] feat(plugins): CLI, MCP integration, prompt injection Add plugin injection into system prompts, MCP server merging, and CLI commands (/plugin list/enable/disable/info). Plugin injection is disabled by default and requires explicit enable. --- crates/tui/src/commands/groups/plugins/mod.rs | 358 ++++++------------ crates/tui/src/main.rs | 2 + crates/tui/src/mcp.rs | 13 +- crates/tui/src/plugins/injection.rs | 86 +++++ crates/tui/src/plugins/mod.rs | 6 +- crates/tui/src/prompts.rs | 11 + 6 files changed, 227 insertions(+), 249 deletions(-) create mode 100644 crates/tui/src/plugins/injection.rs diff --git a/crates/tui/src/commands/groups/plugins/mod.rs b/crates/tui/src/commands/groups/plugins/mod.rs index 6068ca0cc..ed14f904f 100644 --- a/crates/tui/src/commands/groups/plugins/mod.rs +++ b/crates/tui/src/commands/groups/plugins/mod.rs @@ -1,19 +1,9 @@ -//! Plugin command area: list installed plugins and (future) execute plugins. -//! -//! Plugins are script-based tools discovered in a configured plugin directory -//! (default: `~/.codewhale/tools`). The `/plugins` command lists them and -//! shows per-plugin metadata. A future `/plugin` command will handle execution. - -use std::path::PathBuf; - use crate::commands::CommandResult; use crate::commands::traits::{ Command, CommandGroup, CommandInfo, FunctionCommand, RegisterCommand, }; -use crate::config::Config; -use crate::localization::{MessageId, tr}; -use crate::tools::plugin::scan_plugin_dir; -use crate::tools::spec::ApprovalRequirement; +use crate::localization::MessageId; +use crate::plugins; use crate::tui::app::App; pub struct PluginsCommands; @@ -27,14 +17,10 @@ impl CommandGroup for PluginsCommands { } } -// --------------------------------------------------------------------------- -// `/plugins` — list or show detail -// --------------------------------------------------------------------------- - pub(in crate::commands) const PLUGINS_INFO: CommandInfo = CommandInfo { name: "plugins", aliases: &["plugin"], - usage: "/plugins [name]", + usage: "/plugins [list|enable|disable|info] [name]", description_id: MessageId::CmdPluginDescription, }; @@ -46,253 +32,131 @@ impl RegisterCommand for PluginsCmd { } fn execute(app: &mut App, arg: Option<&str>) -> CommandResult { - plugins(app, arg) + plugin_command(app, arg) } } -/// List discovered plugins, or show details for a named plugin. -fn plugins(app: &mut App, arg: Option<&str>) -> CommandResult { - let Some(plugin_dir) = plugin_dir_for(app) else { - return CommandResult::error( - "Could not resolve plugin directory. Set [tools].plugin_dir in config.toml or ensure ~/.codewhale/tools exists.", - ); - }; +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 !plugin_dir.exists() { - return CommandResult::message(format!( - "No plugin directory found at {}", - plugin_dir.display() - )); + if parts.is_empty() || parts[0] == "list" { + return cmd_list(); } - let discovered = scan_plugin_dir(&plugin_dir); - - if let Some(name) = arg.map(str::trim).filter(|s| !s.is_empty()) { - show_plugin_detail(app, name, &discovered) - } else { - list_plugins(app, &plugin_dir, &discovered) + 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]) + } + _ => { + let name = parts.join(" "); + show_plugin_detail(name) + } } } -fn list_plugins( - app: &App, - plugin_dir: &std::path::Path, - discovered: &[(PathBuf, crate::tools::plugin::PluginMetadata)], -) -> CommandResult { - if discovered.is_empty() { - return CommandResult::message( - tr(app.ui_locale, MessageId::CmdPluginNoneFound) - .replace("{dir}", &plugin_dir.display().to_string()), - ); - } - - let mut out = String::new(); - out.push_str( - &tr(app.ui_locale, MessageId::CmdPluginListHeader) - .replace("{count}", &discovered.len().to_string()), - ); - out.push('\n'); - - for (path, meta) in discovered { - out.push_str(&format!( - "• {} — {}\n {}", - meta.name, - meta.description, - path.display() - )); - out.push('\n'); - } - - CommandResult::message(out) +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 show_plugin_detail( - app: &App, - name: &str, - discovered: &[(PathBuf, crate::tools::plugin::PluginMetadata)], -) -> CommandResult { - let Some((path, meta)) = discovered.iter().find(|(_, m)| m.name == name) else { - return CommandResult::error( - tr(app.ui_locale, MessageId::CmdPluginNotFound).replace("{name}", name), - ); - }; - - let schema = serde_json::to_string_pretty(&meta.input_schema).unwrap_or_default(); - let approval = approval_label(meta.approval); - - let mut out = String::new(); - out.push_str(&format!("{}\n", meta.name)); - out.push_str(&format!("{:=<40}\n", "")); - out.push_str(&format!( - "{}\n", - tr(app.ui_locale, MessageId::CmdPluginDetailDescription) - .replace("{description}", &meta.description) - )); - out.push_str(&format!( - "{}\n", - tr(app.ui_locale, MessageId::CmdPluginDetailSchema).replace("{schema}", &schema) - )); - out.push_str(&format!( - "{}\n", - tr(app.ui_locale, MessageId::CmdPluginDetailApproval).replace("{approval}", approval) - )); - out.push_str(&format!( - "{}\n", - tr(app.ui_locale, MessageId::CmdPluginDetailPath) - .replace("{path}", &path.display().to_string()) - )); - - CommandResult::message(out) +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 approval_label(approval: ApprovalRequirement) -> &'static str { - match approval { - ApprovalRequirement::Auto => "auto", - ApprovalRequirement::Suggest => "suggest", - ApprovalRequirement::Required => "required", +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), } } -/// Resolve the configured plugin directory, defaulting to `~/.codewhale/tools`. -fn plugin_dir_for(app: &App) -> Option { - let config = match &app.config_path { - Some(path) => { - Config::load(Some(path.clone()), app.config_profile.as_deref()).unwrap_or_default() +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")), } - None => Config::default(), - }; - - config - .tools - .as_ref() - .and_then(|tools| tools.plugin_dir.as_ref()) - .map(PathBuf::from) - .or_else(default_codewhale_tools_dir) -} - -fn default_codewhale_tools_dir() -> Option { - dirs::home_dir().map(|home| home.join(".codewhale").join("tools")) + }) } -#[cfg(test)] -mod tests { - use super::*; - use crate::config::Config; - use crate::tui::app::{App, TuiOptions}; - use tempfile::TempDir; - - fn create_test_app_with_plugin_dir(plugin_dir: &std::path::Path) -> (App, TempDir) { - let tmp = TempDir::new().expect("tempdir"); - let config_path = tmp.path().join("config.toml"); - let tools_dir = plugin_dir - .canonicalize() - .unwrap_or_else(|_| plugin_dir.to_path_buf()); - std::fs::write( - &config_path, - format!( - "[tools]\nplugin_dir = {}\n", - toml::Value::String(tools_dir.to_string_lossy().to_string()) - ), - ) - .expect("write config"); - - let options = TuiOptions { - model: "deepseek-v4-pro".to_string(), - workspace: tmp.path().to_path_buf(), - config_path: Some(config_path), - config_profile: None, - allow_shell: false, - use_alt_screen: true, - use_mouse_capture: false, - use_bracketed_paste: true, - max_subagents: 1, - skills_dir: tmp.path().join("skills"), - memory_path: tmp.path().join("memory.md"), - notes_path: tmp.path().join("notes.txt"), - mcp_config_path: tmp.path().join("mcp.json"), - use_memory: false, - start_in_agent_mode: false, - skip_onboarding: true, - yolo: false, - resume_session_id: None, - initial_input: None, - }; - let app = App::new(options, &Config::default()); - (app, tmp) - } - - #[test] - fn test_plugins_lists_discovered_tools() { - let dir = TempDir::new().unwrap(); - std::fs::write( - dir.path().join("greet.sh"), - "# name: greet\n# description: Say hello\n# schema: {\"type\":\"object\"}\n# approval: auto\n", - ) - .unwrap(); - std::fs::write( - dir.path().join("audit.sh"), - "# name: audit\n# description: Audit wrapper\n# approval: required\n", - ) - .unwrap(); - - let (mut app, _tmp) = create_test_app_with_plugin_dir(dir.path()); - let result = plugins(&mut app, None); - let msg = result.message.expect("should return list"); - assert!(msg.contains("Plugin tools (2):")); - assert!(msg.contains("greet")); - assert!(msg.contains("Say hello")); - assert!(msg.contains("audit")); - assert!(msg.contains("Audit wrapper")); - assert!(msg.contains("greet.sh")); - assert!(!result.is_error); - } - - #[test] - fn test_plugins_empty_directory() { - let dir = TempDir::new().unwrap(); - let (mut app, _tmp) = create_test_app_with_plugin_dir(dir.path()); - let result = plugins(&mut app, None); - let msg = result.message.expect("should return message"); - assert!(msg.contains("No plugin tools discovered")); - assert!(msg.contains(&dir.path().canonicalize().unwrap().display().to_string())); - assert!(!result.is_error); - } - - #[test] - fn test_plugins_detail_shows_metadata() { - let dir = TempDir::new().unwrap(); - std::fs::write( - dir.path().join("tool.sh"), - "# name: my-tool\n# description: Does a thing\n# schema: {\"type\":\"object\",\"properties\":{\"x\":{\"type\":\"string\"}}}\n# approval: required\n", - ) - .unwrap(); - - let (mut app, _tmp) = create_test_app_with_plugin_dir(dir.path()); - let result = plugins(&mut app, Some("my-tool")); - let msg = result.message.expect("should return detail"); - assert!(msg.contains("my-tool")); - assert!(msg.contains("Does a thing")); - assert!(msg.contains("\"type\": \"object\"")); - assert!(msg.contains("\"x\"")); - assert!(msg.contains("required")); - assert!(msg.contains("tool.sh")); - assert!(!result.is_error); - } - - #[test] - fn test_plugins_detail_not_found() { - let dir = TempDir::new().unwrap(); - std::fs::write( - dir.path().join("existing.sh"), - "# name: existing\n# description: exists\n", - ) - .unwrap(); - - let (mut app, _tmp) = create_test_app_with_plugin_dir(dir.path()); - let result = plugins(&mut app, Some("missing")); - assert!(result.is_error); - let msg = result.message.expect("should return error"); - assert!(msg.contains("missing")); - assert!(msg.contains("not found")); - } +fn show_plugin_detail(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/main.rs b/crates/tui/src/main.rs index b5083247f..5864e5cc6 100644 --- a/crates/tui/src/main.rs +++ b/crates/tui/src/main.rs @@ -65,6 +65,7 @@ mod models; mod network_policy; mod oauth; mod palette; +mod plugins; mod prefix_cache; mod pricing; mod project_context; @@ -1390,6 +1391,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 a104f93ed..7e72519e6 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,14 @@ pub struct McpPool { last_mtimes: Vec>, } +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 +2056,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 +2152,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/injection.rs b/crates/tui/src/plugins/injection.rs new file mode 100644 index 000000000..8e1707076 --- /dev/null +++ b/crates/tui/src/plugins/injection.rs @@ -0,0 +1,86 @@ +use super::registry::PluginRegistry; + +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/mod.rs b/crates/tui/src/plugins/mod.rs index 242c4412d..3ae6efad4 100644 --- a/crates/tui/src/plugins/mod.rs +++ b/crates/tui/src/plugins/mod.rs @@ -1,8 +1,12 @@ +pub mod injection; +pub mod registry; + mod discovery; mod manifest; -mod registry; pub use discovery::discover_all; +pub use injection::render_plugin_block; +#[allow(unused_imports)] pub use manifest::{LoadedPlugin, PluginManifest, PluginSource}; pub use registry::PluginRegistry; diff --git a/crates/tui/src/prompts.rs b/crates/tui/src/prompts.rs index 22224c29e..c9f801317 100644 --- a/crates/tui/src/prompts.rs +++ b/crates/tui/src/prompts.rs @@ -1143,6 +1143,17 @@ pub fn system_prompt_for_mode_with_context_skills_session_and_approval( full_prompt = format!("{full_prompt}\n\n{block}"); } + if let Some(block) = crate::plugins::try_with_registry(|r| crate::plugins::render_plugin_block(r)) + .flatten() + .or_else(|| { + 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( From 7416a59773af92184ec87392c8b965fe12cc0fbf Mon Sep 17 00:00:00 2001 From: laopan <147567034@qq.com> Date: Sat, 27 Jun 2026 22:36:28 +0800 Subject: [PATCH 4/4] fix(plugins): borrow and visibility fixes for compilation --- crates/tui/src/commands/groups/plugins/mod.rs | 2 +- crates/tui/src/skills/mod.rs | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/tui/src/commands/groups/plugins/mod.rs b/crates/tui/src/commands/groups/plugins/mod.rs index ed14f904f..f28261ceb 100644 --- a/crates/tui/src/commands/groups/plugins/mod.rs +++ b/crates/tui/src/commands/groups/plugins/mod.rs @@ -69,7 +69,7 @@ pub fn plugin_command(_app: &mut App, arg: Option<&str>) -> CommandResult { } _ => { let name = parts.join(" "); - show_plugin_detail(name) + show_plugin_detail(&name) } } } diff --git a/crates/tui/src/skills/mod.rs b/crates/tui/src/skills/mod.rs index c1331f69d..32839fa3b 100644 --- a/crates/tui/src/skills/mod.rs +++ b/crates/tui/src/skills/mod.rs @@ -286,7 +286,7 @@ impl SkillRegistry { self.warnings.push(warning); } - fn parse_skill(_path: &Path, content: &str) -> std::result::Result { + pub(crate) fn parse_skill(_path: &Path, content: &str) -> std::result::Result { let trimmed = content.trim_start(); // Try to parse frontmatter block first. If absent, fall back to