mirror of
https://github.com/instructkr/claw-code.git
synced 2026-04-03 19:04:48 +08:00
feat: plugins progress
This commit is contained in:
@@ -72,6 +72,21 @@ impl PluginHooks {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct PluginLifecycle {
|
||||
#[serde(rename = "Init", default)]
|
||||
pub init: Vec<String>,
|
||||
#[serde(rename = "Shutdown", default)]
|
||||
pub shutdown: Vec<String>,
|
||||
}
|
||||
|
||||
impl PluginLifecycle {
|
||||
#[must_use]
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.init.is_empty() && self.shutdown.is_empty()
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct PluginManifest {
|
||||
pub name: String,
|
||||
@@ -81,6 +96,8 @@ pub struct PluginManifest {
|
||||
pub default_enabled: bool,
|
||||
#[serde(default)]
|
||||
pub hooks: PluginHooks,
|
||||
#[serde(default)]
|
||||
pub lifecycle: PluginLifecycle,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
@@ -112,24 +129,30 @@ pub struct InstalledPluginRegistry {
|
||||
pub struct BuiltinPlugin {
|
||||
metadata: PluginMetadata,
|
||||
hooks: PluginHooks,
|
||||
lifecycle: PluginLifecycle,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct BundledPlugin {
|
||||
metadata: PluginMetadata,
|
||||
hooks: PluginHooks,
|
||||
lifecycle: PluginLifecycle,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct ExternalPlugin {
|
||||
metadata: PluginMetadata,
|
||||
hooks: PluginHooks,
|
||||
lifecycle: PluginLifecycle,
|
||||
}
|
||||
|
||||
pub trait Plugin {
|
||||
fn metadata(&self) -> &PluginMetadata;
|
||||
fn hooks(&self) -> &PluginHooks;
|
||||
fn lifecycle(&self) -> &PluginLifecycle;
|
||||
fn validate(&self) -> Result<(), PluginError>;
|
||||
fn initialize(&self) -> Result<(), PluginError>;
|
||||
fn shutdown(&self) -> Result<(), PluginError>;
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
@@ -148,9 +171,21 @@ impl Plugin for BuiltinPlugin {
|
||||
&self.hooks
|
||||
}
|
||||
|
||||
fn lifecycle(&self) -> &PluginLifecycle {
|
||||
&self.lifecycle
|
||||
}
|
||||
|
||||
fn validate(&self) -> Result<(), PluginError> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn initialize(&self) -> Result<(), PluginError> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn shutdown(&self) -> Result<(), PluginError> {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
impl Plugin for BundledPlugin {
|
||||
@@ -162,8 +197,26 @@ impl Plugin for BundledPlugin {
|
||||
&self.hooks
|
||||
}
|
||||
|
||||
fn lifecycle(&self) -> &PluginLifecycle {
|
||||
&self.lifecycle
|
||||
}
|
||||
|
||||
fn validate(&self) -> Result<(), PluginError> {
|
||||
validate_hook_paths(self.metadata.root.as_deref(), &self.hooks)
|
||||
validate_hook_paths(self.metadata.root.as_deref(), &self.hooks)?;
|
||||
validate_lifecycle_paths(self.metadata.root.as_deref(), &self.lifecycle)
|
||||
}
|
||||
|
||||
fn initialize(&self) -> Result<(), PluginError> {
|
||||
run_lifecycle_commands(self.metadata(), self.lifecycle(), "init", &self.lifecycle.init)
|
||||
}
|
||||
|
||||
fn shutdown(&self) -> Result<(), PluginError> {
|
||||
run_lifecycle_commands(
|
||||
self.metadata(),
|
||||
self.lifecycle(),
|
||||
"shutdown",
|
||||
&self.lifecycle.shutdown,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -176,8 +229,26 @@ impl Plugin for ExternalPlugin {
|
||||
&self.hooks
|
||||
}
|
||||
|
||||
fn lifecycle(&self) -> &PluginLifecycle {
|
||||
&self.lifecycle
|
||||
}
|
||||
|
||||
fn validate(&self) -> Result<(), PluginError> {
|
||||
validate_hook_paths(self.metadata.root.as_deref(), &self.hooks)
|
||||
validate_hook_paths(self.metadata.root.as_deref(), &self.hooks)?;
|
||||
validate_lifecycle_paths(self.metadata.root.as_deref(), &self.lifecycle)
|
||||
}
|
||||
|
||||
fn initialize(&self) -> Result<(), PluginError> {
|
||||
run_lifecycle_commands(self.metadata(), self.lifecycle(), "init", &self.lifecycle.init)
|
||||
}
|
||||
|
||||
fn shutdown(&self) -> Result<(), PluginError> {
|
||||
run_lifecycle_commands(
|
||||
self.metadata(),
|
||||
self.lifecycle(),
|
||||
"shutdown",
|
||||
&self.lifecycle.shutdown,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -198,6 +269,14 @@ impl Plugin for PluginDefinition {
|
||||
}
|
||||
}
|
||||
|
||||
fn lifecycle(&self) -> &PluginLifecycle {
|
||||
match self {
|
||||
Self::Builtin(plugin) => plugin.lifecycle(),
|
||||
Self::Bundled(plugin) => plugin.lifecycle(),
|
||||
Self::External(plugin) => plugin.lifecycle(),
|
||||
}
|
||||
}
|
||||
|
||||
fn validate(&self) -> Result<(), PluginError> {
|
||||
match self {
|
||||
Self::Builtin(plugin) => plugin.validate(),
|
||||
@@ -205,6 +284,22 @@ impl Plugin for PluginDefinition {
|
||||
Self::External(plugin) => plugin.validate(),
|
||||
}
|
||||
}
|
||||
|
||||
fn initialize(&self) -> Result<(), PluginError> {
|
||||
match self {
|
||||
Self::Builtin(plugin) => plugin.initialize(),
|
||||
Self::Bundled(plugin) => plugin.initialize(),
|
||||
Self::External(plugin) => plugin.initialize(),
|
||||
}
|
||||
}
|
||||
|
||||
fn shutdown(&self) -> Result<(), PluginError> {
|
||||
match self {
|
||||
Self::Builtin(plugin) => plugin.shutdown(),
|
||||
Self::Bundled(plugin) => plugin.shutdown(),
|
||||
Self::External(plugin) => plugin.shutdown(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
@@ -241,6 +336,14 @@ impl RegisteredPlugin {
|
||||
self.definition.validate()
|
||||
}
|
||||
|
||||
pub fn initialize(&self) -> Result<(), PluginError> {
|
||||
self.definition.initialize()
|
||||
}
|
||||
|
||||
pub fn shutdown(&self) -> Result<(), PluginError> {
|
||||
self.definition.shutdown()
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn summary(&self) -> PluginSummary {
|
||||
PluginSummary {
|
||||
@@ -299,6 +402,21 @@ impl PluginRegistry {
|
||||
Ok(acc.merged_with(plugin.hooks()))
|
||||
})
|
||||
}
|
||||
|
||||
pub fn initialize(&self) -> Result<(), PluginError> {
|
||||
for plugin in self.plugins.iter().filter(|plugin| plugin.is_enabled()) {
|
||||
plugin.validate()?;
|
||||
plugin.initialize()?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn shutdown(&self) -> Result<(), PluginError> {
|
||||
for plugin in self.plugins.iter().rev().filter(|plugin| plugin.is_enabled()) {
|
||||
plugin.shutdown()?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
@@ -687,6 +805,7 @@ pub fn builtin_plugins() -> Vec<PluginDefinition> {
|
||||
root: None,
|
||||
},
|
||||
hooks: PluginHooks::default(),
|
||||
lifecycle: PluginLifecycle::default(),
|
||||
})]
|
||||
}
|
||||
|
||||
@@ -708,10 +827,23 @@ fn load_plugin_definition(
|
||||
root: Some(root.to_path_buf()),
|
||||
};
|
||||
let hooks = resolve_hooks(root, &manifest.hooks);
|
||||
let lifecycle = resolve_lifecycle(root, &manifest.lifecycle);
|
||||
Ok(match kind {
|
||||
PluginKind::Builtin => PluginDefinition::Builtin(BuiltinPlugin { metadata, hooks }),
|
||||
PluginKind::Bundled => PluginDefinition::Bundled(BundledPlugin { metadata, hooks }),
|
||||
PluginKind::External => PluginDefinition::External(ExternalPlugin { metadata, hooks }),
|
||||
PluginKind::Builtin => PluginDefinition::Builtin(BuiltinPlugin {
|
||||
metadata,
|
||||
hooks,
|
||||
lifecycle,
|
||||
}),
|
||||
PluginKind::Bundled => PluginDefinition::Bundled(BundledPlugin {
|
||||
metadata,
|
||||
hooks,
|
||||
lifecycle,
|
||||
}),
|
||||
PluginKind::External => PluginDefinition::External(ExternalPlugin {
|
||||
metadata,
|
||||
hooks,
|
||||
lifecycle,
|
||||
}),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -719,6 +851,7 @@ fn load_validated_manifest_from_root(root: &Path) -> Result<PluginManifest, Plug
|
||||
let manifest = load_manifest_from_root(root)?;
|
||||
validate_manifest(&manifest)?;
|
||||
validate_hook_paths(Some(root), &manifest.hooks)?;
|
||||
validate_lifecycle_paths(Some(root), &manifest.lifecycle)?;
|
||||
Ok(manifest)
|
||||
}
|
||||
|
||||
@@ -767,25 +900,58 @@ fn resolve_hooks(root: &Path, hooks: &PluginHooks) -> PluginHooks {
|
||||
}
|
||||
}
|
||||
|
||||
fn resolve_lifecycle(root: &Path, lifecycle: &PluginLifecycle) -> PluginLifecycle {
|
||||
PluginLifecycle {
|
||||
init: lifecycle
|
||||
.init
|
||||
.iter()
|
||||
.map(|entry| resolve_hook_entry(root, entry))
|
||||
.collect(),
|
||||
shutdown: lifecycle
|
||||
.shutdown
|
||||
.iter()
|
||||
.map(|entry| resolve_hook_entry(root, entry))
|
||||
.collect(),
|
||||
}
|
||||
}
|
||||
|
||||
fn validate_hook_paths(root: Option<&Path>, hooks: &PluginHooks) -> Result<(), PluginError> {
|
||||
let Some(root) = root else {
|
||||
return Ok(());
|
||||
};
|
||||
for entry in hooks.pre_tool_use.iter().chain(hooks.post_tool_use.iter()) {
|
||||
if is_literal_command(entry) {
|
||||
continue;
|
||||
}
|
||||
let path = if Path::new(entry).is_absolute() {
|
||||
PathBuf::from(entry)
|
||||
} else {
|
||||
root.join(entry)
|
||||
};
|
||||
if !path.exists() {
|
||||
return Err(PluginError::InvalidManifest(format!(
|
||||
"hook path `{}` does not exist",
|
||||
path.display()
|
||||
)));
|
||||
}
|
||||
validate_command_path(root, entry, "hook")?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn validate_lifecycle_paths(
|
||||
root: Option<&Path>,
|
||||
lifecycle: &PluginLifecycle,
|
||||
) -> Result<(), PluginError> {
|
||||
let Some(root) = root else {
|
||||
return Ok(());
|
||||
};
|
||||
for entry in lifecycle.init.iter().chain(lifecycle.shutdown.iter()) {
|
||||
validate_command_path(root, entry, "lifecycle command")?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn validate_command_path(root: &Path, entry: &str, kind: &str) -> Result<(), PluginError> {
|
||||
if is_literal_command(entry) {
|
||||
return Ok(());
|
||||
}
|
||||
let path = if Path::new(entry).is_absolute() {
|
||||
PathBuf::from(entry)
|
||||
} else {
|
||||
root.join(entry)
|
||||
};
|
||||
if !path.exists() {
|
||||
return Err(PluginError::InvalidManifest(format!(
|
||||
"{kind} path `{}` does not exist",
|
||||
path.display()
|
||||
)));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -802,6 +968,48 @@ fn is_literal_command(entry: &str) -> bool {
|
||||
!entry.starts_with("./") && !entry.starts_with("../")
|
||||
}
|
||||
|
||||
fn run_lifecycle_commands(
|
||||
metadata: &PluginMetadata,
|
||||
lifecycle: &PluginLifecycle,
|
||||
phase: &str,
|
||||
commands: &[String],
|
||||
) -> Result<(), PluginError> {
|
||||
if lifecycle.is_empty() || commands.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
for command in commands {
|
||||
let output = if Path::new(command).exists() {
|
||||
if cfg!(windows) {
|
||||
Command::new("cmd").arg("/C").arg(command).output()?
|
||||
} else {
|
||||
Command::new("sh").arg(command).output()?
|
||||
}
|
||||
} else if cfg!(windows) {
|
||||
Command::new("cmd").arg("/C").arg(command).output()?
|
||||
} else {
|
||||
Command::new("sh").arg("-lc").arg(command).output()?
|
||||
};
|
||||
|
||||
if !output.status.success() {
|
||||
let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
|
||||
return Err(PluginError::CommandFailed(format!(
|
||||
"plugin `{}` {} failed for `{}`: {}",
|
||||
metadata.id,
|
||||
phase,
|
||||
command,
|
||||
if stderr.is_empty() {
|
||||
format!("exit status {}", output.status)
|
||||
} else {
|
||||
stderr
|
||||
}
|
||||
)));
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn resolve_local_source(source: &str) -> Result<PathBuf, PluginError> {
|
||||
let path = PathBuf::from(source);
|
||||
if path.exists() {
|
||||
@@ -992,6 +1200,30 @@ mod tests {
|
||||
.expect("write broken manifest");
|
||||
}
|
||||
|
||||
fn write_lifecycle_plugin(root: &Path, name: &str, version: &str) -> PathBuf {
|
||||
fs::create_dir_all(root.join(".claude-plugin")).expect("manifest dir");
|
||||
fs::create_dir_all(root.join("lifecycle")).expect("lifecycle dir");
|
||||
let log_path = root.join("lifecycle.log");
|
||||
fs::write(
|
||||
root.join("lifecycle").join("init.sh"),
|
||||
"#!/bin/sh\nprintf 'init\\n' >> \"$(dirname \"$0\")/../lifecycle.log\"\n",
|
||||
)
|
||||
.expect("write init hook");
|
||||
fs::write(
|
||||
root.join("lifecycle").join("shutdown.sh"),
|
||||
"#!/bin/sh\nprintf 'shutdown\\n' >> \"$(dirname \"$0\")/../lifecycle.log\"\n",
|
||||
)
|
||||
.expect("write shutdown hook");
|
||||
fs::write(
|
||||
root.join(MANIFEST_RELATIVE_PATH),
|
||||
format!(
|
||||
"{{\n \"name\": \"{name}\",\n \"version\": \"{version}\",\n \"description\": \"lifecycle plugin\",\n \"lifecycle\": {{\n \"Init\": [\"./lifecycle/init.sh\"],\n \"Shutdown\": [\"./lifecycle/shutdown.sh\"]\n }}\n}}"
|
||||
),
|
||||
)
|
||||
.expect("write manifest");
|
||||
log_path
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validates_manifest_shape() {
|
||||
let error = validate_manifest(&PluginManifest {
|
||||
@@ -1127,4 +1359,26 @@ mod tests {
|
||||
let _ = fs::remove_dir_all(config_home);
|
||||
let _ = fs::remove_dir_all(source_root);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn plugin_registry_runs_initialize_and_shutdown_for_enabled_plugins() {
|
||||
let config_home = temp_dir("lifecycle-home");
|
||||
let source_root = temp_dir("lifecycle-source");
|
||||
let log_path = write_lifecycle_plugin(&source_root, "lifecycle-demo", "1.0.0");
|
||||
|
||||
let mut manager = PluginManager::new(PluginManagerConfig::new(&config_home));
|
||||
manager
|
||||
.install(source_root.to_str().expect("utf8 path"))
|
||||
.expect("install should succeed");
|
||||
|
||||
let registry = manager.plugin_registry().expect("registry should build");
|
||||
registry.initialize().expect("init should succeed");
|
||||
registry.shutdown().expect("shutdown should succeed");
|
||||
|
||||
let log = fs::read_to_string(&log_path).expect("lifecycle log should exist");
|
||||
assert_eq!(log, "init\nshutdown\n");
|
||||
|
||||
let _ = fs::remove_dir_all(config_home);
|
||||
let _ = fs::remove_dir_all(source_root);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user