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) ]
1515struct 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\n description: 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\n hooks:\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