feat: plugin subsystem — loader, hooks, tools, bundled, CLI

This commit is contained in:
Yeachan-Heo
2026-04-01 06:45:13 +00:00
parent f8d4da3e68
commit e488e94307
7 changed files with 965 additions and 178 deletions

View File

@@ -113,6 +113,21 @@ pub struct ConversationRuntime<C, T> {
plugins_shutdown: bool,
}
impl<C, T> ConversationRuntime<C, T> {
fn shutdown_registered_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> ConversationRuntime<C, T>
where
C: ApiClient,
@@ -144,7 +159,7 @@ where
tool_executor: T,
permission_policy: PermissionPolicy,
system_prompt: Vec<String>,
feature_config: RuntimeFeatureConfig,
feature_config: RuntimeFeatureConfig,
) -> Self {
let usage_tracker = UsageTracker::from_session(&session);
Self {
@@ -172,6 +187,11 @@ where
feature_config: RuntimeFeatureConfig,
plugin_registry: PluginRegistry,
) -> Result<Self, RuntimeError> {
let hook_runner =
HookRunner::from_feature_config_and_plugins(&feature_config, &plugin_registry)
.map_err(|error| {
RuntimeError::new(format!("plugin hook registration failed: {error}"))
})?;
plugin_registry
.initialize()
.map_err(|error| RuntimeError::new(format!("plugin initialization failed: {error}")))?;
@@ -183,6 +203,7 @@ where
system_prompt,
feature_config,
);
runtime.hook_runner = hook_runner;
runtime.plugin_registry = Some(plugin_registry);
Ok(runtime)
}
@@ -336,21 +357,12 @@ where
#[must_use]
pub fn into_session(mut self) -> Session {
let _ = self.shutdown_plugins();
let _ = self.shutdown_registered_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(())
self.shutdown_registered_plugins()
}
fn maybe_auto_compact(&mut self) -> Option<AutoCompactionEvent> {
@@ -381,7 +393,7 @@ where
impl<C, T> Drop for ConversationRuntime<C, T> {
fn drop(&mut self) {
let _ = self.shutdown_plugins();
let _ = self.shutdown_registered_plugins();
}
}
@@ -525,6 +537,8 @@ mod tests {
use crate::usage::TokenUsage;
use plugins::{PluginManager, PluginManagerConfig};
use std::fs;
#[cfg(unix)]
use std::os::unix::fs::PermissionsExt;
use std::path::Path;
use std::path::PathBuf;
use std::time::{SystemTime, UNIX_EPOCH};
@@ -603,12 +617,12 @@ mod tests {
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",
"#!/bin/sh\nprintf 'init\\n' >> 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",
"#!/bin/sh\nprintf 'shutdown\\n' >> lifecycle.log\n",
)
.expect("write shutdown script");
fs::write(
@@ -621,6 +635,36 @@ mod tests {
log_path
}
fn write_hook_plugin(root: &Path, name: &str, pre_message: &str, post_message: &str) {
fs::create_dir_all(root.join(".claude-plugin")).expect("manifest dir");
fs::create_dir_all(root.join("hooks")).expect("hooks dir");
fs::write(
root.join("hooks").join("pre.sh"),
format!("#!/bin/sh\nprintf '%s\\n' '{pre_message}'\n"),
)
.expect("write pre hook");
fs::write(
root.join("hooks").join("post.sh"),
format!("#!/bin/sh\nprintf '%s\\n' '{post_message}'\n"),
)
.expect("write post hook");
#[cfg(unix)]
{
let exec_mode = fs::Permissions::from_mode(0o755);
fs::set_permissions(root.join("hooks").join("pre.sh"), exec_mode.clone())
.expect("chmod pre hook");
fs::set_permissions(root.join("hooks").join("post.sh"), exec_mode)
.expect("chmod post hook");
}
fs::write(
root.join(".claude-plugin").join("plugin.json"),
format!(
"{{\n \"name\": \"{name}\",\n \"version\": \"1.0.0\",\n \"description\": \"runtime hook plugin\",\n \"hooks\": {{\n \"PreToolUse\": [\"./hooks/pre.sh\"],\n \"PostToolUse\": [\"./hooks/post.sh\"]\n }}\n}}"
),
)
.expect("write plugin manifest");
}
#[test]
fn runs_user_to_tool_to_result_loop_end_to_end_and_tracks_usage() {
let api_client = ScriptedApiClient { call_count: 0 };
@@ -866,12 +910,13 @@ mod tests {
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 _ = write_lifecycle_plugin(&source_root, "runtime-lifecycle");
let mut manager = PluginManager::new(PluginManagerConfig::new(&config_home));
manager
let install = manager
.install(source_root.to_str().expect("utf8 path"))
.expect("install should succeed");
let log_path = install.install_path.join("lifecycle.log");
let registry = manager.plugin_registry().expect("registry should load");
{
@@ -898,6 +943,116 @@ mod tests {
let _ = fs::remove_dir_all(source_root);
}
#[test]
fn executes_hooks_from_installed_plugins_during_tool_use() {
struct TwoCallApiClient {
calls: usize,
}
impl ApiClient for TwoCallApiClient {
fn stream(&mut self, request: ApiRequest) -> Result<Vec<AssistantEvent>, RuntimeError> {
self.calls += 1;
match self.calls {
1 => Ok(vec![
AssistantEvent::ToolUse {
id: "tool-1".to_string(),
name: "add".to_string(),
input: r#"{"lhs":2,"rhs":2}"#.to_string(),
},
AssistantEvent::MessageStop,
]),
2 => {
assert!(request
.messages
.iter()
.any(|message| message.role == MessageRole::Tool));
Ok(vec![
AssistantEvent::TextDelta("done".to_string()),
AssistantEvent::MessageStop,
])
}
_ => Err(RuntimeError::new("unexpected extra API call")),
}
}
}
let config_home = temp_dir("hook-config");
let first_source_root = temp_dir("hook-source-a");
let second_source_root = temp_dir("hook-source-b");
write_hook_plugin(
&first_source_root,
"first",
"plugin pre one",
"plugin post one",
);
write_hook_plugin(
&second_source_root,
"second",
"plugin pre two",
"plugin post two",
);
let mut manager = PluginManager::new(PluginManagerConfig::new(&config_home));
manager
.install(first_source_root.to_str().expect("utf8 path"))
.expect("first plugin install should succeed");
manager
.install(second_source_root.to_str().expect("utf8 path"))
.expect("second plugin install should succeed");
let registry = manager.plugin_registry().expect("registry should load");
let mut runtime = ConversationRuntime::new_with_plugins(
Session::new(),
TwoCallApiClient { calls: 0 },
StaticToolExecutor::new().register("add", |_input| Ok("4".to_string())),
PermissionPolicy::new(PermissionMode::DangerFullAccess),
vec!["system".to_string()],
RuntimeFeatureConfig::default(),
registry,
)
.expect("runtime should load plugin hooks");
let summary = runtime
.run_turn("use add", None)
.expect("tool loop succeeds");
assert_eq!(summary.tool_results.len(), 1);
let ContentBlock::ToolResult {
is_error, output, ..
} = &summary.tool_results[0].blocks[0]
else {
panic!("expected tool result block");
};
assert!(
!*is_error,
"plugin hooks should not force an error: {output:?}"
);
assert!(
output.contains('4'),
"tool output missing value: {output:?}"
);
assert!(
output.contains("plugin pre one"),
"tool output missing first pre hook feedback: {output:?}"
);
assert!(
output.contains("plugin pre two"),
"tool output missing second pre hook feedback: {output:?}"
);
assert!(
output.contains("plugin post one"),
"tool output missing first post hook feedback: {output:?}"
);
assert!(
output.contains("plugin post two"),
"tool output missing second post hook feedback: {output:?}"
);
let _ = fs::remove_dir_all(config_home);
let _ = fs::remove_dir_all(first_source_root);
let _ = fs::remove_dir_all(second_source_root);
}
#[test]
fn reconstructs_usage_tracker_from_restored_session() {
struct SimpleApi;

View File

@@ -1,6 +1,8 @@
use std::ffi::OsStr;
use std::path::Path;
use std::process::Command;
use plugins::{PluginError, PluginRegistry};
use serde_json::json;
use crate::config::{RuntimeFeatureConfig, RuntimeHookConfig};
@@ -62,6 +64,19 @@ impl HookRunner {
Self::new(feature_config.hooks().clone())
}
pub fn from_feature_config_and_plugins(
feature_config: &RuntimeFeatureConfig,
plugin_registry: &PluginRegistry,
) -> Result<Self, PluginError> {
let mut config = feature_config.hooks().clone();
let plugin_hooks = plugin_registry.aggregated_hooks()?;
config.extend(&RuntimeHookConfig::new(
plugin_hooks.pre_tool_use,
plugin_hooks.post_tool_use,
));
Ok(Self::new(config))
}
#[must_use]
pub fn run_pre_tool_use(&self, tool_name: &str, tool_input: &str) -> HookRunResult {
self.run_commands(
@@ -238,7 +253,11 @@ fn shell_command(command: &str) -> CommandWithStdin {
};
#[cfg(not(windows))]
let command_builder = {
let command_builder = if Path::new(command).exists() {
let mut command_builder = Command::new("sh");
command_builder.arg(command);
CommandWithStdin::new(command_builder)
} else {
let mut command_builder = Command::new("sh");
command_builder.arg("-lc").arg(command);
CommandWithStdin::new(command_builder)
@@ -294,6 +313,50 @@ impl CommandWithStdin {
mod tests {
use super::{HookRunResult, HookRunner};
use crate::config::{RuntimeFeatureConfig, RuntimeHookConfig};
use plugins::{PluginManager, PluginManagerConfig};
use std::fs;
#[cfg(unix)]
use std::os::unix::fs::PermissionsExt;
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!("hook-runner-{label}-{nanos}"))
}
fn write_hook_plugin(root: &Path, name: &str) {
fs::create_dir_all(root.join(".claude-plugin")).expect("manifest dir");
fs::create_dir_all(root.join("hooks")).expect("hooks dir");
fs::write(
root.join("hooks").join("pre.sh"),
"#!/bin/sh\nprintf 'plugin pre'\n",
)
.expect("write pre hook");
fs::write(
root.join("hooks").join("post.sh"),
"#!/bin/sh\nprintf 'plugin post'\n",
)
.expect("write post hook");
#[cfg(unix)]
{
let exec_mode = fs::Permissions::from_mode(0o755);
fs::set_permissions(root.join("hooks").join("pre.sh"), exec_mode.clone())
.expect("chmod pre hook");
fs::set_permissions(root.join("hooks").join("post.sh"), exec_mode)
.expect("chmod post hook");
}
fs::write(
root.join(".claude-plugin").join("plugin.json"),
format!(
"{{\n \"name\": \"{name}\",\n \"version\": \"1.0.0\",\n \"description\": \"hook plugin\",\n \"hooks\": {{\n \"PreToolUse\": [\"./hooks/pre.sh\"],\n \"PostToolUse\": [\"./hooks/post.sh\"]\n }}\n}}"
),
)
.expect("write plugin manifest");
}
#[test]
fn allows_exit_code_zero_and_captures_stdout() {
@@ -338,6 +401,40 @@ mod tests {
.any(|message| message.contains("allowing tool execution to continue")));
}
#[test]
fn collects_hooks_from_enabled_plugins() {
let config_home = temp_dir("config");
let source_root = temp_dir("source");
write_hook_plugin(&source_root, "hooked");
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");
let runner = HookRunner::from_feature_config_and_plugins(
&RuntimeFeatureConfig::default(),
&registry,
)
.expect("plugin hooks should load");
let pre_result = runner.run_pre_tool_use("Read", r#"{"path":"README.md"}"#);
let post_result = runner.run_post_tool_use("Read", r#"{"path":"README.md"}"#, "ok", false);
assert_eq!(
pre_result,
HookRunResult::allow(vec!["plugin pre".to_string()])
);
assert_eq!(
post_result,
HookRunResult::allow(vec!["plugin post".to_string()])
);
let _ = fs::remove_dir_all(config_home);
let _ = fs::remove_dir_all(source_root);
}
#[cfg(windows)]
fn shell_snippet(script: &str) -> String {
script.replace('\'', "\"")