spool-memory 0.1.1

Local-first developer memory system — persistent, structured knowledge for AI coding tools
Documentation
//! Multi-platform installer registry for spool hook runtime.
//!
//! Borrowed from Trellis' Configurator pattern but slimmed down: each AI
//! client (Claude Code, Codex, Cursor, …) implements [`Installer`] and is
//! routed through the unified `spool mcp install/uninstall/doctor` CLI.
//!
//! R1 scope: only [`claude::ClaudeInstaller`] is wired. The trait + the
//! [`shared`] helper module are designed so that adding a new client
//! becomes "copy `claude.rs`, swap config path / hook layout".
//!
//! ## Boundaries
//! - Installers MUST be idempotent — re-running `install` on an already
//!   installed client either no-ops or reports a recoverable conflict.
//! - Installers MUST stay side-effect-free in `dry_run` mode: only build
//!   a [`InstallReport`] / [`UninstallReport`] without touching disk.
//! - Installers MUST keep all transport / API key concerns out of band:
//!   they only stitch local config files. Hooks are shipped as inert
//!   shell scripts that shell out to `spool`.

pub mod claude;
pub mod codex;
pub mod cursor;
pub mod opencode;
pub mod shared;
pub mod templates;

use serde::{Deserialize, Serialize};
use std::path::PathBuf;

/// Stable identifier for an AI client target.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum ClientId {
    Claude,
    Codex,
    Cursor,
    OpenCode,
}

impl ClientId {
    pub fn as_str(self) -> &'static str {
        match self {
            ClientId::Claude => "claude",
            ClientId::Codex => "codex",
            ClientId::Cursor => "cursor",
            ClientId::OpenCode => "opencode",
        }
    }
}

/// Inputs shared by all installer entry points.
#[derive(Debug, Clone)]
pub struct InstallContext {
    /// Optional override for the spool-mcp binary path. When `None`, the
    /// installer is responsible for resolving a stable path (e.g. via
    /// `cargo install`). When set, it must be an absolute path.
    pub binary_path: Option<PathBuf>,
    /// spool config TOML used by the registered MCP entry.
    pub config_path: PathBuf,
    /// When true, installer must NOT write to disk; instead populate the
    /// returned report's `planned_writes` list.
    pub dry_run: bool,
    /// When true, installer is allowed to overwrite an existing client
    /// entry in mcpServers. Default behavior on conflict is to refuse
    /// and report `Conflict`.
    pub force: bool,
}

impl InstallContext {
    pub fn new(config_path: PathBuf) -> Self {
        Self {
            binary_path: None,
            config_path,
            dry_run: false,
            force: false,
        }
    }
}

/// Outcome of an `install` call.
#[derive(Debug, Clone, Serialize)]
pub struct InstallReport {
    pub client: String,
    pub binary_path: PathBuf,
    pub config_path: PathBuf,
    pub status: InstallStatus,
    /// Files the installer wrote (or, in dry_run, would have written).
    pub planned_writes: Vec<PathBuf>,
    /// Backup files actually created during install. Empty in dry_run.
    pub backups: Vec<PathBuf>,
    pub notes: Vec<String>,
}

#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum InstallStatus {
    /// Fresh install; nothing previously registered.
    Installed,
    /// Already installed and matched the desired state — no writes.
    Unchanged,
    /// Already installed but with different command/args. With `force`
    /// the entry is rewritten; without it the installer refuses.
    Conflict,
    /// Dry-run — nothing touched. `planned_writes` describes the diff.
    DryRun,
}

/// Outcome of an `uninstall` call.
#[derive(Debug, Clone, Serialize)]
pub struct UninstallReport {
    pub client: String,
    pub status: UninstallStatus,
    pub removed_paths: Vec<PathBuf>,
    pub backups: Vec<PathBuf>,
    pub notes: Vec<String>,
}

#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum UninstallStatus {
    Removed,
    NotInstalled,
    DryRun,
}

/// Outcome of an `update` call.
#[derive(Debug, Clone, Serialize)]
pub struct UpdateReport {
    pub client: String,
    pub status: UpdateStatus,
    /// Files the installer wrote (or, in dry_run, would have written).
    pub updated_paths: Vec<PathBuf>,
    pub notes: Vec<String>,
}

#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum UpdateStatus {
    /// At least one template file was re-rendered and written.
    Updated,
    /// All template files already match the current version — no writes.
    Unchanged,
    /// spool is not installed for this client (no mcpServers entry).
    NotInstalled,
    /// Dry-run — nothing touched. `updated_paths` describes what would change.
    DryRun,
}

/// Outcome of a `diagnose` call (used by `spool mcp doctor`).
#[derive(Debug, Clone, Serialize)]
pub struct DiagnosticReport {
    pub client: String,
    pub checks: Vec<DiagnosticCheck>,
}

#[derive(Debug, Clone, Serialize)]
pub struct DiagnosticCheck {
    pub name: String,
    pub status: DiagnosticStatus,
    pub detail: String,
}

#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum DiagnosticStatus {
    Ok,
    Warn,
    Fail,
    NotApplicable,
}

/// Single per-client installer surface.
///
/// All methods MUST be safe to call multiple times. `install` and
/// `uninstall` are responsible for backing up any file they touch.
pub trait Installer {
    /// Stable identifier matching `ClientId::as_str()`.
    fn id(&self) -> ClientId;

    /// Returns true when the local environment looks like the client is
    /// installed (e.g. `~/.claude/` exists). Used to power doctor.
    fn detect(&self) -> anyhow::Result<bool>;

    fn install(&self, ctx: &InstallContext) -> anyhow::Result<InstallReport>;
    fn update(&self, ctx: &InstallContext) -> anyhow::Result<UpdateReport>;
    fn uninstall(&self, ctx: &InstallContext) -> anyhow::Result<UninstallReport>;
    fn diagnose(&self, ctx: &InstallContext) -> anyhow::Result<DiagnosticReport>;
}

/// Resolve a [`ClientId`] to a concrete installer.
pub fn installer_for(id: ClientId) -> Box<dyn Installer> {
    match id {
        ClientId::Claude => Box::new(claude::ClaudeInstaller::new()),
        ClientId::Codex => Box::new(codex::CodexInstaller::new()),
        ClientId::Cursor => Box::new(cursor::CursorInstaller::new()),
        ClientId::OpenCode => Box::new(opencode::OpenCodeInstaller::new()),
    }
}