From 9074da891186b4548b3c9e05238cd4efd88934db Mon Sep 17 00:00:00 2001 From: Paulo Aboim Pinto Date: Thu, 25 Jun 2026 22:47:09 +0200 Subject: [PATCH 1/4] Phase 2: Establish command-module contracts and metadata boundaries for FEAT-007 Move CommandInfo statics from group mod.rs files into their respective command modules via the RegisterCommand trait pattern. Group files now contain only group wiring (CommandGroup impls with FunctionCommand adapters). This is the data layer and contract boundary for all 15 mandatory FEAT-007 commands: Project: init, goal, share, lsp (new lsp.rs module created) Memory: memory, note Skills: skills, skill, review, restore Utility: attach, task, jobs, mcp, network Key changes: - Each command module now owns its CommandInfo, XxxCmd struct, and RegisterCommand impl with info() and execute() methods - Group mod.rs files simplified to group wiring only - /lsp module created as project/lsp.rs delegating to config::config::lsp_command() - /plugins remains inline in utility/mod.rs as discretionary scope - All imports updated: super::CommandResult -> crate::commands::CommandResult - Registration order and user-command precedence preserved unchanged - All 127+ command-specific and 11+ contract-level tests pass --- .../tui/src/commands/groups/memory/memory.rs | 22 +++- crates/tui/src/commands/groups/memory/mod.rs | 52 ++------- crates/tui/src/commands/groups/memory/note.rs | 22 +++- .../tui/src/commands/groups/project/goal.rs | 23 +++- .../tui/src/commands/groups/project/init.rs | 22 +++- crates/tui/src/commands/groups/project/lsp.rs | 28 +++++ crates/tui/src/commands/groups/project/mod.rs | 83 ++++---------- .../tui/src/commands/groups/project/share.rs | 23 +++- crates/tui/src/commands/groups/skills/mod.rs | 82 +++----------- .../tui/src/commands/groups/skills/restore.rs | 22 +++- .../tui/src/commands/groups/skills/review.rs | 22 +++- .../tui/src/commands/groups/skills/skills.rs | 42 +++++++- .../src/commands/groups/utility/attachment.rs | 23 +++- .../tui/src/commands/groups/utility/jobs.rs | 23 +++- crates/tui/src/commands/groups/utility/mcp.rs | 23 +++- crates/tui/src/commands/groups/utility/mod.rs | 102 +++++------------- .../src/commands/groups/utility/network.rs | 23 +++- .../tui/src/commands/groups/utility/task.rs | 23 +++- 18 files changed, 400 insertions(+), 260 deletions(-) create mode 100644 crates/tui/src/commands/groups/project/lsp.rs diff --git a/crates/tui/src/commands/groups/memory/memory.rs b/crates/tui/src/commands/groups/memory/memory.rs index 0c9af71a67..6e76013eac 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]"; @@ -85,6 +85,26 @@ 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..3298497969 100644 --- a/crates/tui/src/commands/groups/memory/note.rs +++ b/crates/tui/src/commands/groups/memory/note.rs @@ -5,7 +5,7 @@ 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"; @@ -266,6 +266,26 @@ 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/project/goal.rs b/crates/tui/src/commands/groups/project/goal.rs index 67f6d220c0..61c61dddde 100644 --- a/crates/tui/src/commands/groups/project/goal.rs +++ b/crates/tui/src/commands/groups/project/goal.rs @@ -2,10 +2,12 @@ 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 { @@ -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..353bd1ff9e 100644 --- a/crates/tui/src/commands/groups/project/init.rs +++ b/crates/tui/src/commands/groups/project/init.rs @@ -13,7 +13,7 @@ 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. @@ -794,6 +794,26 @@ 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..e2ea14cc30 100644 --- a/crates/tui/src/commands/groups/project/share.rs +++ b/crates/tui/src/commands/groups/project/share.rs @@ -11,8 +11,10 @@ use std::io::Write; use std::path::Path; -use super::CommandResult; +use crate::commands::traits::{CommandInfo, RegisterCommand}; +use crate::commands::CommandResult; use crate::dependencies::ExternalTool; +use crate::localization::MessageId; use crate::tui::app::{App, AppAction}; /// Share the current session as a web URL. @@ -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..9ed0f1b4a7 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; @@ -168,6 +168,26 @@ 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..2467483ad8 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() { @@ -62,6 +62,26 @@ 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..1d3d7a7cbd 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! { @@ -622,6 +622,46 @@ 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..920520ce31 100644 --- a/crates/tui/src/commands/groups/utility/attachment.rs +++ b/crates/tui/src/commands/groups/utility/attachment.rs @@ -2,9 +2,30 @@ use std::path::{Path, PathBuf}; -use super::CommandResult; +use crate::commands::traits::{CommandInfo, RegisterCommand}; +use crate::commands::CommandResult; +use crate::localization::MessageId; use crate::tui::app::App; +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) + } +} + pub 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..351fcc990f 100644 --- a/crates/tui/src/commands/groups/utility/jobs.rs +++ b/crates/tui/src/commands/groups/utility/jobs.rs @@ -1,8 +1,29 @@ //! 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(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) + } +} pub fn jobs(_app: &mut App, args: Option<&str>) -> CommandResult { let raw = args.unwrap_or("").trim(); diff --git a/crates/tui/src/commands/groups/utility/mcp.rs b/crates/tui/src/commands/groups/utility/mcp.rs index 37284e88e2..608af29ae0 100644 --- a/crates/tui/src/commands/groups/utility/mcp.rs +++ b/crates/tui/src/commands/groups/utility/mcp.rs @@ -1,8 +1,29 @@ //! 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(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) + } +} pub fn mcp(_app: &mut App, args: Option<&str>) -> CommandResult { let raw = args.unwrap_or("").trim(); diff --git a/crates/tui/src/commands/groups/utility/mod.rs b/crates/tui/src/commands/groups/utility/mod.rs index 9108d1a41a..c4f6e85f04 100644 --- a/crates/tui/src/commands/groups/utility/mod.rs +++ b/crates/tui/src/commands/groups/utility/mod.rs @@ -7,56 +7,46 @@ mod mcp; mod network; mod task; -use crate::commands::CommandResult; -use crate::commands::traits::{Command, CommandGroup, CommandInfo, FunctionCommand}; +use crate::commands::traits::{Command, CommandGroup, CommandInfo, FunctionCommand, RegisterCommand}; use crate::localization::MessageId; use crate::tui::app::App; +use crate::commands::CommandResult; + 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( + 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, + )), + // `/plugins` is registered here but remains discretionary for + // FEAT-007 extraction. The CommandInfo and dispatch function + // stay inline until scope is explicitly widened. Box::new(FunctionCommand::new(&PLUGINS_INFO, run_plugins)), ] } } -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, -}; +/// `/plugins` command metadata — discretionary, kept inline. static PLUGINS_INFO: CommandInfo = CommandInfo { name: "plugins", aliases: &["plugin"], @@ -64,42 +54,6 @@ static PLUGINS_INFO: CommandInfo = CommandInfo { 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) + crate::commands::plugins::plugins(app, arg) } diff --git a/crates/tui/src/commands/groups/utility/network.rs b/crates/tui/src/commands/groups/utility/network.rs index c1535e6701..b14f7db7b5 100644 --- a/crates/tui/src/commands/groups/utility/network.rs +++ b/crates/tui/src/commands/groups/utility/network.rs @@ -6,10 +6,31 @@ use std::path::Path; use anyhow::{Context, bail}; use toml::Value; -use super::CommandResult; +use crate::commands::traits::{CommandInfo, RegisterCommand}; +use crate::commands::CommandResult; +use crate::localization::MessageId; use crate::network_policy::host_from_url; use crate::tui::app::App; +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) + } +} + pub fn network(_app: &mut App, arg: Option<&str>) -> CommandResult { match network_inner(arg) { Ok(message) => CommandResult::message(message), diff --git a/crates/tui/src/commands/groups/utility/task.rs b/crates/tui/src/commands/groups/utility/task.rs index c96fe29a1f..dab1b77824 100644 --- a/crates/tui/src/commands/groups/utility/task.rs +++ b/crates/tui/src/commands/groups/utility/task.rs @@ -1,8 +1,29 @@ //! 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(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) + } +} pub fn task(_app: &mut App, args: Option<&str>) -> CommandResult { let raw = args.unwrap_or("").trim(); From ef92286e72c75f3d662aa0868d71b769937691eb Mon Sep 17 00:00:00 2001 From: Paulo Aboim Pinto Date: Thu, 25 Jun 2026 23:11:40 +0200 Subject: [PATCH 2/4] Phase 3: complete command implementation body extraction Make standalone command functions private/limited-visibility to complete the ownership transfer to RegisterCommand pattern: - Changed 13 command functions from pub fn to fn (private) across project, memory, skills, and utility command modules - Changed run_skill_by_name from pub fn to pub(in crate::commands) (preserving the re-export through skills/mod.rs) - Kept share::perform_share as pub (external caller from tui/ui.rs) All existing dispatch, argument parsing, and precedence behavior is preserved. All group and contract tests pass unchanged. --- crates/tui/src/commands/groups/memory/memory.rs | 2 +- crates/tui/src/commands/groups/memory/note.rs | 2 +- crates/tui/src/commands/groups/project/goal.rs | 2 +- crates/tui/src/commands/groups/project/init.rs | 2 +- crates/tui/src/commands/groups/project/share.rs | 2 +- crates/tui/src/commands/groups/skills/restore.rs | 2 +- crates/tui/src/commands/groups/skills/review.rs | 2 +- crates/tui/src/commands/groups/skills/skills.rs | 8 ++++---- crates/tui/src/commands/groups/utility/attachment.rs | 2 +- crates/tui/src/commands/groups/utility/jobs.rs | 2 +- crates/tui/src/commands/groups/utility/mcp.rs | 2 +- crates/tui/src/commands/groups/utility/network.rs | 2 +- crates/tui/src/commands/groups/utility/task.rs | 2 +- 13 files changed, 16 insertions(+), 16 deletions(-) diff --git a/crates/tui/src/commands/groups/memory/memory.rs b/crates/tui/src/commands/groups/memory/memory.rs index 6e76013eac..4b4fec778c 100644 --- a/crates/tui/src/commands/groups/memory/memory.rs +++ b/crates/tui/src/commands/groups/memory/memory.rs @@ -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.", diff --git a/crates/tui/src/commands/groups/memory/note.rs b/crates/tui/src/commands/groups/memory/note.rs index 3298497969..6d57e87133 100644 --- a/crates/tui/src/commands/groups/memory/note.rs +++ b/crates/tui/src/commands/groups/memory/note.rs @@ -10,7 +10,7 @@ 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 => { diff --git a/crates/tui/src/commands/groups/project/goal.rs b/crates/tui/src/commands/groups/project/goal.rs index 61c61dddde..54c05d5c1b 100644 --- a/crates/tui/src/commands/groups/project/goal.rs +++ b/crates/tui/src/commands/groups/project/goal.rs @@ -10,7 +10,7 @@ use crate::tui::app::{App, AppAction, HuntVerdict}; 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; diff --git a/crates/tui/src/commands/groups/project/init.rs b/crates/tui/src/commands/groups/project/init.rs index 353bd1ff9e..f07469485c 100644 --- a/crates/tui/src/commands/groups/project/init.rs +++ b/crates/tui/src/commands/groups/project/init.rs @@ -17,7 +17,7 @@ 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. diff --git a/crates/tui/src/commands/groups/project/share.rs b/crates/tui/src/commands/groups/project/share.rs index e2ea14cc30..0563ccda94 100644 --- a/crates/tui/src/commands/groups/project/share.rs +++ b/crates/tui/src/commands/groups/project/share.rs @@ -18,7 +18,7 @@ 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 { diff --git a/crates/tui/src/commands/groups/skills/restore.rs b/crates/tui/src/commands/groups/skills/restore.rs index 9ed0f1b4a7..29de8910a6 100644 --- a/crates/tui/src/commands/groups/skills/restore.rs +++ b/crates/tui/src/commands/groups/skills/restore.rs @@ -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, diff --git a/crates/tui/src/commands/groups/skills/review.rs b/crates/tui/src/commands/groups/skills/review.rs index 2467483ad8..db01c8b94a 100644 --- a/crates/tui/src/commands/groups/skills/review.rs +++ b/crates/tui/src/commands/groups/skills/review.rs @@ -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 "); diff --git a/crates/tui/src/commands/groups/skills/skills.rs b/crates/tui/src/commands/groups/skills/skills.rs index 1d3d7a7cbd..44fe65676b 100644 --- a/crates/tui/src/commands/groups/skills/skills.rs +++ b/crates/tui/src/commands/groups/skills/skills.rs @@ -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,7 @@ 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 +222,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 +400,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 { diff --git a/crates/tui/src/commands/groups/utility/attachment.rs b/crates/tui/src/commands/groups/utility/attachment.rs index 920520ce31..6d534274e0 100644 --- a/crates/tui/src/commands/groups/utility/attachment.rs +++ b/crates/tui/src/commands/groups/utility/attachment.rs @@ -26,7 +26,7 @@ impl RegisterCommand for AttachCmd { } } -pub fn attach(app: &mut App, arg: Option<&str>) -> CommandResult { +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 351fcc990f..f54c937f50 100644 --- a/crates/tui/src/commands/groups/utility/jobs.rs +++ b/crates/tui/src/commands/groups/utility/jobs.rs @@ -25,7 +25,7 @@ impl RegisterCommand for JobsCmd { } } -pub fn jobs(_app: &mut App, args: Option<&str>) -> CommandResult { +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 608af29ae0..f06caa3ccd 100644 --- a/crates/tui/src/commands/groups/utility/mcp.rs +++ b/crates/tui/src/commands/groups/utility/mcp.rs @@ -25,7 +25,7 @@ impl RegisterCommand for McpCmd { } } -pub fn mcp(_app: &mut App, args: Option<&str>) -> CommandResult { +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/network.rs b/crates/tui/src/commands/groups/utility/network.rs index b14f7db7b5..b149bd7d21 100644 --- a/crates/tui/src/commands/groups/utility/network.rs +++ b/crates/tui/src/commands/groups/utility/network.rs @@ -31,7 +31,7 @@ impl RegisterCommand for NetworkCmd { } } -pub fn network(_app: &mut App, arg: Option<&str>) -> CommandResult { +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 dab1b77824..08cd48d160 100644 --- a/crates/tui/src/commands/groups/utility/task.rs +++ b/crates/tui/src/commands/groups/utility/task.rs @@ -25,7 +25,7 @@ impl RegisterCommand for TaskCmd { } } -pub fn task(_app: &mut App, args: Option<&str>) -> CommandResult { +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); From eb6f0107c0907e29cc3d42edefc37ea13f5ff886 Mon Sep 17 00:00:00 2001 From: Paulo Aboim Pinto Date: Fri, 26 Jun 2026 11:45:29 +0200 Subject: [PATCH 3/4] Phase 6/7: Extract /plugins to its own group module Move the discretionary /plugins command from inline registration in utility/mod.rs (with old commands/plugins.rs dispatch) into a focused plugins command group at commands/groups/plugins/mod.rs. Changes: - Create commands/groups/plugins/mod.rs with PluginsCommands group and extracted PluginsCmd implementing RegisterCommand - Remove inline PLUGINS_INFO and run_plugins from utility/mod.rs - Remove mod plugins; from commands/mod.rs (orphaned) - Add pub mod plugins and PluginsCommands to groups/mod.rs - Add 4 unit tests for plugins listing, empty, detail, and not-found - Add plugin_e2e_acceptance.rs and .feature for E2E coverage - Fix trailing blank line at EOF in utility/mod.rs --- crates/tui/src/commands/groups/mod.rs | 2 + .../{plugins.rs => groups/plugins/mod.rs} | 64 ++- crates/tui/src/commands/groups/utility/mod.rs | 22 +- crates/tui/src/commands/mod.rs | 1 - .../features/plugin_e2e_acceptance.feature | 31 ++ crates/tui/tests/plugin_e2e_acceptance.rs | 440 ++++++++++++++++++ 6 files changed, 530 insertions(+), 30 deletions(-) rename crates/tui/src/commands/{plugins.rs => groups/plugins/mod.rs} (81%) create mode 100644 crates/tui/tests/features/plugin_e2e_acceptance.feature create mode 100644 crates/tui/tests/plugin_e2e_acceptance.rs 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 81% rename from crates/tui/src/commands/plugins.rs rename to crates/tui/src/commands/groups/plugins/mod.rs index 0e230b7a89..9e957c8ee3 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::traits::{ + Command, CommandGroup, CommandInfo, FunctionCommand, RegisterCommand, +}; use crate::commands::CommandResult; 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", } } @@ -212,7 +254,13 @@ mod tests { 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!(msg.contains( + &dir.path() + .canonicalize() + .unwrap() + .display() + .to_string() + )); assert!(!result.is_error); } diff --git a/crates/tui/src/commands/groups/utility/mod.rs b/crates/tui/src/commands/groups/utility/mod.rs index c4f6e85f04..cd46bee0e7 100644 --- a/crates/tui/src/commands/groups/utility/mod.rs +++ b/crates/tui/src/commands/groups/utility/mod.rs @@ -7,11 +7,7 @@ mod mcp; mod network; mod task; -use crate::commands::traits::{Command, CommandGroup, CommandInfo, FunctionCommand, RegisterCommand}; -use crate::localization::MessageId; -use crate::tui::app::App; - -use crate::commands::CommandResult; +use crate::commands::traits::{Command, CommandGroup, FunctionCommand, RegisterCommand}; pub struct UtilityCommands; @@ -38,22 +34,6 @@ impl CommandGroup for UtilityCommands { network::NetworkCmd::info(), network::NetworkCmd::execute, )), - // `/plugins` is registered here but remains discretionary for - // FEAT-007 extraction. The CommandInfo and dispatch function - // stay inline until scope is explicitly widened. - Box::new(FunctionCommand::new(&PLUGINS_INFO, run_plugins)), ] } } - -/// `/plugins` command metadata — discretionary, kept inline. -static PLUGINS_INFO: CommandInfo = CommandInfo { - name: "plugins", - aliases: &["plugin"], - usage: "/plugins [name]", - description_id: MessageId::CmdPluginDescription, -}; - -fn run_plugins(app: &mut App, arg: Option<&str>) -> CommandResult { - crate::commands::plugins::plugins(app, arg) -} 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..8fa41286d0 --- /dev/null +++ b/crates/tui/tests/plugin_e2e_acceptance.rs @@ -0,0 +1,440 @@ +//! 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 +} From 0271a308a507035506e17029d747c79bc1607cf1 Mon Sep 17 00:00:00 2001 From: Paulo Aboim Pinto Date: Fri, 26 Jun 2026 12:31:53 +0200 Subject: [PATCH 4/4] Fix formatting across FEAT-007 command group files cargo fmt --all fixes: - plugins/mod.rs import ordering - memory/memory.rs, memory/note.rs fn signature wrapping - project/init.rs, project/share.rs formatting - skills/restore.rs, skills/review.rs, skills/skills.rs formatting - utility/attachment.rs, utility/network.rs formatting - plugin_e2e_acceptance.rs formatting --- .../tui/src/commands/groups/memory/memory.rs | 5 ++- crates/tui/src/commands/groups/memory/note.rs | 5 ++- crates/tui/src/commands/groups/plugins/mod.rs | 10 ++---- .../tui/src/commands/groups/project/init.rs | 5 ++- .../tui/src/commands/groups/project/share.rs | 2 +- .../tui/src/commands/groups/skills/restore.rs | 5 ++- .../tui/src/commands/groups/skills/review.rs | 5 ++- .../tui/src/commands/groups/skills/skills.rs | 16 +++++++-- .../src/commands/groups/utility/attachment.rs | 2 +- .../src/commands/groups/utility/network.rs | 2 +- crates/tui/tests/plugin_e2e_acceptance.rs | 34 +++++++------------ 11 files changed, 50 insertions(+), 41 deletions(-) diff --git a/crates/tui/src/commands/groups/memory/memory.rs b/crates/tui/src/commands/groups/memory/memory.rs index 4b4fec778c..0fd5b3f570 100644 --- a/crates/tui/src/commands/groups/memory/memory.rs +++ b/crates/tui/src/commands/groups/memory/memory.rs @@ -100,7 +100,10 @@ impl crate::commands::traits::RegisterCommand for MemoryCmd { &COMMAND_INFO } - fn execute(app: &mut crate::tui::app::App, arg: Option<&str>) -> crate::commands::CommandResult { + fn execute( + app: &mut crate::tui::app::App, + arg: Option<&str>, + ) -> crate::commands::CommandResult { memory(app, arg) } } diff --git a/crates/tui/src/commands/groups/memory/note.rs b/crates/tui/src/commands/groups/memory/note.rs index 6d57e87133..2965544d1b 100644 --- a/crates/tui/src/commands/groups/memory/note.rs +++ b/crates/tui/src/commands/groups/memory/note.rs @@ -281,7 +281,10 @@ impl crate::commands::traits::RegisterCommand for NoteCmd { &COMMAND_INFO } - fn execute(app: &mut crate::tui::app::App, arg: Option<&str>) -> crate::commands::CommandResult { + fn execute( + app: &mut crate::tui::app::App, + arg: Option<&str>, + ) -> crate::commands::CommandResult { note(app, arg) } } diff --git a/crates/tui/src/commands/groups/plugins/mod.rs b/crates/tui/src/commands/groups/plugins/mod.rs index 9e957c8ee3..6068ca0ccb 100644 --- a/crates/tui/src/commands/groups/plugins/mod.rs +++ b/crates/tui/src/commands/groups/plugins/mod.rs @@ -6,10 +6,10 @@ use std::path::PathBuf; +use crate::commands::CommandResult; use crate::commands::traits::{ Command, CommandGroup, CommandInfo, FunctionCommand, RegisterCommand, }; -use crate::commands::CommandResult; use crate::config::Config; use crate::localization::{MessageId, tr}; use crate::tools::plugin::scan_plugin_dir; @@ -254,13 +254,7 @@ mod tests { 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!(msg.contains(&dir.path().canonicalize().unwrap().display().to_string())); assert!(!result.is_error); } diff --git a/crates/tui/src/commands/groups/project/init.rs b/crates/tui/src/commands/groups/project/init.rs index f07469485c..5d78f7f4f6 100644 --- a/crates/tui/src/commands/groups/project/init.rs +++ b/crates/tui/src/commands/groups/project/init.rs @@ -809,7 +809,10 @@ impl crate::commands::traits::RegisterCommand for InitCmd { &COMMAND_INFO } - fn execute(app: &mut crate::tui::app::App, _arg: Option<&str>) -> crate::commands::CommandResult { + fn execute( + app: &mut crate::tui::app::App, + _arg: Option<&str>, + ) -> crate::commands::CommandResult { init(app) } } diff --git a/crates/tui/src/commands/groups/project/share.rs b/crates/tui/src/commands/groups/project/share.rs index 0563ccda94..c0bade5091 100644 --- a/crates/tui/src/commands/groups/project/share.rs +++ b/crates/tui/src/commands/groups/project/share.rs @@ -11,8 +11,8 @@ use std::io::Write; use std::path::Path; -use crate::commands::traits::{CommandInfo, RegisterCommand}; use crate::commands::CommandResult; +use crate::commands::traits::{CommandInfo, RegisterCommand}; use crate::dependencies::ExternalTool; use crate::localization::MessageId; use crate::tui::app::{App, AppAction}; diff --git a/crates/tui/src/commands/groups/skills/restore.rs b/crates/tui/src/commands/groups/skills/restore.rs index 29de8910a6..a3b384ecd2 100644 --- a/crates/tui/src/commands/groups/skills/restore.rs +++ b/crates/tui/src/commands/groups/skills/restore.rs @@ -183,7 +183,10 @@ impl crate::commands::traits::RegisterCommand for RestoreCmd { &COMMAND_INFO } - fn execute(app: &mut crate::tui::app::App, arg: Option<&str>) -> crate::commands::CommandResult { + fn execute( + app: &mut crate::tui::app::App, + arg: Option<&str>, + ) -> crate::commands::CommandResult { restore(app, arg) } } diff --git a/crates/tui/src/commands/groups/skills/review.rs b/crates/tui/src/commands/groups/skills/review.rs index db01c8b94a..7524af4c60 100644 --- a/crates/tui/src/commands/groups/skills/review.rs +++ b/crates/tui/src/commands/groups/skills/review.rs @@ -77,7 +77,10 @@ impl crate::commands::traits::RegisterCommand for ReviewCmd { &COMMAND_INFO } - fn execute(app: &mut crate::tui::app::App, arg: Option<&str>) -> crate::commands::CommandResult { + fn execute( + app: &mut crate::tui::app::App, + arg: Option<&str>, + ) -> crate::commands::CommandResult { review(app, arg) } } diff --git a/crates/tui/src/commands/groups/skills/skills.rs b/crates/tui/src/commands/groups/skills/skills.rs index 44fe65676b..325ce6f76c 100644 --- a/crates/tui/src/commands/groups/skills/skills.rs +++ b/crates/tui/src/commands/groups/skills/skills.rs @@ -213,7 +213,11 @@ 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(in crate::commands) 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)) @@ -637,7 +641,10 @@ impl crate::commands::traits::RegisterCommand for SkillsCmd { &SKILLS_INFO } - fn execute(app: &mut crate::tui::app::App, arg: Option<&str>) -> crate::commands::CommandResult { + fn execute( + app: &mut crate::tui::app::App, + arg: Option<&str>, + ) -> crate::commands::CommandResult { list_skills(app, arg) } } @@ -657,7 +664,10 @@ impl crate::commands::traits::RegisterCommand for SkillCmd { &SKILL_INFO } - fn execute(app: &mut crate::tui::app::App, arg: Option<&str>) -> crate::commands::CommandResult { + fn execute( + app: &mut crate::tui::app::App, + arg: Option<&str>, + ) -> crate::commands::CommandResult { run_skill(app, arg) } } diff --git a/crates/tui/src/commands/groups/utility/attachment.rs b/crates/tui/src/commands/groups/utility/attachment.rs index 6d534274e0..8e46687bf4 100644 --- a/crates/tui/src/commands/groups/utility/attachment.rs +++ b/crates/tui/src/commands/groups/utility/attachment.rs @@ -2,8 +2,8 @@ use std::path::{Path, PathBuf}; -use crate::commands::traits::{CommandInfo, RegisterCommand}; use crate::commands::CommandResult; +use crate::commands::traits::{CommandInfo, RegisterCommand}; use crate::localization::MessageId; use crate::tui::app::App; diff --git a/crates/tui/src/commands/groups/utility/network.rs b/crates/tui/src/commands/groups/utility/network.rs index b149bd7d21..969bc96d3a 100644 --- a/crates/tui/src/commands/groups/utility/network.rs +++ b/crates/tui/src/commands/groups/utility/network.rs @@ -6,8 +6,8 @@ use std::path::Path; use anyhow::{Context, bail}; use toml::Value; -use crate::commands::traits::{CommandInfo, RegisterCommand}; 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; diff --git a/crates/tui/tests/plugin_e2e_acceptance.rs b/crates/tui/tests/plugin_e2e_acceptance.rs index 8fa41286d0..9b1e9ed1ef 100644 --- a/crates/tui/tests/plugin_e2e_acceptance.rs +++ b/crates/tui/tests/plugin_e2e_acceptance.rs @@ -58,7 +58,9 @@ fn parse_frontmatter(content: &str) -> Option { .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 }; + 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(), @@ -248,7 +250,10 @@ fn plugin_scanner_discovers_plugins(world: &mut PluginE2EWorld) { #[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"); + 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 ") @@ -264,10 +269,7 @@ fn plugin_scanner_runs(world: &mut PluginE2EWorld) { #[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"); + let discovered = world.discovered.as_ref().expect("scanner should have run"); assert_eq!( discovered.len(), expected_count, @@ -282,10 +284,7 @@ fn scanned_plugin_should_have_description( name: String, expected_description: String, ) { - let discovered = world - .discovered - .as_ref() - .expect("scanner should have run"); + let discovered = world.discovered.as_ref().expect("scanner should have run"); let meta = discovered .iter() .find(|(_, m)| m.name == name) @@ -304,10 +303,7 @@ fn scanned_plugin_should_have_approval( name: String, expected_approval: String, ) { - let discovered = world - .discovered - .as_ref() - .expect("scanner should have run"); + let discovered = world.discovered.as_ref().expect("scanner should have run"); let meta = discovered .iter() .find(|(_, m)| m.name == name) @@ -327,10 +323,7 @@ fn scanned_plugin_should_have_approval( #[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"); + 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" @@ -339,10 +332,7 @@ fn scanned_plugin_should_not_be_found(world: &mut PluginE2EWorld, name: String) #[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"); + let discovered = world.discovered.as_ref().expect("scanner should have run"); assert!( discovered.is_empty(), "expected empty results for missing directory, got: {discovered:#?}"