diff --git a/Cargo.lock b/Cargo.lock index b521e898..e0944ad9 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -280,7 +280,7 @@ dependencies = [ "futures-lite", "parking", "polling", - "rustix 1.1.3", + "rustix 1.1.4", "slab", "windows-sys 0.61.2", ] @@ -311,7 +311,7 @@ dependencies = [ "cfg-if", "event-listener", "futures-lite", - "rustix 1.1.3", + "rustix 1.1.4", ] [[package]] @@ -337,7 +337,7 @@ dependencies = [ "cfg-if", "futures-core", "futures-io", - "rustix 1.1.3", + "rustix 1.1.4", "signal-hook-registry", "slab", "windows-sys 0.61.2", @@ -656,6 +656,12 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6d43a04d8753f35258c91f8ec639f792891f748a1edbd759cf1dcea3382ad83c" +[[package]] +name = "cff-parser" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "31f5b6e9141c036f3ff4ce7b2f7e432b0f00dee416ddcd4f17741d189ddc2e9d" + [[package]] name = "cfg-if" version = "1.0.4" @@ -1243,7 +1249,7 @@ dependencies = [ "document-features", "mio", "parking_lot", - "rustix 1.1.3", + "rustix 1.1.4", "signal-hook", "signal-hook-mio", "winapi", @@ -1676,6 +1682,15 @@ version = "1.0.20" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" +[[package]] +name = "ecb" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a8bfa975b1aec2145850fcaa1c6fe269a16578c44705a532ae3edc92b8881c7" +dependencies = [ + "cipher", +] + [[package]] name = "either" version = "1.15.0" @@ -1884,7 +1899,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0ce92ff622d6dadf7349484f42c93271a0d49b7cc4d466a936405bacbe10aa78" dependencies = [ "cfg-if", - "rustix 1.1.3", + "rustix 1.1.4", "windows-sys 0.59.0", ] @@ -1939,9 +1954,9 @@ checksum = "0ce7134b9999ecaf8bcd65542e436736ef32ddca1b3e06094cb6ec5755203b80" [[package]] name = "flate2" -version = "1.1.5" +version = "1.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bfe33edd8e85a12a67454e37f8c75e730830d83e313556ab9ebf9ee7fbeb3bfb" +checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c" dependencies = [ "crc32fast", "miniz_oxide", @@ -2115,7 +2130,7 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1bd49230192a3797a9a4d6abe9b3eed6f7fa4c8a8a4947977c6f80025f92cbd8" dependencies = [ - "rustix 1.1.3", + "rustix 1.1.4", "windows-link 0.2.1", ] @@ -2563,9 +2578,9 @@ dependencies = [ [[package]] name = "image" -version = "0.25.9" +version = "0.25.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e6506c6c10786659413faa717ceebcb8f70731c0a60cbae39795fdf114519c1a" +checksum = "85ab80394333c02fe689eaf900ab500fbd0c2213da414687ebf995a65d5a6104" dependencies = [ "bytemuck", "byteorder-lite", @@ -2926,9 +2941,9 @@ checksum = "d26c52dbd32dccf2d10cac7725f8eae5296885fb5703b261f7d0a0739ec807ab" [[package]] name = "linux-raw-sys" -version = "0.11.0" +version = "0.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df1d3c3b53da64cf5760482273a98e575c651a67eec7f77df96b5b642de8f039" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" [[package]] name = "litemap" @@ -2982,19 +2997,29 @@ dependencies = [ [[package]] name = "lopdf" -version = "0.34.0" +version = "0.38.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c5c8ecfc6c72051981c0459f75ccc585e7ff67c70829560cda8e647882a9abff" +checksum = "c7184fdea2bc3cd272a1acec4030c321a8f9875e877b3f92a53f2f6033fdc289" dependencies = [ + "aes", + "bitflags 2.12.1", + "cbc", + "ecb", "encoding_rs", "flate2", + "getrandom 0.3.4", "indexmap", "itoa", "log", "md-5", - "nom 7.1.3", + "nom 8.0.0", + "nom_locate", + "rand 0.9.4", "rangemap", - "time", + "sha2 0.10.9", + "stringprep", + "thiserror 2.0.18", + "ttf-parser", "weezl", ] @@ -3143,9 +3168,9 @@ dependencies = [ [[package]] name = "moxcms" -version = "0.7.11" +version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ac9557c559cd6fc9867e122e20d2cbefc9ca29d80d027a8e39310920ed2f0a97" +checksum = "bb85c154ba489f01b25c0d36ae69a87e4a1c73a72631fc6c0eb6dde34a73e44b" dependencies = [ "num-traits", "pxfm", @@ -3356,9 +3381,9 @@ dependencies = [ [[package]] name = "objc2" -version = "0.6.3" +version = "0.6.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b7c2599ce0ec54857b29ce62166b0ed9b4f6f1a70ccc9a71165b6154caca8c05" +checksum = "3a12a8ed07aefc768292f076dc3ac8c48f3781c8f2d5851dd3d98950e8c5a89f" dependencies = [ "objc2-encode", ] @@ -3513,13 +3538,15 @@ checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" [[package]] name = "pdf-extract" -version = "0.7.12" +version = "0.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cbb3a5387b94b9053c1e69d8abfd4dd6dae7afda65a5c5279bc1f42ab39df575" +checksum = "1e28ba1758a3d3f361459645780e09570b573fc3c82637449e9963174c813a98" dependencies = [ "adobe-cmap-parser", + "cff-parser", "encoding_rs", "euclid 0.20.14", + "log", "lopdf", "postscript", "type1-encoding-parser", @@ -3639,7 +3666,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3c80231409c20246a13fddb31776fb942c38553c51e871f8cbd687a4cfb5843d" dependencies = [ "phf_shared", - "rand", + "rand 0.8.6", ] [[package]] @@ -3736,7 +3763,7 @@ dependencies = [ "concurrent-queue", "hermit-abi", "pin-project-lite", - "rustix 1.1.3", + "rustix 1.1.4", "windows-sys 0.61.2", ] @@ -3890,8 +3917,18 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5ca0ecfa931c29007047d1bc58e623ab12e5590e8c7cc53200d5202b69266d8a" dependencies = [ "libc", - "rand_chacha", - "rand_core", + "rand_chacha 0.3.1", + "rand_core 0.6.4", +] + +[[package]] +name = "rand" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44c5af06bb1b7d3216d91932aed5265164bf384dc89cd6ba05cf59a35f5f76ea" +dependencies = [ + "rand_chacha 0.9.0", + "rand_core 0.9.5", ] [[package]] @@ -3901,7 +3938,17 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" dependencies = [ "ppv-lite86", - "rand_core", + "rand_core 0.6.4", +] + +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core 0.9.5", ] [[package]] @@ -3913,6 +3960,15 @@ dependencies = [ "getrandom 0.2.16", ] +[[package]] +name = "rand_core" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" +dependencies = [ + "getrandom 0.3.4", +] + [[package]] name = "rangemap" version = "1.7.1" @@ -4211,14 +4267,14 @@ dependencies = [ [[package]] name = "rustix" -version = "1.1.3" +version = "1.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "146c9e247ccc180c1f61615433868c99f3de3ae256a30a43b49f67c2d9171f34" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" dependencies = [ "bitflags 2.12.1", "errno", "libc", - "linux-raw-sys 0.11.0", + "linux-raw-sys 0.12.1", "windows-sys 0.61.2", ] @@ -4467,7 +4523,7 @@ dependencies = [ "hkdf", "num", "once_cell", - "rand", + "rand 0.8.6", "serde", "sha2 0.10.9", "zbus", @@ -4899,6 +4955,17 @@ dependencies = [ "precomputed-hash", ] +[[package]] +name = "stringprep" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b4df3d392d81bd458a8a621b8bffbd2302a12ffe288a9d931670948749463b1" +dependencies = [ + "unicode-bidi", + "unicode-normalization", + "unicode-properties", +] + [[package]] name = "strsim" version = "0.10.0" @@ -5026,14 +5093,14 @@ dependencies = [ [[package]] name = "tempfile" -version = "3.24.0" +version = "3.27.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "655da9c7eb6305c55742045d5a8d2037996d61d8de95806335c7c86ce0f82e9c" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" dependencies = [ "fastrand", "getrandom 0.3.4", "once_cell", - "rustix 1.1.3", + "rustix 1.1.4", "windows-sys 0.61.2", ] @@ -5054,7 +5121,7 @@ version = "0.4.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "230a1b821ccbd75b185820a1f1ff7b14d21da1e442e22c0863ea5f08771a8874" dependencies = [ - "rustix 1.1.3", + "rustix 1.1.4", "windows-sys 0.61.2", ] @@ -5192,9 +5259,9 @@ dependencies = [ [[package]] name = "tiff" -version = "0.10.3" +version = "0.11.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "af9605de7fee8d9551863fd692cce7637f548dbd9db9180fcc07ccc6d26c336f" +checksum = "b63feaf3343d35b6ca4d50483f94843803b0f51634937cc2ec519fc32232bc52" dependencies = [ "fax", "flate2", @@ -5211,14 +5278,12 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "743bd48c283afc0388f9b8827b976905fb217ad9e647fae3a379a9283c4def2c" dependencies = [ "deranged", - "itoa", "libc", "num-conv", "num_threads", "powerfmt", "serde_core", "time-core", - "time-macros", ] [[package]] @@ -5227,16 +5292,6 @@ version = "0.1.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7694e1cfe791f8d31026952abf09c69ca6f6fa4e1a1229e18988f06a04a12dca" -[[package]] -name = "time-macros" -version = "0.2.27" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2e70e4c5a0e0a8a4823ad65dfe1a6930e4f4d756dcd9dd7939022b5e8c501215" -dependencies = [ - "num-conv", - "time-core", -] - [[package]] name = "tiny-keccak" version = "2.0.2" @@ -5528,6 +5583,12 @@ version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" +[[package]] +name = "ttf-parser" +version = "0.25.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2df906b07856748fa3f6e0ad0cbaa047052d4a7dd609e231c4f72cee8c36f31" + [[package]] name = "type1-encoding-parser" version = "0.1.0" @@ -5586,6 +5647,12 @@ version = "2.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142" +[[package]] +name = "unicode-bidi" +version = "0.3.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c1cb5db39152898a79168971543b1cb5020dff7fe43c8dc468b0885f5e29df5" + [[package]] name = "unicode-general-category" version = "1.1.0" @@ -5613,6 +5680,12 @@ dependencies = [ "tinyvec", ] +[[package]] +name = "unicode-properties" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7df058c713841ad818f1dc5d3fd88063241cc61f49f5fbea4b951e8cf5a8d71d" + [[package]] name = "unicode-segmentation" version = "1.12.0" @@ -6452,7 +6525,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9993aa5be5a26815fe2c3eacfc1fde061fc1a1f094bf1ad2a18bf9c495dd7414" dependencies = [ "gethostname", - "rustix 1.1.3", + "rustix 1.1.4", "x11rb-protocol", ] @@ -6469,7 +6542,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32e45ad4206f6d2479085147f02bc2ef834ac85886624a23575ae137c8aa8156" dependencies = [ "libc", - "rustix 1.1.3", + "rustix 1.1.4", ] [[package]] @@ -6529,7 +6602,7 @@ dependencies = [ "hex", "nix 0.29.0", "ordered-stream", - "rand", + "rand 0.8.6", "serde", "serde_repr", "sha1", @@ -6669,15 +6742,15 @@ checksum = "2fc5a66a20078bf1251bde995aa2fdcc4b800c70b5d92dd2c62abc5c60f679f8" [[package]] name = "zune-core" -version = "0.4.12" +version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3f423a2c17029964870cfaabb1f13dfab7d092a62a29a89264f4d36990ca414a" +checksum = "cb8a0807f7c01457d0379ba880ba6322660448ddebc890ce29bb64da71fb40f9" [[package]] name = "zune-jpeg" -version = "0.4.21" +version = "0.5.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "29ce2c8a9384ad323cf564b67da86e21d3cfdff87908bc1223ed5c99bc792713" +checksum = "27bc9d5b815bc103f142aa054f561d9187d191692ec7c2d1e2b4737f8dbd7296" dependencies = [ "zune-core", ] diff --git a/crates/app-server/Cargo.toml b/crates/app-server/Cargo.toml index 5773e49e..8bea5392 100644 --- a/crates/app-server/Cargo.toml +++ b/crates/app-server/Cargo.toml @@ -28,5 +28,5 @@ tracing.workspace = true uuid.workspace = true [dev-dependencies] -tempfile = "3.16" +tempfile = "3.27" tower = "0.5" diff --git a/crates/cli/Cargo.toml b/crates/cli/Cargo.toml index 25c27ac0..55a50c51 100644 --- a/crates/cli/Cargo.toml +++ b/crates/cli/Cargo.toml @@ -36,7 +36,7 @@ rustls.workspace = true semver.workspace = true tokio.workspace = true sha2.workspace = true -tempfile = "3.16" +tempfile = "3.27" tracing.workspace = true [dev-dependencies] diff --git a/crates/secrets/Cargo.toml b/crates/secrets/Cargo.toml index 58c804fb..1bc9ef60 100644 --- a/crates/secrets/Cargo.toml +++ b/crates/secrets/Cargo.toml @@ -23,4 +23,4 @@ keyring = { version = "3", features = ["windows-native"] } keyring = { version = "3", features = ["linux-native-sync-persistent", "crypto-rust"] } [dev-dependencies] -tempfile = "3.16" +tempfile = "3.27" diff --git a/crates/tui/Cargo.toml b/crates/tui/Cargo.toml index d14e58da..e7e40f0e 100644 --- a/crates/tui/Cargo.toml +++ b/crates/tui/Cargo.toml @@ -55,7 +55,7 @@ unicode-width = "0.2" unicode-segmentation = "1.12" uuid = { version = "1.11", features = ["v4"] } chrono = { version = "0.4", features = ["serde"] } -tempfile = "3.16" +tempfile = "3.27" thiserror = "2.0" tracing = "0.1" tracing-subscriber = { workspace = true } @@ -68,7 +68,7 @@ ignore = "0.4" image = { version = "0.25", default-features = false, features = ["png"] } lru = "0.16" parking_lot = "0.12" -pdf-extract = "0.7" +pdf-extract = "0.10" tar = "0.4" flate2 = "1.1" sha2 = "0.10" diff --git a/crates/tui/src/commands/groups/utility/mod.rs b/crates/tui/src/commands/groups/utility/mod.rs index f46f42b3..9108d1a4 100644 --- a/crates/tui/src/commands/groups/utility/mod.rs +++ b/crates/tui/src/commands/groups/utility/mod.rs @@ -22,6 +22,7 @@ impl CommandGroup for UtilityCommands { Box::new(FunctionCommand::new(&JOBS_INFO, run_jobs)), Box::new(FunctionCommand::new(&MCP_INFO, run_mcp)), Box::new(FunctionCommand::new(&NETWORK_INFO, run_network)), + Box::new(FunctionCommand::new(&PLUGINS_INFO, run_plugins)), ] } } @@ -56,6 +57,12 @@ static NETWORK_INFO: CommandInfo = CommandInfo { 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") @@ -76,6 +83,9 @@ fn run_mcp(app: &mut App, arg: Option<&str>) -> CommandResult { fn run_network(app: &mut App, arg: Option<&str>) -> CommandResult { run_registered(app, "network", arg) } +fn run_plugins(app: &mut App, arg: Option<&str>) -> CommandResult { + run_registered(app, "plugins", arg) +} pub(in crate::commands) fn dispatch( app: &mut App, @@ -88,6 +98,7 @@ pub(in crate::commands) fn dispatch( "jobs" | "job" | "zuoye" => jobs::jobs(app, arg), "mcp" => mcp::mcp(app, arg), "network" => network::network(app, arg), + "plugins" | "plugin" => crate::commands::plugins::plugins(app, arg), _ => return None, }; Some(result) diff --git a/crates/tui/src/commands/mod.rs b/crates/tui/src/commands/mod.rs index 01aad281..3f18d19f 100644 --- a/crates/tui/src/commands/mod.rs +++ b/crates/tui/src/commands/mod.rs @@ -7,6 +7,7 @@ //! fall-through behaviour. mod groups; +mod plugins; pub mod traits; pub mod user_commands; diff --git a/crates/tui/src/commands/plugins.rs b/crates/tui/src/commands/plugins.rs new file mode 100644 index 00000000..0e230b7a --- /dev/null +++ b/crates/tui/src/commands/plugins.rs @@ -0,0 +1,256 @@ +//! `/plugins` slash command — list and inspect script plugin tools. + +use std::path::PathBuf; + +use crate::commands::CommandResult; +use crate::config::Config; +use crate::localization::{MessageId, tr}; +use crate::tools::plugin::scan_plugin_dir; +use crate::tui::app::App; + +/// List discovered plugins, or show details for a named plugin. +pub 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(), + ); + }; + + if !plugin_dir.exists() { + return CommandResult::message(format!( + "No plugin directory found at {}", + plugin_dir.display() + )); + } + + let discovered = scan_plugin_dir(&plugin_dir); + + if let Some(name) = arg.map(str::trim).filter(|s| !s.is_empty()) { + show_plugin_detail(app, name, &discovered) + } else { + list_plugins(app, &plugin_dir, &discovered) + } +} + +fn list_plugins( + app: &App, + plugin_dir: &std::path::Path, + discovered: &[(PathBuf, crate::tools::plugin::PluginMetadata)], +) -> CommandResult { + if discovered.is_empty() { + return CommandResult::message( + tr(app.ui_locale, MessageId::CmdPluginNoneFound) + .replace("{dir}", &plugin_dir.display().to_string()), + ); + } + + let mut out = String::new(); + out.push_str( + &tr(app.ui_locale, MessageId::CmdPluginListHeader) + .replace("{count}", &discovered.len().to_string()), + ); + out.push('\n'); + + for (path, meta) in discovered { + out.push_str(&format!( + "• {} — {}\n {}", + meta.name, + meta.description, + path.display() + )); + out.push('\n'); + } + + CommandResult::message(out) +} + +fn show_plugin_detail( + app: &App, + name: &str, + discovered: &[(PathBuf, crate::tools::plugin::PluginMetadata)], +) -> CommandResult { + let Some((path, meta)) = discovered.iter().find(|(_, m)| m.name == name) else { + return CommandResult::error( + tr(app.ui_locale, MessageId::CmdPluginNotFound).replace("{name}", name), + ); + }; + + let schema = serde_json::to_string_pretty(&meta.input_schema).unwrap_or_default(); + let approval = approval_label(meta.approval); + + let mut out = String::new(); + out.push_str(&format!("{}\n", meta.name)); + out.push_str(&format!("{:=<40}\n", "")); + out.push_str(&format!( + "{}\n", + tr(app.ui_locale, MessageId::CmdPluginDetailDescription) + .replace("{description}", &meta.description) + )); + out.push_str(&format!( + "{}\n", + tr(app.ui_locale, MessageId::CmdPluginDetailSchema).replace("{schema}", &schema) + )); + out.push_str(&format!( + "{}\n", + tr(app.ui_locale, MessageId::CmdPluginDetailApproval).replace("{approval}", approval) + )); + out.push_str(&format!( + "{}\n", + tr(app.ui_locale, MessageId::CmdPluginDetailPath) + .replace("{path}", &path.display().to_string()) + )); + + CommandResult::message(out) +} + +fn approval_label(approval: crate::tools::spec::ApprovalRequirement) -> &'static str { + match approval { + crate::tools::spec::ApprovalRequirement::Auto => "auto", + crate::tools::spec::ApprovalRequirement::Suggest => "suggest", + crate::tools::spec::ApprovalRequirement::Required => "required", + } +} + +/// Resolve the configured plugin directory, defaulting to `~/.codewhale/tools`. +fn plugin_dir_for(app: &App) -> Option { + let config = match &app.config_path { + Some(path) => { + Config::load(Some(path.clone()), app.config_profile.as_deref()).unwrap_or_default() + } + None => Config::default(), + }; + + config + .tools + .as_ref() + .and_then(|tools| tools.plugin_dir.as_ref()) + .map(PathBuf::from) + .or_else(default_codewhale_tools_dir) +} + +fn default_codewhale_tools_dir() -> Option { + dirs::home_dir().map(|home| home.join(".codewhale").join("tools")) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::config::Config; + use crate::tui::app::{App, TuiOptions}; + use tempfile::TempDir; + + fn create_test_app_with_plugin_dir(plugin_dir: &std::path::Path) -> (App, TempDir) { + let tmp = TempDir::new().expect("tempdir"); + let config_path = tmp.path().join("config.toml"); + let tools_dir = plugin_dir + .canonicalize() + .unwrap_or_else(|_| plugin_dir.to_path_buf()); + std::fs::write( + &config_path, + format!( + "[tools]\nplugin_dir = {}\n", + toml::Value::String(tools_dir.to_string_lossy().to_string()) + ), + ) + .expect("write config"); + + let options = TuiOptions { + model: "deepseek-v4-pro".to_string(), + workspace: tmp.path().to_path_buf(), + config_path: Some(config_path), + config_profile: None, + allow_shell: false, + use_alt_screen: true, + use_mouse_capture: false, + use_bracketed_paste: true, + max_subagents: 1, + skills_dir: tmp.path().join("skills"), + memory_path: tmp.path().join("memory.md"), + notes_path: tmp.path().join("notes.txt"), + mcp_config_path: tmp.path().join("mcp.json"), + use_memory: false, + start_in_agent_mode: false, + skip_onboarding: true, + yolo: false, + resume_session_id: None, + initial_input: None, + }; + let app = App::new(options, &Config::default()); + (app, tmp) + } + + #[test] + fn test_plugins_lists_discovered_tools() { + let dir = TempDir::new().unwrap(); + std::fs::write( + dir.path().join("greet.sh"), + "# name: greet\n# description: Say hello\n# schema: {\"type\":\"object\"}\n# approval: auto\n", + ) + .unwrap(); + std::fs::write( + dir.path().join("audit.sh"), + "# name: audit\n# description: Audit wrapper\n# approval: required\n", + ) + .unwrap(); + + let (mut app, _tmp) = create_test_app_with_plugin_dir(dir.path()); + let result = plugins(&mut app, None); + let msg = result.message.expect("should return list"); + assert!(msg.contains("Plugin tools (2):")); + assert!(msg.contains("greet")); + assert!(msg.contains("Say hello")); + assert!(msg.contains("audit")); + assert!(msg.contains("Audit wrapper")); + assert!(msg.contains("greet.sh")); + assert!(!result.is_error); + } + + #[test] + fn test_plugins_empty_directory() { + let dir = TempDir::new().unwrap(); + let (mut app, _tmp) = create_test_app_with_plugin_dir(dir.path()); + let result = plugins(&mut app, None); + let msg = result.message.expect("should return message"); + assert!(msg.contains("No plugin tools discovered")); + assert!(msg.contains(&dir.path().canonicalize().unwrap().display().to_string())); + assert!(!result.is_error); + } + + #[test] + fn test_plugins_detail_shows_metadata() { + let dir = TempDir::new().unwrap(); + std::fs::write( + dir.path().join("tool.sh"), + "# name: my-tool\n# description: Does a thing\n# schema: {\"type\":\"object\",\"properties\":{\"x\":{\"type\":\"string\"}}}\n# approval: required\n", + ) + .unwrap(); + + let (mut app, _tmp) = create_test_app_with_plugin_dir(dir.path()); + let result = plugins(&mut app, Some("my-tool")); + let msg = result.message.expect("should return detail"); + assert!(msg.contains("my-tool")); + assert!(msg.contains("Does a thing")); + assert!(msg.contains("\"type\": \"object\"")); + assert!(msg.contains("\"x\"")); + assert!(msg.contains("required")); + assert!(msg.contains("tool.sh")); + assert!(!result.is_error); + } + + #[test] + fn test_plugins_detail_not_found() { + let dir = TempDir::new().unwrap(); + std::fs::write( + dir.path().join("existing.sh"), + "# name: existing\n# description: exists\n", + ) + .unwrap(); + + let (mut app, _tmp) = create_test_app_with_plugin_dir(dir.path()); + let result = plugins(&mut app, Some("missing")); + assert!(result.is_error); + let msg = result.message.expect("should return error"); + assert!(msg.contains("missing")); + assert!(msg.contains("not found")); + } +} diff --git a/crates/tui/src/localization.rs b/crates/tui/src/localization.rs index 455de7ce..aad101df 100644 --- a/crates/tui/src/localization.rs +++ b/crates/tui/src/localization.rs @@ -312,6 +312,14 @@ pub enum MessageId { CmdLogoutDescription, CmdMcpDescription, CmdMemoryDescription, + CmdPluginDescription, + CmdPluginNoneFound, + CmdPluginNotFound, + CmdPluginListHeader, + CmdPluginDetailDescription, + CmdPluginDetailSchema, + CmdPluginDetailApproval, + CmdPluginDetailPath, CmdModeDescription, CmdModelDescription, CmdModelsDescription, @@ -741,6 +749,14 @@ pub const ALL_MESSAGE_IDS: &[MessageId] = &[ MessageId::CmdLoadDescription, MessageId::CmdLogoutDescription, MessageId::CmdMcpDescription, + MessageId::CmdPluginDescription, + MessageId::CmdPluginNoneFound, + MessageId::CmdPluginNotFound, + MessageId::CmdPluginListHeader, + MessageId::CmdPluginDetailDescription, + MessageId::CmdPluginDetailSchema, + MessageId::CmdPluginDetailApproval, + MessageId::CmdPluginDetailPath, MessageId::CmdMemoryDescription, MessageId::CmdModeDescription, MessageId::CmdModelDescription, @@ -1375,6 +1391,14 @@ fn english(id: MessageId) -> &'static str { MessageId::CmdLoadDescription => "Load session from file", MessageId::CmdLogoutDescription => "Clear API key and return to setup", MessageId::CmdMcpDescription => "Open or manage MCP servers", + MessageId::CmdPluginDescription => "List discovered plugin tools or show details for one", + MessageId::CmdPluginNoneFound => "No plugin tools discovered in {dir}", + MessageId::CmdPluginNotFound => "Plugin '{name}' not found", + MessageId::CmdPluginListHeader => "Plugin tools ({count}):", + MessageId::CmdPluginDetailDescription => "Description: {description}", + MessageId::CmdPluginDetailSchema => "Schema:\n{schema}", + MessageId::CmdPluginDetailApproval => "Approval: {approval}", + MessageId::CmdPluginDetailPath => "Path: {path}", MessageId::CmdMemoryDescription => "Inspect or manage the persistent user-memory file", MessageId::CmdModeDescription => { "Switch mode or open picker: /mode [agent|plan|yolo|1|2|3]" @@ -1973,6 +1997,14 @@ fn vietnamese(id: MessageId) -> Option<&'static str> { MessageId::CmdLoadDescription => "Tải phiên làm việc từ tệp", MessageId::CmdLogoutDescription => "Xóa khóa API và quay lại bước thiết lập", MessageId::CmdMcpDescription => "Mở hoặc quản lý các máy chủ MCP", + MessageId::CmdPluginDescription => "List discovered plugin tools or show details for one", + MessageId::CmdPluginNoneFound => "No plugin tools discovered in {dir}", + MessageId::CmdPluginNotFound => "Plugin '{name}' not found", + MessageId::CmdPluginListHeader => "Plugin tools ({count}):", + MessageId::CmdPluginDetailDescription => "Description: {description}", + MessageId::CmdPluginDetailSchema => "Schema:\n{schema}", + MessageId::CmdPluginDetailApproval => "Approval: {approval}", + MessageId::CmdPluginDetailPath => "Path: {path}", MessageId::CmdMemoryDescription => "Kiểm tra hoặc quản lý tệp bộ nhớ người dùng liên tục", MessageId::CmdModeDescription => { "Chuyển đổi chế độ hoặc mở bảng chọn: /mode [agent|plan|yolo|1|2|3]" @@ -2468,6 +2500,14 @@ fn vietnamese(id: MessageId) -> Option<&'static str> { fn traditional_chinese(id: MessageId) -> Option<&'static str> { Some(match id { MessageId::CmdRelayDescription => "為新執行緒建立會話接力摘要", + MessageId::CmdPluginDescription => "List discovered plugin tools or show details for one", + MessageId::CmdPluginNoneFound => "No plugin tools discovered in {dir}", + MessageId::CmdPluginNotFound => "Plugin '{name}' not found", + MessageId::CmdPluginListHeader => "Plugin tools ({count}):", + MessageId::CmdPluginDetailDescription => "Description: {description}", + MessageId::CmdPluginDetailSchema => "Schema:\n{schema}", + MessageId::CmdPluginDetailApproval => "Approval: {approval}", + MessageId::CmdPluginDetailPath => "Path: {path}", MessageId::CmdTranslateDescription => "切換輸出翻譯為目前系統語言的開關狀態", MessageId::CmdTranslateOff => "輸出翻譯已關閉(顯示原始模型輸出)", MessageId::CmdTranslateOn => "輸出翻譯已開啟:模型回覆將以繁體中文顯示", @@ -2762,6 +2802,14 @@ fn japanese(id: MessageId) -> Option<&'static str> { MessageId::CmdLoadDescription => "ファイルからセッションを読み込み", MessageId::CmdLogoutDescription => "API キーを消去してセットアップに戻る", MessageId::CmdMcpDescription => "MCP サーバを開く・管理する", + MessageId::CmdPluginDescription => "List discovered plugin tools or show details for one", + MessageId::CmdPluginNoneFound => "No plugin tools discovered in {dir}", + MessageId::CmdPluginNotFound => "Plugin '{name}' not found", + MessageId::CmdPluginListHeader => "Plugin tools ({count}):", + MessageId::CmdPluginDetailDescription => "Description: {description}", + MessageId::CmdPluginDetailSchema => "Schema:\n{schema}", + MessageId::CmdPluginDetailApproval => "Approval: {approval}", + MessageId::CmdPluginDetailPath => "Path: {path}", MessageId::CmdMemoryDescription => "永続ユーザーメモリファイルを確認・管理", MessageId::CmdModeDescription => { "動作モードを切り替え、または選択画面を開く: /mode [agent|plan|yolo|1|2|3]" @@ -3333,6 +3381,14 @@ fn chinese_simplified(id: MessageId) -> Option<&'static str> { MessageId::CmdLoadDescription => "从文件加载会话", MessageId::CmdLogoutDescription => "清除 API 密钥并返回设置", MessageId::CmdMcpDescription => "打开或管理 MCP 服务器", + MessageId::CmdPluginDescription => "List discovered plugin tools or show details for one", + MessageId::CmdPluginNoneFound => "No plugin tools discovered in {dir}", + MessageId::CmdPluginNotFound => "Plugin '{name}' not found", + MessageId::CmdPluginListHeader => "Plugin tools ({count}):", + MessageId::CmdPluginDetailDescription => "Description: {description}", + MessageId::CmdPluginDetailSchema => "Schema:\n{schema}", + MessageId::CmdPluginDetailApproval => "Approval: {approval}", + MessageId::CmdPluginDetailPath => "Path: {path}", MessageId::CmdMemoryDescription => "查看或管理持久用户记忆文件", MessageId::CmdModeDescription => "切换运行模式或打开选择器:/mode [agent|plan|yolo|1|2|3]", MessageId::CmdModelDescription => "切换或查看当前模型", @@ -3866,6 +3922,14 @@ fn portuguese_brazil(id: MessageId) -> Option<&'static str> { MessageId::CmdLoadDescription => "Carregar a sessão de um arquivo", MessageId::CmdLogoutDescription => "Limpar a chave de API e voltar à configuração", MessageId::CmdMcpDescription => "Abrir ou gerenciar servidores MCP", + MessageId::CmdPluginDescription => "List discovered plugin tools or show details for one", + MessageId::CmdPluginNoneFound => "No plugin tools discovered in {dir}", + MessageId::CmdPluginNotFound => "Plugin '{name}' not found", + MessageId::CmdPluginListHeader => "Plugin tools ({count}):", + MessageId::CmdPluginDetailDescription => "Description: {description}", + MessageId::CmdPluginDetailSchema => "Schema:\n{schema}", + MessageId::CmdPluginDetailApproval => "Approval: {approval}", + MessageId::CmdPluginDetailPath => "Path: {path}", MessageId::CmdMemoryDescription => { "Inspecionar ou gerenciar o arquivo persistente de memória do usuário" } @@ -4481,6 +4545,14 @@ fn spanish_latin_america(id: MessageId) -> Option<&'static str> { MessageId::CmdLoadDescription => "Cargar la sesión desde un archivo", MessageId::CmdLogoutDescription => "Limpiar la clave de API y volver a la configuración", MessageId::CmdMcpDescription => "Abrir o gestionar servidores MCP", + MessageId::CmdPluginDescription => "List discovered plugin tools or show details for one", + MessageId::CmdPluginNoneFound => "No plugin tools discovered in {dir}", + MessageId::CmdPluginNotFound => "Plugin '{name}' not found", + MessageId::CmdPluginListHeader => "Plugin tools ({count}):", + MessageId::CmdPluginDetailDescription => "Description: {description}", + MessageId::CmdPluginDetailSchema => "Schema:\n{schema}", + MessageId::CmdPluginDetailApproval => "Approval: {approval}", + MessageId::CmdPluginDetailPath => "Path: {path}", MessageId::CmdMemoryDescription => { "Inspeccionar o gestionar el archivo persistente de memoria del usuario" }