1use crate::infra::hook::types::*;
2use crate::permission::JcliConfig;
3use crate::util::log::{write_error_log, write_info_log};
4use serde::{Deserialize, Serialize};
5use std::fmt;
6use std::fs;
7use std::path::{Path, PathBuf};
8use std::sync::Arc;
9
10#[derive(Clone)]
14pub enum HookKind {
15 Shell(ShellHook),
17 Llm(LlmHook),
19 Builtin(BuiltinHook),
21}
22
23#[derive(Debug, Clone)]
25pub struct ShellHook {
26 pub name: Option<String>,
28 pub command: String,
29 pub timeout: u64,
30 pub retry: u32,
31 pub on_error: OnError,
32 pub filter: HookFilter,
33 pub dir_path: Option<PathBuf>,
35}
36
37#[derive(Debug, Clone)]
39pub struct LlmHook {
40 pub name: Option<String>,
42 pub prompt: String,
44 pub model: Option<String>,
46 pub timeout: u64,
48 pub retry: u32,
50 pub on_error: OnError,
52 pub filter: HookFilter,
54 #[allow(dead_code)]
56 pub dir_path: Option<PathBuf>,
57}
58
59pub type BuiltinHookFn = Arc<dyn Fn(&HookContext) -> Option<HookResult> + Send + Sync>;
61
62pub struct BuiltinHook {
64 pub name: String,
66 pub handler: BuiltinHookFn,
68}
69
70impl Clone for BuiltinHook {
71 fn clone(&self) -> Self {
72 BuiltinHook {
73 name: self.name.clone(),
74 handler: Arc::clone(&self.handler),
75 }
76 }
77}
78
79impl fmt::Debug for HookKind {
80 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
81 match self {
82 HookKind::Shell(shell) => f
83 .debug_struct("HookKind::Shell")
84 .field("name", &shell.name)
85 .field("command", &shell.command)
86 .field("timeout", &shell.timeout)
87 .field("on_error", &shell.on_error)
88 .finish(),
89 HookKind::Llm(llm) => f
90 .debug_struct("HookKind::Llm")
91 .field("name", &llm.name)
92 .field("prompt", &llm.prompt.len())
93 .field("model", &llm.model)
94 .field("timeout", &llm.timeout)
95 .field("retry", &llm.retry)
96 .finish(),
97 HookKind::Builtin(builtin) => f
98 .debug_struct("HookKind::Builtin")
99 .field("name", &builtin.name)
100 .finish(),
101 }
102 }
103}
104
105#[derive(Debug, Clone, Serialize, Deserialize)]
128pub struct HookDef {
129 #[serde(default)]
131 pub r#type: HookType,
132 #[serde(default, skip_serializing_if = "Option::is_none")]
134 pub command: Option<String>,
135 #[serde(default, skip_serializing_if = "Option::is_none")]
137 pub prompt: Option<String>,
138 #[serde(default, skip_serializing_if = "Option::is_none")]
140 pub model: Option<String>,
141 #[serde(default = "default_timeout")]
143 pub timeout: u64,
144 #[serde(default)]
146 pub retry: u32,
147 #[serde(default)]
149 pub on_error: OnError,
150 #[serde(default, skip_serializing_if = "HookFilter::is_empty")]
152 pub filter: HookFilter,
153}
154
155impl HookDef {
156 pub fn into_hook_kind(self) -> Result<HookKind, String> {
158 match self.r#type {
159 HookType::Bash => {
160 let command = self.command.unwrap_or_default();
161 if command.is_empty() {
162 return Err("bash hook 缺少 command 字段".to_string());
163 }
164 Ok(HookKind::Shell(ShellHook {
165 name: None,
166 command,
167 timeout: self.timeout,
168 retry: self.retry,
169 on_error: self.on_error,
170 filter: self.filter,
171 dir_path: None,
172 }))
173 }
174 HookType::Llm => {
175 let prompt = self.prompt.unwrap_or_default();
176 if prompt.is_empty() {
177 return Err("llm hook 缺少 prompt 字段".to_string());
178 }
179 Ok(HookKind::Llm(LlmHook {
180 name: None,
181 prompt,
182 model: self.model,
183 timeout: if self.timeout == default_timeout() {
184 default_llm_timeout()
185 } else {
186 self.timeout
187 },
188 retry: if self.retry == 0 { 1 } else { self.retry },
189 on_error: self.on_error,
190 filter: self.filter,
191 dir_path: None,
192 }))
193 }
194 }
195 }
196}
197
198impl From<HookDef> for HookKind {
199 fn from(def: HookDef) -> Self {
200 def.into_hook_kind().unwrap_or_else(|e| {
201 write_error_log("HookDef::into_hook_kind", &e);
202 HookKind::Shell(ShellHook {
204 name: None,
205 command: String::new(),
206 timeout: 0,
207 retry: 0,
208 on_error: OnError::Skip,
209 filter: HookFilter::default(),
210 dir_path: None,
211 })
212 })
213 }
214}
215
216#[derive(Debug, Clone, Serialize, Deserialize)]
223pub struct HookDirDef {
224 pub events: Vec<HookEvent>,
226 #[serde(default)]
228 pub r#type: HookType,
229 #[serde(default, skip_serializing_if = "Option::is_none")]
231 pub command: Option<String>,
232 #[serde(default, skip_serializing_if = "Option::is_none")]
234 pub prompt: Option<String>,
235 #[serde(default, skip_serializing_if = "Option::is_none")]
237 pub model: Option<String>,
238 #[serde(default = "default_timeout")]
240 pub timeout: u64,
241 #[serde(default)]
243 pub retry: u32,
244 #[serde(default)]
246 pub on_error: OnError,
247 #[serde(default, skip_serializing_if = "HookFilter::is_empty")]
249 pub filter: HookFilter,
250}
251
252impl HookDirDef {
253 pub fn into_hook_kinds(
255 self,
256 name: &str,
257 dir_path: &Path,
258 ) -> Result<Vec<(HookEvent, HookKind)>, String> {
259 if self.events.is_empty() {
260 return Err(format!("hook '{}' 的 events 为空", name));
261 }
262 let kind = match self.r#type {
263 HookType::Bash => {
264 let command = self.command.unwrap_or_default();
265 if command.is_empty() {
266 return Err(format!("bash hook '{}' 缺少 command 字段", name));
267 }
268 HookKind::Shell(ShellHook {
269 name: Some(name.to_string()),
270 command,
271 timeout: self.timeout,
272 retry: self.retry,
273 on_error: self.on_error,
274 filter: self.filter,
275 dir_path: Some(dir_path.to_path_buf()),
276 })
277 }
278 HookType::Llm => {
279 let prompt = self.prompt.unwrap_or_default();
280 if prompt.is_empty() {
281 return Err(format!("llm hook '{}' 缺少 prompt 字段", name));
282 }
283 HookKind::Llm(LlmHook {
284 name: Some(name.to_string()),
285 prompt,
286 model: self.model,
287 timeout: if self.timeout == default_timeout() {
288 default_llm_timeout()
289 } else {
290 self.timeout
291 },
292 retry: if self.retry == 0 { 1 } else { self.retry },
293 on_error: self.on_error,
294 filter: self.filter,
295 dir_path: Some(dir_path.to_path_buf()),
296 })
297 }
298 };
299 Ok(self.events.into_iter().map(|e| (e, kind.clone())).collect())
300 }
301}
302
303pub fn hooks_dir() -> PathBuf {
307 let dir = crate::constants::data_root().join("agent").join("hooks");
308 let _ = fs::create_dir_all(&dir);
309 dir
310}
311
312pub fn project_hooks_dir() -> Option<PathBuf> {
314 let config_dir = JcliConfig::find_config_dir()?;
315 let dir = config_dir.join("hooks");
316 if dir.is_dir() { Some(dir) } else { None }
317}
318
319pub(crate) fn load_hooks_from_dir(
321 dir: &Path,
322 source_name: &str,
323) -> Vec<(String, HookDirDef, PathBuf)> {
324 let mut hooks = Vec::new();
325 let entries = match fs::read_dir(dir) {
326 Ok(e) => e,
327 Err(_) => return hooks,
328 };
329 for entry in entries.flatten() {
330 let path = entry.path();
331 if !path.is_dir() {
332 continue;
333 }
334 let hook_name = path
335 .file_name()
336 .unwrap_or_default()
337 .to_string_lossy()
338 .to_string();
339
340 if hook_name == "example" {
342 continue;
343 }
344
345 let hook_yaml = if path.join("HOOK.yaml").exists() {
347 path.join("HOOK.yaml")
348 } else if path.join("HOOK.yml").exists() {
349 path.join("HOOK.yml")
350 } else {
351 continue;
352 };
353 let yaml_file_name = hook_yaml
354 .file_name()
355 .unwrap_or_default()
356 .to_string_lossy()
357 .to_string();
358 match fs::read_to_string(&hook_yaml) {
359 Ok(content) => match serde_yaml::from_str::<HookDirDef>(&content) {
360 Ok(def) => {
361 if def.events.is_empty() {
362 write_error_log(
363 "load_hooks_from_dir",
364 &format!("hook '{}' 的 events 为空,跳过", hook_name),
365 );
366 continue;
367 }
368 hooks.push((hook_name, def, path));
369 }
370 Err(e) => write_error_log(
371 "load_hooks_from_dir",
372 &format!("解析 {}/{} 失败: {}", hook_name, yaml_file_name, e),
373 ),
374 },
375 Err(e) => write_error_log(
376 "load_hooks_from_dir",
377 &format!("读取 {}/{} 失败: {}", hook_name, yaml_file_name, e),
378 ),
379 }
380 }
381 write_info_log(
382 "load_hooks_from_dir",
383 &format!("从 {} 加载了 {} 个 hook", source_name, hooks.len()),
384 );
385 hooks
386}