spool/bootstrap/
auto_configure.rs1use anyhow::Result;
12use serde::Serialize;
13use std::path::Path;
14
15use super::layout::SpoolLayout;
16use crate::installers::{self, ClientId, InstallContext, InstallStatus, UpdateStatus};
17
18#[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#[derive(Debug, Clone, Default, Serialize)]
30pub struct AutoConfigureReport {
31 pub clients: Vec<ClientConfigReport>,
32 pub any_registered: bool,
34 pub hooks_installed: bool,
36}
37
38const CLIENTS: &[ClientId] = &[
40 ClientId::Claude,
41 ClientId::Codex,
42 ClientId::Cursor,
43 ClientId::OpenCode,
44];
45
46pub 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 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 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 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
165fn 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 let temp = tempdir().unwrap();
198 let layout = SpoolLayout::from_root(temp.path().join(".spool"));
199 layout.ensure_dirs().unwrap();
200
201 let original_home = std::env::var("HOME").ok();
204 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 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}