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)
This commit is contained in:
Yeachan-Heo
2026-04-02 00:04:23 +00:00
parent a2f22b1ece
commit b8d78c9a53
4 changed files with 240 additions and 19 deletions

View File

@@ -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

View File

@@ -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<bool, Box<dyn std::error::Error>> {
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<dyn std::error::Error>> {
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 <session-id>]"));
assert!(help.contains(
"/plugin [list|install <path>|enable <name>|disable <name>|uninstall <id>|update <id>]"
"/plugin [inspect|list|install <path>|enable <name>|disable <name>|uninstall <id>|update <id>]"
));
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")

View File

@@ -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 <path>|enable <name>|disable <name>|uninstall <id>|update <id>]",
"[inspect|list|install <path>|enable <name>|disable <name>|uninstall <id>|update <id>]",
),
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<String>,
target: Option<String>,
},
ReloadPlugins,
Agents {
args: Option<String>,
},
@@ -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<PluginsCommandResult, PluginError> {
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 <session-id>]"));
assert!(help.contains(
"/plugin [list|install <path>|enable <name>|disable <name>|uninstall <id>|update <id>]"
"/plugin [inspect|list|install <path>|enable <name>|disable <name>|uninstall <id>|update <id>]"
));
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");

View File

@@ -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<PathBuf>,
pub discoverable_plugins: Vec<PluginSummary>,
pub installed_plugins: Vec<PluginSummary>,
}
#[derive(Debug, Clone, Default, PartialEq)]
pub struct PluginRegistry {
plugins: Vec<RegisteredPlugin>,
@@ -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<PluginInspection, PluginError> {
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<PluginRegistry, PluginError> {
Ok(PluginRegistry::new(
self.discover_plugins()?