Skip to main content

spool/installers/
templates.rs

1//! Compile-time-embedded hook / command / skill templates for AI clients.
2//!
3//! Templates live as plain text files under
4//! `src/installers/templates/{claude,codex}/` and get pulled into the
5//! binary at build time via `include_str!`. This keeps the install
6//! footprint single-binary and lets the user move `spool-mcp` between
7//! machines without losing the hook payload.
8//!
9//! ## Substitution
10//! Hook scripts contain two placeholders:
11//! - `@@SPOOL_BIN@@` — replaced with the absolute path to `spool-mcp`
12//!   (note: the hook scripts call `spool`, not `spool-mcp` — see
13//!   [`bin_path_for_hook`])
14//! - `@@SPOOL_CONFIG@@` — replaced with the absolute path to the user's
15//!   spool.toml
16//!
17//! Commands and skill markdown have NO substitution; they ship as-is
18//! and refer to `spool` / `spool mcp` by name.
19
20use 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
36// ─── Codex CLI templates ──────────────────────────────────────────────
37pub 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
41// ─── Cursor templates ─────────────────────────────────────────────────
42pub 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
47// ─── OpenCode templates ───────────────────────────────────────────────
48pub 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
53/// One hook spec to render and write under `~/.claude/hooks/`.
54pub struct HookSpec {
55    /// File name on disk (e.g. `spool-SessionStart.sh`). Always
56    /// prefixed with `spool-` so uninstall can sweep files via prefix
57    /// matching without affecting unrelated tools.
58    pub file_name: &'static str,
59    /// Raw template body before substitution.
60    pub body: &'static str,
61    /// The Claude Code hook event this script implements. We use this
62    /// to wire the entry under `~/.claude/settings.json` `hooks`.
63    pub hook_event: &'static str,
64}
65
66/// Order matters only for stable diff output (smoke tests + dry-run).
67pub 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
97/// One slash command markdown file to render under
98/// `~/.claude/commands/`. Identifier is exposed as `/spool:<name>`.
99pub 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    /// Skill directory name (e.g. `spool-runtime`).
127    pub dir_name: &'static str,
128    /// File body for `<skill-dir>/SKILL.md`.
129    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
139/// Codex CLI hook specs. Codex supports fewer hook events than Claude
140/// Code: `session_start`, `post_tool_use`, and `session_end`.
141pub 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
161/// Cursor hook specs. Cursor supports only two hook events:
162/// `onSessionStart` and `onSessionEnd`.
163pub 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
178/// OpenCode hook specs. OpenCode supports only two hook events:
179/// `on_session_start` and `on_session_end`.
180pub 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
195/// Hook scripts shell out to `spool <subcommand>`, NOT `spool-mcp`.
196/// Given a `~/.cargo/bin/spool-mcp` we infer the matching `spool` next
197/// to it. When the user supplies a custom `--binary-path` for the MCP
198/// server we still default the hook bin to the same parent dir so the
199/// two stay in sync.
200pub 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
207/// Render a hook script with the resolved spool binary + config paths.
208pub 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}