1use std::path::{Path, PathBuf};
21
22pub const HOOK_SESSION_START: &str = include_str!("templates/claude/hooks/SessionStart.sh");
23pub const HOOK_USER_PROMPT_SUBMIT: &str =
24 include_str!("templates/claude/hooks/UserPromptSubmit.sh");
25pub const HOOK_POST_TOOL_USE: &str = include_str!("templates/claude/hooks/PostToolUse.sh");
26pub const HOOK_STOP: &str = include_str!("templates/claude/hooks/Stop.sh");
27pub const HOOK_PRE_COMPACT: &str = include_str!("templates/claude/hooks/PreCompact.sh");
28
29pub const COMMAND_WAKEUP: &str = include_str!("templates/claude/commands/spool-wakeup.md");
30pub const COMMAND_CAPTURE: &str = include_str!("templates/claude/commands/spool-capture.md");
31pub const COMMAND_REVIEW: &str = include_str!("templates/claude/commands/spool-review.md");
32pub const COMMAND_DOCTOR: &str = include_str!("templates/claude/commands/spool-doctor.md");
33
34pub const SKILL_RUNTIME: &str = include_str!("templates/claude/skills/spool-runtime.md");
35
36pub const CODEX_HOOK_SESSION_START: &str = include_str!("templates/codex/hooks/session_start.sh");
38pub const CODEX_HOOK_POST_TOOL_USE: &str = include_str!("templates/codex/hooks/post_tool_use.sh");
39pub const CODEX_HOOK_SESSION_END: &str = include_str!("templates/codex/hooks/session_end.sh");
40
41pub const CURSOR_HOOK_ON_SESSION_START: &str =
43 include_str!("templates/cursor/hooks/on_session_start.sh");
44pub const CURSOR_HOOK_ON_SESSION_END: &str =
45 include_str!("templates/cursor/hooks/on_session_end.sh");
46
47pub const OPENCODE_HOOK_ON_SESSION_START: &str =
49 include_str!("templates/opencode/hooks/on_session_start.sh");
50pub const OPENCODE_HOOK_ON_SESSION_END: &str =
51 include_str!("templates/opencode/hooks/on_session_end.sh");
52
53pub struct HookSpec {
55 pub file_name: &'static str,
59 pub body: &'static str,
61 pub hook_event: &'static str,
64}
65
66pub fn claude_hook_specs() -> Vec<HookSpec> {
68 vec![
69 HookSpec {
70 file_name: "spool-SessionStart.sh",
71 body: HOOK_SESSION_START,
72 hook_event: "SessionStart",
73 },
74 HookSpec {
75 file_name: "spool-UserPromptSubmit.sh",
76 body: HOOK_USER_PROMPT_SUBMIT,
77 hook_event: "UserPromptSubmit",
78 },
79 HookSpec {
80 file_name: "spool-PostToolUse.sh",
81 body: HOOK_POST_TOOL_USE,
82 hook_event: "PostToolUse",
83 },
84 HookSpec {
85 file_name: "spool-Stop.sh",
86 body: HOOK_STOP,
87 hook_event: "Stop",
88 },
89 HookSpec {
90 file_name: "spool-PreCompact.sh",
91 body: HOOK_PRE_COMPACT,
92 hook_event: "PreCompact",
93 },
94 ]
95}
96
97pub struct CommandSpec {
100 pub file_name: &'static str,
101 pub body: &'static str,
102}
103
104pub fn claude_command_specs() -> Vec<CommandSpec> {
105 vec![
106 CommandSpec {
107 file_name: "spool-wakeup.md",
108 body: COMMAND_WAKEUP,
109 },
110 CommandSpec {
111 file_name: "spool-capture.md",
112 body: COMMAND_CAPTURE,
113 },
114 CommandSpec {
115 file_name: "spool-review.md",
116 body: COMMAND_REVIEW,
117 },
118 CommandSpec {
119 file_name: "spool-doctor.md",
120 body: COMMAND_DOCTOR,
121 },
122 ]
123}
124
125pub struct SkillSpec {
126 pub dir_name: &'static str,
128 pub body: &'static str,
130}
131
132pub fn claude_skill_specs() -> Vec<SkillSpec> {
133 vec![SkillSpec {
134 dir_name: "spool-runtime",
135 body: SKILL_RUNTIME,
136 }]
137}
138
139pub fn codex_hook_specs() -> Vec<HookSpec> {
142 vec![
143 HookSpec {
144 file_name: "spool-session_start.sh",
145 body: CODEX_HOOK_SESSION_START,
146 hook_event: "session_start",
147 },
148 HookSpec {
149 file_name: "spool-post_tool_use.sh",
150 body: CODEX_HOOK_POST_TOOL_USE,
151 hook_event: "post_tool_use",
152 },
153 HookSpec {
154 file_name: "spool-session_end.sh",
155 body: CODEX_HOOK_SESSION_END,
156 hook_event: "session_end",
157 },
158 ]
159}
160
161pub fn cursor_hook_specs() -> Vec<HookSpec> {
164 vec![
165 HookSpec {
166 file_name: "spool-on_session_start.sh",
167 body: CURSOR_HOOK_ON_SESSION_START,
168 hook_event: "onSessionStart",
169 },
170 HookSpec {
171 file_name: "spool-on_session_end.sh",
172 body: CURSOR_HOOK_ON_SESSION_END,
173 hook_event: "onSessionEnd",
174 },
175 ]
176}
177
178pub fn opencode_hook_specs() -> Vec<HookSpec> {
181 vec![
182 HookSpec {
183 file_name: "spool-on_session_start.sh",
184 body: OPENCODE_HOOK_ON_SESSION_START,
185 hook_event: "on_session_start",
186 },
187 HookSpec {
188 file_name: "spool-on_session_end.sh",
189 body: OPENCODE_HOOK_ON_SESSION_END,
190 hook_event: "on_session_end",
191 },
192 ]
193}
194
195pub fn bin_path_for_hook(mcp_binary_path: &Path) -> PathBuf {
201 match mcp_binary_path.parent() {
202 Some(parent) => parent.join("spool"),
203 None => PathBuf::from("spool"),
204 }
205}
206
207pub fn render_hook(body: &str, spool_bin: &Path, config_path: &Path) -> String {
209 body.replace("@@SPOOL_BIN@@", &spool_bin.to_string_lossy())
210 .replace("@@SPOOL_CONFIG@@", &config_path.to_string_lossy())
211}
212
213#[cfg(test)]
214mod tests {
215 use super::*;
216
217 #[test]
218 fn hook_specs_cover_five_events() {
219 let specs = claude_hook_specs();
220 let events: Vec<&str> = specs.iter().map(|s| s.hook_event).collect();
221 assert_eq!(
222 events,
223 vec![
224 "SessionStart",
225 "UserPromptSubmit",
226 "PostToolUse",
227 "Stop",
228 "PreCompact",
229 ]
230 );
231 }
232
233 #[test]
234 fn hook_specs_use_spool_prefix_for_filenames() {
235 for spec in claude_hook_specs() {
236 assert!(
237 spec.file_name.starts_with("spool-"),
238 "{} must start with spool-",
239 spec.file_name
240 );
241 }
242 }
243
244 #[test]
245 fn render_hook_substitutes_placeholders() {
246 let bin = Path::new("/abs/.cargo/bin/spool");
247 let cfg = Path::new("/abs/spool.toml");
248 let out = render_hook(HOOK_SESSION_START, bin, cfg);
249 assert!(out.contains("/abs/.cargo/bin/spool"));
250 assert!(out.contains("/abs/spool.toml"));
251 assert!(!out.contains("@@SPOOL_BIN@@"));
252 assert!(!out.contains("@@SPOOL_CONFIG@@"));
253 }
254
255 #[test]
256 fn bin_path_for_hook_swaps_filename() {
257 let mcp = Path::new("/u/.cargo/bin/spool-mcp");
258 let derived = bin_path_for_hook(mcp);
259 assert_eq!(derived, Path::new("/u/.cargo/bin/spool"));
260 }
261
262 #[test]
263 fn bin_path_for_hook_falls_back_for_bare_filename() {
264 let derived = bin_path_for_hook(Path::new("spool-mcp"));
265 assert_eq!(derived, PathBuf::from("spool"));
266 }
267
268 #[test]
269 fn command_specs_have_expected_set() {
270 let names: Vec<&str> = claude_command_specs().iter().map(|c| c.file_name).collect();
271 assert!(names.contains(&"spool-wakeup.md"));
272 assert!(names.contains(&"spool-capture.md"));
273 assert!(names.contains(&"spool-review.md"));
274 assert!(names.contains(&"spool-doctor.md"));
275 }
276
277 #[test]
278 fn skill_specs_have_runtime_skill() {
279 let specs = claude_skill_specs();
280 assert_eq!(specs.len(), 1);
281 assert_eq!(specs[0].dir_name, "spool-runtime");
282 }
283
284 #[test]
285 fn codex_hook_specs_cover_three_events() {
286 let specs = codex_hook_specs();
287 let events: Vec<&str> = specs.iter().map(|s| s.hook_event).collect();
288 assert_eq!(
289 events,
290 vec!["session_start", "post_tool_use", "session_end"]
291 );
292 }
293
294 #[test]
295 fn codex_hook_specs_use_spool_prefix_for_filenames() {
296 for spec in codex_hook_specs() {
297 assert!(
298 spec.file_name.starts_with("spool-"),
299 "{} must start with spool-",
300 spec.file_name
301 );
302 }
303 }
304
305 #[test]
306 fn render_codex_hook_substitutes_placeholders() {
307 let bin = Path::new("/abs/.cargo/bin/spool");
308 let cfg = Path::new("/abs/spool.toml");
309 let out = render_hook(CODEX_HOOK_SESSION_START, bin, cfg);
310 assert!(out.contains("/abs/.cargo/bin/spool"));
311 assert!(out.contains("/abs/spool.toml"));
312 assert!(!out.contains("@@SPOOL_BIN@@"));
313 assert!(!out.contains("@@SPOOL_CONFIG@@"));
314 }
315
316 #[test]
317 fn cursor_hook_specs_cover_two_events() {
318 let specs = cursor_hook_specs();
319 let events: Vec<&str> = specs.iter().map(|s| s.hook_event).collect();
320 assert_eq!(events, vec!["onSessionStart", "onSessionEnd"]);
321 }
322
323 #[test]
324 fn cursor_hook_specs_use_spool_prefix_for_filenames() {
325 for spec in cursor_hook_specs() {
326 assert!(
327 spec.file_name.starts_with("spool-"),
328 "{} must start with spool-",
329 spec.file_name
330 );
331 }
332 }
333
334 #[test]
335 fn render_cursor_hook_substitutes_placeholders() {
336 let bin = Path::new("/abs/.cargo/bin/spool");
337 let cfg = Path::new("/abs/spool.toml");
338 let out = render_hook(CURSOR_HOOK_ON_SESSION_START, bin, cfg);
339 assert!(out.contains("/abs/.cargo/bin/spool"));
340 assert!(out.contains("/abs/spool.toml"));
341 assert!(!out.contains("@@SPOOL_BIN@@"));
342 assert!(!out.contains("@@SPOOL_CONFIG@@"));
343 }
344
345 #[test]
346 fn opencode_hook_specs_cover_two_events() {
347 let specs = opencode_hook_specs();
348 let events: Vec<&str> = specs.iter().map(|s| s.hook_event).collect();
349 assert_eq!(events, vec!["on_session_start", "on_session_end"]);
350 }
351
352 #[test]
353 fn opencode_hook_specs_use_spool_prefix_for_filenames() {
354 for spec in opencode_hook_specs() {
355 assert!(
356 spec.file_name.starts_with("spool-"),
357 "{} must start with spool-",
358 spec.file_name
359 );
360 }
361 }
362
363 #[test]
364 fn render_opencode_hook_substitutes_placeholders() {
365 let bin = Path::new("/abs/.cargo/bin/spool");
366 let cfg = Path::new("/abs/spool.toml");
367 let out = render_hook(OPENCODE_HOOK_ON_SESSION_START, bin, cfg);
368 assert!(out.contains("/abs/.cargo/bin/spool"));
369 assert!(out.contains("/abs/spool.toml"));
370 assert!(!out.contains("@@SPOOL_BIN@@"));
371 assert!(!out.contains("@@SPOOL_CONFIG@@"));
372 }
373}