use crate::command::chat::infra::hook::types::*;
use crate::command::chat::permission::JcliConfig;
use crate::config::YamlConfig;
use crate::util::log::{write_error_log, write_info_log};
use serde::{Deserialize, Serialize};
use std::path::{Path, PathBuf};
use std::sync::Arc;
#[derive(Clone)]
pub enum HookKind {
Shell(ShellHook),
Llm(LlmHook),
Builtin(BuiltinHook),
}
#[derive(Debug, Clone)]
pub struct ShellHook {
pub name: Option<String>,
pub command: String,
pub timeout: u64,
pub retry: u32,
pub on_error: OnError,
pub filter: HookFilter,
pub dir_path: Option<PathBuf>,
}
#[derive(Debug, Clone)]
pub struct LlmHook {
pub name: Option<String>,
pub prompt: String,
pub model: Option<String>,
pub timeout: u64,
pub retry: u32,
pub on_error: OnError,
pub filter: HookFilter,
#[allow(dead_code)]
pub dir_path: Option<PathBuf>,
}
pub type BuiltinHookFn = Arc<dyn Fn(&HookContext) -> Option<HookResult> + Send + Sync>;
pub struct BuiltinHook {
pub name: String,
pub handler: BuiltinHookFn,
}
impl Clone for BuiltinHook {
fn clone(&self) -> Self {
BuiltinHook {
name: self.name.clone(),
handler: Arc::clone(&self.handler),
}
}
}
impl std::fmt::Debug for HookKind {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
HookKind::Shell(shell) => f
.debug_struct("HookKind::Shell")
.field("name", &shell.name)
.field("command", &shell.command)
.field("timeout", &shell.timeout)
.field("on_error", &shell.on_error)
.finish(),
HookKind::Llm(llm) => f
.debug_struct("HookKind::Llm")
.field("name", &llm.name)
.field("prompt", &llm.prompt.len())
.field("model", &llm.model)
.field("timeout", &llm.timeout)
.field("retry", &llm.retry)
.finish(),
HookKind::Builtin(builtin) => f
.debug_struct("HookKind::Builtin")
.field("name", &builtin.name)
.finish(),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct HookDef {
#[serde(default)]
pub r#type: HookType,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub command: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub prompt: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub model: Option<String>,
#[serde(default = "default_timeout")]
pub timeout: u64,
#[serde(default)]
pub retry: u32,
#[serde(default)]
pub on_error: OnError,
#[serde(default, skip_serializing_if = "HookFilter::is_empty")]
pub filter: HookFilter,
}
impl HookDef {
pub fn into_hook_kind(self) -> Result<HookKind, String> {
match self.r#type {
HookType::Bash => {
let command = self.command.unwrap_or_default();
if command.is_empty() {
return Err("bash hook 缺少 command 字段".to_string());
}
Ok(HookKind::Shell(ShellHook {
name: None,
command,
timeout: self.timeout,
retry: self.retry,
on_error: self.on_error,
filter: self.filter,
dir_path: None,
}))
}
HookType::Llm => {
let prompt = self.prompt.unwrap_or_default();
if prompt.is_empty() {
return Err("llm hook 缺少 prompt 字段".to_string());
}
Ok(HookKind::Llm(LlmHook {
name: None,
prompt,
model: self.model,
timeout: if self.timeout == default_timeout() {
default_llm_timeout()
} else {
self.timeout
},
retry: if self.retry == 0 { 1 } else { self.retry },
on_error: self.on_error,
filter: self.filter,
dir_path: None,
}))
}
}
}
}
impl From<HookDef> for HookKind {
fn from(def: HookDef) -> Self {
def.into_hook_kind().unwrap_or_else(|e| {
write_error_log("HookDef::into_hook_kind", &e);
HookKind::Shell(ShellHook {
name: None,
command: String::new(),
timeout: 0,
retry: 0,
on_error: OnError::Skip,
filter: HookFilter::default(),
dir_path: None,
})
})
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct HookDirDef {
pub events: Vec<HookEvent>,
#[serde(default)]
pub r#type: HookType,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub command: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub prompt: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub model: Option<String>,
#[serde(default = "default_timeout")]
pub timeout: u64,
#[serde(default)]
pub retry: u32,
#[serde(default)]
pub on_error: OnError,
#[serde(default, skip_serializing_if = "HookFilter::is_empty")]
pub filter: HookFilter,
}
impl HookDirDef {
pub fn into_hook_kinds(
self,
name: &str,
dir_path: &Path,
) -> Result<Vec<(HookEvent, HookKind)>, String> {
if self.events.is_empty() {
return Err(format!("hook '{}' 的 events 为空", name));
}
let kind = match self.r#type {
HookType::Bash => {
let command = self.command.unwrap_or_default();
if command.is_empty() {
return Err(format!("bash hook '{}' 缺少 command 字段", name));
}
HookKind::Shell(ShellHook {
name: Some(name.to_string()),
command,
timeout: self.timeout,
retry: self.retry,
on_error: self.on_error,
filter: self.filter,
dir_path: Some(dir_path.to_path_buf()),
})
}
HookType::Llm => {
let prompt = self.prompt.unwrap_or_default();
if prompt.is_empty() {
return Err(format!("llm hook '{}' 缺少 prompt 字段", name));
}
HookKind::Llm(LlmHook {
name: Some(name.to_string()),
prompt,
model: self.model,
timeout: if self.timeout == default_timeout() {
default_llm_timeout()
} else {
self.timeout
},
retry: if self.retry == 0 { 1 } else { self.retry },
on_error: self.on_error,
filter: self.filter,
dir_path: Some(dir_path.to_path_buf()),
})
}
};
Ok(self.events.into_iter().map(|e| (e, kind.clone())).collect())
}
}
pub fn hooks_dir() -> PathBuf {
let dir = YamlConfig::data_dir().join("agent").join("hooks");
let _ = std::fs::create_dir_all(&dir);
dir
}
pub fn project_hooks_dir() -> Option<PathBuf> {
let config_dir = JcliConfig::find_config_dir()?;
let dir = config_dir.join("hooks");
if dir.is_dir() { Some(dir) } else { None }
}
pub(crate) fn load_hooks_from_dir(
dir: &Path,
source_name: &str,
) -> Vec<(String, HookDirDef, PathBuf)> {
let mut hooks = Vec::new();
let entries = match std::fs::read_dir(dir) {
Ok(e) => e,
Err(_) => return hooks,
};
for entry in entries.flatten() {
let path = entry.path();
if !path.is_dir() {
continue;
}
let hook_name = path
.file_name()
.unwrap_or_default()
.to_string_lossy()
.to_string();
if hook_name == "example" {
continue;
}
let hook_yaml = if path.join("HOOK.yaml").exists() {
path.join("HOOK.yaml")
} else if path.join("HOOK.yml").exists() {
path.join("HOOK.yml")
} else {
continue;
};
let yaml_file_name = hook_yaml
.file_name()
.unwrap_or_default()
.to_string_lossy()
.to_string();
match std::fs::read_to_string(&hook_yaml) {
Ok(content) => match serde_yaml::from_str::<HookDirDef>(&content) {
Ok(def) => {
if def.events.is_empty() {
write_error_log(
"load_hooks_from_dir",
&format!("hook '{}' 的 events 为空,跳过", hook_name),
);
continue;
}
hooks.push((hook_name, def, path));
}
Err(e) => write_error_log(
"load_hooks_from_dir",
&format!("解析 {}/{} 失败: {}", hook_name, yaml_file_name, e),
),
},
Err(e) => write_error_log(
"load_hooks_from_dir",
&format!("读取 {}/{} 失败: {}", hook_name, yaml_file_name, e),
),
}
}
write_info_log(
"load_hooks_from_dir",
&format!("从 {} 加载了 {} 个 hook", source_name, hooks.len()),
);
hooks
}