diff --git a/crates/tui/src/commands/groups/memory/memory.rs b/crates/tui/src/commands/groups/memory/memory.rs index 0c9af71a67..0fd5b3f570 100644 --- a/crates/tui/src/commands/groups/memory/memory.rs +++ b/crates/tui/src/commands/groups/memory/memory.rs @@ -20,7 +20,7 @@ use std::fs; use std::path::Path; -use super::CommandResult; +use crate::commands::CommandResult; use crate::tui::app::App; const MEMORY_USAGE: &str = "/memory [show|path|clear|edit|help]"; @@ -43,7 +43,7 @@ fn memory_help(path: &Path) -> String { ) } -pub fn memory(app: &mut App, arg: Option<&str>) -> CommandResult { +fn memory(app: &mut App, arg: Option<&str>) -> CommandResult { if !app.use_memory { return CommandResult::error( "user memory is disabled. Enable with `[memory] enabled = true` in `~/.codewhale/config.toml` or `DEEPSEEK_MEMORY=on` in your environment, then restart the TUI.", @@ -85,6 +85,29 @@ pub fn memory(app: &mut App, arg: Option<&str>) -> CommandResult { } } +pub(in crate::commands) const COMMAND_INFO: crate::commands::traits::CommandInfo = + crate::commands::traits::CommandInfo { + name: "memory", + aliases: &[], + usage: "/memory [show|path|clear|edit|help]", + description_id: crate::localization::MessageId::CmdMemoryDescription, + }; + +pub(in crate::commands) struct MemoryCmd; + +impl crate::commands::traits::RegisterCommand for MemoryCmd { + fn info() -> &'static crate::commands::traits::CommandInfo { + &COMMAND_INFO + } + + fn execute( + app: &mut crate::tui::app::App, + arg: Option<&str>, + ) -> crate::commands::CommandResult { + memory(app, arg) + } +} + #[cfg(test)] mod tests { use super::*; diff --git a/crates/tui/src/commands/groups/memory/mod.rs b/crates/tui/src/commands/groups/memory/mod.rs index ffac7cfc07..ea24c542d1 100644 --- a/crates/tui/src/commands/groups/memory/mod.rs +++ b/crates/tui/src/commands/groups/memory/mod.rs @@ -7,55 +7,21 @@ mod memory; mod note; -use crate::commands::CommandResult; -use crate::commands::traits::{Command, CommandGroup, CommandInfo, FunctionCommand}; -use crate::localization::MessageId; -use crate::tui::app::App; +use crate::commands::traits::{Command, CommandGroup, FunctionCommand, RegisterCommand}; pub struct MemoryCommands; impl CommandGroup for MemoryCommands { fn commands(&self) -> Vec> { vec![ - Box::new(FunctionCommand::new(&NOTE_INFO, run_note)), - Box::new(FunctionCommand::new(&MEMORY_INFO, run_memory)), + Box::new(FunctionCommand::new( + note::NoteCmd::info(), + note::NoteCmd::execute, + )), + Box::new(FunctionCommand::new( + memory::MemoryCmd::info(), + memory::MemoryCmd::execute, + )), ] } } - -static NOTE_INFO: CommandInfo = CommandInfo { - name: "note", - aliases: &[], - usage: "/note [add|list|show|edit|remove|clear|path]", - description_id: MessageId::CmdNoteDescription, -}; -static MEMORY_INFO: CommandInfo = CommandInfo { - name: "memory", - aliases: &[], - usage: "/memory [show|path|clear|edit|help]", - description_id: MessageId::CmdMemoryDescription, -}; - -fn run_registered(app: &mut App, name: &str, arg: Option<&str>) -> CommandResult { - dispatch(app, name, arg).expect("registered memory command should dispatch") -} - -fn run_note(app: &mut App, arg: Option<&str>) -> CommandResult { - run_registered(app, "note", arg) -} -fn run_memory(app: &mut App, arg: Option<&str>) -> CommandResult { - run_registered(app, "memory", arg) -} - -pub(in crate::commands) fn dispatch( - app: &mut App, - command: &str, - arg: Option<&str>, -) -> Option { - let result = match command { - "memory" => memory::memory(app, arg), - "note" => note::note(app, arg), - _ => return None, - }; - Some(result) -} diff --git a/crates/tui/src/commands/groups/memory/note.rs b/crates/tui/src/commands/groups/memory/note.rs index 6efe441347..2965544d1b 100644 --- a/crates/tui/src/commands/groups/memory/note.rs +++ b/crates/tui/src/commands/groups/memory/note.rs @@ -5,12 +5,12 @@ use std::fs; use std::io::Write; use std::path::{Path, PathBuf}; -use super::CommandResult; +use crate::commands::CommandResult; const USAGE: &str = "/note | /note add | /note list | /note show | /note edit | /note remove | /note clear | /note path"; /// Manage the persistent workspace notes file. -pub fn note(app: &mut App, content: Option<&str>) -> CommandResult { +fn note(app: &mut App, content: Option<&str>) -> CommandResult { let input = match content { Some(c) => c.trim(), None => { @@ -266,6 +266,29 @@ fn parse_note_index(rest: Option<&str>, note_count: usize, usage: &str) -> Resul Ok(index - 1) } +pub(in crate::commands) const COMMAND_INFO: crate::commands::traits::CommandInfo = + crate::commands::traits::CommandInfo { + name: "note", + aliases: &[], + usage: "/note [add|list|show|edit|remove|clear|path]", + description_id: crate::localization::MessageId::CmdNoteDescription, + }; + +pub(in crate::commands) struct NoteCmd; + +impl crate::commands::traits::RegisterCommand for NoteCmd { + fn info() -> &'static crate::commands::traits::CommandInfo { + &COMMAND_INFO + } + + fn execute( + app: &mut crate::tui::app::App, + arg: Option<&str>, + ) -> crate::commands::CommandResult { + note(app, arg) + } +} + #[cfg(test)] mod tests { use super::*; diff --git a/crates/tui/src/commands/groups/mod.rs b/crates/tui/src/commands/groups/mod.rs index f483acde10..53bef83c03 100644 --- a/crates/tui/src/commands/groups/mod.rs +++ b/crates/tui/src/commands/groups/mod.rs @@ -9,6 +9,7 @@ pub mod config; pub mod core; pub mod debug; pub mod memory; +pub mod plugins; pub mod project; pub mod session; pub mod skills; @@ -25,6 +26,7 @@ pub fn all_command_groups() -> Vec<&'static dyn CommandGroup> { &project::ProjectCommands, &skills::SkillsCommands, &memory::MemoryCommands, + &plugins::PluginsCommands, &utility::UtilityCommands, ] } diff --git a/crates/tui/src/commands/plugins.rs b/crates/tui/src/commands/groups/plugins/mod.rs similarity index 82% rename from crates/tui/src/commands/plugins.rs rename to crates/tui/src/commands/groups/plugins/mod.rs index 0e230b7a89..6068ca0ccb 100644 --- a/crates/tui/src/commands/plugins.rs +++ b/crates/tui/src/commands/groups/plugins/mod.rs @@ -1,18 +1,60 @@ -//! `/plugins` slash command — list and inspect script plugin tools. +//! 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::tui::app::App; +pub struct PluginsCommands; + +impl CommandGroup for PluginsCommands { + fn commands(&self) -> Vec> { + vec![Box::new(FunctionCommand::new( + PluginsCmd::info(), + PluginsCmd::execute, + ))] + } +} + +// --------------------------------------------------------------------------- +// `/plugins` — list or show detail +// --------------------------------------------------------------------------- + +pub(in crate::commands) const PLUGINS_INFO: CommandInfo = CommandInfo { + name: "plugins", + aliases: &["plugin"], + usage: "/plugins [name]", + description_id: MessageId::CmdPluginDescription, +}; + +pub(in crate::commands) struct PluginsCmd; + +impl RegisterCommand for PluginsCmd { + fn info() -> &'static CommandInfo { + &PLUGINS_INFO + } + + fn execute(app: &mut App, arg: Option<&str>) -> CommandResult { + plugins(app, arg) + } +} + /// List discovered plugins, or show details for a named plugin. -pub fn plugins(app: &mut App, arg: Option<&str>) -> CommandResult { +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.".to_string(), + "Could not resolve plugin directory. Set [tools].plugin_dir in config.toml or ensure ~/.codewhale/tools exists.", ); }; @@ -103,11 +145,11 @@ fn show_plugin_detail( CommandResult::message(out) } -fn approval_label(approval: crate::tools::spec::ApprovalRequirement) -> &'static str { +fn approval_label(approval: ApprovalRequirement) -> &'static str { match approval { - crate::tools::spec::ApprovalRequirement::Auto => "auto", - crate::tools::spec::ApprovalRequirement::Suggest => "suggest", - crate::tools::spec::ApprovalRequirement::Required => "required", + ApprovalRequirement::Auto => "auto", + ApprovalRequirement::Suggest => "suggest", + ApprovalRequirement::Required => "required", } } diff --git a/crates/tui/src/commands/groups/project/goal.rs b/crates/tui/src/commands/groups/project/goal.rs index 67f6d220c0..54c05d5c1b 100644 --- a/crates/tui/src/commands/groups/project/goal.rs +++ b/crates/tui/src/commands/groups/project/goal.rs @@ -2,13 +2,15 @@ use std::io::Write; +use crate::commands::traits::{CommandInfo, RegisterCommand}; +use crate::localization::MessageId; use crate::tools::goal::GoalStatus; use crate::tui::app::{App, AppAction, HuntVerdict}; -use super::CommandResult; +use crate::commands::CommandResult; /// Declare, show, pause, resume, or close a goal. -pub fn hunt(app: &mut App, arg: Option<&str>) -> CommandResult { +fn hunt(app: &mut App, arg: Option<&str>) -> CommandResult { match arg { Some("clear") | Some("reset") => { app.hunt.quarry = None; @@ -301,6 +303,25 @@ fn write_trophy_card_contents(mut f: impl Write, card: TrophyCard<'_>) -> std::i Ok(()) } +pub(in crate::commands) const COMMAND_INFO: CommandInfo = CommandInfo { + name: "goal", + aliases: &["hunt", "mubiao", "狩猎"], + usage: "/goal [objective|clear|pause|resume|complete|blocked] [budget: N]", + description_id: MessageId::CmdGoalDescription, +}; + +pub(in crate::commands) struct GoalCmd; + +impl RegisterCommand for GoalCmd { + fn info() -> &'static CommandInfo { + &COMMAND_INFO + } + + fn execute(app: &mut App, arg: Option<&str>) -> CommandResult { + hunt(app, arg) + } +} + #[cfg(test)] mod tests { use super::*; diff --git a/crates/tui/src/commands/groups/project/init.rs b/crates/tui/src/commands/groups/project/init.rs index 641555ede7..5d78f7f4f6 100644 --- a/crates/tui/src/commands/groups/project/init.rs +++ b/crates/tui/src/commands/groups/project/init.rs @@ -13,11 +13,11 @@ use std::process::Command; use crate::project_context; use crate::tui::app::{App, AppAction}; -use super::CommandResult; +use crate::commands::CommandResult; /// Generate an AGENTS.md file for the current project by gathering context and /// delegating content generation to the LLM agent. -pub fn init(app: &mut App) -> CommandResult { +fn init(app: &mut App) -> CommandResult { let workspace = &app.workspace; // Ensure .deepseek/ is gitignored if we're inside a git repo. @@ -794,6 +794,29 @@ fn build_init_prompt( prompt } +pub(in crate::commands) const COMMAND_INFO: crate::commands::traits::CommandInfo = + crate::commands::traits::CommandInfo { + name: "init", + aliases: &[], + usage: "/init", + description_id: crate::localization::MessageId::CmdInitDescription, + }; + +pub(in crate::commands) struct InitCmd; + +impl crate::commands::traits::RegisterCommand for InitCmd { + fn info() -> &'static crate::commands::traits::CommandInfo { + &COMMAND_INFO + } + + fn execute( + app: &mut crate::tui::app::App, + _arg: Option<&str>, + ) -> crate::commands::CommandResult { + init(app) + } +} + // --------------------------------------------------------------------------- // Tests // --------------------------------------------------------------------------- diff --git a/crates/tui/src/commands/groups/project/lsp.rs b/crates/tui/src/commands/groups/project/lsp.rs new file mode 100644 index 0000000000..b1012fe3c0 --- /dev/null +++ b/crates/tui/src/commands/groups/project/lsp.rs @@ -0,0 +1,28 @@ +//! `/lsp` command — enable/disable LSP integration. +//! +//! Bridges to config::config::lsp_command for actual execution. + +use crate::commands::traits::{CommandInfo, RegisterCommand}; +use crate::localization::MessageId; +use crate::tui::app::App; + +use crate::commands::CommandResult; + +pub(in crate::commands) const COMMAND_INFO: CommandInfo = CommandInfo { + name: "lsp", + aliases: &[], + usage: "/lsp [on|off|status]", + description_id: MessageId::CmdLspDescription, +}; + +pub(in crate::commands) struct LspCmd; + +impl RegisterCommand for LspCmd { + fn info() -> &'static CommandInfo { + &COMMAND_INFO + } + + fn execute(app: &mut App, arg: Option<&str>) -> CommandResult { + super::super::config::config::lsp_command(app, arg) + } +} diff --git a/crates/tui/src/commands/groups/project/mod.rs b/crates/tui/src/commands/groups/project/mod.rs index 70a1f18b78..cc2311a98d 100644 --- a/crates/tui/src/commands/groups/project/mod.rs +++ b/crates/tui/src/commands/groups/project/mod.rs @@ -2,79 +2,32 @@ mod goal; mod init; +mod lsp; pub mod share; -use crate::commands::CommandResult; -use crate::commands::traits::{Command, CommandGroup, CommandInfo, FunctionCommand}; -use crate::localization::MessageId; -use crate::tui::app::App; +use crate::commands::traits::{Command, CommandGroup, FunctionCommand, RegisterCommand}; pub struct ProjectCommands; impl CommandGroup for ProjectCommands { fn commands(&self) -> Vec> { vec![ - Box::new(FunctionCommand::new(&INIT_INFO, run_init)), - Box::new(FunctionCommand::new(&LSP_INFO, run_lsp)), - Box::new(FunctionCommand::new(&SHARE_INFO, run_share)), - Box::new(FunctionCommand::new(&GOAL_INFO, run_goal)), + Box::new(FunctionCommand::new( + init::InitCmd::info(), + init::InitCmd::execute, + )), + Box::new(FunctionCommand::new( + lsp::LspCmd::info(), + lsp::LspCmd::execute, + )), + Box::new(FunctionCommand::new( + share::ShareCmd::info(), + share::ShareCmd::execute, + )), + Box::new(FunctionCommand::new( + goal::GoalCmd::info(), + goal::GoalCmd::execute, + )), ] } } - -static INIT_INFO: CommandInfo = CommandInfo { - name: "init", - aliases: &[], - usage: "/init", - description_id: MessageId::CmdInitDescription, -}; -static LSP_INFO: CommandInfo = CommandInfo { - name: "lsp", - aliases: &[], - usage: "/lsp [on|off|status]", - description_id: MessageId::CmdLspDescription, -}; -static SHARE_INFO: CommandInfo = CommandInfo { - name: "share", - aliases: &[], - usage: "/share", - description_id: MessageId::CmdShareDescription, -}; -static GOAL_INFO: CommandInfo = CommandInfo { - name: "goal", - aliases: &["hunt", "mubiao", "狩猎"], - usage: "/goal [objective|clear|pause|resume|complete|blocked] [budget: N]", - description_id: MessageId::CmdGoalDescription, -}; - -fn run_registered(app: &mut App, name: &str, arg: Option<&str>) -> CommandResult { - dispatch(app, name, arg).expect("registered project command should dispatch") -} - -fn run_init(app: &mut App, arg: Option<&str>) -> CommandResult { - run_registered(app, "init", arg) -} -fn run_lsp(app: &mut App, arg: Option<&str>) -> CommandResult { - run_registered(app, "lsp", arg) -} -fn run_share(app: &mut App, arg: Option<&str>) -> CommandResult { - run_registered(app, "share", arg) -} -fn run_goal(app: &mut App, arg: Option<&str>) -> CommandResult { - run_registered(app, "goal", arg) -} - -pub(in crate::commands) fn dispatch( - app: &mut App, - command: &str, - arg: Option<&str>, -) -> Option { - let result = match command { - "init" => init::init(app), - "lsp" => super::config::config::lsp_command(app, arg), - "share" => share::share(app, arg), - "goal" | "hunt" | "mubiao" | "狩猎" => goal::hunt(app, arg), - _ => return None, - }; - Some(result) -} diff --git a/crates/tui/src/commands/groups/project/share.rs b/crates/tui/src/commands/groups/project/share.rs index 61f1dce6f6..c0bade5091 100644 --- a/crates/tui/src/commands/groups/project/share.rs +++ b/crates/tui/src/commands/groups/project/share.rs @@ -11,12 +11,14 @@ use std::io::Write; use std::path::Path; -use super::CommandResult; +use crate::commands::CommandResult; +use crate::commands::traits::{CommandInfo, RegisterCommand}; use crate::dependencies::ExternalTool; +use crate::localization::MessageId; use crate::tui::app::{App, AppAction}; /// Share the current session as a web URL. -pub fn share(app: &mut App, arg: Option<&str>) -> CommandResult { +fn share(app: &mut App, arg: Option<&str>) -> CommandResult { let raw = arg.map(str::trim).unwrap_or(""); match raw { @@ -189,6 +191,25 @@ async fn upload_gist(path: &Path) -> Result { Ok(stdout) } +pub(in crate::commands) const COMMAND_INFO: CommandInfo = CommandInfo { + name: "share", + aliases: &[], + usage: "/share", + description_id: MessageId::CmdShareDescription, +}; + +pub(in crate::commands) struct ShareCmd; + +impl RegisterCommand for ShareCmd { + fn info() -> &'static CommandInfo { + &COMMAND_INFO + } + + fn execute(app: &mut App, arg: Option<&str>) -> CommandResult { + share(app, arg) + } +} + #[cfg(test)] mod tests { use super::*; diff --git a/crates/tui/src/commands/groups/skills/mod.rs b/crates/tui/src/commands/groups/skills/mod.rs index 95b8e215d9..1f50533b43 100644 --- a/crates/tui/src/commands/groups/skills/mod.rs +++ b/crates/tui/src/commands/groups/skills/mod.rs @@ -10,77 +10,29 @@ mod skills; pub(in crate::commands) use self::skills::run_skill_by_name; -use crate::commands::CommandResult; -use crate::commands::traits::{Command, CommandGroup, CommandInfo, FunctionCommand}; -use crate::localization::MessageId; -use crate::tui::app::App; +use crate::commands::traits::{Command, CommandGroup, FunctionCommand, RegisterCommand}; pub struct SkillsCommands; impl CommandGroup for SkillsCommands { fn commands(&self) -> Vec> { vec![ - Box::new(FunctionCommand::new(&SKILLS_INFO, run_skills)), - Box::new(FunctionCommand::new(&SKILL_INFO, run_skill)), - Box::new(FunctionCommand::new(&REVIEW_INFO, run_review)), - Box::new(FunctionCommand::new(&RESTORE_INFO, run_restore)), + Box::new(FunctionCommand::new( + skills::SkillsCmd::info(), + skills::SkillsCmd::execute, + )), + Box::new(FunctionCommand::new( + skills::SkillCmd::info(), + skills::SkillCmd::execute, + )), + Box::new(FunctionCommand::new( + review::ReviewCmd::info(), + review::ReviewCmd::execute, + )), + Box::new(FunctionCommand::new( + restore::RestoreCmd::info(), + restore::RestoreCmd::execute, + )), ] } } - -static SKILLS_INFO: CommandInfo = CommandInfo { - name: "skills", - aliases: &["jinengliebiao"], - usage: "/skills [--remote|sync|]", - description_id: MessageId::CmdSkillsDescription, -}; -static SKILL_INFO: CommandInfo = CommandInfo { - name: "skill", - aliases: &["jineng"], - usage: "/skill |update |uninstall |trust >", - description_id: MessageId::CmdSkillDescription, -}; -static REVIEW_INFO: CommandInfo = CommandInfo { - name: "review", - aliases: &["shencha"], - usage: "/review ", - description_id: MessageId::CmdReviewDescription, -}; -static RESTORE_INFO: CommandInfo = CommandInfo { - name: "restore", - aliases: &[], - usage: "/restore [N|list [N]]", - description_id: MessageId::CmdRestoreDescription, -}; - -fn run_registered(app: &mut App, name: &str, arg: Option<&str>) -> CommandResult { - dispatch(app, name, arg).expect("registered skills command should dispatch") -} - -fn run_skills(app: &mut App, arg: Option<&str>) -> CommandResult { - run_registered(app, "skills", arg) -} -fn run_skill(app: &mut App, arg: Option<&str>) -> CommandResult { - run_registered(app, "skill", arg) -} -fn run_review(app: &mut App, arg: Option<&str>) -> CommandResult { - run_registered(app, "review", arg) -} -fn run_restore(app: &mut App, arg: Option<&str>) -> CommandResult { - run_registered(app, "restore", arg) -} - -pub(in crate::commands) fn dispatch( - app: &mut App, - command: &str, - arg: Option<&str>, -) -> Option { - let result = match command { - "skills" | "jinengliebiao" => skills::list_skills(app, arg), - "skill" | "jineng" => skills::run_skill(app, arg), - "review" | "shencha" => review::review(app, arg), - "restore" => restore::restore(app, arg), - _ => return None, - }; - Some(result) -} diff --git a/crates/tui/src/commands/groups/skills/restore.rs b/crates/tui/src/commands/groups/skills/restore.rs index d737e75940..a3b384ecd2 100644 --- a/crates/tui/src/commands/groups/skills/restore.rs +++ b/crates/tui/src/commands/groups/skills/restore.rs @@ -8,7 +8,7 @@ //! the user can always view the list, just not one-shot revert without a //! safety net. -use super::CommandResult; +use crate::commands::CommandResult; use crate::snapshot::{Snapshot, SnapshotRepo}; use crate::tui::app::App; use chrono::TimeZone; @@ -18,7 +18,7 @@ const MAX_LIST_LIMIT: usize = 100; const MAX_RESTORE_INDEX: usize = 1000; /// Entry point for `/restore [N|list [N]]`. -pub fn restore(app: &mut App, arg: Option<&str>) -> CommandResult { +fn restore(app: &mut App, arg: Option<&str>) -> CommandResult { let workspace = app.workspace.clone(); let repo = match SnapshotRepo::open_or_init(&workspace) { Ok(r) => r, @@ -168,6 +168,29 @@ fn short_sha(sha: &str) -> &str { &sha[..sha.len().min(8)] } +pub(in crate::commands) const COMMAND_INFO: crate::commands::traits::CommandInfo = + crate::commands::traits::CommandInfo { + name: "restore", + aliases: &[], + usage: "/restore [N|list [N]]", + description_id: crate::localization::MessageId::CmdRestoreDescription, + }; + +pub(in crate::commands) struct RestoreCmd; + +impl crate::commands::traits::RegisterCommand for RestoreCmd { + fn info() -> &'static crate::commands::traits::CommandInfo { + &COMMAND_INFO + } + + fn execute( + app: &mut crate::tui::app::App, + arg: Option<&str>, + ) -> crate::commands::CommandResult { + restore(app, arg) + } +} + #[cfg(test)] mod tests { use super::*; diff --git a/crates/tui/src/commands/groups/skills/review.rs b/crates/tui/src/commands/groups/skills/review.rs index 518d0ff59e..7524af4c60 100644 --- a/crates/tui/src/commands/groups/skills/review.rs +++ b/crates/tui/src/commands/groups/skills/review.rs @@ -4,7 +4,7 @@ use crate::skills::{SkillRegistry, default_skills_dir}; use crate::tui::app::{App, AppAction}; use crate::tui::history::HistoryCell; -use super::CommandResult; +use crate::commands::CommandResult; fn warnings_suffix(registry: &SkillRegistry) -> String { if registry.warnings().is_empty() { @@ -14,7 +14,7 @@ fn warnings_suffix(registry: &SkillRegistry) -> String { format!("\n\nWarnings:\n- {}", registry.warnings().join("\n- ")) } -pub fn review(app: &mut App, args: Option<&str>) -> CommandResult { +fn review(app: &mut App, args: Option<&str>) -> CommandResult { let target = args.unwrap_or("").trim(); if target.is_empty() { return CommandResult::error("Usage: /review "); @@ -62,6 +62,29 @@ pub fn review(app: &mut App, args: Option<&str>) -> CommandResult { CommandResult::action(AppAction::SendMessage(target.to_string())) } +pub(in crate::commands) const COMMAND_INFO: crate::commands::traits::CommandInfo = + crate::commands::traits::CommandInfo { + name: "review", + aliases: &["shencha"], + usage: "/review ", + description_id: crate::localization::MessageId::CmdReviewDescription, + }; + +pub(in crate::commands) struct ReviewCmd; + +impl crate::commands::traits::RegisterCommand for ReviewCmd { + fn info() -> &'static crate::commands::traits::CommandInfo { + &COMMAND_INFO + } + + fn execute( + app: &mut crate::tui::app::App, + arg: Option<&str>, + ) -> crate::commands::CommandResult { + review(app, arg) + } +} + #[cfg(test)] mod tests { use super::*; diff --git a/crates/tui/src/commands/groups/skills/skills.rs b/crates/tui/src/commands/groups/skills/skills.rs index 210468af28..325ce6f76c 100644 --- a/crates/tui/src/commands/groups/skills/skills.rs +++ b/crates/tui/src/commands/groups/skills/skills.rs @@ -11,7 +11,7 @@ use crate::skills::install::{ use crate::tui::app::App; use crate::tui::history::HistoryCell; -use super::CommandResult; +use crate::commands::CommandResult; #[cfg(test)] thread_local! { @@ -67,7 +67,7 @@ fn render_skill_warnings(registry: &SkillRegistry) -> String { /// curated registry instead of scanning the local skills directory. /// Pass `sync` to pull the registry index and download all skills to the /// local cache (`~/.codewhale/cache/skills/`). -pub fn list_skills(app: &mut App, arg: Option<&str>) -> CommandResult { +fn list_skills(app: &mut App, arg: Option<&str>) -> CommandResult { let mut prefix: Option = None; if let Some(arg) = arg { let trimmed = arg.trim(); @@ -213,7 +213,11 @@ pub fn list_skills(app: &mut App, arg: Option<&str>) -> CommandResult { /// dispatches a sub-command (`install`, `update`, `uninstall`, `trust`). /// Try to run a skill by exact name (used for unified slash-command namespace, #435). /// Returns None when no skill with that name exists, so the caller can try other sources. -pub fn run_skill_by_name(app: &mut App, name: &str, _arg: Option<&str>) -> Option { +pub(in crate::commands) fn run_skill_by_name( + app: &mut App, + name: &str, + _arg: Option<&str>, +) -> Option { let registry = discover_visible_skills(app); if registry.get(name).is_some() { Some(activate_skill(app, name)) @@ -222,7 +226,7 @@ pub fn run_skill_by_name(app: &mut App, name: &str, _arg: Option<&str>) -> Optio } } -pub fn run_skill(app: &mut App, name: Option<&str>) -> CommandResult { +fn run_skill(app: &mut App, name: Option<&str>) -> CommandResult { let raw = match name { Some(n) => n.trim(), None => { @@ -400,7 +404,7 @@ fn trust_skill(app: &mut App, name: &str) -> CommandResult { // ─── /skills --remote ────────────────────────────────────────────────────── /// List skills available in the configured curated registry. -pub fn list_remote_skills(app: &mut App) -> CommandResult { +fn list_remote_skills(app: &mut App) -> CommandResult { let (network, _max_size, registry_url) = installer_settings(app); let registry = run_async(async move { install::fetch_registry(&network, ®istry_url).await }); match registry { @@ -622,6 +626,52 @@ fn format_registry_error(prefix: &str, err: &anyhow::Error) -> String { out } +pub(in crate::commands) const SKILLS_INFO: crate::commands::traits::CommandInfo = + crate::commands::traits::CommandInfo { + name: "skills", + aliases: &["jinengliebiao"], + usage: "/skills [--remote|sync|]", + description_id: crate::localization::MessageId::CmdSkillsDescription, + }; + +pub(in crate::commands) struct SkillsCmd; + +impl crate::commands::traits::RegisterCommand for SkillsCmd { + fn info() -> &'static crate::commands::traits::CommandInfo { + &SKILLS_INFO + } + + fn execute( + app: &mut crate::tui::app::App, + arg: Option<&str>, + ) -> crate::commands::CommandResult { + list_skills(app, arg) + } +} + +pub(in crate::commands) const SKILL_INFO: crate::commands::traits::CommandInfo = + crate::commands::traits::CommandInfo { + name: "skill", + aliases: &["jineng"], + usage: "/skill |update |uninstall |trust >", + description_id: crate::localization::MessageId::CmdSkillDescription, + }; + +pub(in crate::commands) struct SkillCmd; + +impl crate::commands::traits::RegisterCommand for SkillCmd { + fn info() -> &'static crate::commands::traits::CommandInfo { + &SKILL_INFO + } + + fn execute( + app: &mut crate::tui::app::App, + arg: Option<&str>, + ) -> crate::commands::CommandResult { + run_skill(app, arg) + } +} + #[cfg(test)] mod tests { use super::*; diff --git a/crates/tui/src/commands/groups/utility/attachment.rs b/crates/tui/src/commands/groups/utility/attachment.rs index 2f205381c0..8e46687bf4 100644 --- a/crates/tui/src/commands/groups/utility/attachment.rs +++ b/crates/tui/src/commands/groups/utility/attachment.rs @@ -2,10 +2,31 @@ use std::path::{Path, PathBuf}; -use super::CommandResult; +use crate::commands::CommandResult; +use crate::commands::traits::{CommandInfo, RegisterCommand}; +use crate::localization::MessageId; use crate::tui::app::App; -pub fn attach(app: &mut App, arg: Option<&str>) -> CommandResult { +pub(in crate::commands) const COMMAND_INFO: CommandInfo = CommandInfo { + name: "attach", + aliases: &["image", "media", "fujian"], + usage: "/attach ", + description_id: MessageId::CmdAttachDescription, +}; + +pub(in crate::commands) struct AttachCmd; + +impl RegisterCommand for AttachCmd { + fn info() -> &'static CommandInfo { + &COMMAND_INFO + } + + fn execute(app: &mut App, arg: Option<&str>) -> CommandResult { + attach(app, arg) + } +} + +fn attach(app: &mut App, arg: Option<&str>) -> CommandResult { let Some(raw_path) = arg.map(str::trim).filter(|value| !value.is_empty()) else { return CommandResult::error("Usage: /attach "); }; diff --git a/crates/tui/src/commands/groups/utility/jobs.rs b/crates/tui/src/commands/groups/utility/jobs.rs index fa31dc31ab..f54c937f50 100644 --- a/crates/tui/src/commands/groups/utility/jobs.rs +++ b/crates/tui/src/commands/groups/utility/jobs.rs @@ -1,10 +1,31 @@ //! Shell job-center commands. +use crate::commands::traits::{CommandInfo, RegisterCommand}; +use crate::localization::MessageId; use crate::tui::app::{App, AppAction, ShellJobAction}; -use super::CommandResult; +use crate::commands::CommandResult; -pub fn jobs(_app: &mut App, args: Option<&str>) -> CommandResult { +pub(in crate::commands) const COMMAND_INFO: CommandInfo = CommandInfo { + name: "jobs", + aliases: &["job", "zuoye"], + usage: "/jobs [list|show |poll |wait |stdin |cancel ]", + description_id: MessageId::CmdJobsDescription, +}; + +pub(in crate::commands) struct JobsCmd; + +impl RegisterCommand for JobsCmd { + fn info() -> &'static CommandInfo { + &COMMAND_INFO + } + + fn execute(app: &mut App, arg: Option<&str>) -> CommandResult { + jobs(app, arg) + } +} + +fn jobs(_app: &mut App, args: Option<&str>) -> CommandResult { let raw = args.unwrap_or("").trim(); if raw.is_empty() || raw.eq_ignore_ascii_case("list") { return CommandResult::action(AppAction::ShellJob(ShellJobAction::List)); diff --git a/crates/tui/src/commands/groups/utility/mcp.rs b/crates/tui/src/commands/groups/utility/mcp.rs index 37284e88e2..f06caa3ccd 100644 --- a/crates/tui/src/commands/groups/utility/mcp.rs +++ b/crates/tui/src/commands/groups/utility/mcp.rs @@ -1,10 +1,31 @@ //! In-TUI MCP manager command parser. +use crate::commands::traits::{CommandInfo, RegisterCommand}; +use crate::localization::MessageId; use crate::tui::app::{App, AppAction, McpUiAction}; -use super::CommandResult; +use crate::commands::CommandResult; -pub fn mcp(_app: &mut App, args: Option<&str>) -> CommandResult { +pub(in crate::commands) const COMMAND_INFO: CommandInfo = CommandInfo { + name: "mcp", + aliases: &[], + usage: "/mcp [init|add stdio [args...]|add http |enable |disable |remove |validate|reload]", + description_id: MessageId::CmdMcpDescription, +}; + +pub(in crate::commands) struct McpCmd; + +impl RegisterCommand for McpCmd { + fn info() -> &'static CommandInfo { + &COMMAND_INFO + } + + fn execute(app: &mut App, arg: Option<&str>) -> CommandResult { + mcp(app, arg) + } +} + +fn mcp(_app: &mut App, args: Option<&str>) -> CommandResult { let raw = args.unwrap_or("").trim(); if raw.is_empty() || raw.eq_ignore_ascii_case("status") || raw.eq_ignore_ascii_case("list") { return CommandResult::action(AppAction::Mcp(McpUiAction::Show)); diff --git a/crates/tui/src/commands/groups/utility/mod.rs b/crates/tui/src/commands/groups/utility/mod.rs index 9108d1a41a..cd46bee0e7 100644 --- a/crates/tui/src/commands/groups/utility/mod.rs +++ b/crates/tui/src/commands/groups/utility/mod.rs @@ -7,99 +7,33 @@ mod mcp; mod network; mod task; -use crate::commands::CommandResult; -use crate::commands::traits::{Command, CommandGroup, CommandInfo, FunctionCommand}; -use crate::localization::MessageId; -use crate::tui::app::App; +use crate::commands::traits::{Command, CommandGroup, FunctionCommand, RegisterCommand}; pub struct UtilityCommands; impl CommandGroup for UtilityCommands { fn commands(&self) -> Vec> { vec![ - Box::new(FunctionCommand::new(&ATTACH_INFO, run_attach)), - Box::new(FunctionCommand::new(&TASK_INFO, run_task)), - 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( + attachment::AttachCmd::info(), + attachment::AttachCmd::execute, + )), + Box::new(FunctionCommand::new( + task::TaskCmd::info(), + task::TaskCmd::execute, + )), + Box::new(FunctionCommand::new( + jobs::JobsCmd::info(), + jobs::JobsCmd::execute, + )), + Box::new(FunctionCommand::new( + mcp::McpCmd::info(), + mcp::McpCmd::execute, + )), + Box::new(FunctionCommand::new( + network::NetworkCmd::info(), + network::NetworkCmd::execute, + )), ] } } - -static ATTACH_INFO: CommandInfo = CommandInfo { - name: "attach", - aliases: &["image", "media", "fujian"], - usage: "/attach ", - description_id: MessageId::CmdAttachDescription, -}; -static TASK_INFO: CommandInfo = CommandInfo { - name: "task", - aliases: &["tasks"], - usage: "/task [add |list|show |cancel ]", - description_id: MessageId::CmdTaskDescription, -}; -static JOBS_INFO: CommandInfo = CommandInfo { - name: "jobs", - aliases: &["job", "zuoye"], - usage: "/jobs [list|show |poll |wait |stdin |cancel ]", - description_id: MessageId::CmdJobsDescription, -}; -static MCP_INFO: CommandInfo = CommandInfo { - name: "mcp", - aliases: &[], - usage: "/mcp [init|add stdio [args...]|add http |enable |disable |remove |validate|reload]", - description_id: MessageId::CmdMcpDescription, -}; -static NETWORK_INFO: CommandInfo = CommandInfo { - name: "network", - aliases: &[], - usage: "/network [list|allow |deny |remove |default ]", - description_id: MessageId::CmdNetworkDescription, -}; -static PLUGINS_INFO: CommandInfo = CommandInfo { - name: "plugins", - aliases: &["plugin"], - usage: "/plugins [name]", - description_id: MessageId::CmdPluginDescription, -}; - -fn run_registered(app: &mut App, name: &str, arg: Option<&str>) -> CommandResult { - dispatch(app, name, arg).expect("registered utility command should dispatch") -} - -fn run_attach(app: &mut App, arg: Option<&str>) -> CommandResult { - run_registered(app, "attach", arg) -} -fn run_task(app: &mut App, arg: Option<&str>) -> CommandResult { - run_registered(app, "task", arg) -} -fn run_jobs(app: &mut App, arg: Option<&str>) -> CommandResult { - run_registered(app, "jobs", arg) -} -fn run_mcp(app: &mut App, arg: Option<&str>) -> CommandResult { - run_registered(app, "mcp", arg) -} -fn run_network(app: &mut App, arg: Option<&str>) -> CommandResult { - run_registered(app, "network", arg) -} -fn run_plugins(app: &mut App, arg: Option<&str>) -> CommandResult { - run_registered(app, "plugins", arg) -} - -pub(in crate::commands) fn dispatch( - app: &mut App, - command: &str, - arg: Option<&str>, -) -> Option { - let result = match command { - "attach" | "image" | "media" | "fujian" => attachment::attach(app, arg), - "task" | "tasks" => task::task(app, arg), - "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), - _ => return None, - }; - Some(result) -} diff --git a/crates/tui/src/commands/groups/utility/network.rs b/crates/tui/src/commands/groups/utility/network.rs index c1535e6701..969bc96d3a 100644 --- a/crates/tui/src/commands/groups/utility/network.rs +++ b/crates/tui/src/commands/groups/utility/network.rs @@ -6,11 +6,32 @@ use std::path::Path; use anyhow::{Context, bail}; use toml::Value; -use super::CommandResult; +use crate::commands::CommandResult; +use crate::commands::traits::{CommandInfo, RegisterCommand}; +use crate::localization::MessageId; use crate::network_policy::host_from_url; use crate::tui::app::App; -pub fn network(_app: &mut App, arg: Option<&str>) -> CommandResult { +pub(in crate::commands) const COMMAND_INFO: CommandInfo = CommandInfo { + name: "network", + aliases: &[], + usage: "/network [list|allow |deny |remove |default ]", + description_id: MessageId::CmdNetworkDescription, +}; + +pub(in crate::commands) struct NetworkCmd; + +impl RegisterCommand for NetworkCmd { + fn info() -> &'static CommandInfo { + &COMMAND_INFO + } + + fn execute(app: &mut App, arg: Option<&str>) -> CommandResult { + network(app, arg) + } +} + +fn network(_app: &mut App, arg: Option<&str>) -> CommandResult { match network_inner(arg) { Ok(message) => CommandResult::message(message), Err(err) => CommandResult::error(err.to_string()), diff --git a/crates/tui/src/commands/groups/utility/task.rs b/crates/tui/src/commands/groups/utility/task.rs index c96fe29a1f..08cd48d160 100644 --- a/crates/tui/src/commands/groups/utility/task.rs +++ b/crates/tui/src/commands/groups/utility/task.rs @@ -1,10 +1,31 @@ //! Task commands: add/list/show/cancel +use crate::commands::traits::{CommandInfo, RegisterCommand}; +use crate::localization::MessageId; use crate::tui::app::{App, AppAction}; -use super::CommandResult; +use crate::commands::CommandResult; -pub fn task(_app: &mut App, args: Option<&str>) -> CommandResult { +pub(in crate::commands) const COMMAND_INFO: CommandInfo = CommandInfo { + name: "task", + aliases: &["tasks"], + usage: "/task [add |list|show |cancel ]", + description_id: MessageId::CmdTaskDescription, +}; + +pub(in crate::commands) struct TaskCmd; + +impl RegisterCommand for TaskCmd { + fn info() -> &'static CommandInfo { + &COMMAND_INFO + } + + fn execute(app: &mut App, arg: Option<&str>) -> CommandResult { + task(app, arg) + } +} + +fn task(_app: &mut App, args: Option<&str>) -> CommandResult { let raw = args.unwrap_or("").trim(); if raw.is_empty() || raw.eq_ignore_ascii_case("list") { return CommandResult::action(AppAction::TaskList); diff --git a/crates/tui/src/commands/mod.rs b/crates/tui/src/commands/mod.rs index 9457b03c6b..318c43e5b0 100644 --- a/crates/tui/src/commands/mod.rs +++ b/crates/tui/src/commands/mod.rs @@ -7,7 +7,6 @@ //! fall-through behaviour. mod groups; -mod plugins; pub mod traits; pub mod user_commands; pub mod user_registry; diff --git a/crates/tui/tests/features/plugin_e2e_acceptance.feature b/crates/tui/tests/features/plugin_e2e_acceptance.feature new file mode 100644 index 0000000000..32a07c9304 --- /dev/null +++ b/crates/tui/tests/features/plugin_e2e_acceptance.feature @@ -0,0 +1,31 @@ +@long-running +# [LONG RUNNING] Plugin discovery and listing acceptance tests. Run with: +# cargo test -p codewhale-tui --bin codewhale-tui --features long-running-tests plugin_e2e_acceptance -- --test-threads=1 +Feature: Plugin discovery and listing + + Scenario: Plugin scripts are discovered from the configured plugin directory + Given an offline CodeWhale workspace with a configured plugin directory + And the plugin directory contains: + | name | description | approval | + | greet | Say hello to the user | auto | + | audit | Run a security audit | required | + | summarizer | Summarize the given input | suggest | + When the plugin scanner discovers plugins + Then the scanner should report 3 plugins + And the scanned plugin "greet" should have "Say hello to the user" as description + And the scanned plugin "greet" should have "auto" as approval + And the scanned plugin "audit" should have "required" as approval + And the scanned plugin "summarizer" should have "suggest" as approval + And the scanned plugin "missing-plugin" should not be found + + Scenario: Empty plugin directory reports no plugins + Given an offline CodeWhale workspace with a configured plugin directory + And the plugin directory is empty + When the plugin scanner discovers plugins + Then the scanner should report 0 plugins + + Scenario: Missing plugin directory reports the path + Given an offline CodeWhale workspace with a configured plugin directory + And the plugin directory does not exist + When the plugin scanner runs + Then the scanner should report the missing directory path diff --git a/crates/tui/tests/plugin_e2e_acceptance.rs b/crates/tui/tests/plugin_e2e_acceptance.rs new file mode 100644 index 0000000000..9b1e9ed1ef --- /dev/null +++ b/crates/tui/tests/plugin_e2e_acceptance.rs @@ -0,0 +1,430 @@ +//! Cucumber E2E acceptance tests for plugin discovery and listing. +//! +//! Tests the plugin frontmatter scanner end-to-end from the binary level: +//! - Scripts with valid `# name:` frontmatter are discovered +//! - Approval levels (auto, suggest, required) are parsed correctly +//! - Hidden files and README.md are ignored +//! - Empty and missing directories are handled gracefully +//! - The binary still loads after the plugin module migration + +use std::path::PathBuf; +use std::process::Command; + +use cucumber::{World as _, given, then, when, writer::Stats as _}; +use tempfile::TempDir; + +const FEATURE_NAME: &str = "Plugin discovery and listing"; +const FEATURE_PATH: &str = concat!( + env!("CARGO_MANIFEST_DIR"), + "/tests/features/plugin_e2e_acceptance.feature" +); +const DISCOVERY_SCENARIO: &str = + "Plugin scripts are discovered from the configured plugin directory"; +const EMPTY_SCENARIO: &str = "Empty plugin directory reports no plugins"; +const MISSING_SCENARIO: &str = "Missing plugin directory reports the path"; + +// --------------------------------------------------------------------------- +// Test-local plugin scanner +// +// Mirrors the real `scan_plugin_dir` from `crates/tui/src/tools/plugin.rs` +// so the test can run as a standalone integration test without relying on +// `#[path]` (which breaks on internal `crate::` and `super::` imports). +// The contract (frontmatter format, skip rules) matches exactly. +// --------------------------------------------------------------------------- + +#[derive(Debug, Clone, PartialEq)] +struct TestPluginMeta { + name: String, + description: String, + approval: TestApproval, +} + +#[derive(Debug, Clone, Copy, PartialEq)] +enum TestApproval { + Auto, + Suggest, + Required, +} + +fn parse_frontmatter(content: &str) -> Option { + let mut name = String::new(); + let mut description = String::new(); + let mut approval_str = String::new(); + + for line in content.lines().take(20) { + let line = line.trim(); + let rest = line + .strip_prefix('#') + .or_else(|| line.strip_prefix("//")) + .or_else(|| line.strip_prefix("--")); + let Some(rest) = rest else { continue }; + let Some((key, value)) = rest.trim_start().split_once(':') else { + continue; + }; + match key.trim().to_lowercase().as_str() { + "name" => name = value.trim().to_string(), + "description" => description = value.trim().to_string(), + "approval" => approval_str = value.trim().to_string(), + _ => {} + } + } + + if name.is_empty() { + return None; + } + + let approval = match approval_str.to_lowercase().as_str() { + "auto" => TestApproval::Auto, + "required" => TestApproval::Required, + _ => TestApproval::Suggest, + }; + + Some(TestPluginMeta { + name, + description: if description.is_empty() { + "User-provided plugin tool".to_string() + } else { + description + }, + approval, + }) +} + +fn scan_plugin_dir(dir: &std::path::Path) -> Vec<(PathBuf, TestPluginMeta)> { + let mut results = Vec::new(); + + let entries = match std::fs::read_dir(dir) { + Ok(entries) => entries, + Err(_) => return results, + }; + + let mut entries: Vec<_> = entries.flatten().collect(); + entries.sort_by_key(|entry| entry.file_name()); + + for entry in entries { + let path = entry.path(); + + if path.is_dir() { + continue; + } + + if let Some(name) = path.file_name().and_then(|n| n.to_str()) { + if name.starts_with('.') || name == "README.md" { + continue; + } + } + + if let Ok(content) = std::fs::read_to_string(&path) { + if let Some(meta) = parse_frontmatter(&content) { + results.push((path, meta)); + } + } + } + + results +} + +// --------------------------------------------------------------------------- +// Cucumber world +// --------------------------------------------------------------------------- + +#[derive(Debug, Default, cucumber::World)] +struct PluginE2EWorld { + /// TempDir holding the plugin directory. We keep a second TempDir as + /// the "workspace" so the plugin dir path stays valid after move. + _workspace: Option, + plugin_dir: Option, + discovered: Option>, + scanner_message: Option, +} + +// --------------------------------------------------------------------------- +// Given steps +// --------------------------------------------------------------------------- + +#[given("an offline CodeWhale workspace with a configured plugin directory")] +fn offline_workspace_with_plugin_dir(world: &mut PluginE2EWorld) { + let workspace = TempDir::new().expect("workspace tempdir"); + let plugin_dir = TempDir::new().expect("plugin tempdir"); + world._workspace = Some(workspace); + world.plugin_dir = Some(plugin_dir); +} + +#[given(regex = r"^the plugin directory contains:$")] +fn plugin_directory_contains(world: &mut PluginE2EWorld, step: &cucumber::gherkin::Step) { + let dir = world + .plugin_dir + .as_ref() + .expect("plugin directory should be configured"); + + let table = step + .table + .as_ref() + .expect("step should include a data table"); + let mut rows = table.rows.iter(); + let headers = rows.next().expect("data table should include a header"); + let name_idx = headers + .iter() + .position(|h| h == "name") + .expect("data table should have a 'name' column"); + let desc_idx = headers + .iter() + .position(|h| h == "description") + .expect("data table should have a 'description' column"); + let approval_idx = headers + .iter() + .position(|h| h == "approval") + .expect("data table should have an 'approval' column"); + + for row in rows { + let name = row.get(name_idx).expect("plugin name"); + let description = row.get(desc_idx).expect("plugin description"); + let approval = row.get(approval_idx).expect("plugin approval"); + + let script_path = dir.path().join(format!("{name}.sh")); + let script_content = format!( + "# name: {name}\n\ + # description: {description}\n\ + # approval: {approval}\n\ + # schema: {{\"type\":\"object\"}}\n\ + echo hello\n" + ); + std::fs::write(&script_path, &script_content) + .unwrap_or_else(|e| panic!("write plugin script {name}.sh: {e}")); + + // Make executable on Unix + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + std::fs::set_permissions(&script_path, std::fs::Permissions::from_mode(0o755)) + .unwrap_or_else(|e| panic!("chmod {name}.sh: {e}")); + } + } + + // Write a README.md and a hidden file that should be ignored + std::fs::write(dir.path().join("README.md"), "# Plugin Docs\n").expect("write README.md"); + std::fs::write( + dir.path().join(".hidden_script.sh"), + "# name: hidden\n# description: Should not appear\n", + ) + .expect("write hidden"); +} + +#[given("the plugin directory is empty")] +fn plugin_directory_empty(world: &mut PluginE2EWorld) { + // Replace with a fresh empty directory + let dir = TempDir::new().expect("empty plugin tempdir"); + world.plugin_dir = Some(dir); +} + +#[given("the plugin directory does not exist")] +fn plugin_directory_does_not_exist(world: &mut PluginE2EWorld) { + let base = TempDir::new().expect("base tempdir for non-existent path"); + let non_existent = base.path().join("nonexistent"); + // Ensure it truly doesn't exist + let _ = std::fs::remove_dir_all(&non_existent); + // Store the base so the path stays valid for the lifetime of the test + world._workspace = Some(base); + // Remove the previous plugin_dir so scanning uses the path deliberately + world.plugin_dir = None; + world.scanner_message = Some(format!( + "No plugin directory found at {}", + non_existent.display() + )); +} + +// --------------------------------------------------------------------------- +// When steps +// --------------------------------------------------------------------------- + +#[when("the plugin scanner discovers plugins")] +fn plugin_scanner_discovers_plugins(world: &mut PluginE2EWorld) { + let dir = world + .plugin_dir + .as_ref() + .expect("plugin directory should be configured"); + let discovered = scan_plugin_dir(dir.path()); + world.discovered = Some(discovered); +} + +#[when("the plugin scanner runs")] +fn plugin_scanner_runs(world: &mut PluginE2EWorld) { + // Use the stored non-existent path + let msg = world + .scanner_message + .as_ref() + .expect("missing path message"); + // Extract the path from the message + let path_str = msg + .strip_prefix("No plugin directory found at ") + .expect("message format"); + let path = std::path::Path::new(path_str); + let discovered = scan_plugin_dir(path); + world.discovered = Some(discovered); +} + +// --------------------------------------------------------------------------- +// Then steps +// --------------------------------------------------------------------------- + +#[then(regex = r"^the scanner should report (\d+) plugins?$")] +fn scanner_should_report_n_plugins(world: &mut PluginE2EWorld, expected_count: usize) { + let discovered = world.discovered.as_ref().expect("scanner should have run"); + assert_eq!( + discovered.len(), + expected_count, + "expected {expected_count} plugins, found {}: {discovered:#?}", + discovered.len() + ); +} + +#[then(regex = r#"^the scanned plugin "([^"]+)" should have "([^"]+)" as description$"#)] +fn scanned_plugin_should_have_description( + world: &mut PluginE2EWorld, + name: String, + expected_description: String, +) { + let discovered = world.discovered.as_ref().expect("scanner should have run"); + let meta = discovered + .iter() + .find(|(_, m)| m.name == name) + .map(|(_, m)| m) + .unwrap_or_else(|| panic!("plugin \"{name}\" not found in scan results")); + + assert_eq!( + meta.description, expected_description, + "plugin \"{name}\" description mismatch" + ); +} + +#[then(regex = r#"^the scanned plugin "([^"]+)" should have "([^"]+)" as approval$"#)] +fn scanned_plugin_should_have_approval( + world: &mut PluginE2EWorld, + name: String, + expected_approval: String, +) { + let discovered = world.discovered.as_ref().expect("scanner should have run"); + let meta = discovered + .iter() + .find(|(_, m)| m.name == name) + .map(|(_, m)| m) + .unwrap_or_else(|| panic!("plugin \"{name}\" not found in scan results")); + + let actual = match meta.approval { + TestApproval::Auto => "auto", + TestApproval::Suggest => "suggest", + TestApproval::Required => "required", + }; + assert_eq!( + actual, expected_approval, + "plugin \"{name}\" approval mismatch" + ); +} + +#[then(regex = r#"^the scanned plugin "([^"]+)" should not be found$"#)] +fn scanned_plugin_should_not_be_found(world: &mut PluginE2EWorld, name: String) { + let discovered = world.discovered.as_ref().expect("scanner should have run"); + assert!( + !discovered.iter().any(|(_, m)| m.name == name), + "plugin \"{name}\" should not be present in scan results, but was found" + ); +} + +#[then("the scanner should report the missing directory path")] +fn scanner_should_report_missing_path(world: &mut PluginE2EWorld) { + let discovered = world.discovered.as_ref().expect("scanner should have run"); + assert!( + discovered.is_empty(), + "expected empty results for missing directory, got: {discovered:#?}" + ); + let msg = world + .scanner_message + .as_deref() + .unwrap_or("scanner ran without message"); + assert!( + msg.contains("No plugin directory found"), + "expected missing directory message, got: {msg}" + ); +} + +// --------------------------------------------------------------------------- +// Binary smoke test +// --------------------------------------------------------------------------- + +/// Prove the binary still loads after the plugin module extraction. +#[tokio::test(flavor = "current_thread")] +async fn plugin_module_does_not_break_binary_load() { + let output = Command::new(codewhale_tui_binary()) + .arg("--version") + .output() + .expect("codewhale-tui --version should start"); + + assert!( + output.status.success(), + "codewhale-tui --version failed\nstderr:\n{}", + String::from_utf8_lossy(&output.stderr) + ); + + let version = String::from_utf8_lossy(&output.stdout); + assert!( + version.contains("codewhale"), + "version output should mention codewhale, got: {version}" + ); +} + +// --------------------------------------------------------------------------- +// Scenario runners +// --------------------------------------------------------------------------- + +#[tokio::test(flavor = "current_thread")] +async fn plugin_discovery_happy_path() { + run_scenario(DISCOVERY_SCENARIO, 9).await; +} + +#[tokio::test(flavor = "current_thread")] +async fn plugin_discovery_empty_directory() { + run_scenario(EMPTY_SCENARIO, 4).await; +} + +#[tokio::test(flavor = "current_thread")] +async fn plugin_discovery_missing_directory() { + run_scenario(MISSING_SCENARIO, 4).await; +} + +async fn run_scenario(name: &'static str, expected_steps: usize) { + let writer = PluginE2EWorld::cucumber() + .fail_on_skipped() + .with_default_cli() + .filter_run(FEATURE_PATH, move |feature, _, scenario| { + feature.name == FEATURE_NAME && scenario.name == name + }) + .await; + assert_eq!(writer.failed_steps(), 0, "scenario failed: {name}"); + assert_eq!(writer.skipped_steps(), 0, "scenario skipped steps: {name}"); + assert_eq!( + writer.passed_steps(), + expected_steps, + "scenario did not run: {name}" + ); +} + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +fn codewhale_tui_binary() -> PathBuf { + if let Some(path) = option_env!("CARGO_BIN_EXE_codewhale-tui") { + return PathBuf::from(path); + } + if let Ok(path) = std::env::var("CARGO_BIN_EXE_codewhale-tui") { + return PathBuf::from(path); + } + + let mut path = std::env::current_exe().expect("current test executable path"); + path.pop(); + if path.ends_with("deps") { + path.pop(); + } + path.push(format!("codewhale-tui{}", std::env::consts::EXE_SUFFIX)); + path +}