mirror of
https://github.com/lWolvesl/claw-code.git
synced 2026-04-03 02:51:52 +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)]
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
pub struct PluginManifest {
|
pub struct PluginManifest {
|
||||||
pub name: String,
|
pub name: String,
|
||||||
@@ -81,6 +96,8 @@ pub struct PluginManifest {
|
|||||||
pub default_enabled: bool,
|
pub default_enabled: bool,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub hooks: PluginHooks,
|
pub hooks: PluginHooks,
|
||||||
|
#[serde(default)]
|
||||||
|
pub lifecycle: PluginLifecycle,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
@@ -112,24 +129,30 @@ pub struct InstalledPluginRegistry {
|
|||||||
pub struct BuiltinPlugin {
|
pub struct BuiltinPlugin {
|
||||||
metadata: PluginMetadata,
|
metadata: PluginMetadata,
|
||||||
hooks: PluginHooks,
|
hooks: PluginHooks,
|
||||||
|
lifecycle: PluginLifecycle,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
pub struct BundledPlugin {
|
pub struct BundledPlugin {
|
||||||
metadata: PluginMetadata,
|
metadata: PluginMetadata,
|
||||||
hooks: PluginHooks,
|
hooks: PluginHooks,
|
||||||
|
lifecycle: PluginLifecycle,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
pub struct ExternalPlugin {
|
pub struct ExternalPlugin {
|
||||||
metadata: PluginMetadata,
|
metadata: PluginMetadata,
|
||||||
hooks: PluginHooks,
|
hooks: PluginHooks,
|
||||||
|
lifecycle: PluginLifecycle,
|
||||||
}
|
}
|
||||||
|
|
||||||
pub trait Plugin {
|
pub trait Plugin {
|
||||||
fn metadata(&self) -> &PluginMetadata;
|
fn metadata(&self) -> &PluginMetadata;
|
||||||
fn hooks(&self) -> &PluginHooks;
|
fn hooks(&self) -> &PluginHooks;
|
||||||
|
fn lifecycle(&self) -> &PluginLifecycle;
|
||||||
fn validate(&self) -> Result<(), PluginError>;
|
fn validate(&self) -> Result<(), PluginError>;
|
||||||
|
fn initialize(&self) -> Result<(), PluginError>;
|
||||||
|
fn shutdown(&self) -> Result<(), PluginError>;
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
@@ -148,9 +171,21 @@ impl Plugin for BuiltinPlugin {
|
|||||||
&self.hooks
|
&self.hooks
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn lifecycle(&self) -> &PluginLifecycle {
|
||||||
|
&self.lifecycle
|
||||||
|
}
|
||||||
|
|
||||||
fn validate(&self) -> Result<(), PluginError> {
|
fn validate(&self) -> Result<(), PluginError> {
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn initialize(&self) -> Result<(), PluginError> {
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn shutdown(&self) -> Result<(), PluginError> {
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Plugin for BundledPlugin {
|
impl Plugin for BundledPlugin {
|
||||||
@@ -162,8 +197,26 @@ impl Plugin for BundledPlugin {
|
|||||||
&self.hooks
|
&self.hooks
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn lifecycle(&self) -> &PluginLifecycle {
|
||||||
|
&self.lifecycle
|
||||||
|
}
|
||||||
|
|
||||||
fn validate(&self) -> Result<(), PluginError> {
|
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
|
&self.hooks
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn lifecycle(&self) -> &PluginLifecycle {
|
||||||
|
&self.lifecycle
|
||||||
|
}
|
||||||
|
|
||||||
fn validate(&self) -> Result<(), PluginError> {
|
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> {
|
fn validate(&self) -> Result<(), PluginError> {
|
||||||
match self {
|
match self {
|
||||||
Self::Builtin(plugin) => plugin.validate(),
|
Self::Builtin(plugin) => plugin.validate(),
|
||||||
@@ -205,6 +284,22 @@ impl Plugin for PluginDefinition {
|
|||||||
Self::External(plugin) => plugin.validate(),
|
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)]
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
@@ -241,6 +336,14 @@ impl RegisteredPlugin {
|
|||||||
self.definition.validate()
|
self.definition.validate()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn initialize(&self) -> Result<(), PluginError> {
|
||||||
|
self.definition.initialize()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn shutdown(&self) -> Result<(), PluginError> {
|
||||||
|
self.definition.shutdown()
|
||||||
|
}
|
||||||
|
|
||||||
#[must_use]
|
#[must_use]
|
||||||
pub fn summary(&self) -> PluginSummary {
|
pub fn summary(&self) -> PluginSummary {
|
||||||
PluginSummary {
|
PluginSummary {
|
||||||
@@ -299,6 +402,21 @@ impl PluginRegistry {
|
|||||||
Ok(acc.merged_with(plugin.hooks()))
|
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)]
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
@@ -687,6 +805,7 @@ pub fn builtin_plugins() -> Vec<PluginDefinition> {
|
|||||||
root: None,
|
root: None,
|
||||||
},
|
},
|
||||||
hooks: PluginHooks::default(),
|
hooks: PluginHooks::default(),
|
||||||
|
lifecycle: PluginLifecycle::default(),
|
||||||
})]
|
})]
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -708,10 +827,23 @@ fn load_plugin_definition(
|
|||||||
root: Some(root.to_path_buf()),
|
root: Some(root.to_path_buf()),
|
||||||
};
|
};
|
||||||
let hooks = resolve_hooks(root, &manifest.hooks);
|
let hooks = resolve_hooks(root, &manifest.hooks);
|
||||||
|
let lifecycle = resolve_lifecycle(root, &manifest.lifecycle);
|
||||||
Ok(match kind {
|
Ok(match kind {
|
||||||
PluginKind::Builtin => PluginDefinition::Builtin(BuiltinPlugin { metadata, hooks }),
|
PluginKind::Builtin => PluginDefinition::Builtin(BuiltinPlugin {
|
||||||
PluginKind::Bundled => PluginDefinition::Bundled(BundledPlugin { metadata, hooks }),
|
metadata,
|
||||||
PluginKind::External => PluginDefinition::External(ExternalPlugin { metadata, hooks }),
|
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)?;
|
let manifest = load_manifest_from_root(root)?;
|
||||||
validate_manifest(&manifest)?;
|
validate_manifest(&manifest)?;
|
||||||
validate_hook_paths(Some(root), &manifest.hooks)?;
|
validate_hook_paths(Some(root), &manifest.hooks)?;
|
||||||
|
validate_lifecycle_paths(Some(root), &manifest.lifecycle)?;
|
||||||
Ok(manifest)
|
Ok(manifest)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -767,13 +900,47 @@ 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> {
|
fn validate_hook_paths(root: Option<&Path>, hooks: &PluginHooks) -> Result<(), PluginError> {
|
||||||
let Some(root) = root else {
|
let Some(root) = root else {
|
||||||
return Ok(());
|
return Ok(());
|
||||||
};
|
};
|
||||||
for entry in hooks.pre_tool_use.iter().chain(hooks.post_tool_use.iter()) {
|
for entry in hooks.pre_tool_use.iter().chain(hooks.post_tool_use.iter()) {
|
||||||
|
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) {
|
if is_literal_command(entry) {
|
||||||
continue;
|
return Ok(());
|
||||||
}
|
}
|
||||||
let path = if Path::new(entry).is_absolute() {
|
let path = if Path::new(entry).is_absolute() {
|
||||||
PathBuf::from(entry)
|
PathBuf::from(entry)
|
||||||
@@ -782,11 +949,10 @@ fn validate_hook_paths(root: Option<&Path>, hooks: &PluginHooks) -> Result<(), P
|
|||||||
};
|
};
|
||||||
if !path.exists() {
|
if !path.exists() {
|
||||||
return Err(PluginError::InvalidManifest(format!(
|
return Err(PluginError::InvalidManifest(format!(
|
||||||
"hook path `{}` does not exist",
|
"{kind} path `{}` does not exist",
|
||||||
path.display()
|
path.display()
|
||||||
)));
|
)));
|
||||||
}
|
}
|
||||||
}
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -802,6 +968,48 @@ fn is_literal_command(entry: &str) -> bool {
|
|||||||
!entry.starts_with("./") && !entry.starts_with("../")
|
!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> {
|
fn resolve_local_source(source: &str) -> Result<PathBuf, PluginError> {
|
||||||
let path = PathBuf::from(source);
|
let path = PathBuf::from(source);
|
||||||
if path.exists() {
|
if path.exists() {
|
||||||
@@ -992,6 +1200,30 @@ mod tests {
|
|||||||
.expect("write broken manifest");
|
.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]
|
#[test]
|
||||||
fn validates_manifest_shape() {
|
fn validates_manifest_shape() {
|
||||||
let error = validate_manifest(&PluginManifest {
|
let error = validate_manifest(&PluginManifest {
|
||||||
@@ -1127,4 +1359,26 @@ mod tests {
|
|||||||
let _ = fs::remove_dir_all(config_home);
|
let _ = fs::remove_dir_all(config_home);
|
||||||
let _ = fs::remove_dir_all(source_root);
|
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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ publish.workspace = true
|
|||||||
[dependencies]
|
[dependencies]
|
||||||
sha2 = "0.10"
|
sha2 = "0.10"
|
||||||
glob = "0.3"
|
glob = "0.3"
|
||||||
|
plugins = { path = "../plugins" }
|
||||||
regex = "1"
|
regex = "1"
|
||||||
serde = { version = "1", features = ["derive"] }
|
serde = { version = "1", features = ["derive"] }
|
||||||
serde_json = "1"
|
serde_json = "1"
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
use std::collections::BTreeMap;
|
use std::collections::BTreeMap;
|
||||||
use std::fmt::{Display, Formatter};
|
use std::fmt::{Display, Formatter};
|
||||||
|
|
||||||
|
use plugins::PluginRegistry;
|
||||||
|
|
||||||
use crate::compact::{
|
use crate::compact::{
|
||||||
compact_session, estimate_session_tokens, CompactionConfig, CompactionResult,
|
compact_session, estimate_session_tokens, CompactionConfig, CompactionResult,
|
||||||
};
|
};
|
||||||
@@ -97,6 +99,8 @@ pub struct ConversationRuntime<C, T> {
|
|||||||
max_iterations: usize,
|
max_iterations: usize,
|
||||||
usage_tracker: UsageTracker,
|
usage_tracker: UsageTracker,
|
||||||
hook_runner: HookRunner,
|
hook_runner: HookRunner,
|
||||||
|
plugin_registry: Option<PluginRegistry>,
|
||||||
|
plugins_shutdown: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl<C, T> ConversationRuntime<C, T>
|
impl<C, T> ConversationRuntime<C, T>
|
||||||
@@ -142,9 +146,36 @@ where
|
|||||||
max_iterations: usize::MAX,
|
max_iterations: usize::MAX,
|
||||||
usage_tracker,
|
usage_tracker,
|
||||||
hook_runner: HookRunner::from_feature_config(&feature_config),
|
hook_runner: HookRunner::from_feature_config(&feature_config),
|
||||||
|
plugin_registry: None,
|
||||||
|
plugins_shutdown: false,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[allow(clippy::needless_pass_by_value)]
|
||||||
|
pub fn new_with_plugins(
|
||||||
|
session: Session,
|
||||||
|
api_client: C,
|
||||||
|
tool_executor: T,
|
||||||
|
permission_policy: PermissionPolicy,
|
||||||
|
system_prompt: Vec<String>,
|
||||||
|
feature_config: RuntimeFeatureConfig,
|
||||||
|
plugin_registry: PluginRegistry,
|
||||||
|
) -> Result<Self, RuntimeError> {
|
||||||
|
plugin_registry
|
||||||
|
.initialize()
|
||||||
|
.map_err(|error| RuntimeError::new(format!("plugin initialization failed: {error}")))?;
|
||||||
|
let mut runtime = Self::new_with_features(
|
||||||
|
session,
|
||||||
|
api_client,
|
||||||
|
tool_executor,
|
||||||
|
permission_policy,
|
||||||
|
system_prompt,
|
||||||
|
feature_config,
|
||||||
|
);
|
||||||
|
runtime.plugin_registry = Some(plugin_registry);
|
||||||
|
Ok(runtime)
|
||||||
|
}
|
||||||
|
|
||||||
#[must_use]
|
#[must_use]
|
||||||
pub fn with_max_iterations(mut self, max_iterations: usize) -> Self {
|
pub fn with_max_iterations(mut self, max_iterations: usize) -> Self {
|
||||||
self.max_iterations = max_iterations;
|
self.max_iterations = max_iterations;
|
||||||
@@ -284,8 +315,28 @@ where
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[must_use]
|
#[must_use]
|
||||||
pub fn into_session(self) -> Session {
|
pub fn into_session(mut self) -> Session {
|
||||||
self.session
|
let _ = self.shutdown_plugins();
|
||||||
|
std::mem::take(&mut self.session)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn shutdown_plugins(&mut self) -> Result<(), RuntimeError> {
|
||||||
|
if self.plugins_shutdown {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
if let Some(registry) = &self.plugin_registry {
|
||||||
|
registry
|
||||||
|
.shutdown()
|
||||||
|
.map_err(|error| RuntimeError::new(format!("plugin shutdown failed: {error}")))?;
|
||||||
|
}
|
||||||
|
self.plugins_shutdown = true;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<C, T> Drop for ConversationRuntime<C, T> {
|
||||||
|
fn drop(&mut self) {
|
||||||
|
let _ = self.shutdown_plugins();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -409,7 +460,11 @@ mod tests {
|
|||||||
use crate::prompt::{ProjectContext, SystemPromptBuilder};
|
use crate::prompt::{ProjectContext, SystemPromptBuilder};
|
||||||
use crate::session::{ContentBlock, MessageRole, Session};
|
use crate::session::{ContentBlock, MessageRole, Session};
|
||||||
use crate::usage::TokenUsage;
|
use crate::usage::TokenUsage;
|
||||||
|
use plugins::{PluginManager, PluginManagerConfig};
|
||||||
|
use std::fs;
|
||||||
|
use std::path::Path;
|
||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
|
use std::time::{SystemTime, UNIX_EPOCH};
|
||||||
|
|
||||||
struct ScriptedApiClient {
|
struct ScriptedApiClient {
|
||||||
call_count: usize,
|
call_count: usize,
|
||||||
@@ -471,6 +526,38 @@ mod tests {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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!("runtime-plugin-{label}-{nanos}"))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn write_lifecycle_plugin(root: &Path, name: &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 script");
|
||||||
|
fs::write(
|
||||||
|
root.join("lifecycle").join("shutdown.sh"),
|
||||||
|
"#!/bin/sh\nprintf 'shutdown\\n' >> \"$(dirname \"$0\")/../lifecycle.log\"\n",
|
||||||
|
)
|
||||||
|
.expect("write shutdown script");
|
||||||
|
fs::write(
|
||||||
|
root.join(".claude-plugin").join("plugin.json"),
|
||||||
|
format!(
|
||||||
|
"{{\n \"name\": \"{name}\",\n \"version\": \"1.0.0\",\n \"description\": \"runtime lifecycle plugin\",\n \"lifecycle\": {{\n \"Init\": [\"./lifecycle/init.sh\"],\n \"Shutdown\": [\"./lifecycle/shutdown.sh\"]\n }}\n}}"
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.expect("write plugin manifest");
|
||||||
|
log_path
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn runs_user_to_tool_to_result_loop_end_to_end_and_tracks_usage() {
|
fn runs_user_to_tool_to_result_loop_end_to_end_and_tracks_usage() {
|
||||||
let api_client = ScriptedApiClient { call_count: 0 };
|
let api_client = ScriptedApiClient { call_count: 0 };
|
||||||
@@ -711,6 +798,42 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn initializes_and_shuts_down_plugins_with_runtime_lifecycle() {
|
||||||
|
let config_home = temp_dir("config");
|
||||||
|
let source_root = temp_dir("source");
|
||||||
|
let log_path = write_lifecycle_plugin(&source_root, "runtime-lifecycle");
|
||||||
|
|
||||||
|
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 load");
|
||||||
|
|
||||||
|
{
|
||||||
|
let runtime = ConversationRuntime::new_with_plugins(
|
||||||
|
Session::new(),
|
||||||
|
ScriptedApiClient { call_count: 0 },
|
||||||
|
StaticToolExecutor::new().register("add", |_input| Ok("4".to_string())),
|
||||||
|
PermissionPolicy::new(PermissionMode::WorkspaceWrite),
|
||||||
|
vec!["system".to_string()],
|
||||||
|
RuntimeFeatureConfig::default(),
|
||||||
|
registry,
|
||||||
|
)
|
||||||
|
.expect("runtime should initialize plugins");
|
||||||
|
|
||||||
|
let log = fs::read_to_string(&log_path).expect("init log should exist");
|
||||||
|
assert_eq!(log, "init\n");
|
||||||
|
drop(runtime);
|
||||||
|
}
|
||||||
|
|
||||||
|
let log = fs::read_to_string(&log_path).expect("shutdown log should exist");
|
||||||
|
assert_eq!(log, "init\nshutdown\n");
|
||||||
|
|
||||||
|
let _ = fs::remove_dir_all(config_home);
|
||||||
|
let _ = fs::remove_dir_all(source_root);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn reconstructs_usage_tracker_from_restored_session() {
|
fn reconstructs_usage_tracker_from_restored_session() {
|
||||||
struct SimpleApi;
|
struct SimpleApi;
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ use commands::{
|
|||||||
};
|
};
|
||||||
use compat_harness::{extract_manifest, UpstreamPaths};
|
use compat_harness::{extract_manifest, UpstreamPaths};
|
||||||
use init::initialize_repo;
|
use init::initialize_repo;
|
||||||
use plugins::{PluginKind, PluginManager, PluginManagerConfig, PluginSummary};
|
use plugins::{PluginKind, PluginManager, PluginManagerConfig, PluginRegistry, PluginSummary};
|
||||||
use render::{MarkdownStreamState, Spinner, TerminalRenderer};
|
use render::{MarkdownStreamState, Spinner, TerminalRenderer};
|
||||||
use runtime::{
|
use runtime::{
|
||||||
clear_oauth_credentials, generate_pkce_pair, generate_state, load_system_prompt,
|
clear_oauth_credentials, generate_pkce_pair, generate_state, load_system_prompt,
|
||||||
@@ -2052,20 +2052,22 @@ fn build_system_prompt() -> Result<Vec<String>, Box<dyn std::error::Error>> {
|
|||||||
)?)
|
)?)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn build_runtime_feature_config(
|
fn build_runtime_plugin_state(
|
||||||
) -> Result<runtime::RuntimeFeatureConfig, Box<dyn std::error::Error>> {
|
) -> Result<(runtime::RuntimeFeatureConfig, PluginRegistry), Box<dyn std::error::Error>> {
|
||||||
let cwd = env::current_dir()?;
|
let cwd = env::current_dir()?;
|
||||||
let loader = ConfigLoader::default_for(&cwd);
|
let loader = ConfigLoader::default_for(&cwd);
|
||||||
let runtime_config = loader.load()?;
|
let runtime_config = loader.load()?;
|
||||||
let plugin_manager = build_plugin_manager(&cwd, &loader, &runtime_config);
|
let plugin_manager = build_plugin_manager(&cwd, &loader, &runtime_config);
|
||||||
let plugin_hooks = plugin_manager.aggregated_hooks()?;
|
let plugin_registry = plugin_manager.plugin_registry()?;
|
||||||
Ok(runtime_config
|
let plugin_hooks = plugin_registry.aggregated_hooks()?;
|
||||||
|
let feature_config = runtime_config
|
||||||
.feature_config()
|
.feature_config()
|
||||||
.clone()
|
.clone()
|
||||||
.with_hooks(runtime_config.hooks().merged(&RuntimeHookConfig::new(
|
.with_hooks(runtime_config.hooks().merged(&RuntimeHookConfig::new(
|
||||||
plugin_hooks.pre_tool_use,
|
plugin_hooks.pre_tool_use,
|
||||||
plugin_hooks.post_tool_use,
|
plugin_hooks.post_tool_use,
|
||||||
))))
|
)));
|
||||||
|
Ok((feature_config, plugin_registry))
|
||||||
}
|
}
|
||||||
|
|
||||||
fn build_plugin_manager(
|
fn build_plugin_manager(
|
||||||
@@ -2114,14 +2116,16 @@ fn build_runtime(
|
|||||||
permission_mode: PermissionMode,
|
permission_mode: PermissionMode,
|
||||||
) -> Result<ConversationRuntime<AnthropicRuntimeClient, CliToolExecutor>, Box<dyn std::error::Error>>
|
) -> Result<ConversationRuntime<AnthropicRuntimeClient, CliToolExecutor>, Box<dyn std::error::Error>>
|
||||||
{
|
{
|
||||||
Ok(ConversationRuntime::new_with_features(
|
let (feature_config, plugin_registry) = build_runtime_plugin_state()?;
|
||||||
|
Ok(ConversationRuntime::new_with_plugins(
|
||||||
session,
|
session,
|
||||||
AnthropicRuntimeClient::new(model, enable_tools, emit_output, allowed_tools.clone())?,
|
AnthropicRuntimeClient::new(model, enable_tools, emit_output, allowed_tools.clone())?,
|
||||||
CliToolExecutor::new(allowed_tools, emit_output),
|
CliToolExecutor::new(allowed_tools, emit_output),
|
||||||
permission_policy(permission_mode),
|
permission_policy(permission_mode),
|
||||||
system_prompt,
|
system_prompt,
|
||||||
build_runtime_feature_config()?,
|
feature_config,
|
||||||
))
|
plugin_registry,
|
||||||
|
)?)
|
||||||
}
|
}
|
||||||
|
|
||||||
struct CliPermissionPrompter {
|
struct CliPermissionPrompter {
|
||||||
|
|||||||
Reference in New Issue
Block a user