1
+//! Extensibility hooks for agent lifecycle and tool execution.
2
+//!
3
+//! Hooks allow users to run custom logic at key moments:
4
+//! - `SessionStart`: when a session begins (new/load/fork)
5
+//! - `PreToolUse`: before a tool is executed
6
+//! - `PostToolUse`: after a tool is executed
7
+//!
8
+//! Hooks are configured in `settings.toml` under `[hooks]`:
9
+//! ```toml
10
+//! [hooks]
11
+//! session_start = ["echo 'Starting siGit session'"]
12
+//! pre_tool_use = ["echo 'About to run {tool_name}'"]
13
+//! post_tool_use = ["echo 'Finished {tool_name}: {tool_result_len} chars'"]
14
+//! ```
15
+//!
16
+//! Hooks support substitution for context variables:
17
+//! - SessionStart: `{cwd}`, `{session_id}`
18
+//! - PreToolUse: `{tool_name}`, `{tool_args_len}`
19
+//! - PostToolUse: `{tool_name}`, `{tool_result_len}`
20
+//!
21
+//! Hooks run in the session cwd and inherit the environment. Shell hook failures
22
+//! are logged but do not interrupt the session.
23
+
24
+use serde::{Deserialize, Serialize};
25
+use std::path::Path;
26
+use std::process::Command;
27
+
28
+/// Hook configuration: lists of shell commands to run at key moments.
29
+#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
30
+pub struct HookSettings {
31
+ /// Commands to run when a session starts (new/load/fork).
32
+ #[serde(default)]
33
+ pub session_start: Vec<String>,
34
+ /// Commands to run before a tool is executed.
35
+ #[serde(default)]
36
+ pub pre_tool_use: Vec<String>,
37
+ /// Commands to run after a tool is executed.
38
+ #[serde(default)]
39
+ pub post_tool_use: Vec<String>,
40
+}
41
+
42
+impl HookSettings {
43
+ /// Whether any hooks are configured.
44
+ pub fn has_hooks(&self) -> bool {
45
+ !self.session_start.is_empty()
46
+ || !self.pre_tool_use.is_empty()
47
+ || !self.post_tool_use.is_empty()
48
+ }
49
+}
50
+
51
+/// Context passed to hook substitution.
52
+pub struct HookContext {
53
+ pub cwd: Option<String>,
54
+ pub session_id: Option<String>,
55
+ pub tool_name: Option<String>,
56
+ pub tool_args_len: Option<usize>,
57
+ pub tool_result_len: Option<usize>,
58
+}
59
+
60
+impl HookContext {
61
+ /// Create a new empty context.
62
+ pub fn new() -> Self {
63
+ Self {
64
+ cwd: None,
65
+ session_id: None,
66
+ tool_name: None,
67
+ tool_args_len: None,
68
+ tool_result_len: None,
69
+ }
70
+ }
71
+
72
+ /// Substitute variables in the command string.
73
+ fn substitute(&self, cmd: &str) -> String {
74
+ let mut result = cmd.to_string();
75
+ if let Some(cwd) = &self.cwd {
76
+ result = result.replace("{cwd}", cwd);
77
+ }
78
+ if let Some(session_id) = &self.session_id {
79
+ result = result.replace("{session_id}", session_id);
80
+ }
81
+ if let Some(tool_name) = &self.tool_name {
82
+ result = result.replace("{tool_name}", tool_name);
83
+ }
84
+ if let Some(tool_args_len) = self.tool_args_len {
85
+ result = result.replace("{tool_args_len}", &tool_args_len.to_string());
86
+ }
87
+ if let Some(tool_result_len) = self.tool_result_len {
88
+ result = result.replace("{tool_result_len}", &tool_result_len.to_string());
89
+ }
90
+ result
91
+ }
92
+}
93
+
94
+impl Default for HookContext {
95
+ fn default() -> Self {
96
+ Self::new()
97
+ }
98
+}
99
+
100
+/// Run a single hook command in the given context.
101
+fn run_hook(cmd: &str, context: &HookContext, cwd: &Path) {
102
+ let substituted = context.substitute(cmd);
103
+ log::debug!("Running hook: {substituted}");
104
+ match Command::new("sh")
105
+ .arg("-c")
106
+ .arg(&substituted)
107
+ .current_dir(cwd)
108
+ .output()
109
+ {
110
+ Ok(output) => {
111
+ if !output.status.success() {
112
+ let stderr = String::from_utf8_lossy(&output.stderr);
113
+ log::warn!(
114
+ "Hook failed with status {:?}: {}",
115
+ output.status.code(),
116
+ stderr.trim()
117
+ );
118
+ } else {
119
+ let stdout = String::from_utf8_lossy(&output.stdout);
120
+ if !stdout.is_empty() {
121
+ log::debug!("Hook output: {}", stdout.trim());
122
+ }
123
+ }
124
+ }
125
+ Err(err) => {
126
+ log::warn!("Failed to run hook: {err}");
127
+ }
128
+ }
129
+}
130
+
131
+/// Run all session start hooks.
132
+pub fn run_session_start_hooks(hooks: &HookSettings, cwd: &Path, session_id: &str) {
133
+ if hooks.session_start.is_empty() {
134
+ return;
135
+ }
136
+ log::info!("Running {} session_start hooks", hooks.session_start.len());
137
+ let context = HookContext {
138
+ cwd: Some(cwd.display().to_string()),
139
+ session_id: Some(session_id.to_string()),
140
+ ..HookContext::new()
141
+ };
142
+ for cmd in &hooks.session_start {
143
+ run_hook(cmd, &context, cwd);
144
+ }
145
+}
146
+
147
+/// Run all pre-tool-use hooks.
148
+pub fn run_pre_tool_use_hooks(hooks: &HookSettings, tool_name: &str, tool_args: &str, cwd: &Path) {
149
+ if hooks.pre_tool_use.is_empty() {
150
+ return;
151
+ }
152
+ log::debug!("Running {} pre_tool_use hooks", hooks.pre_tool_use.len());
153
+ let context = HookContext {
154
+ cwd: Some(cwd.display().to_string()),
155
+ tool_name: Some(tool_name.to_string()),
156
+ tool_args_len: Some(tool_args.len()),
157
+ ..HookContext::new()
158
+ };
159
+ for cmd in &hooks.pre_tool_use {
160
+ run_hook(cmd, &context, cwd);
161
+ }
162
+}
163
+
164
+/// Run all post-tool-use hooks.
165
+pub fn run_post_tool_use_hooks(
166
+ hooks: &HookSettings,
167
+ tool_name: &str,
168
+ tool_result: &str,
169
+ cwd: &Path,
170
+) {
171
+ if hooks.post_tool_use.is_empty() {
172
+ return;
173
+ }
174
+ log::debug!("Running {} post_tool_use hooks", hooks.post_tool_use.len());
175
+ let context = HookContext {
176
+ cwd: Some(cwd.display().to_string()),
177
+ tool_name: Some(tool_name.to_string()),
178
+ tool_result_len: Some(tool_result.len()),
179
+ ..HookContext::new()
180
+ };
181
+ for cmd in &hooks.post_tool_use {
182
+ run_hook(cmd, &context, cwd);
183
+ }
184
+}
185
+
186
+#[cfg(test)]
187
+mod tests {
188
+ use super::*;
189
+
190
+ #[test]
191
+ fn test_hook_context_substitution() {
192
+ let context = HookContext {
193
+ cwd: Some("/home/user/project".to_string()),
194
+ session_id: Some("session-123".to_string()),
195
+ tool_name: Some("read_file".to_string()),
196
+ tool_args_len: Some(42),
197
+ tool_result_len: Some(1000),
198
+ };
199
+ assert_eq!(
200
+ context.substitute("cd {cwd} && echo {session_id}"),
201
+ "cd /home/user/project && echo session-123"
202
+ );
203
+ assert_eq!(
204
+ context.substitute("Tool: {tool_name}, args={tool_args_len}, result={tool_result_len}"),
205
+ "Tool: read_file, args=42, result=1000"
206
+ );
207
+ }
208
+
209
+ #[test]
210
+ fn test_hook_settings_has_hooks() {
211
+ let empty = HookSettings::default();
212
+ assert!(!empty.has_hooks());
213
+
214
+ let with_hooks = HookSettings {
215
+ session_start: vec!["echo start".to_string()],
216
+ ..HookSettings::default()
217
+ };
218
+ assert!(with_hooks.has_hooks());
219
+ }
220
+}