codineer_plugins/
hooks.rs1use runtime::{HookCommandSource, HookRunResult, HookRunner};
2
3use crate::{PluginError, PluginHooks, PluginRegistry};
4
5impl HookCommandSource for PluginHooks {
6 fn pre_tool_use_commands(&self) -> &[String] {
7 &self.pre_tool_use
8 }
9
10 fn post_tool_use_commands(&self) -> &[String] {
11 &self.post_tool_use
12 }
13}
14
15#[derive(Debug, Clone, PartialEq, Eq)]
16pub struct PluginHookRunner {
17 inner: HookRunner<PluginHooks>,
18}
19
20impl PluginHookRunner {
21 #[must_use]
22 pub fn new(source: PluginHooks) -> Self {
23 Self {
24 inner: HookRunner::new(source),
25 }
26 }
27
28 pub fn from_registry(registry: &PluginRegistry) -> Result<Self, PluginError> {
29 Ok(Self::new(registry.aggregated_hooks()?))
30 }
31
32 #[must_use]
33 pub fn run_pre_tool_use(&self, tool_name: &str, tool_input: &str) -> HookRunResult {
34 self.inner.run_pre_tool_use(tool_name, tool_input)
35 }
36
37 #[must_use]
38 pub fn run_post_tool_use(
39 &self,
40 tool_name: &str,
41 tool_input: &str,
42 tool_output: &str,
43 is_error: bool,
44 ) -> HookRunResult {
45 self.inner
46 .run_post_tool_use(tool_name, tool_input, tool_output, is_error)
47 }
48}
49
50#[cfg(test)]
51#[cfg(unix)]
52mod tests {
53 use super::PluginHookRunner;
54 use crate::{PluginManager, PluginManagerConfig};
55 use runtime::HookRunResult;
56 use std::fs;
57 use std::path::{Path, PathBuf};
58 use std::time::{SystemTime, UNIX_EPOCH};
59
60 fn temp_dir(label: &str) -> PathBuf {
61 let nanos = SystemTime::now()
62 .duration_since(UNIX_EPOCH)
63 .expect("time should be after epoch")
64 .as_nanos();
65 std::env::temp_dir().join(format!("plugins-hook-runner-{label}-{nanos}"))
66 }
67
68 fn write_hook_plugin(root: &Path, name: &str, pre_message: &str, post_message: &str) {
69 fs::create_dir_all(root.join(".codineer-plugin")).expect("manifest dir");
70 fs::create_dir_all(root.join("hooks")).expect("hooks dir");
71 fs::write(
72 root.join("hooks").join("pre.sh"),
73 format!("#!/bin/sh\nprintf '%s\\n' '{pre_message}'\n"),
74 )
75 .expect("write pre hook");
76 fs::write(
77 root.join("hooks").join("post.sh"),
78 format!("#!/bin/sh\nprintf '%s\\n' '{post_message}'\n"),
79 )
80 .expect("write post hook");
81 fs::write(
82 root.join(".codineer-plugin").join("plugin.json"),
83 format!(
84 "{{\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}}"
85 ),
86 )
87 .expect("write plugin manifest");
88 }
89
90 #[test]
91 fn collects_and_runs_hooks_from_enabled_plugins() {
92 let config_home = temp_dir("config");
93 let first_source_root = temp_dir("source-a");
94 let second_source_root = temp_dir("source-b");
95 write_hook_plugin(
96 &first_source_root,
97 "first",
98 "plugin pre one",
99 "plugin post one",
100 );
101 write_hook_plugin(
102 &second_source_root,
103 "second",
104 "plugin pre two",
105 "plugin post two",
106 );
107
108 let mut manager = PluginManager::new(PluginManagerConfig::new(&config_home));
109 manager
110 .install(first_source_root.to_str().expect("utf8 path"))
111 .expect("first plugin install should succeed");
112 manager
113 .install(second_source_root.to_str().expect("utf8 path"))
114 .expect("second plugin install should succeed");
115 let registry = manager.plugin_registry().expect("registry should build");
116
117 let runner = PluginHookRunner::from_registry(®istry).expect("plugin hooks should load");
118
119 assert_eq!(
120 runner.run_pre_tool_use("Read", r#"{"path":"README.md"}"#),
121 HookRunResult::allow(vec![
122 "plugin pre one".to_string(),
123 "plugin pre two".to_string(),
124 ])
125 );
126 assert_eq!(
127 runner.run_post_tool_use("Read", r#"{"path":"README.md"}"#, "ok", false),
128 HookRunResult::allow(vec![
129 "plugin post one".to_string(),
130 "plugin post two".to_string(),
131 ])
132 );
133
134 let _ = fs::remove_dir_all(config_home);
135 let _ = fs::remove_dir_all(first_source_root);
136 let _ = fs::remove_dir_all(second_source_root);
137 }
138
139 #[test]
140 fn pre_tool_use_denies_when_plugin_hook_exits_two() {
141 let runner = PluginHookRunner::new(crate::PluginHooks {
142 pre_tool_use: vec!["printf 'blocked by plugin'; exit 2".to_string()],
143 post_tool_use: Vec::new(),
144 });
145
146 let result = runner.run_pre_tool_use("Bash", r#"{"command":"pwd"}"#);
147
148 assert!(result.is_denied());
149 assert_eq!(result.messages(), &["blocked by plugin".to_string()]);
150 }
151}