spool-memory 0.2.3

Local-first developer memory system — persistent, structured knowledge for AI coding tools
Documentation
//! Compile-time-embedded hook / command / skill templates for AI clients.
//!
//! Templates live as plain text files under
//! `src/installers/templates/{claude,codex}/` and get pulled into the
//! binary at build time via `include_str!`. This keeps the install
//! footprint single-binary and lets the user move `spool-mcp` between
//! machines without losing the hook payload.
//!
//! ## Substitution
//! Hook scripts contain two placeholders:
//! - `@@SPOOL_BIN@@` — replaced with the absolute path to `spool-mcp`
//!   (note: the hook scripts call `spool`, not `spool-mcp` — see
//!   [`bin_path_for_hook`])
//! - `@@SPOOL_CONFIG@@` — replaced with the absolute path to the user's
//!   spool.toml
//!
//! Commands and skill markdown have NO substitution; they ship as-is
//! and refer to `spool` / `spool mcp` by name.

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");

// ─── Codex CLI templates ──────────────────────────────────────────────
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");

// ─── Cursor templates ─────────────────────────────────────────────────
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");

// ─── OpenCode templates ───────────────────────────────────────────────
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");

/// One hook spec to render and write under `~/.claude/hooks/`.
pub struct HookSpec {
    /// File name on disk (e.g. `spool-SessionStart.sh`). Always
    /// prefixed with `spool-` so uninstall can sweep files via prefix
    /// matching without affecting unrelated tools.
    pub file_name: &'static str,
    /// Raw template body before substitution.
    pub body: &'static str,
    /// The Claude Code hook event this script implements. We use this
    /// to wire the entry under `~/.claude/settings.json` `hooks`.
    pub hook_event: &'static str,
}

/// Order matters only for stable diff output (smoke tests + dry-run).
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",
        },
    ]
}

/// One slash command markdown file to render under
/// `~/.claude/commands/`. Identifier is exposed as `/spool:<name>`.
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 {
    /// Skill directory name (e.g. `spool-runtime`).
    pub dir_name: &'static str,
    /// File body for `<skill-dir>/SKILL.md`.
    pub body: &'static str,
}

pub fn claude_skill_specs() -> Vec<SkillSpec> {
    vec![SkillSpec {
        dir_name: "spool-runtime",
        body: SKILL_RUNTIME,
    }]
}

/// Codex CLI hook specs. Codex supports fewer hook events than Claude
/// Code: `session_start`, `post_tool_use`, and `session_end`.
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",
        },
    ]
}

/// Cursor hook specs. Cursor supports only two hook events:
/// `onSessionStart` and `onSessionEnd`.
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",
        },
    ]
}

/// OpenCode hook specs. OpenCode supports only two hook events:
/// `on_session_start` and `on_session_end`.
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",
        },
    ]
}

/// Hook scripts shell out to `spool <subcommand>`, NOT `spool-mcp`.
/// Given a `~/.cargo/bin/spool-mcp` we infer the matching `spool` next
/// to it. When the user supplies a custom `--binary-path` for the MCP
/// server we still default the hook bin to the same parent dir so the
/// two stay in sync.
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"),
    }
}

/// Render a hook script with the resolved spool binary + config paths.
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@@"));
    }
}