spool-memory 0.2.3

Local-first developer memory system — persistent, structured knowledge for AI coding tools
Documentation
//! Auto-configure AI tools after binary release.
//!
//! Runs after [`super::release::release_binaries`] succeeds. For each
//! detected AI client (Claude Code, Codex, Cursor, OpenCode), registers
//! the MCP integration pointing at `~/.spool/bin/spool-mcp` and installs
//! Claude Code hooks where applicable.
//!
//! Best-effort: any individual client failure is recorded in the report
//! but never aborts the overall bootstrap.

use anyhow::Result;
use serde::Serialize;
use std::path::Path;

use super::layout::SpoolLayout;
use crate::installers::{self, ClientId, InstallContext, InstallStatus, UpdateStatus};

/// Per-client auto-configuration outcome.
#[derive(Debug, Clone, Serialize)]
pub struct ClientConfigReport {
    pub client: String,
    pub detected: bool,
    pub installed: bool,
    pub status: String,
    pub notes: Vec<String>,
}

/// Aggregate report for the auto-configure phase.
#[derive(Debug, Clone, Default, Serialize)]
pub struct AutoConfigureReport {
    pub clients: Vec<ClientConfigReport>,
    /// True when at least one client was successfully registered.
    pub any_registered: bool,
    /// True when Claude Code hooks were installed (subset of MCP install).
    pub hooks_installed: bool,
}

/// All clients we attempt to auto-configure.
const CLIENTS: &[ClientId] = &[
    ClientId::Claude,
    ClientId::Codex,
    ClientId::Cursor,
    ClientId::OpenCode,
];

/// Auto-configure every detected AI client.
///
/// - `layout`: standard spool layout (binary path resolved from `bin_dir()`)
/// - `force`: when true, overwrites existing client entries; when false,
///   leaves user-managed entries alone (returns `Conflict` status).
pub fn auto_configure_clients(layout: &SpoolLayout, force: bool) -> AutoConfigureReport {
    let mut report = AutoConfigureReport::default();

    let mcp_binary = layout.binary_path("spool-mcp");
    let config_path = layout.config_file();

    // Ensure config exists with sane defaults so MCP can start.
    if !config_path.exists()
        && let Err(err) = write_default_config(&config_path)
    {
        eprintln!("[spool bootstrap] failed to write default config: {err:#}");
    }

    for client_id in CLIENTS {
        let result = configure_one_client(*client_id, &mcp_binary, &config_path, force);
        match result {
            Ok(client_report) => {
                if client_report.installed {
                    report.any_registered = true;
                    if matches!(client_id, ClientId::Claude) {
                        report.hooks_installed = true;
                    }
                }
                report.clients.push(client_report);
            }
            Err(err) => {
                report.clients.push(ClientConfigReport {
                    client: client_id.as_str().to_string(),
                    detected: false,
                    installed: false,
                    status: "error".to_string(),
                    notes: vec![format!("{err:#}")],
                });
            }
        }
    }

    report
}

fn configure_one_client(
    client_id: ClientId,
    mcp_binary: &Path,
    config_path: &Path,
    force: bool,
) -> Result<ClientConfigReport> {
    let installer = installers::installer_for(client_id);
    let detected = installer.detect().unwrap_or(false);

    let mut report = ClientConfigReport {
        client: client_id.as_str().to_string(),
        detected,
        installed: false,
        status: "skipped".to_string(),
        notes: Vec::new(),
    };

    if !detected {
        report
            .notes
            .push("client not detected on this system".to_string());
        return Ok(report);
    }

    let mut ctx = InstallContext::new(config_path.to_path_buf());
    ctx.binary_path = Some(mcp_binary.to_path_buf());
    ctx.force = force;

    // Try install first; if already installed, fall back to update so
    // template drift gets resolved without changing user customizations.
    match installer.install(&ctx) {
        Ok(install_report) => {
            report.notes.extend(install_report.notes);
            match install_report.status {
                InstallStatus::Installed => {
                    report.installed = true;
                    report.status = "installed".to_string();
                }
                InstallStatus::Unchanged => {
                    report.installed = true;
                    report.status = "unchanged".to_string();
                }
                InstallStatus::Conflict => {
                    report.status = "conflict".to_string();
                    report
                        .notes
                        .push("existing entry differs; pass force=true to overwrite".to_string());
                }
                InstallStatus::DryRun => {
                    report.status = "dry_run".to_string();
                }
            }
        }
        Err(err) => {
            report.status = "error".to_string();
            report.notes.push(format!("{err:#}"));
        }
    }

    // If install left us in `unchanged`, also run update to refresh hook
    // templates that may have drifted between releases.
    if report.status == "unchanged"
        && let Ok(update_report) = installer.update(&ctx)
        && matches!(update_report.status, UpdateStatus::Updated)
    {
        report.notes.push(format!(
            "templates refreshed: {} files",
            update_report.updated_paths.len()
        ));
    }

    Ok(report)
}

/// Write a minimal default config so the freshly-installed MCP server has
/// somewhere to read settings from. Users can edit later via the GUI.
fn write_default_config(path: &Path) -> Result<()> {
    if let Some(parent) = path.parent() {
        std::fs::create_dir_all(parent)?;
    }
    let body = r#"# Spool default configuration — generated by bootstrap.
# Edit this file or use the desktop app to customize.

[vault]
# Set to your Obsidian vault root if you want vault-backed retrieval.
# Leave commented out for ledger-only operation.
# root = "/path/to/your/obsidian/vault"

[output]
default_format = "prompt"
max_chars = 12000
max_notes = 8
max_lifecycle = 5
"#;
    std::fs::write(path, body)?;
    Ok(())
}

#[cfg(test)]
mod tests {
    use super::*;
    use tempfile::tempdir;

    #[test]
    fn auto_configure_should_skip_undetected_clients() {
        // No client install dirs exist — every client should skip.
        let temp = tempdir().unwrap();
        let layout = SpoolLayout::from_root(temp.path().join(".spool"));
        layout.ensure_dirs().unwrap();

        // Override HOME so installers don't pick up the developer's real
        // ~/.claude/, ~/.codex/, etc.
        let original_home = std::env::var("HOME").ok();
        // SAFETY: Tests run sequentially within a process by default; only
        // unsafe due to env mutation rules.
        unsafe {
            std::env::set_var("HOME", temp.path());
        }

        let report = auto_configure_clients(&layout, true);

        if let Some(home) = original_home {
            unsafe {
                std::env::set_var("HOME", home);
            }
        } else {
            unsafe {
                std::env::remove_var("HOME");
            }
        }

        assert_eq!(report.clients.len(), 4);
        // No clients exist in the temp HOME, so none should be installed.
        // (We don't strictly assert all skipped, because `detect()` for
        // some installers may return true when their dir exists from
        // unrelated state. The contract is: undetected → skipped.)
        for c in &report.clients {
            if !c.detected {
                assert_eq!(c.status, "skipped");
                assert!(!c.installed);
            }
        }
    }

    #[test]
    fn write_default_config_creates_parent() {
        let temp = tempdir().unwrap();
        let path = temp.path().join("nested/.spool/data/config.toml");
        write_default_config(&path).unwrap();
        assert!(path.exists());
        let content = std::fs::read_to_string(&path).unwrap();
        assert!(content.contains("[vault]"));
        assert!(content.contains("[output]"));
    }
}