Skip to main content

stynx_code_engine/application/
hook_runner.rs

1use stynx_code_config::HooksConfig;
2use stynx_code_config::HookEntry;
3use tokio::process::Command;
4
5struct Outcome {
6    exit_code: i32,
7    stdout: String,
8}
9
10async fn run_cmd(command: &str, env_vars: &[(&str, &str)]) -> Outcome {
11    let (shell, flag) = stynx_code_config::shell_command();
12    let mut cmd = Command::new(shell);
13    cmd.arg(flag).arg(command);
14    for (k, v) in env_vars {
15        cmd.env(k, v);
16    }
17    match cmd.output().await {
18        Ok(out) => Outcome {
19            exit_code: out.status.code().unwrap_or(-1),
20            stdout: String::from_utf8_lossy(&out.stdout).trim().to_string(),
21        },
22        Err(_) => Outcome { exit_code: 1, stdout: String::new() },
23    }
24}
25
26fn tool_matches(entry: &HookEntry, tool_name: &str) -> bool {
27    match &entry.matcher {
28        None => true,
29        Some(m) if m.is_empty() => true,
30        Some(m) => tool_name.contains(m.as_str()),
31    }
32}
33
34pub struct PreToolDecision {
35    pub blocked: bool,
36    pub reason: String,
37    pub output: String,
38}
39
40pub async fn run_pre_tool_use(hooks: &HooksConfig, tool_name: &str, input_json: &str) -> PreToolDecision {
41    let env = [("CLAUDE_TOOL_NAME", tool_name), ("CLAUDE_TOOL_INPUT", input_json)];
42    for entry in hooks.pre_tool_use.iter().filter(|e| tool_matches(e, tool_name)) {
43        let out = run_cmd(&entry.command, &env).await;
44        if out.exit_code != 0 {
45            let reason = if out.stdout.is_empty() {
46                format!("blocked by hook (exit {})", out.exit_code)
47            } else {
48                out.stdout
49            };
50            return PreToolDecision { blocked: true, reason, output: String::new() };
51        }
52        if !out.stdout.is_empty() {
53            return PreToolDecision { blocked: false, reason: String::new(), output: out.stdout };
54        }
55    }
56    PreToolDecision { blocked: false, reason: String::new(), output: String::new() }
57}
58
59pub async fn run_post_tool_use(
60    hooks: &HooksConfig,
61    tool_name: &str,
62    input_json: &str,
63    tool_output: &str,
64) -> String {
65    let env = [
66        ("CLAUDE_TOOL_NAME", tool_name),
67        ("CLAUDE_TOOL_INPUT", input_json),
68        ("CLAUDE_TOOL_OUTPUT", tool_output),
69    ];
70    let mut out = String::new();
71    for entry in hooks.post_tool_use.iter().filter(|e| tool_matches(e, tool_name)) {
72        let result = run_cmd(&entry.command, &env).await;
73        if !result.stdout.is_empty() {
74            if !out.is_empty() { out.push('\n'); }
75            out.push_str(&result.stdout);
76        }
77    }
78    out
79}
80
81pub async fn run_stop_hooks(hooks: &HooksConfig) -> String {
82    let env: [(&str, &str); 0] = [];
83    let mut out = String::new();
84    for entry in &hooks.stop {
85        let result = run_cmd(&entry.command, &env).await;
86        if !result.stdout.is_empty() {
87            if !out.is_empty() { out.push('\n'); }
88            out.push_str(&result.stdout);
89        }
90    }
91    out
92}
93
94pub async fn run_session_start_hooks(hooks: &HooksConfig) -> String {
95    let env: [(&str, &str); 0] = [];
96    let mut out = String::new();
97    for entry in &hooks.session_start {
98        let result = run_cmd(&entry.command, &env).await;
99        if !result.stdout.is_empty() {
100            if !out.is_empty() { out.push('\n'); }
101            out.push_str(&result.stdout);
102        }
103    }
104    out
105}