mirror of
https://github.com/instructkr/claw-code.git
synced 2026-04-03 19:34:47 +08:00
feat: plugin subsystem — loader, hooks, tools, bundled, CLI
This commit is contained in:
@@ -9,4 +9,5 @@ publish.workspace = true
|
||||
workspace = true
|
||||
|
||||
[dependencies]
|
||||
plugins = { path = "../plugins" }
|
||||
runtime = { path = "../runtime" }
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
use plugins::{PluginError, PluginManager, PluginSummary};
|
||||
use runtime::{compact_session, CompactionConfig, Session};
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
@@ -356,6 +357,151 @@ pub struct SlashCommandResult {
|
||||
pub session: Session,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct PluginsCommandResult {
|
||||
pub message: String,
|
||||
pub reload_runtime: bool,
|
||||
}
|
||||
|
||||
pub fn handle_plugins_slash_command(
|
||||
action: Option<&str>,
|
||||
target: Option<&str>,
|
||||
manager: &mut PluginManager,
|
||||
) -> Result<PluginsCommandResult, PluginError> {
|
||||
match action {
|
||||
None | Some("list") => Ok(PluginsCommandResult {
|
||||
message: render_plugins_report(&manager.list_plugins()?),
|
||||
reload_runtime: false,
|
||||
}),
|
||||
Some("install") => {
|
||||
let Some(target) = target else {
|
||||
return Ok(PluginsCommandResult {
|
||||
message: "Usage: /plugins install <path>".to_string(),
|
||||
reload_runtime: false,
|
||||
});
|
||||
};
|
||||
let install = manager.install(target)?;
|
||||
let plugin = manager
|
||||
.list_plugins()?
|
||||
.into_iter()
|
||||
.find(|plugin| plugin.metadata.id == install.plugin_id);
|
||||
Ok(PluginsCommandResult {
|
||||
message: render_plugin_install_report(&install.plugin_id, plugin.as_ref()),
|
||||
reload_runtime: true,
|
||||
})
|
||||
}
|
||||
Some("enable") => {
|
||||
let Some(target) = target else {
|
||||
return Ok(PluginsCommandResult {
|
||||
message: "Usage: /plugins enable <plugin-id>".to_string(),
|
||||
reload_runtime: false,
|
||||
});
|
||||
};
|
||||
manager.enable(target)?;
|
||||
Ok(PluginsCommandResult {
|
||||
message: format!(
|
||||
"Plugins\n Result enabled {target}\n Status enabled"
|
||||
),
|
||||
reload_runtime: true,
|
||||
})
|
||||
}
|
||||
Some("disable") => {
|
||||
let Some(target) = target else {
|
||||
return Ok(PluginsCommandResult {
|
||||
message: "Usage: /plugins disable <plugin-id>".to_string(),
|
||||
reload_runtime: false,
|
||||
});
|
||||
};
|
||||
manager.disable(target)?;
|
||||
Ok(PluginsCommandResult {
|
||||
message: format!(
|
||||
"Plugins\n Result disabled {target}\n Status disabled"
|
||||
),
|
||||
reload_runtime: true,
|
||||
})
|
||||
}
|
||||
Some("uninstall") => {
|
||||
let Some(target) = target else {
|
||||
return Ok(PluginsCommandResult {
|
||||
message: "Usage: /plugins uninstall <plugin-id>".to_string(),
|
||||
reload_runtime: false,
|
||||
});
|
||||
};
|
||||
manager.uninstall(target)?;
|
||||
Ok(PluginsCommandResult {
|
||||
message: format!("Plugins\n Result uninstalled {target}"),
|
||||
reload_runtime: true,
|
||||
})
|
||||
}
|
||||
Some("update") => {
|
||||
let Some(target) = target else {
|
||||
return Ok(PluginsCommandResult {
|
||||
message: "Usage: /plugins update <plugin-id>".to_string(),
|
||||
reload_runtime: false,
|
||||
});
|
||||
};
|
||||
let update = manager.update(target)?;
|
||||
let plugin = manager
|
||||
.list_plugins()?
|
||||
.into_iter()
|
||||
.find(|plugin| plugin.metadata.id == update.plugin_id);
|
||||
Ok(PluginsCommandResult {
|
||||
message: format!(
|
||||
"Plugins\n Result updated {}\n Name {}\n Old version {}\n New version {}\n Status {}",
|
||||
update.plugin_id,
|
||||
plugin
|
||||
.as_ref()
|
||||
.map_or_else(|| update.plugin_id.clone(), |plugin| plugin.metadata.name.clone()),
|
||||
update.old_version,
|
||||
update.new_version,
|
||||
plugin
|
||||
.as_ref()
|
||||
.map_or("unknown", |plugin| if plugin.enabled { "enabled" } else { "disabled" }),
|
||||
),
|
||||
reload_runtime: true,
|
||||
})
|
||||
}
|
||||
Some(other) => Ok(PluginsCommandResult {
|
||||
message: format!(
|
||||
"Unknown /plugins action '{other}'. Use list, install, enable, disable, uninstall, or update."
|
||||
),
|
||||
reload_runtime: false,
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn render_plugins_report(plugins: &[PluginSummary]) -> String {
|
||||
let mut lines = vec!["Plugins".to_string()];
|
||||
if plugins.is_empty() {
|
||||
lines.push(" No plugins discovered.".to_string());
|
||||
return lines.join("\n");
|
||||
}
|
||||
for plugin in plugins {
|
||||
let enabled = if plugin.enabled {
|
||||
"enabled"
|
||||
} else {
|
||||
"disabled"
|
||||
};
|
||||
lines.push(format!(
|
||||
" {name:<20} v{version:<10} {enabled}",
|
||||
name = plugin.metadata.name,
|
||||
version = plugin.metadata.version,
|
||||
));
|
||||
}
|
||||
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());
|
||||
let enabled = plugin.is_some_and(|plugin| plugin.enabled);
|
||||
format!(
|
||||
"Plugins\n Result installed {plugin_id}\n Name {name}\n Version {version}\n Status {}",
|
||||
if enabled { "enabled" } else { "disabled" }
|
||||
)
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn handle_slash_command(
|
||||
input: &str,
|
||||
@@ -410,10 +556,34 @@ pub fn handle_slash_command(
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{
|
||||
handle_slash_command, render_slash_command_help, resume_supported_slash_commands,
|
||||
slash_command_specs, SlashCommand,
|
||||
handle_plugins_slash_command, handle_slash_command, render_plugins_report,
|
||||
render_slash_command_help, resume_supported_slash_commands, slash_command_specs,
|
||||
SlashCommand,
|
||||
};
|
||||
use plugins::{PluginManager, PluginManagerConfig, PluginMetadata, PluginSummary};
|
||||
use runtime::{CompactionConfig, ContentBlock, ConversationMessage, MessageRole, Session};
|
||||
use std::fs;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
fn temp_dir(label: &str) -> PathBuf {
|
||||
let nanos = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.expect("time should be after epoch")
|
||||
.as_nanos();
|
||||
std::env::temp_dir().join(format!("commands-plugin-{label}-{nanos}"))
|
||||
}
|
||||
|
||||
fn write_external_plugin(root: &Path, name: &str, version: &str) {
|
||||
fs::create_dir_all(root.join(".claude-plugin")).expect("manifest dir");
|
||||
fs::write(
|
||||
root.join(".claude-plugin").join("plugin.json"),
|
||||
format!(
|
||||
"{{\n \"name\": \"{name}\",\n \"version\": \"{version}\",\n \"description\": \"commands plugin\"\n}}"
|
||||
),
|
||||
)
|
||||
.expect("write manifest");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_supported_slash_commands() {
|
||||
@@ -519,6 +689,13 @@ mod tests {
|
||||
target: Some("demo".to_string())
|
||||
})
|
||||
);
|
||||
assert_eq!(
|
||||
SlashCommand::parse("/plugins list"),
|
||||
Some(SlashCommand::Plugins {
|
||||
action: Some("list".to_string()),
|
||||
target: None
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -652,4 +829,73 @@ mod tests {
|
||||
handle_slash_command("/plugins list", &session, CompactionConfig::default()).is_none()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn renders_plugins_report_with_name_version_and_status() {
|
||||
let rendered = render_plugins_report(&[
|
||||
PluginSummary {
|
||||
metadata: PluginMetadata {
|
||||
id: "demo@external".to_string(),
|
||||
name: "demo".to_string(),
|
||||
version: "1.2.3".to_string(),
|
||||
description: "demo plugin".to_string(),
|
||||
kind: plugins::PluginKind::External,
|
||||
source: "demo".to_string(),
|
||||
default_enabled: false,
|
||||
root: None,
|
||||
},
|
||||
enabled: true,
|
||||
},
|
||||
PluginSummary {
|
||||
metadata: PluginMetadata {
|
||||
id: "sample@external".to_string(),
|
||||
name: "sample".to_string(),
|
||||
version: "0.9.0".to_string(),
|
||||
description: "sample plugin".to_string(),
|
||||
kind: plugins::PluginKind::External,
|
||||
source: "sample".to_string(),
|
||||
default_enabled: false,
|
||||
root: None,
|
||||
},
|
||||
enabled: false,
|
||||
},
|
||||
]);
|
||||
|
||||
assert!(rendered.contains("demo"));
|
||||
assert!(rendered.contains("v1.2.3"));
|
||||
assert!(rendered.contains("enabled"));
|
||||
assert!(rendered.contains("sample"));
|
||||
assert!(rendered.contains("v0.9.0"));
|
||||
assert!(rendered.contains("disabled"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn installs_plugin_from_path_and_lists_it() {
|
||||
let config_home = temp_dir("home");
|
||||
let source_root = temp_dir("source");
|
||||
write_external_plugin(&source_root, "demo", "1.0.0");
|
||||
|
||||
let mut manager = PluginManager::new(PluginManagerConfig::new(&config_home));
|
||||
let install = handle_plugins_slash_command(
|
||||
Some("install"),
|
||||
Some(source_root.to_str().expect("utf8 path")),
|
||||
&mut manager,
|
||||
)
|
||||
.expect("install command should succeed");
|
||||
assert!(install.reload_runtime);
|
||||
assert!(install.message.contains("installed demo@external"));
|
||||
assert!(install.message.contains("Name demo"));
|
||||
assert!(install.message.contains("Version 1.0.0"));
|
||||
assert!(install.message.contains("Status enabled"));
|
||||
|
||||
let list = handle_plugins_slash_command(Some("list"), None, &mut manager)
|
||||
.expect("list command should succeed");
|
||||
assert!(!list.reload_runtime);
|
||||
assert!(list.message.contains("demo"));
|
||||
assert!(list.message.contains("v1.0.0"));
|
||||
assert!(list.message.contains("enabled"));
|
||||
|
||||
let _ = fs::remove_dir_all(config_home);
|
||||
let _ = fs::remove_dir_all(source_root);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user