Skip to content

Commit e7c22c2

Browse files
committed
feat(plugin): implement Phase 4 — hook system complete
Implement all 7 todo!() items across 3 files: - hook_types.rs: 4 execute_* functions — command hooks delegate to crab_process::spawn with env vars (CRAB_HOOK_EVENT, CRAB_TOOL_NAME, CRAB_TOOL_INPUT), agent hooks expand prompt templates, HTTP hooks use curl subprocess with SSRF validation, prompt hooks expand templates for caller LLM dispatch - hook_watchers.rs: polling-based file watcher with debounce (500ms), recursive .md/.json scanning, shutdown signal via tokio::Notify - frontmatter_hooks.rs: simple YAML parser for skill frontmatter, hooks section extraction, async registration with HookRegistry, event type mapping (8 event types supported) hook_registry.rs (531 LOC), hook.rs (874 LOC), and config/hooks.rs (139 LOC) were already complete.
1 parent 6d89f75 commit e7c22c2

3 files changed

Lines changed: 506 additions & 59 deletions

File tree

crates/plugin/src/frontmatter_hooks.rs

Lines changed: 275 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -6,12 +6,12 @@
66
//!
77
//! Maps to CCB `hooks/registerFrontmatterHooks.ts` + `hooks/registerSkillHooks.ts`.
88
9-
use super::hook_registry::HookRegistry;
9+
use super::hook_registry::{HookEventType, HookRegistry, HookSource, RegisteredHook};
10+
use super::hook_types::{CommandHook, HookType, PromptHook};
1011

1112
// ─── Frontmatter hook definition ───────────────────────────────────────
1213

