mirror of
https://github.com/instructkr/claw-code.git
synced 2026-04-05 07:51:13 +08:00
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:
19
PARITY.md
19
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
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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()?
|
||||
|
||||
Reference in New Issue
Block a user