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..5ae6a8556f --- /dev/null +++ b/crates/tui/assets/plugins/rust-toolkit/plugin.toml @@ -0,0 +1,12 @@ +[plugin] +name = "rust-toolkit" +description = "Rust development toolkit with cargo check integration" +version = "0.1.0" +author = "CodeWhale Team" + +[skills] +path = "skills" + +[when] +os = ["windows", "linux", "macos"] +binaries = ["cargo"] \ No newline at end of file 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..e1d7f8dd40 --- /dev/null +++ b/crates/tui/assets/plugins/rust-toolkit/skills/rust-check/SKILL.md @@ -0,0 +1,18 @@ +--- +name: rust-check +description: Run cargo check on the current Rust project to find compile errors +--- + +Use this skill when you need to check for Rust compile errors. + +**How to use:** +1. Run `cargo check` in the project root +2. Analyze the output for errors and warnings +3. Fix any issues found + +**Example:** +```bash +cargo check --all-targets +``` + +This skill is part of the rust-toolkit plugin. \ No newline at end of file diff --git a/crates/tui/src/commands/groups/plugins/mod.rs b/crates/tui/src/commands/groups/plugins/mod.rs index 6068ca0ccb..b03728d84e 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; @@ -21,278 +11,169 @@ pub struct PluginsCommands; impl CommandGroup for PluginsCommands { fn commands(&self) -> Vec> { vec![Box::new(FunctionCommand::new( - PluginsCmd::info(), - PluginsCmd::execute, + PluginListCmd::info(), + PluginListCmd::execute, ))] } } -// --------------------------------------------------------------------------- -// `/plugins` — list or show detail -// --------------------------------------------------------------------------- - -pub(in crate::commands) const PLUGINS_INFO: CommandInfo = CommandInfo { - name: "plugins", - aliases: &["plugin"], - usage: "/plugins [name]", +pub(in crate::commands) const PLUGIN_LIST_INFO: CommandInfo = CommandInfo { + name: "plugin", + aliases: &["plugins"], + usage: "/plugin [list|enable |disable |info ]", description_id: MessageId::CmdPluginDescription, }; -pub(in crate::commands) struct PluginsCmd; +pub(in crate::commands) struct PluginListCmd; -impl RegisterCommand for PluginsCmd { +impl RegisterCommand for PluginListCmd { fn info() -> &'static CommandInfo { - &PLUGINS_INFO + &PLUGIN_LIST_INFO } fn execute(app: &mut App, arg: Option<&str>) -> CommandResult { - plugins(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.", - ); - }; - - if !plugin_dir.exists() { - return CommandResult::message(format!( - "No plugin directory found at {}", - plugin_dir.display() - )); - } - - let discovered = scan_plugin_dir(&plugin_dir); + let Some(arg) = arg.map(str::trim).filter(|arg| !arg.is_empty()) else { + return plugin_list(app); + }; - 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) + let mut parts = arg.splitn(2, char::is_whitespace); + let action = parts.next().unwrap_or_default(); + let rest = parts.next().unwrap_or_default().trim(); + match action { + "list" | "ls" => plugin_list(app), + "enable" => { + if rest.is_empty() { + CommandResult::error("Usage: /plugin enable ") + } else { + plugin_enable(app, rest) + } + } + "disable" => { + if rest.is_empty() { + CommandResult::error("Usage: /plugin disable ") + } else { + plugin_disable(app, rest) + } + } + "info" => { + if rest.is_empty() { + CommandResult::error("Usage: /plugin info ") + } else { + plugin_info(app, rest) + } + } + name => plugin_info(app, 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'); +fn plugin_list(_app: &App) -> CommandResult { + plugins::try_with_registry(|r| { + if r.is_empty() { + return CommandResult::message("No plugins discovered."); + } - for (path, meta) in discovered { + let mut out = String::new(); + let enabled_count = r.enabled_plugins().len(); out.push_str(&format!( - "• {} — {}\n {}", - meta.name, - meta.description, - path.display() + "Plugins ({}, {} enabled)\n", + r.len(), + enabled_count )); + out.push_str(&"=".repeat(40)); out.push('\n'); - } - - CommandResult::message(out) -} - -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 approval_label(approval: ApprovalRequirement) -> &'static str { - match approval { - ApprovalRequirement::Auto => "auto", - ApprovalRequirement::Suggest => "suggest", - ApprovalRequirement::Required => "required", - } -} - -/// 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() + for (name, plugin) in r.list() { + let status = if r.is_enabled(name) { + "enabled" + } else { + "disabled" + }; + let description = plugin + .manifest + .plugin + .description + .as_deref() + .unwrap_or("No description"); + out.push_str(&format!("• {} [{}]\n {}\n", name, status, description)); } - 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")) + CommandResult::message(out) + }) + .unwrap_or_else(|| CommandResult::error("Plugin registry not initialized.")) } -#[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); - } +fn plugin_enable(_app: &App, name: &str) -> CommandResult { + let result = plugins::with_registry(|r| r.enable(name)); - #[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); + match result { + Some(true) => CommandResult::message(format!("Plugin '{}' enabled.", name)), + Some(false) => CommandResult::error(format!("Plugin '{}' not found.", name)), + None => CommandResult::error("Plugin registry not initialized."), } +} - #[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(); +fn plugin_disable(_app: &App, name: &str) -> CommandResult { + let result = plugins::with_registry(|r| r.disable(name)); - 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); + match result { + Some(true) => CommandResult::message(format!("Plugin '{}' disabled.", name)), + Some(false) => CommandResult::error(format!("Plugin '{}' not found.", name)), + None => CommandResult::error("Plugin registry not initialized."), } +} - #[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 plugin_info(_app: &App, name: &str) -> CommandResult { + plugins::try_with_registry(|r| match r.get(name) { + Some(plugin) => { + let mut out = String::new(); + out.push_str(&format!("{}\n", plugin.manifest.plugin.name)); + out.push_str(&"=".repeat(40)); + out.push('\n'); + if let Some(desc) = &plugin.manifest.plugin.description { + out.push_str(&format!("Description: {}\n", desc)); + } + if let Some(version) = &plugin.manifest.plugin.version { + out.push_str(&format!("Version: {}\n", version)); + } + if let Some(author) = &plugin.manifest.plugin.author { + out.push_str(&format!("Author: {}\n", author)); + } + out.push_str(&format!( + "Status: {}\n", + if plugin.enabled { + "enabled" + } else { + "disabled" + } + )); + out.push_str(&format!("Path: {}\n", plugin.base_path.display())); + if let Some(skills) = &plugin.manifest.skills { + if let Some(path) = &skills.path { + out.push_str(&format!("Skills: {}\n", path)); + } + } + if let Some(mcp_servers) = &plugin.manifest.mcp_servers { + out.push_str(&format!("MCP servers: {}\n", mcp_servers.len())); + for (server_name, server) in mcp_servers { + out.push_str(&format!(" - {}: {}\n", server_name, server.command)); + if let Some(args) = &server.args { + out.push_str(&format!(" args: {}\n", args.join(" "))); + } + if let Some(env) = &server.env { + out.push_str(&format!(" env vars: {}\n", env.len())); + } + if let Some(cwd) = &server.cwd { + out.push_str(&format!(" cwd: {}\n", cwd)); + } + if let Some(sandbox) = server.sandbox { + out.push_str(&format!(" sandbox: {}\n", sandbox)); + } + } + } + CommandResult::message(out) + } + None => CommandResult::error(format!("Plugin '{}' not found.", name)), + }) + .unwrap_or_else(|| CommandResult::error("Plugin registry not initialized.")) } diff --git a/crates/tui/src/main.rs b/crates/tui/src/main.rs index b5083247f7..5864e5cc60 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/plugins/discovery.rs b/crates/tui/src/plugins/discovery.rs new file mode 100644 index 0000000000..6d87f4f637 --- /dev/null +++ b/crates/tui/src/plugins/discovery.rs @@ -0,0 +1,68 @@ +use std::path::{Path, PathBuf}; + +use super::manifest::{LoadedPlugin, PluginManifest}; +use super::registry::PluginRegistry; + +const PLUGIN_MANIFEST: &str = "plugin.toml"; + +pub fn default_user_plugins_dir() -> PathBuf { + dirs::home_dir() + .map(|p| p.join(".codewhale").join("plugins")) + .unwrap_or_else(|| PathBuf::from("/tmp/codewhale/plugins")) +} + +pub fn discover_all(builtin_dirs: &[&str]) -> PluginRegistry { + let mut registry = PluginRegistry::new(); + + for dir in builtin_dirs { + let path = PathBuf::from(dir); + if path.exists() { + discover_from_dir(&path, &mut registry, true); + } + } + + let user_dir = default_user_plugins_dir(); + if user_dir.exists() { + discover_from_dir(&user_dir, &mut registry, false); + } + + registry +} + +fn discover_from_dir(dir: &Path, registry: &mut PluginRegistry, builtin: bool) { + let Ok(entries) = std::fs::read_dir(dir) else { + return; + }; + + for entry in entries.flatten() { + let path = entry.path(); + if !path.is_dir() { + continue; + } + + let manifest_path = path.join(PLUGIN_MANIFEST); + if !manifest_path.exists() { + continue; + } + + match PluginManifest::from_path(&manifest_path) { + Ok(manifest) => { + if !manifest.check_when() { + continue; + } + + let name = manifest.plugin.name.clone(); + let plugin = LoadedPlugin { + manifest, + base_path: path, + enabled: !builtin, + }; + + registry.register(name, plugin); + } + Err(e) => { + tracing::warn!("Failed to load plugin from {}: {}", path.display(), e); + } + } + } +} diff --git a/crates/tui/src/plugins/manifest.rs b/crates/tui/src/plugins/manifest.rs new file mode 100644 index 0000000000..3c3ab5fa8f --- /dev/null +++ b/crates/tui/src/plugins/manifest.rs @@ -0,0 +1,92 @@ +use std::collections::HashMap; +use std::path::{Path, PathBuf}; + +use serde::Deserialize; + +#[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: Option, + pub version: Option, + pub author: Option, +} + +#[derive(Debug, Clone, Deserialize)] +pub struct PluginSkills { + pub path: Option, +} + +#[derive(Debug, Clone, Deserialize)] +pub struct McpServerConfig { + pub command: String, + pub args: Option>, + pub env: Option>, + pub cwd: Option, + pub sandbox: Option, +} + +#[derive(Debug, Clone, Deserialize)] +pub struct PluginWhen { + pub os: Option>, + pub binaries: Option>, +} + +#[derive(Debug, Clone)] +pub struct LoadedPlugin { + pub manifest: PluginManifest, + pub base_path: PathBuf, + pub enabled: bool, +} + +impl PluginManifest { + pub fn from_path(path: &Path) -> Result { + let content = std::fs::read_to_string(path) + .map_err(|e| format!("failed to read plugin.toml: {}", e))?; + toml::from_str(&content).map_err(|e| format!("failed to parse plugin.toml: {}", e)) + } + + pub fn check_when(&self) -> bool { + if let Some(when) = &self.when { + if let Some(os_list) = &when.os { + let os = std::env::consts::OS; + if !os_list.iter().any(|o| o.eq_ignore_ascii_case(os)) { + return false; + } + } + if let Some(binaries) = &when.binaries { + for binary in binaries { + if !Self::has_binary(binary) { + return false; + } + } + } + } + true + } + + fn has_binary(name: &str) -> bool { + let paths = std::env::var_os("PATH").unwrap_or_default(); + for path in std::env::split_paths(&paths) { + let candidate = path.join(name); + if candidate.exists() { + return true; + } + #[cfg(windows)] + { + let candidate_exe = candidate.with_extension("exe"); + if candidate_exe.exists() { + return true; + } + } + } + false + } +} diff --git a/crates/tui/src/plugins/mod.rs b/crates/tui/src/plugins/mod.rs new file mode 100644 index 0000000000..d175394d91 --- /dev/null +++ b/crates/tui/src/plugins/mod.rs @@ -0,0 +1,30 @@ +use std::sync::{Mutex, OnceLock}; + +pub mod discovery; +pub mod manifest; +pub mod registry; + +#[cfg(test)] +mod tests; + +use discovery::discover_all; +use registry::PluginRegistry; + +static REGISTRY: OnceLock> = OnceLock::new(); + +pub fn init_registry(builtin_dirs: &[&str]) { + let registry = discover_all(builtin_dirs); + REGISTRY.set(Mutex::new(registry)).ok(); +} + +pub fn try_with_registry(f: impl FnOnce(&PluginRegistry) -> R) -> Option { + REGISTRY + .get() + .and_then(|lock| lock.lock().ok().map(|registry| f(®istry))) +} + +pub fn with_registry(f: impl FnOnce(&mut PluginRegistry) -> R) -> Option { + REGISTRY + .get() + .and_then(|lock| lock.lock().ok().map(|mut registry| f(&mut registry))) +} diff --git a/crates/tui/src/plugins/registry.rs b/crates/tui/src/plugins/registry.rs new file mode 100644 index 0000000000..0376c6ee50 --- /dev/null +++ b/crates/tui/src/plugins/registry.rs @@ -0,0 +1,70 @@ +use std::collections::HashMap; + +use super::manifest::LoadedPlugin; + +#[derive(Debug, Clone)] +pub struct PluginRegistry { + plugins: HashMap, + user_overrides: HashMap, +} + +impl PluginRegistry { + pub fn new() -> Self { + Self { + plugins: HashMap::new(), + user_overrides: HashMap::new(), + } + } + + pub fn register(&mut self, name: String, plugin: LoadedPlugin) { + self.plugins.insert(name, plugin); + } + + pub fn enable(&mut self, name: &str) -> bool { + if let Some(plugin) = self.plugins.get_mut(name) { + plugin.enabled = true; + self.user_overrides.insert(name.to_string(), true); + true + } else { + false + } + } + + pub fn disable(&mut self, name: &str) -> bool { + if let Some(plugin) = self.plugins.get_mut(name) { + plugin.enabled = false; + self.user_overrides.insert(name.to_string(), false); + true + } else { + false + } + } + + pub fn list(&self) -> Vec<(&String, &LoadedPlugin)> { + self.plugins.iter().collect() + } + + pub fn get(&self, name: &str) -> Option<&LoadedPlugin> { + self.plugins.get(name) + } + + pub fn enabled_plugins(&self) -> Vec<(&String, &LoadedPlugin)> { + self.list_enabled() + } + + pub fn list_enabled(&self) -> Vec<(&String, &LoadedPlugin)> { + self.plugins.iter().filter(|(_, p)| p.enabled).collect() + } + + pub fn is_enabled(&self, name: &str) -> bool { + self.plugins.get(name).map_or(false, |p| p.enabled) + } + + pub fn len(&self) -> usize { + self.plugins.len() + } + + pub fn is_empty(&self) -> bool { + self.plugins.is_empty() + } +} diff --git a/crates/tui/src/plugins/tests.rs b/crates/tui/src/plugins/tests.rs new file mode 100644 index 0000000000..1f07dcaf83 --- /dev/null +++ b/crates/tui/src/plugins/tests.rs @@ -0,0 +1,146 @@ +use std::path::PathBuf; + +use super::manifest::{PluginManifest, PluginMeta}; +use super::registry::PluginRegistry; + +#[test] +fn test_manifest_parsing() { + let toml_content = r#" +[plugin] +name = "test-plugin" +description = "A test plugin" +version = "1.0.0" +author = "Test Author" + +[when] +os = ["windows", "linux"] +binaries = ["cargo"] +"#; + + let manifest: PluginManifest = toml::from_str(toml_content).unwrap(); + assert_eq!(manifest.plugin.name, "test-plugin"); + assert_eq!(manifest.plugin.description.unwrap(), "A test plugin"); + assert_eq!(manifest.plugin.version.unwrap(), "1.0.0"); + assert_eq!(manifest.plugin.author.unwrap(), "Test Author"); +} + +#[test] +fn test_manifest_when_os_filter() { + let manifest = PluginManifest { + plugin: PluginMeta { + name: "test".to_string(), + description: None, + version: None, + author: None, + }, + skills: None, + mcp_servers: None, + when: Some(super::manifest::PluginWhen { + os: Some(vec![std::env::consts::OS.to_string()]), + binaries: None, + }), + }; + + assert!(manifest.check_when()); +} + +#[test] +fn test_manifest_when_os_mismatch() { + let manifest = PluginManifest { + plugin: PluginMeta { + name: "test".to_string(), + description: None, + version: None, + author: None, + }, + skills: None, + mcp_servers: None, + when: Some(super::manifest::PluginWhen { + os: Some(vec!["nonexistent-os".to_string()]), + binaries: None, + }), + }; + + assert!(!manifest.check_when()); +} + +#[test] +fn test_registry_enable_disable() { + let mut registry = PluginRegistry::new(); + + let manifest = PluginManifest { + plugin: PluginMeta { + name: "test-plugin".to_string(), + description: None, + version: None, + author: None, + }, + skills: None, + mcp_servers: None, + when: None, + }; + + let plugin = super::manifest::LoadedPlugin { + manifest, + base_path: PathBuf::new(), + enabled: false, + }; + + registry.register("test-plugin".to_string(), plugin); + + assert!(!registry.is_enabled("test-plugin")); + assert!(registry.enable("test-plugin")); + assert!(registry.is_enabled("test-plugin")); + assert!(registry.disable("test-plugin")); + assert!(!registry.is_enabled("test-plugin")); +} + +#[test] +fn test_registry_list() { + let mut registry = PluginRegistry::new(); + + let manifest1 = PluginManifest { + plugin: PluginMeta { + name: "plugin-1".to_string(), + description: None, + version: None, + author: None, + }, + skills: None, + mcp_servers: None, + when: None, + }; + + let manifest2 = PluginManifest { + plugin: PluginMeta { + name: "plugin-2".to_string(), + description: None, + version: None, + author: None, + }, + skills: None, + mcp_servers: None, + when: None, + }; + + let plugin1 = super::manifest::LoadedPlugin { + manifest: manifest1, + base_path: PathBuf::new(), + enabled: true, + }; + + let plugin2 = super::manifest::LoadedPlugin { + manifest: manifest2, + base_path: PathBuf::new(), + enabled: false, + }; + + registry.register("plugin-1".to_string(), plugin1); + registry.register("plugin-2".to_string(), plugin2); + + assert_eq!(registry.len(), 2); + assert_eq!(registry.enabled_plugins().len(), 1); + assert_eq!(registry.list_enabled().len(), 1); + assert!(registry.is_enabled("plugin-1")); + assert!(!registry.is_enabled("plugin-2")); +} diff --git a/crates/tui/src/skills/mod.rs b/crates/tui/src/skills/mod.rs index c1331f69d6..32839fa3b4 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