mirror of
https://github.com/instructkr/claw-code.git
synced 2026-04-03 18:14:48 +08:00
feat: plugin system with hooks pipeline and bundled plugins
This commit is contained in:
13
rust/crates/plugins/Cargo.toml
Normal file
13
rust/crates/plugins/Cargo.toml
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
[package]
|
||||||
|
name = "plugins"
|
||||||
|
version.workspace = true
|
||||||
|
edition.workspace = true
|
||||||
|
license.workspace = true
|
||||||
|
publish.workspace = true
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
serde = { version = "1", features = ["derive"] }
|
||||||
|
serde_json.workspace = true
|
||||||
|
|
||||||
|
[lints]
|
||||||
|
workspace = true
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
{
|
||||||
|
"name": "example-bundled",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"description": "Example bundled plugin scaffold for the Rust plugin system",
|
||||||
|
"defaultEnabled": false,
|
||||||
|
"hooks": {
|
||||||
|
"PreToolUse": ["./hooks/pre.sh"],
|
||||||
|
"PostToolUse": ["./hooks/post.sh"]
|
||||||
|
}
|
||||||
|
}
|
||||||
2
rust/crates/plugins/bundled/example-bundled/hooks/post.sh
Executable file
2
rust/crates/plugins/bundled/example-bundled/hooks/post.sh
Executable file
@@ -0,0 +1,2 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
printf '%s\n' 'example bundled post hook'
|
||||||
2
rust/crates/plugins/bundled/example-bundled/hooks/pre.sh
Executable file
2
rust/crates/plugins/bundled/example-bundled/hooks/pre.sh
Executable file
@@ -0,0 +1,2 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
printf '%s\n' 'example bundled pre hook'
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
{
|
||||||
|
"name": "sample-hooks",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"description": "Bundled sample plugin scaffold for hook integration tests.",
|
||||||
|
"defaultEnabled": false,
|
||||||
|
"hooks": {
|
||||||
|
"PreToolUse": ["./hooks/pre.sh"],
|
||||||
|
"PostToolUse": ["./hooks/post.sh"]
|
||||||
|
}
|
||||||
|
}
|
||||||
2
rust/crates/plugins/bundled/sample-hooks/hooks/post.sh
Executable file
2
rust/crates/plugins/bundled/sample-hooks/hooks/post.sh
Executable file
@@ -0,0 +1,2 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
printf 'sample bundled post hook'
|
||||||
2
rust/crates/plugins/bundled/sample-hooks/hooks/pre.sh
Executable file
2
rust/crates/plugins/bundled/sample-hooks/hooks/pre.sh
Executable file
@@ -0,0 +1,2 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
printf 'sample bundled pre hook'
|
||||||
395
rust/crates/plugins/src/hooks.rs
Normal file
395
rust/crates/plugins/src/hooks.rs
Normal file
@@ -0,0 +1,395 @@
|
|||||||
|
use std::ffi::OsStr;
|
||||||
|
use std::path::Path;
|
||||||
|
use std::process::Command;
|
||||||
|
|
||||||
|
use serde_json::json;
|
||||||
|
|
||||||
|
use crate::{PluginError, PluginHooks, PluginRegistry};
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub enum HookEvent {
|
||||||
|
PreToolUse,
|
||||||
|
PostToolUse,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl HookEvent {
|
||||||
|
fn as_str(self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
Self::PreToolUse => "PreToolUse",
|
||||||
|
Self::PostToolUse => "PostToolUse",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub struct HookRunResult {
|
||||||
|
denied: bool,
|
||||||
|
messages: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl HookRunResult {
|
||||||
|
#[must_use]
|
||||||
|
pub fn allow(messages: Vec<String>) -> Self {
|
||||||
|
Self {
|
||||||
|
denied: false,
|
||||||
|
messages,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[must_use]
|
||||||
|
pub fn is_denied(&self) -> bool {
|
||||||
|
self.denied
|
||||||
|
}
|
||||||
|
|
||||||
|
#[must_use]
|
||||||
|
pub fn messages(&self) -> &[String] {
|
||||||
|
&self.messages
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Default)]
|
||||||
|
pub struct HookRunner {
|
||||||
|
hooks: PluginHooks,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl HookRunner {
|
||||||
|
#[must_use]
|
||||||
|
pub fn new(hooks: PluginHooks) -> Self {
|
||||||
|
Self { hooks }
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn from_registry(plugin_registry: &PluginRegistry) -> Result<Self, PluginError> {
|
||||||
|
Ok(Self::new(plugin_registry.aggregated_hooks()?))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[must_use]
|
||||||
|
pub fn run_pre_tool_use(&self, tool_name: &str, tool_input: &str) -> HookRunResult {
|
||||||
|
self.run_commands(
|
||||||
|
HookEvent::PreToolUse,
|
||||||
|
&self.hooks.pre_tool_use,
|
||||||
|
tool_name,
|
||||||
|
tool_input,
|
||||||
|
None,
|
||||||
|
false,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[must_use]
|
||||||
|
pub fn run_post_tool_use(
|
||||||
|
&self,
|
||||||
|
tool_name: &str,
|
||||||
|
tool_input: &str,
|
||||||
|
tool_output: &str,
|
||||||
|
is_error: bool,
|
||||||
|
) -> HookRunResult {
|
||||||
|
self.run_commands(
|
||||||
|
HookEvent::PostToolUse,
|
||||||
|
&self.hooks.post_tool_use,
|
||||||
|
tool_name,
|
||||||
|
tool_input,
|
||||||
|
Some(tool_output),
|
||||||
|
is_error,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn run_commands(
|
||||||
|
&self,
|
||||||
|
event: HookEvent,
|
||||||
|
commands: &[String],
|
||||||
|
tool_name: &str,
|
||||||
|
tool_input: &str,
|
||||||
|
tool_output: Option<&str>,
|
||||||
|
is_error: bool,
|
||||||
|
) -> HookRunResult {
|
||||||
|
if commands.is_empty() {
|
||||||
|
return HookRunResult::allow(Vec::new());
|
||||||
|
}
|
||||||
|
|
||||||
|
let payload = json!({
|
||||||
|
"hook_event_name": event.as_str(),
|
||||||
|
"tool_name": tool_name,
|
||||||
|
"tool_input": parse_tool_input(tool_input),
|
||||||
|
"tool_input_json": tool_input,
|
||||||
|
"tool_output": tool_output,
|
||||||
|
"tool_result_is_error": is_error,
|
||||||
|
})
|
||||||
|
.to_string();
|
||||||
|
|
||||||
|
let mut messages = Vec::new();
|
||||||
|
|
||||||
|
for command in commands {
|
||||||
|
match self.run_command(
|
||||||
|
command,
|
||||||
|
event,
|
||||||
|
tool_name,
|
||||||
|
tool_input,
|
||||||
|
tool_output,
|
||||||
|
is_error,
|
||||||
|
&payload,
|
||||||
|
) {
|
||||||
|
HookCommandOutcome::Allow { message } => {
|
||||||
|
if let Some(message) = message {
|
||||||
|
messages.push(message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
HookCommandOutcome::Deny { message } => {
|
||||||
|
messages.push(message.unwrap_or_else(|| {
|
||||||
|
format!("{} hook denied tool `{tool_name}`", event.as_str())
|
||||||
|
}));
|
||||||
|
return HookRunResult {
|
||||||
|
denied: true,
|
||||||
|
messages,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
HookCommandOutcome::Warn { message } => messages.push(message),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
HookRunResult::allow(messages)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[allow(clippy::too_many_arguments, clippy::unused_self)]
|
||||||
|
fn run_command(
|
||||||
|
&self,
|
||||||
|
command: &str,
|
||||||
|
event: HookEvent,
|
||||||
|
tool_name: &str,
|
||||||
|
tool_input: &str,
|
||||||
|
tool_output: Option<&str>,
|
||||||
|
is_error: bool,
|
||||||
|
payload: &str,
|
||||||
|
) -> HookCommandOutcome {
|
||||||
|
let mut child = shell_command(command);
|
||||||
|
child.stdin(std::process::Stdio::piped());
|
||||||
|
child.stdout(std::process::Stdio::piped());
|
||||||
|
child.stderr(std::process::Stdio::piped());
|
||||||
|
child.env("HOOK_EVENT", event.as_str());
|
||||||
|
child.env("HOOK_TOOL_NAME", tool_name);
|
||||||
|
child.env("HOOK_TOOL_INPUT", tool_input);
|
||||||
|
child.env("HOOK_TOOL_IS_ERROR", if is_error { "1" } else { "0" });
|
||||||
|
if let Some(tool_output) = tool_output {
|
||||||
|
child.env("HOOK_TOOL_OUTPUT", tool_output);
|
||||||
|
}
|
||||||
|
|
||||||
|
match child.output_with_stdin(payload.as_bytes()) {
|
||||||
|
Ok(output) => {
|
||||||
|
let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string();
|
||||||
|
let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
|
||||||
|
let message = (!stdout.is_empty()).then_some(stdout);
|
||||||
|
match output.status.code() {
|
||||||
|
Some(0) => HookCommandOutcome::Allow { message },
|
||||||
|
Some(2) => HookCommandOutcome::Deny { message },
|
||||||
|
Some(code) => HookCommandOutcome::Warn {
|
||||||
|
message: format_hook_warning(
|
||||||
|
command,
|
||||||
|
code,
|
||||||
|
message.as_deref(),
|
||||||
|
stderr.as_str(),
|
||||||
|
),
|
||||||
|
},
|
||||||
|
None => HookCommandOutcome::Warn {
|
||||||
|
message: format!(
|
||||||
|
"{} hook `{command}` terminated by signal while handling `{tool_name}`",
|
||||||
|
event.as_str()
|
||||||
|
),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(error) => HookCommandOutcome::Warn {
|
||||||
|
message: format!(
|
||||||
|
"{} hook `{command}` failed to start for `{tool_name}`: {error}",
|
||||||
|
event.as_str()
|
||||||
|
),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
enum HookCommandOutcome {
|
||||||
|
Allow { message: Option<String> },
|
||||||
|
Deny { message: Option<String> },
|
||||||
|
Warn { message: String },
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_tool_input(tool_input: &str) -> serde_json::Value {
|
||||||
|
serde_json::from_str(tool_input).unwrap_or_else(|_| json!({ "raw": tool_input }))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn format_hook_warning(command: &str, code: i32, stdout: Option<&str>, stderr: &str) -> String {
|
||||||
|
let mut message =
|
||||||
|
format!("Hook `{command}` exited with status {code}; allowing tool execution to continue");
|
||||||
|
if let Some(stdout) = stdout.filter(|stdout| !stdout.is_empty()) {
|
||||||
|
message.push_str(": ");
|
||||||
|
message.push_str(stdout);
|
||||||
|
} else if !stderr.is_empty() {
|
||||||
|
message.push_str(": ");
|
||||||
|
message.push_str(stderr);
|
||||||
|
}
|
||||||
|
message
|
||||||
|
}
|
||||||
|
|
||||||
|
fn shell_command(command: &str) -> CommandWithStdin {
|
||||||
|
#[cfg(windows)]
|
||||||
|
let command_builder = {
|
||||||
|
let mut command_builder = Command::new("cmd");
|
||||||
|
command_builder.arg("/C").arg(command);
|
||||||
|
CommandWithStdin::new(command_builder)
|
||||||
|
};
|
||||||
|
|
||||||
|
#[cfg(not(windows))]
|
||||||
|
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)
|
||||||
|
};
|
||||||
|
|
||||||
|
command_builder
|
||||||
|
}
|
||||||
|
|
||||||
|
struct CommandWithStdin {
|
||||||
|
command: Command,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl CommandWithStdin {
|
||||||
|
fn new(command: Command) -> Self {
|
||||||
|
Self { command }
|
||||||
|
}
|
||||||
|
|
||||||
|
fn stdin(&mut self, cfg: std::process::Stdio) -> &mut Self {
|
||||||
|
self.command.stdin(cfg);
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
fn stdout(&mut self, cfg: std::process::Stdio) -> &mut Self {
|
||||||
|
self.command.stdout(cfg);
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
fn stderr(&mut self, cfg: std::process::Stdio) -> &mut Self {
|
||||||
|
self.command.stderr(cfg);
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
fn env<K, V>(&mut self, key: K, value: V) -> &mut Self
|
||||||
|
where
|
||||||
|
K: AsRef<OsStr>,
|
||||||
|
V: AsRef<OsStr>,
|
||||||
|
{
|
||||||
|
self.command.env(key, value);
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
fn output_with_stdin(&mut self, stdin: &[u8]) -> std::io::Result<std::process::Output> {
|
||||||
|
let mut child = self.command.spawn()?;
|
||||||
|
if let Some(mut child_stdin) = child.stdin.take() {
|
||||||
|
use std::io::Write as _;
|
||||||
|
child_stdin.write_all(stdin)?;
|
||||||
|
}
|
||||||
|
child.wait_with_output()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::{HookRunResult, HookRunner};
|
||||||
|
use crate::{PluginManager, PluginManagerConfig};
|
||||||
|
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!("plugins-hook-runner-{label}-{nanos}"))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn write_hook_plugin(root: &Path, name: &str, pre_message: &str, post_message: &str) {
|
||||||
|
fs::create_dir_all(root.join(".claw-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");
|
||||||
|
fs::write(
|
||||||
|
root.join(".claw-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 collects_and_runs_hooks_from_enabled_plugins() {
|
||||||
|
let config_home = temp_dir("config");
|
||||||
|
let first_source_root = temp_dir("source-a");
|
||||||
|
let second_source_root = temp_dir("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 build");
|
||||||
|
|
||||||
|
let runner = HookRunner::from_registry(®istry).expect("plugin hooks should load");
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
runner.run_pre_tool_use("Read", r#"{"path":"README.md"}"#),
|
||||||
|
HookRunResult::allow(vec![
|
||||||
|
"plugin pre one".to_string(),
|
||||||
|
"plugin pre two".to_string(),
|
||||||
|
])
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
runner.run_post_tool_use("Read", r#"{"path":"README.md"}"#, "ok", false),
|
||||||
|
HookRunResult::allow(vec![
|
||||||
|
"plugin post one".to_string(),
|
||||||
|
"plugin post two".to_string(),
|
||||||
|
])
|
||||||
|
);
|
||||||
|
|
||||||
|
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 pre_tool_use_denies_when_plugin_hook_exits_two() {
|
||||||
|
let runner = HookRunner::new(crate::PluginHooks {
|
||||||
|
pre_tool_use: vec!["printf 'blocked by plugin'; exit 2".to_string()],
|
||||||
|
post_tool_use: Vec::new(),
|
||||||
|
});
|
||||||
|
|
||||||
|
let result = runner.run_pre_tool_use("Bash", r#"{"command":"pwd"}"#);
|
||||||
|
|
||||||
|
assert!(result.is_denied());
|
||||||
|
assert_eq!(result.messages(), &["blocked by plugin".to_string()]);
|
||||||
|
}
|
||||||
|
}
|
||||||
2943
rust/crates/plugins/src/lib.rs
Normal file
2943
rust/crates/plugins/src/lib.rs
Normal file
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user