Skip to content

Commit 373f310

Browse files
committed
feat(hooks): implement HTTP hook handler
1 parent a0b1fcf commit 373f310

5 files changed

Lines changed: 382 additions & 74 deletions

File tree

src/cli/commands.rs

Lines changed: 58 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -359,7 +359,7 @@ pub fn run_mcp_list_command(json: bool) -> Result<()> {
359359
}
360360

361361
use crate::cli::args::HooksCommand;
362-
use crate::hooks::config::{HookEvent, HookHandlerConfig};
362+
use crate::hooks::config::{HookEvent, HookHandlerConfig, HttpHandlerConfig};
363363

364364
pub async fn run_hooks_command(args: HooksCommand) -> Result<()> {
365365
match args {
@@ -402,11 +402,8 @@ fn parse_hook_event(event_str: &str) -> Result<HookEvent> {
402402
.ok_or_else(|| anyhow::anyhow!("Invalid event name '{}'. Valid events: pre_tool_use, post_tool_use, pre_session, post_session, error, custom:<name>", event_str))
403403
}
404404

405-
fn parse_handler_type(handler_type: &str) -> Result<HookHandlerConfig> {
406-
match handler_type.to_ascii_lowercase().as_str() {
407-
"command" => Ok(HookHandlerConfig::default()),
408-
_ => anyhow::bail!("Invalid handler type '{}'. Valid types: command", handler_type),
409-
}
405+
fn parse_handler_type(_handler_type: &str) -> Result<HookHandlerConfig> {
406+
anyhow::bail!("parse_handler_type is deprecated; use handler type specific parsing in run_hooks_add")
410407
}
411408

412409
async fn run_hooks_list(event: Option<String>) -> Result<()> {
@@ -433,10 +430,20 @@ async fn run_hooks_list(event: Option<String>) -> Result<()> {
433430
printed_header = true;
434431
}
435432

436-
println!(
437-
"[{}] command=\"{}\" timeout={:?}",
438-
event_name, handler.command, handler.timeout_secs
439-
);
433+
match handler {
434+
HookHandlerConfig::Command(cmd) => {
435+
println!(
436+
"[{}] type=command command=\"{}\" timeout={:?}",
437+
event_name, cmd.command, cmd.timeout_secs
438+
);
439+
}
440+
HookHandlerConfig::Http(http) => {
441+
println!(
442+
"[{}] type=http url=\"{}\" method={} timeout={:?}",
443+
event_name, http.url, http.method, http.timeout_secs
444+
);
445+
}
446+
}
440447
}
441448

442449
if !printed_header && event.is_some() {
@@ -447,36 +454,57 @@ async fn run_hooks_list(event: Option<String>) -> Result<()> {
447454
}
448455

449456
async fn run_hooks_add(event: String, handler_type: String, config_json: String) -> Result<()> {
450-
// Parse event
451457
let hook_event = parse_hook_event(&event)?;
452458
let event_key = match &hook_event {
453459
HookEvent::Custom(name) => format!("custom:{}", name),
454460
other => format!("{:?}", other).to_lowercase(),
455461
};
456462

457-
// Parse handler type
458-
let mut handler = parse_handler_type(&handler_type)?;
459-
460-
// Parse config JSON to extract command
461463
let config: serde_json::Value = serde_json::from_str(&config_json)
462464
.with_context(|| format!("Failed to parse config JSON: {}", config_json))?;
463465

464-
// Extract command (required)
465-
let command = config
466-
.get("command")
467-
.and_then(|v| v.as_str())
468-
.ok_or_else(|| anyhow::anyhow!("Config JSON must include 'command' field"))?;
469-
handler.command = command.to_string();
470-
471-
// Extract optional args
472-
if let Some(args) = config.get("args").and_then(|v| v.as_array()) {
473-
handler.args = args
474-
.iter()
475-
.filter_map(|v| v.as_str().map(String::from))
476-
.collect();
477-
}
466+
let handler = match handler_type.to_ascii_lowercase().as_str() {
467+
"command" => {
468+
let command = config
469+
.get("command")
470+
.and_then(|v| v.as_str())
471+
.ok_or_else(|| anyhow::anyhow!("Config JSON must include 'command' field"))?;
472+
let mut handler = crate::hooks::config::CommandHandlerConfig::default();
473+
handler.command = command.to_string();
474+
if let Some(args) = config.get("args").and_then(|v| v.as_array()) {
475+
handler.args = args
476+
.iter()
477+
.filter_map(|v| v.as_str().map(String::from))
478+
.collect();
479+
}
480+
HookHandlerConfig::Command(handler)
481+
}
482+
"http" => {
483+
let url = config
484+
.get("url")
485+
.and_then(|v| v.as_str())
486+
.ok_or_else(|| anyhow::anyhow!("Config JSON must include 'url' field"))?;
487+
let method = config
488+
.get("method")
489+
.and_then(|v| v.as_str())
490+
.unwrap_or("GET");
491+
let mut handler = HttpHandlerConfig::default();
492+
handler.url = url.to_string();
493+
handler.method = method.to_string();
494+
if let Some(headers) = config.get("headers").and_then(|v| v.as_object()) {
495+
handler.headers = headers
496+
.iter()
497+
.filter_map(|(k, v)| v.as_str().map(|s| (k.clone(), s.to_string())))
498+
.collect();
499+
}
500+
if let Some(body) = config.get("body") {
501+
handler.body = Some(body.clone());
502+
}
503+
HookHandlerConfig::Http(handler)
504+
}
505+
_ => anyhow::bail!("Invalid handler type '{}'. Valid types: command, http", handler_type),
506+
};
478507

479-
// Load existing config and add the new hook
480508
let mut config = load_user_hooks_config()?;
481509
config.events.insert(event_key.clone(), handler);
482510
save_user_hooks_config(&config)?;

src/hooks/config.rs

Lines changed: 60 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -53,8 +53,22 @@ impl HookEvent {
5353

5454
/// Handler configuration for a single hook
5555
#[derive(Debug, Clone, Serialize, Deserialize)]
56+
#[serde(tag = "type", rename_all = "lowercase")]
57+
pub enum HookHandlerConfig {
58+
Command(CommandHandlerConfig),
59+
Http(HttpHandlerConfig),
60+
}
61+
62+
impl Default for HookHandlerConfig {
63+
fn default() -> Self {
64+
HookHandlerConfig::Command(CommandHandlerConfig::default())
65+
}
66+
}
67+
68+
/// Command handler configuration
69+
#[derive(Debug, Clone, Serialize, Deserialize)]
5670
#[serde(default)]
57-
pub struct HookHandlerConfig {
71+
pub struct CommandHandlerConfig {
5872
/// The command or script to execute
5973
pub command: String,
6074
/// Arguments to pass to the handler
@@ -71,7 +85,7 @@ pub struct HookHandlerConfig {
7185
pub pass_input_via_stdin: bool,
7286
}
7387

74-
impl Default for HookHandlerConfig {
88+
impl Default for CommandHandlerConfig {
7589
fn default() -> Self {
7690
Self {
7791
command: String::new(),
@@ -84,6 +98,36 @@ impl Default for HookHandlerConfig {
8498
}
8599
}
86100

101+
/// HTTP handler configuration
102+
#[derive(Debug, Clone, Serialize, Deserialize)]
103+
#[serde(default)]
104+
pub struct HttpHandlerConfig {
105+
/// URL to send the HTTP request to
106+
pub url: String,
107+
/// HTTP method (GET, POST, PUT, DELETE, etc.)
108+
pub method: String,
109+
/// HTTP headers
110+
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
111+
pub headers: BTreeMap<String, String>,
112+
/// Request body template
113+
#[serde(default, skip_serializing_if = "Option::is_none")]
114+
pub body: Option<serde_json::Value>,
115+
/// Timeout in seconds (default: 30)
116+
pub timeout_secs: Option<u64>,
117+
}
118+
119+
impl Default for HttpHandlerConfig {
120+
fn default() -> Self {
121+
Self {
122+
url: String::new(),
123+
method: "GET".to_string(),
124+
headers: BTreeMap::new(),
125+
body: None,
126+
timeout_secs: Some(30),
127+
}
128+
}
129+
}
130+
87131
/// Hooks configuration containing mappings of events to their handlers
88132
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
89133
#[serde(default)]
@@ -207,39 +251,39 @@ mod tests {
207251
let mut config1 = HooksConfig::default();
208252
config1.events.insert(
209253
"pre_tool_use".to_string(),
210-
HookHandlerConfig {
254+
HookHandlerConfig::Command(CommandHandlerConfig {
211255
command: "user_handler".to_string(),
212256
..Default::default()
213-
},
257+
}),
214258
);
215259

216260
let mut config2 = HooksConfig::default();
217261
config2.events.insert(
218262
"pre_tool_use".to_string(),
219-
HookHandlerConfig {
263+
HookHandlerConfig::Command(CommandHandlerConfig {
220264
command: "project_handler".to_string(),
221265
..Default::default()
222-
},
266+
}),
223267
);
224268
config2.events.insert(
225269
"post_tool_use".to_string(),
226-
HookHandlerConfig {
270+
HookHandlerConfig::Command(CommandHandlerConfig {
227271
command: "post_handler".to_string(),
228272
..Default::default()
229-
},
273+
}),
230274
);
231275

232276
config1.merge(config2);
233277

234-
// Project handler should override user handler
235-
assert_eq!(
236-
config1.events.get("pre_tool_use").unwrap().command,
237-
"project_handler"
278+
let pre_handler = config1.events.get("pre_tool_use").unwrap();
279+
let post_handler = config1.events.get("post_tool_use").unwrap();
280+
assert!(
281+
matches!(pre_handler, HookHandlerConfig::Command(cmd) if cmd.command == "project_handler"),
282+
"Project handler should override user handler"
238283
);
239-
// New event should be added
240-
assert_eq!(
241-
config1.events.get("post_tool_use").unwrap().command,
242-
"post_handler"
284+
assert!(
285+
matches!(post_handler, HookHandlerConfig::Command(cmd) if cmd.command == "post_handler"),
286+
"New event should be added"
243287
);
244288
}
245289

0 commit comments

Comments
 (0)