1314
/// A hook definition parsed from a skill file's YAML frontmatter.
14-
#[allow(dead_code)]
1515
struct FrontmatterHookDef {
1616
/// The event this hook responds to (e.g. `pre_tool_use`, `session_start`).
1717
event: String,
@@ -29,12 +29,6 @@ struct FrontmatterHookDef {
2929
/// hook definition with the provided [`HookRegistry`]. Returns the IDs of
3030
/// all successfully registered hooks.
3131
///
32-
/// # Arguments
33-
///
34-
/// * `registry` — The hook registry to register hooks with.
35-
/// * `skill_name` — Name of the skill (used for hook ID prefixing and logging).
36-
/// * `frontmatter` — Raw YAML frontmatter string (without `---` delimiters).
37-
///
3832
/// # Expected frontmatter format
3933
///
4034
/// ```yaml
@@ -44,23 +38,193 @@ struct FrontmatterHookDef {
4438
/// - event: session_start
4539
/// prompt: "Initialize the session context for {{tool_name}}"
4640
/// ```
47-
pub fn register_frontmatter_hooks(
48-
_registry: &HookRegistry,
49-
_skill_name: &str,
50-
_frontmatter: &str,
41+
pub async fn register_frontmatter_hooks(
42+
registry: &HookRegistry,
43+
skill_name: &str,
44+
frontmatter: &str,
5145
) -> Vec<String> {
52-
todo!(
53-
"register_frontmatter_hooks: parse frontmatter, extract hooks section, register with registry"
54-
)
46+
// Parse the frontmatter as simple key-value YAML, then extract hooks
47+
let yaml_value = parse_simple_yaml_to_json(frontmatter);
48+
let hook_defs = parse_hooks_section(&yaml_value);
49+
50+
let mut registered_ids = Vec::new();
51+
52+
for def in hook_defs {
53+
// Parse the event type
54+
let event_filter = parse_event_type(&def.event);
55+
56+
// Build the HookType from the definition
57+
let hook_type = if let Some(ref cmd) = def.command {
58+
HookType::Command(CommandHook {
59+
command: cmd.clone(),
60+
timeout_secs: 10,
61+
})
62+
} else if let Some(ref prompt) = def.prompt {
63+
HookType::Prompt(PromptHook {
64+
prompt_template: prompt.clone(),
65+
})
66+
} else {
67+
// Neither command nor prompt — skip
68+
continue;
69+
};
70+
71+
let hook = RegisteredHook {
72+
id: format!("{skill_name}:{}", def.event),
73+
hook_type,
74+
event_filter: event_filter.into_iter().collect(),
75+
source: HookSource::Frontmatter,
76+
};
77+
78+
let id = registry.register(hook).await;
79+
registered_ids.push(id);
80+
}
81+
82+
registered_ids
5583
}
5684

57-
/// Parse the `hooks` section from frontmatter YAML.
85+
/// Parse the `hooks` section from frontmatter JSON/YAML value.
5886
///
5987
/// Extracts an array of hook definitions from the JSON representation of
6088
/// the frontmatter. Returns an empty vec if no `hooks` key is present.
61-
#[allow(dead_code)]
62-
fn parse_hooks_section(_yaml: &serde_json::Value) -> Vec<FrontmatterHookDef> {
63-
todo!("parse_hooks_section: extract hooks array from YAML value and map to FrontmatterHookDef")
89+
fn parse_hooks_section(yaml: &serde_json::Value) -> Vec<FrontmatterHookDef> {
90+
let Some(hooks_array) = yaml.get("hooks").and_then(|v| v.as_array()) else {
91+
return Vec::new();
92+
};
93+
94+
hooks_array
95+
.iter()
96+
.filter_map(|entry| {
97+
let event = entry.get("event")?.as_str()?.to_string();
98+
let command = entry
99+
.get("command")
100+
.and_then(|v| v.as_str())
101+
.map(String::from);
102+
let prompt = entry
103+
.get("prompt")
104+
.and_then(|v| v.as_str())
105+
.map(String::from);
106+
107+
// Must have at least one of command or prompt
108+
if command.is_none() && prompt.is_none() {
109+
return None;
110+
}
111+
112+
Some(FrontmatterHookDef {
113+
event,
114+
command,
115+
prompt,
116+
})
117+
})
118+
.collect()
119+
}
120+
121+
/// Parse a simple flat YAML string into a JSON Value.
122+
///
123+
/// Handles the basic `key: value` and `key:` + array format used in
124+
/// skill frontmatter. This is not a full YAML parser.
125+
fn parse_simple_yaml_to_json(yaml: &str) -> serde_json::Value {
126+
let mut root = serde_json::Map::new();
127+
let mut current_array: Option<(String, Vec<serde_json::Value>)> = None;
128+
let mut current_item: Option<serde_json::Map<String, serde_json::Value>> = None;
129+
130+
for line in yaml.lines() {
131+
let trimmed = line.trim();
132+
if trimmed.is_empty() || trimmed.starts_with('#') {
133+
continue;
134+
}
135+
136+
// Array item: " - event: pre_tool_use" or " - event: ..."
137+
if let Some(rest) = trimmed.strip_prefix("- ") {
138+
// Start a new item in the current array
139+
if let Some(ref mut item) = current_item {
140+
// Save previous item
141+
if let Some((_, ref mut arr)) = current_array {
142+
arr.push(serde_json::Value::Object(item.clone()));
143+
}
144+
}
145+
current_item = Some(serde_json::Map::new());
146+
147+
// Parse key: value from the rest
148+
if let Some((key, value)) = rest.split_once(':') {
149+
let key = key.trim();
150+
let value = value.trim();
151+
if !value.is_empty()
152+
&& let Some(ref mut item) = current_item
153+
{
154+
item.insert(
155+
key.to_string(),
156+
serde_json::Value::String(value.to_string()),
157+
);
158+
}
159+
}
160+
} else if trimmed.contains(':') && !line.starts_with(' ') && !line.starts_with('\t') {
161+
// Top-level key
162+
// Flush any current array
163+
#[allow(clippy::collapsible_if)]
164+
if let Some(ref item) = current_item {
165+
if let Some((_, ref mut arr)) = current_array {
166+
arr.push(serde_json::Value::Object(item.clone()));
167+
}
168+
current_item = None;
169+
}
170+
if let Some((name, arr)) = current_array.take() {
171+
root.insert(name, serde_json::Value::Array(arr));
172+
}
173+
174+
if let Some((key, value)) = trimmed.split_once(':') {
175+
let key = key.trim();
176+
let value = value.trim();
177+
if value.is_empty() {
178+
// Start of an array or nested object
179+
current_array = Some((key.to_string(), Vec::new()));
180+
} else {
181+
root.insert(
182+
key.to_string(),
183+
serde_json::Value::String(value.to_string()),
184+
);
185+
}
186+
}
187+
} else if let Some(ref mut item) = current_item {
188+
// Continuation of array item properties: " command: echo check"
189+
if let Some((key, value)) = trimmed.split_once(':') {
190+
let key = key.trim();
191+
let value = value.trim();
192+
if !value.is_empty() {
193+
item.insert(
194+
key.to_string(),
195+
serde_json::Value::String(value.to_string()),
196+
);
197+
}
198+
}
199+
}
200+
}
201+
202+
// Flush remaining
203+
if let Some(item) = current_item
204+
&& let Some((_, ref mut arr)) = current_array
205+
{
206+
arr.push(serde_json::Value::Object(item));
207+
}
208+
if let Some((name, arr)) = current_array {
209+
root.insert(name, serde_json::Value::Array(arr));
210+
}
211+
212+
serde_json::Value::Object(root)
213+
}
214+
215+
/// Map event name string to `HookEventType`.
216+
fn parse_event_type(event: &str) -> Vec<HookEventType> {
217+
match event.to_lowercase().as_str() {
218+
"session_start" | "sessionstart" => vec![HookEventType::SessionStart],
219+
"session_end" | "sessionend" => vec![HookEventType::SessionEnd],
220+
"pre_tool_use" | "pretooluse" => vec![HookEventType::PreToolUse],
221+
"post_tool_use" | "posttooluse" => vec![HookEventType::PostToolUse],
222+
"user_prompt_submit" | "userpromptsubmit" => vec![HookEventType::UserPromptSubmit],
223+
"stop" => vec![HookEventType::Stop],
224+
"file_changed" | "filechanged" => vec![HookEventType::FileChanged],
225+
"notification" => vec![HookEventType::Notification],
226+
_ => Vec::new(), // Unknown event — no filter, won't match anything
227+
}
64228
}
65229

66230
// ─── Tests ─────────────────────────────────────────────────────────────
@@ -71,7 +235,6 @@ mod tests {
71235

72236
#[test]
73237
fn frontmatter_hook_def_fields() {
74-
// Verify the struct can be constructed and has the expected fields.
75238
let def = FrontmatterHookDef {
76239
event: "pre_tool_use".into(),
77240
command: Some("echo check".into()),
@@ -93,4 +256,96 @@ mod tests {
93256
assert!(def.command.is_none());
94257
assert!(def.prompt.is_some());
95258
}
259+
260+
#[test]
261+
fn parse_hooks_section_with_hooks() {
262+
let yaml = serde_json::json!({
263+
"hooks": [
264+
{"event": "pre_tool_use", "command": "echo check"},
265+
{"event": "session_start", "prompt": "Init context"}
266+
]
267+
});
268+
let defs = parse_hooks_section(&yaml);
269+
assert_eq!(defs.len(), 2);
270+
assert_eq!(defs[0].event, "pre_tool_use");
271+
assert_eq!(defs[0].command.as_deref(), Some("echo check"));
272+
assert_eq!(defs[1].event, "session_start");
273+
assert_eq!(defs[1].prompt.as_deref(), Some("Init context"));
274+
}
275+
276+
#[test]
277+
fn parse_hooks_section_no_hooks_key() {
278+
let yaml = serde_json::json!({"name": "test"});
279+
let defs = parse_hooks_section(&yaml);
280+
assert!(defs.is_empty());
281+
}
282+
283+
#[test]
284+
fn parse_hooks_section_skips_invalid() {
285+
let yaml = serde_json::json!({
286+
"hooks": [
287+
{"event": "pre_tool_use"}, // no command or prompt
288+
{"command": "echo"}, // no event
289+
{"event": "stop", "command": "echo done"}
290+
]
291+
});
292+
let defs = parse_hooks_section(&yaml);
293+
assert_eq!(defs.len(), 1);
294+
assert_eq!(defs[0].event, "stop");
295+
}
296+
297+
#[test]
298+
fn parse_simple_yaml_basic() {
299+
let yaml = "name: test-skill\ndescription: A test";
300+
let json = parse_simple_yaml_to_json(yaml);
301+
assert_eq!(json["name"], "test-skill");
302+
assert_eq!(json["description"], "A test");
303+
}
304+
305+
#[test]
306+
fn parse_simple_yaml_with_hooks_array() {
307+
let yaml = "name: test\nhooks:\n - event: pre_tool_use\n command: echo check\n - event: stop\n prompt: verify";
308+
let json = parse_simple_yaml_to_json(yaml);
309+
let hooks = json["hooks"].as_array().unwrap();
310+
assert_eq!(hooks.len(), 2);
311+
assert_eq!(hooks[0]["event"], "pre_tool_use");
312+
assert_eq!(hooks[0]["command"], "echo check");
313+
assert_eq!(hooks[1]["event"], "stop");
314+
assert_eq!(hooks[1]["prompt"], "verify");
315+
}
316+
317+
#[test]
318+
fn parse_event_type_known() {
319+
assert_eq!(parse_event_type("pre_tool_use").len(), 1);
320+
assert_eq!(parse_event_type("session_start").len(), 1);
321+
assert_eq!(parse_event_type("PreToolUse").len(), 1);
322+
}
323+
324+
#[test]
325+
fn parse_event_type_unknown() {
326+
assert!(parse_event_type("unknown_event").is_empty());
327+
}
328+
329+
#[tokio::test]
330+
async fn register_frontmatter_hooks_basic() {
331+
let registry = HookRegistry::new();
332+
let frontmatter = "hooks:\n - event: pre_tool_use\n command: echo check";
333+
let ids = register_frontmatter_hooks(&registry, "my-skill", frontmatter).await;
334+
assert_eq!(ids.len(), 1);
335+
}
336+
337+
#[tokio::test]
338+
async fn register_frontmatter_hooks_empty() {
339+
let registry = HookRegistry::new();
340+
let ids = register_frontmatter_hooks(&registry, "my-skill", "name: test").await;
341+
assert!(ids.is_empty());
342+
}
343+
344+
#[tokio::test]
345+
async fn register_frontmatter_hooks_multiple() {
346+
let registry = HookRegistry::new();
347+
let frontmatter = "hooks:\n - event: pre_tool_use\n command: echo before\n - event: stop\n prompt: check done";
348+
let ids = register_frontmatter_hooks(&registry, "my-skill", frontmatter).await;
349+
assert_eq!(ids.len(), 2);
350+
}
96351
}

0 commit comments

Comments
 (0)