Skip to main content

spool/bootstrap/
auto_configure.rs

1//! Auto-configure AI tools after binary release.
2//!
3//! Runs after [`super::release::release_binaries`] succeeds. For each
4//! detected AI client (Claude Code, Codex, Cursor, OpenCode), registers
5//! the MCP integration pointing at `~/.spool/bin/spool-mcp` and installs
6//! Claude Code hooks where applicable.
7//!
8//! Best-effort: any individual client failure is recorded in the report
9//! but never aborts the overall bootstrap.
10
11use anyhow::Result;
12use serde::Serialize;
13use std::path::Path;
14
15use super::layout::SpoolLayout;
16use crate::installers::{self, ClientId, InstallContext, InstallStatus, UpdateStatus};
17
18/// Per-client auto-configuration outcome.
19#[derive(Debug, Clone, Serialize)]
20pub struct ClientConfigReport {
21    pub client: String,
22    pub detected: bool,
23    pub installed: bool,
24    pub status: String,
25    pub notes: Vec<String>,
26}
27
28/// Aggregate report for the auto-configure phase.
29#[derive(Debug, Clone, Default, Serialize)]
30pub struct AutoConfigureReport {
31    pub clients: Vec<ClientConfigReport>,
32    /// True when at least one client was successfully registered.
33    pub any_registered: bool,
34    /// True when Claude Code hooks were installed (subset of MCP install).
35    pub hooks_installed: bool,
36}
37
38/// All clients we attempt to auto-configure.
39const CLIENTS: &[ClientId] = &[
40    ClientId::Claude,
41    ClientId::Codex,
42    ClientId::Cursor,
43    ClientId::OpenCode,
44];
45
46/// Auto-configure every detected AI client.
47///
48/// - `layout`: standard spool layout (binary path resolved from `bin_dir()`)
49/// - `force`: when true, overwrites existing client entries; when false,
50///   leaves user-managed entries alone (returns `Conflict` status).
51pub fn auto_configure_clients(layout: &SpoolLayout, force: bool) -> AutoConfigureReport {
52    let mut report = AutoConfigureReport::default();
53
54    let mcp_binary = layout.binary_path("spool-mcp");
55    let config_path = layout.config_file();
56
57    // Ensure config exists with sane defaults so MCP can start.
58    if !config_path.exists()
59        && let Err(err) = write_default_config(&config_path)
60    {
61        eprintln!("[spool bootstrap] failed to write default config: {err:#}");
62    }
63
64    for client_id in CLIENTS {
65        let result = configure_one_client(*client_id, &mcp_binary, &config_path, force);
66        match result {
67            Ok(client_report) => {
68                if client_report.installed {
69                    report.any_registered = true;
70                    if matches!(client_id, ClientId::Claude) {
71                        report.hooks_installed = true;
72                    }
73                }
74                report.clients.push(client_report);
75            }
76            Err(err) => {
77                report.clients.push(ClientConfigReport {
78                    client: client_id.as_str().to_string(),
79                    detected: false,
80                    installed: false,
81                    status: "error".to_string(),
82                    notes: vec![format!("{err:#}")],
83                });
84            }
85        }
86    }
87
88    report
89}
90
91fn configure_one_client(
92    client_id: ClientId,
93    mcp_binary: &Path,
94    config_path: &Path,
95    force: bool,
96) -> Result<ClientConfigReport> {
97    let installer = installers::installer_for(client_id);
98    let detected = installer.detect().unwrap_or(false);
99
100    let mut report = ClientConfigReport {
101        client: client_id.as_str().to_string(),
102        detected,
103        installed: false,
104        status: "skipped".to_string(),
105        notes: Vec::new(),
106    };
107
108    if !detected {
109        report
110            .notes
111            .push("client not detected on this system".to_string());
112        return Ok(report);
113    }
114
115    let mut ctx = InstallContext::new(config_path.to_path_buf());
116    ctx.binary_path = Some(mcp_binary.to_path_buf());
117    ctx.force = force;
118
119    // Try install first; if already installed, fall back to update so
120    // template drift gets resolved without changing user customizations.
121    match installer.install(&ctx) {
122        Ok(install_report) => {
123            report.notes.extend(install_report.notes);
124            match install_report.status {
125                InstallStatus::Installed => {
126                    report.installed = true;
127                    report.status = "installed".to_string();
128                }
129                InstallStatus::Unchanged => {
130                    report.installed = true;
131                    report.status = "unchanged".to_string();
132                }
133                InstallStatus::Conflict => {
134                    report.status = "conflict".to_string();
135                    report
136                        .notes
137                        .push("existing entry differs; pass force=true to overwrite".to_string());
138                }
139                InstallStatus::DryRun => {
140                    report.status = "dry_run".to_string();
141                }
142            }
143        }
144        Err(err) => {
145            report.status = "error".to_string();
146            report.notes.push(format!("{err:#}"));
147        }
148    }
149
150    // If install left us in `unchanged`, also run update to refresh hook
151    // templates that may have drifted between releases.
152    if report.status == "unchanged"
153        && let Ok(update_report) = installer.update(&ctx)
154        && matches!(update_report.status, UpdateStatus::Updated)
155    {
156        report.notes.push(format!(
157            "templates refreshed: {} files",
158            update_report.updated_paths.len()
159        ));
160    }
161
162    Ok(report)
163}
164
165/// Write a minimal default config so the freshly-installed MCP server has
166/// somewhere to read settings from. Users can edit later via the GUI.
167fn write_default_config(path: &Path) -> Result<()> {
168    if let Some(parent) = path.parent() {
169        std::fs::create_dir_all(parent)?;
170    }
171    let body = r#"# Spool default configuration — generated by bootstrap.
172# Edit this file or use the desktop app to customize.
173
174[vault]
175# Set to your Obsidian vault root if you want vault-backed retrieval.
176# Leave commented out for ledger-only operation.
177# root = "/path/to/your/obsidian/vault"
178
179[output]
180default_format = "prompt"
181max_chars = 12000
182max_notes = 8
183max_lifecycle = 5
184"#;
185    std::fs::write(path, body)?;
186    Ok(())
187}
188
189#[cfg(test)]
190mod tests {
191    use super::*;
192    use tempfile::tempdir;
193
194    #[test]
195    fn auto_configure_should_skip_undetected_clients() {
196        // No client install dirs exist — every client should skip.
197        let temp = tempdir().unwrap();
198        let layout = SpoolLayout::from_root(temp.path().join(".spool"));
199        layout.ensure_dirs().unwrap();
200
201        // Override HOME so installers don't pick up the developer's real
202        // ~/.claude/, ~/.codex/, etc.
203        let original_home = std::env::var("HOME").ok();
204        // SAFETY: Tests run sequentially within a process by default; only
205        // unsafe due to env mutation rules.
206        unsafe {
207            std::env::set_var("HOME", temp.path());
208        }
209
210        let report = auto_configure_clients(&layout, true);
211
212        if let Some(home) = original_home {
213            unsafe {
214                std::env::set_var("HOME", home);
215            }
216        } else {
217            unsafe {
218                std::env::remove_var("HOME");
219            }
220        }
221
222        assert_eq!(report.clients.len(), 4);
223        // No clients exist in the temp HOME, so none should be installed.
224        // (We don't strictly assert all skipped, because `detect()` for
225        // some installers may return true when their dir exists from
226        // unrelated state. The contract is: undetected → skipped.)
227        for c in &report.clients {
228            if !c.detected {
229                assert_eq!(c.status, "skipped");
230                assert!(!c.installed);
231            }
232        }
233    }
234
235    #[test]
236    fn write_default_config_creates_parent() {
237        let temp = tempdir().unwrap();
238        let path = temp.path().join("nested/.spool/data/config.toml");
239        write_default_config(&path).unwrap();
240        assert!(path.exists());
241        let content = std::fs::read_to_string(&path).unwrap();
242        assert!(content.contains("[vault]"));
243        assert!(content.contains("[output]"));
244    }
245}