use std::path::{Path, PathBuf};
pub const HOOK_SESSION_START: &str = include_str!("templates/claude/hooks/SessionStart.sh");
pub const HOOK_USER_PROMPT_SUBMIT: &str =
include_str!("templates/claude/hooks/UserPromptSubmit.sh");
pub const HOOK_POST_TOOL_USE: &str = include_str!("templates/claude/hooks/PostToolUse.sh");
pub const HOOK_STOP: &str = include_str!("templates/claude/hooks/Stop.sh");
pub const HOOK_PRE_COMPACT: &str = include_str!("templates/claude/hooks/PreCompact.sh");
pub const COMMAND_WAKEUP: &str = include_str!("templates/claude/commands/spool-wakeup.md");
pub const COMMAND_CAPTURE: &str = include_str!("templates/claude/commands/spool-capture.md");
pub const COMMAND_REVIEW: &str = include_str!("templates/claude/commands/spool-review.md");
pub const COMMAND_DOCTOR: &str = include_str!("templates/claude/commands/spool-doctor.md");
pub const SKILL_RUNTIME: &str = include_str!("templates/claude/skills/spool-runtime.md");
pub const CODEX_HOOK_SESSION_START: &str = include_str!("templates/codex/hooks/session_start.sh");
pub const CODEX_HOOK_POST_TOOL_USE: &str = include_str!("templates/codex/hooks/post_tool_use.sh");
pub const CODEX_HOOK_SESSION_END: &str = include_str!("templates/codex/hooks/session_end.sh");
pub const CURSOR_HOOK_ON_SESSION_START: &str =
include_str!("templates/cursor/hooks/on_session_start.sh");
pub const CURSOR_HOOK_ON_SESSION_END: &str =
include_str!("templates/cursor/hooks/on_session_end.sh");
pub const OPENCODE_HOOK_ON_SESSION_START: &str =
include_str!("templates/opencode/hooks/on_session_start.sh");
pub const OPENCODE_HOOK_ON_SESSION_END: &str =
include_str!("templates/opencode/hooks/on_session_end.sh");
pub struct HookSpec {
pub file_name: &'static str,
pub body: &'static str,
pub hook_event: &'static str,
}
pub fn claude_hook_specs() -> Vec<HookSpec> {
vec![
HookSpec {
file_name: "spool-SessionStart.sh",
body: HOOK_SESSION_START,
hook_event: "SessionStart",
},
HookSpec {
file_name: "spool-UserPromptSubmit.sh",
body: HOOK_USER_PROMPT_SUBMIT,
hook_event: "UserPromptSubmit",
},
HookSpec {
file_name: "spool-PostToolUse.sh",
body: HOOK_POST_TOOL_USE,
hook_event: "PostToolUse",
},
HookSpec {
file_name: "spool-Stop.sh",
body: HOOK_STOP,
hook_event: "Stop",
},
HookSpec {
file_name: "spool-PreCompact.sh",
body: HOOK_PRE_COMPACT,
hook_event: "PreCompact",
},
]
}
pub struct CommandSpec {
pub file_name: &'static str,
pub body: &'static str,
}
pub fn claude_command_specs() -> Vec<CommandSpec> {
vec![
CommandSpec {
file_name: "spool-wakeup.md",
body: COMMAND_WAKEUP,
},
CommandSpec {
file_name: "spool-capture.md",
body: COMMAND_CAPTURE,
},
CommandSpec {
file_name: "spool-review.md",
body: COMMAND_REVIEW,
},
CommandSpec {
file_name: "spool-doctor.md",
body: COMMAND_DOCTOR,
},
]
}
pub struct SkillSpec {
pub dir_name: &'static str,
pub body: &'static str,
}
pub fn claude_skill_specs() -> Vec<SkillSpec> {
vec![SkillSpec {
dir_name: "spool-runtime",
body: SKILL_RUNTIME,
}]
}
pub fn codex_hook_specs() -> Vec<HookSpec> {
vec![
HookSpec {
file_name: "spool-session_start.sh",
body: CODEX_HOOK_SESSION_START,
hook_event: "session_start",
},
HookSpec {
file_name: "spool-post_tool_use.sh",
body: CODEX_HOOK_POST_TOOL_USE,
hook_event: "post_tool_use",
},
HookSpec {
file_name: "spool-session_end.sh",
body: CODEX_HOOK_SESSION_END,
hook_event: "session_end",
},
]
}
pub fn cursor_hook_specs() -> Vec<HookSpec> {
vec![
HookSpec {
file_name: "spool-on_session_start.sh",
body: CURSOR_HOOK_ON_SESSION_START,
hook_event: "onSessionStart",
},
HookSpec {
file_name: "spool-on_session_end.sh",
body: CURSOR_HOOK_ON_SESSION_END,
hook_event: "onSessionEnd",
},
]
}
pub fn opencode_hook_specs() -> Vec<HookSpec> {
vec![
HookSpec {
file_name: "spool-on_session_start.sh",
body: OPENCODE_HOOK_ON_SESSION_START,
hook_event: "on_session_start",
},
HookSpec {
file_name: "spool-on_session_end.sh",
body: OPENCODE_HOOK_ON_SESSION_END,
hook_event: "on_session_end",
},
]
}
pub fn bin_path_for_hook(mcp_binary_path: &Path) -> PathBuf {
match mcp_binary_path.parent() {
Some(parent) => parent.join("spool"),
None => PathBuf::from("spool"),
}
}
pub fn render_hook(body: &str, spool_bin: &Path, config_path: &Path) -> String {
body.replace("@@SPOOL_BIN@@", &spool_bin.to_string_lossy())
.replace("@@SPOOL_CONFIG@@", &config_path.to_string_lossy())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn hook_specs_cover_five_events() {
let specs = claude_hook_specs();
let events: Vec<&str> = specs.iter().map(|s| s.hook_event).collect();
assert_eq!(
events,
vec![
"SessionStart",
"UserPromptSubmit",
"PostToolUse",
"Stop",
"PreCompact",
]
);
}
#[test]
fn hook_specs_use_spool_prefix_for_filenames() {
for spec in claude_hook_specs() {
assert!(
spec.file_name.starts_with("spool-"),
"{} must start with spool-",
spec.file_name
);
}
}
#[test]
fn render_hook_substitutes_placeholders() {
let bin = Path::new("/abs/.cargo/bin/spool");
let cfg = Path::new("/abs/spool.toml");
let out = render_hook(HOOK_SESSION_START, bin, cfg);
assert!(out.contains("/abs/.cargo/bin/spool"));
assert!(out.contains("/abs/spool.toml"));
assert!(!out.contains("@@SPOOL_BIN@@"));
assert!(!out.contains("@@SPOOL_CONFIG@@"));
}
#[test]
fn bin_path_for_hook_swaps_filename() {
let mcp = Path::new("/u/.cargo/bin/spool-mcp");
let derived = bin_path_for_hook(mcp);
assert_eq!(derived, Path::new("/u/.cargo/bin/spool"));
}
#[test]
fn bin_path_for_hook_falls_back_for_bare_filename() {
let derived = bin_path_for_hook(Path::new("spool-mcp"));
assert_eq!(derived, PathBuf::from("spool"));
}
#[test]
fn command_specs_have_expected_set() {
let names: Vec<&str> = claude_command_specs().iter().map(|c| c.file_name).collect();
assert!(names.contains(&"spool-wakeup.md"));
assert!(names.contains(&"spool-capture.md"));
assert!(names.contains(&"spool-review.md"));
assert!(names.contains(&"spool-doctor.md"));
}
#[test]
fn skill_specs_have_runtime_skill() {
let specs = claude_skill_specs();
assert_eq!(specs.len(), 1);
assert_eq!(specs[0].dir_name, "spool-runtime");
}
#[test]
fn codex_hook_specs_cover_three_events() {
let specs = codex_hook_specs();
let events: Vec<&str> = specs.iter().map(|s| s.hook_event).collect();
assert_eq!(
events,
vec!["session_start", "post_tool_use", "session_end"]
);
}
#[test]
fn codex_hook_specs_use_spool_prefix_for_filenames() {
for spec in codex_hook_specs() {
assert!(
spec.file_name.starts_with("spool-"),
"{} must start with spool-",
spec.file_name
);
}
}
#[test]
fn render_codex_hook_substitutes_placeholders() {
let bin = Path::new("/abs/.cargo/bin/spool");
let cfg = Path::new("/abs/spool.toml");
let out = render_hook(CODEX_HOOK_SESSION_START, bin, cfg);
assert!(out.contains("/abs/.cargo/bin/spool"));
assert!(out.contains("/abs/spool.toml"));
assert!(!out.contains("@@SPOOL_BIN@@"));
assert!(!out.contains("@@SPOOL_CONFIG@@"));
}
#[test]
fn cursor_hook_specs_cover_two_events() {
let specs = cursor_hook_specs();
let events: Vec<&str> = specs.iter().map(|s| s.hook_event).collect();
assert_eq!(events, vec!["onSessionStart", "onSessionEnd"]);
}
#[test]
fn cursor_hook_specs_use_spool_prefix_for_filenames() {
for spec in cursor_hook_specs() {
assert!(
spec.file_name.starts_with("spool-"),
"{} must start with spool-",
spec.file_name
);
}
}
#[test]
fn render_cursor_hook_substitutes_placeholders() {
let bin = Path::new("/abs/.cargo/bin/spool");
let cfg = Path::new("/abs/spool.toml");
let out = render_hook(CURSOR_HOOK_ON_SESSION_START, bin, cfg);
assert!(out.contains("/abs/.cargo/bin/spool"));
assert!(out.contains("/abs/spool.toml"));
assert!(!out.contains("@@SPOOL_BIN@@"));
assert!(!out.contains("@@SPOOL_CONFIG@@"));
}
#[test]
fn opencode_hook_specs_cover_two_events() {
let specs = opencode_hook_specs();
let events: Vec<&str> = specs.iter().map(|s| s.hook_event).collect();
assert_eq!(events, vec!["on_session_start", "on_session_end"]);
}
#[test]
fn opencode_hook_specs_use_spool_prefix_for_filenames() {
for spec in opencode_hook_specs() {
assert!(
spec.file_name.starts_with("spool-"),
"{} must start with spool-",
spec.file_name
);
}
}
#[test]
fn render_opencode_hook_substitutes_placeholders() {
let bin = Path::new("/abs/.cargo/bin/spool");
let cfg = Path::new("/abs/spool.toml");
let out = render_hook(OPENCODE_HOOK_ON_SESSION_START, bin, cfg);
assert!(out.contains("/abs/.cargo/bin/spool"));
assert!(out.contains("/abs/spool.toml"));
assert!(!out.contains("@@SPOOL_BIN@@"));
assert!(!out.contains("@@SPOOL_CONFIG@@"));
}
}