Skip to main content

codineer_plugins/
hooks.rs

1use 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(&registry).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}