1use std::time::Duration;
2use async_trait::async_trait;
3use tokio_util::sync::CancellationToken;
4
5#[derive(Debug, Clone)]
7pub struct PostToolsHookResult {
8 pub steering_message: Option<String>,
10 pub success: bool,
11 pub summary: String,
12}
13
14#[async_trait]
16pub trait PostToolsHook: Send + Sync {
17 fn name(&self) -> &str;
18 async fn execute(&self, tool_names_called: &[String], cancel: CancellationToken) -> PostToolsHookResult;
19 fn timeout(&self) -> Duration {
20 Duration::from_secs(30)
21 }
22}
23
24#[derive(Debug, Clone)]
26pub struct HookConfig {
27 pub name: String,
28 pub command: String,
29 pub timeout: u64,
30 pub inject_on_failure: bool,
31 pub trigger_tools: Option<Vec<String>>,
32}
33
34pub struct ShellCommandHook {
36 pub config: HookConfig,
37 pub cwd: std::path::PathBuf,
38}
39
40#[async_trait]
41impl PostToolsHook for ShellCommandHook {
42 fn name(&self) -> &str {
43 &self.config.name
44 }
45
46 fn timeout(&self) -> Duration {
47 Duration::from_secs(self.config.timeout)
48 }
49
50 async fn execute(&self, tool_names_called: &[String], cancel: CancellationToken) -> PostToolsHookResult {
51 if let Some(ref triggers) = self.config.trigger_tools {
53 let any_match = tool_names_called.iter().any(|t| triggers.contains(t));
54 if !any_match {
55 return PostToolsHookResult {
56 steering_message: None,
57 success: true,
58 summary: format!("{}: skipped (no matching tools)", self.config.name),
59 };
60 }
61 }
62
63 let result = tokio::select! {
64 result = tokio::process::Command::new("sh")
65 .arg("-c")
66 .arg(&self.config.command)
67 .current_dir(&self.cwd)
68 .output() => result,
69 _ = cancel.cancelled() => {
70 return PostToolsHookResult {
71 steering_message: None,
72 success: false,
73 summary: format!("{}: cancelled", self.config.name),
74 };
75 }
76 };
77
78 match result {
79 Ok(output) => {
80 let success = output.status.success();
81 let stdout = String::from_utf8_lossy(&output.stdout).to_string();
82 let stderr = String::from_utf8_lossy(&output.stderr).to_string();
83
84 let steering = if !success && self.config.inject_on_failure {
85 let mut msg = format!("[hook:{}] FAILED (exit {})\n", self.config.name, output.status.code().unwrap_or(-1));
86 if !stdout.is_empty() {
87 msg.push_str(&format!("stdout:\n{}\n", stdout.trim()));
88 }
89 if !stderr.is_empty() {
90 msg.push_str(&format!("stderr:\n{}\n", stderr.trim()));
91 }
92 Some(msg)
93 } else {
94 None
95 };
96
97 let summary = if success {
98 format!("{}: ok", self.config.name)
99 } else {
100 format!("{}: failed (exit {})", self.config.name, output.status.code().unwrap_or(-1))
101 };
102
103 PostToolsHookResult {
104 steering_message: steering,
105 success,
106 summary,
107 }
108 }
109 Err(e) => PostToolsHookResult {
110 steering_message: if self.config.inject_on_failure {
111 Some(format!("[hook:{}] Failed to execute: {}", self.config.name, e))
112 } else {
113 None
114 },
115 success: false,
116 summary: format!("{}: error ({})", self.config.name, e),
117 },
118 }
119 }
120}