From b8d78c9a53bac777d2c5dc6c5009e59c6db8e4c7 Mon Sep 17 00:00:00 2001 From: Yeachan-Heo Date: Thu, 2 Apr 2026 00:04:23 +0000 Subject: [PATCH] feat: add honest plugin inspection reporting Shift the Rust parity increment away from implying TS-style plugin UX and toward an honest inspection surface. /plugin now reports current local plugin support, checked directories, and missing runtime wiring, while /reload-plugins rebuilds the runtime and prints the same inspection snapshot.\n\nConstraint: Rust only supports local manifest-backed plugins today; marketplace/discovery parity does not exist\nRejected: Stub marketplace installer flow | would overstate current capability\nRejected: Keep /plugin as list-only output | hides important gaps and checked paths\nConfidence: high\nScope-risk: narrow\nReversibility: clean\nDirective: Keep plugin reporting aligned with actual runtime wiring; do not advertise manifest commands/hooks as active until the runtime uses them\nTested: cargo test -p commands\nTested: cargo test -p claw-cli\nNot-tested: cargo clippy -p commands -p claw-cli --tests -- -D warnings (blocked by pre-existing workspace warnings in commands/claw-cli/lsp) --- PARITY.md | 19 ++-- rust/crates/claw-cli/src/main.rs | 36 ++++++- rust/crates/commands/src/lib.rs | 168 +++++++++++++++++++++++++++++-- rust/crates/plugins/src/lib.rs | 36 +++++++ 4 files changed, 240 insertions(+), 19 deletions(-) diff --git a/PARITY.md b/PARITY.md index a8fff12..4178ee2 100644 --- a/PARITY.md +++ b/PARITY.md @@ -84,16 +84,19 @@ Evidence: ### Rust exists Evidence: -- No dedicated plugin subsystem appears under `rust/crates/`. -- Repo-wide Rust references to plugins are effectively absent beyond text/help mentions. +- Local plugin manifests, registry/state, install/update/uninstall flows, and bundled/external discovery live in `rust/crates/plugins/src/lib.rs`. +- Runtime config parses plugin settings (`enabledPlugins`, external directories, install root, registry path, bundled root) in `rust/crates/runtime/src/config.rs`. +- CLI wiring builds a `PluginManager`, exposes `/plugin` inspection/reporting, and now exposes `/reload-plugins` runtime rebuild/reporting in `rust/crates/commands/src/lib.rs` and `rust/crates/claw-cli/src/main.rs`. +- Plugin-provided tools are merged into the runtime tool registry in `rust/crates/claw-cli/src/main.rs` and `rust/crates/tools/src/lib.rs`. ### Missing or broken in Rust -- No plugin loader. -- No marketplace install/update/enable/disable flow. -- No `/plugin` or `/reload-plugins` parity. -- No plugin-provided hook/tool/command/MCP extension path. +- No TS-style marketplace/discovery/editor UI; current surfaces are local manifest/reporting oriented. +- Plugin-defined slash commands are validated from manifests but not exposed in the CLI runtime. +- Plugin hooks and lifecycle commands are validated but not wired into the conversation runtime startup/shutdown or hook runner. +- No plugin-provided MCP/server extension path. +- `/reload-plugins` only rebuilds the current local runtime; it is not a richer TS hot-reload/plugin-browser flow. -**Status:** missing. +**Status:** local plugin discovery/install/inspection exists; TS marketplace/runtime-extension parity is still partial. --- @@ -133,7 +136,7 @@ Evidence: ### Rust exists Evidence: - Shared slash command registry in `rust/crates/commands/src/lib.rs`. -- Rust slash commands currently cover `help`, `status`, `compact`, `model`, `permissions`, `clear`, `cost`, `resume`, `config`, `memory`, `init`, `diff`, `version`, `export`, `session`, `plugin`, `agents`, and `skills`. +- Rust slash commands currently cover `help`, `status`, `compact`, `model`, `permissions`, `clear`, `cost`, `resume`, `config`, `hooks`, `memory`, `init`, `diff`, `version`, `export`, `session`, `plugin`, `reload-plugins`, `agents`, and `skills`. - Main CLI/repl/prompt handling lives in `rust/crates/claw-cli/src/main.rs`. ### Missing or broken in Rust diff --git a/rust/crates/claw-cli/src/main.rs b/rust/crates/claw-cli/src/main.rs index 1b359b1..b74b4e1 100644 --- a/rust/crates/claw-cli/src/main.rs +++ b/rust/crates/claw-cli/src/main.rs @@ -23,8 +23,8 @@ use api::{ use commands::{ handle_agents_slash_command, handle_hooks_slash_command, handle_plugins_slash_command, - handle_skills_slash_command, render_slash_command_help, resume_supported_slash_commands, - slash_command_specs, suggest_slash_commands, SlashCommand, + handle_skills_slash_command, render_plugin_inspection_report, render_slash_command_help, + resume_supported_slash_commands, slash_command_specs, suggest_slash_commands, SlashCommand, }; use compat_harness::{extract_manifest, UpstreamPaths}; use init::initialize_repo; @@ -1015,6 +1015,7 @@ fn run_resume_command( | SlashCommand::Permissions { .. } | SlashCommand::Session { .. } | SlashCommand::Plugins { .. } + | SlashCommand::ReloadPlugins | SlashCommand::Unknown(_) => Err("unsupported resumed slash command".into()), } } @@ -1340,6 +1341,7 @@ impl LiveCli { SlashCommand::Plugins { action, target } => { self.handle_plugins_command(action.as_deref(), target.as_deref())? } + SlashCommand::ReloadPlugins => self.reload_plugins_command()?, SlashCommand::Agents { args } => { Self::print_agents(args.as_deref())?; false @@ -1671,6 +1673,22 @@ impl LiveCli { Ok(false) } + fn reload_plugins_command(&mut self) -> Result> { + self.reload_runtime_features()?; + + let cwd = env::current_dir()?; + let loader = ConfigLoader::default_for(&cwd); + let runtime_config = loader.load()?; + let manager = build_plugin_manager(&cwd, &loader, &runtime_config); + let inspection = manager.inspect()?; + + println!( + "Plugin runtime reloaded from local manifests.\n{}", + render_plugin_inspection_report(&inspection) + ); + Ok(false) + } + fn reload_runtime_features(&mut self) -> Result<(), Box> { self.runtime = build_runtime( self.runtime.session().clone(), @@ -4528,8 +4546,9 @@ mod tests { assert!(help.contains("/export [file]")); assert!(help.contains("/session [list|switch ]")); assert!(help.contains( - "/plugin [list|install |enable |disable |uninstall |update ]" + "/plugin [inspect|list|install |enable |disable |uninstall |update ]" )); + assert!(help.contains("/reload-plugins")); assert!(help.contains("aliases: /plugins, /marketplace")); assert!(help.contains("/agents")); assert!(help.contains("/skills")); @@ -4556,11 +4575,20 @@ mod tests { .expect("plugin descriptor should exist"); assert_eq!( plugin.description.as_deref(), - Some("Manage Claw Code plugins") + Some("Inspect and manage local Claw Code plugins") ); assert!(plugin.aliases.contains(&"/plugins".to_string())); assert!(plugin.aliases.contains(&"/marketplace".to_string())); + let reload = descriptors + .iter() + .find(|descriptor| descriptor.command == "/reload-plugins") + .expect("reload plugins descriptor should exist"); + assert_eq!( + reload.description.as_deref(), + Some("Reload plugin-derived runtime features and print current support") + ); + let exit = descriptors .iter() .find(|descriptor| descriptor.command == "/exit") diff --git a/rust/crates/commands/src/lib.rs b/rust/crates/commands/src/lib.rs index e28c889..07d1ab1 100644 --- a/rust/crates/commands/src/lib.rs +++ b/rust/crates/commands/src/lib.rs @@ -6,7 +6,7 @@ use std::path::{Path, PathBuf}; use std::process::Command; use std::time::{SystemTime, UNIX_EPOCH}; -use plugins::{PluginError, PluginManager, PluginSummary}; +use plugins::{PluginError, PluginInspection, PluginManager, PluginSummary}; use runtime::{ compact_session, discover_skill_roots, CompactionConfig, ConfigLoader, ConfigSource, RuntimeConfig, Session, SkillDiscoveryRoot, SkillDiscoverySource, SkillRootKind, @@ -285,13 +285,21 @@ const SLASH_COMMAND_SPECS: &[SlashCommandSpec] = &[ SlashCommandSpec { name: "plugin", aliases: &["plugins", "marketplace"], - summary: "Manage Claw Code plugins", + summary: "Inspect and manage local Claw Code plugins", argument_hint: Some( - "[list|install |enable |disable |uninstall |update ]", + "[inspect|list|install |enable |disable |uninstall |update ]", ), resume_supported: false, category: SlashCommandCategory::Automation, }, + SlashCommandSpec { + name: "reload-plugins", + aliases: &[], + summary: "Reload plugin-derived runtime features and print current support", + argument_hint: None, + resume_supported: false, + category: SlashCommandCategory::Automation, + }, SlashCommandSpec { name: "agents", aliases: &[], @@ -378,6 +386,7 @@ pub enum SlashCommand { action: Option, target: Option, }, + ReloadPlugins, Agents { args: Option, }, @@ -467,6 +476,7 @@ impl SlashCommand { (!remainder.is_empty()).then_some(remainder) }, }, + "reload-plugins" => Self::ReloadPlugins, "agents" => Self::Agents { args: remainder_after_command(trimmed, command), }, @@ -688,7 +698,11 @@ pub fn handle_plugins_slash_command( manager: &mut PluginManager, ) -> Result { match action { - None | Some("list") => Ok(PluginsCommandResult { + None | Some("inspect" | "status") => Ok(PluginsCommandResult { + message: render_plugin_inspection_report(&manager.inspect()?), + reload_runtime: false, + }), + Some("list") => Ok(PluginsCommandResult { message: render_plugins_report(&manager.list_installed_plugins()?), reload_runtime: false, }), @@ -786,7 +800,7 @@ pub fn handle_plugins_slash_command( } Some(other) => Ok(PluginsCommandResult { message: format!( - "Unknown /plugins action '{other}'. Use list, install, enable, disable, uninstall, or update." + "Unknown /plugins action '{other}'. Use inspect, list, install, enable, disable, uninstall, or update." ), reload_runtime: false, }), @@ -1242,6 +1256,87 @@ pub fn render_plugins_report(plugins: &[PluginSummary]) -> String { lines.join("\n") } +#[must_use] +pub fn render_plugin_inspection_report(inspection: &PluginInspection) -> String { + let mut lines = vec![ + "Plugins".to_string(), + " Current support Local manifest discovery plus install/update/uninstall and enable/disable state".to_string(), + " Runtime wiring Plugin tools load on runtime rebuild; manifest-defined hooks, lifecycle, slash commands, and MCP extensions are not wired yet".to_string(), + format!( + " Discoverable {} total", + inspection.discoverable_plugins.len() + ), + format!( + " Installed {} total", + inspection.installed_plugins.len() + ), + " Checked locations".to_string(), + render_report_path("Install root", &inspection.install_root, inspection.install_root.exists()), + render_report_path("Bundled root", &inspection.bundled_root, inspection.bundled_root.exists()), + ]; + + if inspection.external_dirs.is_empty() { + lines.push(" External dirs none configured".to_string()); + } else { + for (index, directory) in inspection.external_dirs.iter().enumerate() { + lines.push(render_report_path( + if index == 0 { + "External dirs" + } else { + "External dir" + }, + directory, + directory.exists(), + )); + } + } + + lines.push(render_report_path( + "Registry file", + &inspection.registry_path, + inspection.registry_path.exists(), + )); + lines.push(render_report_path( + "Settings file", + &inspection.settings_path, + inspection.settings_path.exists(), + )); + + lines.push(" Missing parity".to_string()); + lines.push(" TS marketplace/discovery UI is not implemented.".to_string()); + lines.push( + " Plugin-defined slash commands are parsed from manifests but not exposed.".to_string(), + ); + lines.push( + " Plugin hooks/lifecycle are validated but not attached to the conversation runtime." + .to_string(), + ); + lines.push( + " No plugin hot-swap beyond /reload-plugins rebuilding the current runtime.".to_string(), + ); + + lines.push(" Installed plugins".to_string()); + if inspection.installed_plugins.is_empty() { + lines.push(" none".to_string()); + } else { + for plugin in &inspection.installed_plugins { + lines.push(format!( + " - {} · {} v{} · {}", + plugin.metadata.id, + plugin.metadata.kind, + plugin.metadata.version, + if plugin.enabled { + "enabled" + } else { + "disabled" + } + )); + } + } + + lines.join("\n") +} + fn render_plugin_install_report(plugin_id: &str, plugin: Option<&PluginSummary>) -> String { let name = plugin.map_or(plugin_id, |plugin| plugin.metadata.name.as_str()); let version = plugin.map_or("unknown", |plugin| plugin.metadata.version.as_str()); @@ -1272,6 +1367,14 @@ fn resolve_plugin_target( } } +fn render_report_path(label: &str, path: &Path, exists: bool) -> String { + format!( + " {label:<15} {} ({})", + path.display(), + if exists { "present" } else { "missing" } + ) +} + fn discover_definition_roots(cwd: &Path, leaf: &str) -> Vec<(DefinitionSource, PathBuf)> { let mut roots = Vec::new(); @@ -1801,6 +1904,7 @@ pub fn handle_slash_command( | SlashCommand::Export { .. } | SlashCommand::Session { .. } | SlashCommand::Plugins { .. } + | SlashCommand::ReloadPlugins | SlashCommand::Agents { .. } | SlashCommand::Skills { .. } | SlashCommand::Unknown(_) => None, @@ -2135,6 +2239,10 @@ mod tests { target: Some("demo".to_string()) }) ); + assert_eq!( + SlashCommand::parse("/reload-plugins"), + Some(SlashCommand::ReloadPlugins) + ); } #[test] @@ -2173,12 +2281,13 @@ mod tests { assert!(help.contains("/export [file]")); assert!(help.contains("/session [list|switch ]")); assert!(help.contains( - "/plugin [list|install |enable |disable |uninstall |update ]" + "/plugin [inspect|list|install |enable |disable |uninstall |update ]" )); assert!(help.contains("aliases: /plugins, /marketplace")); + assert!(help.contains("/reload-plugins")); assert!(help.contains("/agents")); assert!(help.contains("/skills")); - assert_eq!(slash_command_specs().len(), 29); + assert_eq!(slash_command_specs().len(), 30); assert_eq!(resume_supported_slash_commands().len(), 14); } @@ -2299,6 +2408,10 @@ mod tests { assert!( handle_slash_command("/plugins list", &session, CompactionConfig::default()).is_none() ); + assert!( + handle_slash_command("/reload-plugins", &session, CompactionConfig::default()) + .is_none() + ); } #[test] @@ -2340,6 +2453,47 @@ mod tests { assert!(rendered.contains("disabled")); } + #[test] + fn default_plugin_action_renders_inspection_report() { + let config_home = temp_dir("plugins-inspect-home"); + let bundled_root = temp_dir("plugins-inspect-bundled"); + let bundled_plugin = bundled_root.join("starter"); + write_bundled_plugin(&bundled_plugin, "starter", "0.1.0", false); + + let mut config = PluginManagerConfig::new(&config_home); + config.bundled_root = Some(bundled_root.clone()); + config.external_dirs = vec![config_home.join("external")]; + let mut manager = PluginManager::new(config); + + let inspection = handle_plugins_slash_command(None, None, &mut manager) + .expect("inspect command should succeed"); + assert!(!inspection.reload_runtime); + assert!(inspection.message.contains("Current support")); + assert!(inspection.message.contains("Checked locations")); + assert!(inspection + .message + .contains(&manager.install_root().display().to_string())); + assert!(inspection + .message + .contains(&manager.bundled_root_path().display().to_string())); + assert!(inspection + .message + .contains(&manager.registry_path().display().to_string())); + assert!(inspection + .message + .contains(&manager.settings_path().display().to_string())); + assert!(inspection + .message + .contains("Plugin-defined slash commands are parsed from manifests but not exposed.")); + assert!(inspection.message.contains( + "Plugin hooks/lifecycle are validated but not attached to the conversation runtime." + )); + assert!(inspection.message.contains("starter@bundled")); + + let _ = fs::remove_dir_all(config_home); + let _ = fs::remove_dir_all(bundled_root); + } + #[test] fn lists_agents_from_project_and_user_roots() { let workspace = temp_dir("agents-workspace"); diff --git a/rust/crates/plugins/src/lib.rs b/rust/crates/plugins/src/lib.rs index 6105ad9..cbfa2b0 100644 --- a/rust/crates/plugins/src/lib.rs +++ b/rust/crates/plugins/src/lib.rs @@ -648,6 +648,17 @@ pub struct PluginSummary { pub enabled: bool, } +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct PluginInspection { + pub install_root: PathBuf, + pub registry_path: PathBuf, + pub settings_path: PathBuf, + pub bundled_root: PathBuf, + pub external_dirs: Vec, + pub discoverable_plugins: Vec, + pub installed_plugins: Vec, +} + #[derive(Debug, Clone, Default, PartialEq)] pub struct PluginRegistry { plugins: Vec, @@ -934,6 +945,31 @@ impl PluginManager { self.config.config_home.join(SETTINGS_FILE_NAME) } + #[must_use] + pub fn bundled_root_path(&self) -> PathBuf { + self.config + .bundled_root + .clone() + .unwrap_or_else(Self::bundled_root) + } + + #[must_use] + pub fn external_dirs(&self) -> &[PathBuf] { + &self.config.external_dirs + } + + pub fn inspect(&self) -> Result { + Ok(PluginInspection { + install_root: self.install_root(), + registry_path: self.registry_path(), + settings_path: self.settings_path(), + bundled_root: self.bundled_root_path(), + external_dirs: self.external_dirs().to_vec(), + discoverable_plugins: self.list_plugins()?, + installed_plugins: self.list_installed_plugins()?, + }) + } + pub fn plugin_registry(&self) -> Result { Ok(PluginRegistry::new( self.discover_plugins()?