feat(tui): add /plugins slash command (#3169)
Adds a /plugins [name] command to list discovered script plugin tools and inspect their metadata (description, input schema, approval level, path). Includes localization strings and unit tests. Co-authored-by: CodeWhale Agent <codewhale-agent@hmbown.local>
This commit is contained in:
@@ -19,6 +19,7 @@ mod jobs;
|
||||
mod mcp;
|
||||
mod memory;
|
||||
mod network;
|
||||
mod plugins;
|
||||
mod note;
|
||||
mod provider;
|
||||
mod queue;
|
||||
@@ -284,6 +285,12 @@ pub const COMMANDS: &[CommandInfo] = &[
|
||||
usage: "/network [list|allow <host>|deny <host>|remove <host>|default <allow|deny|prompt>]",
|
||||
description_id: MessageId::CmdNetworkDescription,
|
||||
},
|
||||
CommandInfo {
|
||||
name: "plugins",
|
||||
aliases: &["plugin"],
|
||||
usage: "/plugins [name]",
|
||||
description_id: MessageId::CmdPluginDescription,
|
||||
},
|
||||
// Session commands
|
||||
CommandInfo {
|
||||
name: "rename",
|
||||
@@ -593,6 +600,7 @@ pub fn execute(cmd: &str, app: &mut App) -> CommandResult {
|
||||
"jobs" | "job" | "zuoye" => jobs::jobs(app, arg),
|
||||
"mcp" => mcp::mcp(app, arg),
|
||||
"network" => network::network(app, arg),
|
||||
"plugins" | "plugin" => plugins::plugins(app, arg),
|
||||
|
||||
// Session commands
|
||||
"rename" | "gaiming" | "chongmingming" => rename::rename(app, arg),
|
||||
|
||||
@@ -0,0 +1,254 @@
|
||||
//! `/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::tui::app::App;
|
||||
use crate::tools::plugin::scan_plugin_dir;
|
||||
|
||||
/// 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<PathBuf> {
|
||||
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<PathBuf> {
|
||||
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"));
|
||||
}
|
||||
}
|
||||
@@ -289,6 +289,14 @@ pub enum MessageId {
|
||||
CmdLogoutDescription,
|
||||
CmdMcpDescription,
|
||||
CmdMemoryDescription,
|
||||
CmdPluginDescription,
|
||||
CmdPluginNoneFound,
|
||||
CmdPluginNotFound,
|
||||
CmdPluginListHeader,
|
||||
CmdPluginDetailDescription,
|
||||
CmdPluginDetailSchema,
|
||||
CmdPluginDetailApproval,
|
||||
CmdPluginDetailPath,
|
||||
CmdModeDescription,
|
||||
CmdModelDescription,
|
||||
CmdModelsDescription,
|
||||
@@ -666,6 +674,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,
|
||||
@@ -1248,6 +1264,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]"
|
||||
@@ -1785,6 +1809,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]"
|
||||
@@ -2244,6 +2276,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 => "輸出翻譯已開啟:模型回覆將以繁體中文顯示",
|
||||
@@ -2456,6 +2496,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]"
|
||||
@@ -2966,6 +3014,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 => "切换或查看当前模型",
|
||||
@@ -3442,6 +3498,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"
|
||||
}
|
||||
@@ -3996,6 +4060,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"
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user