Skip to main content

spool/cli/
mcp_install.rs

1//! `spool mcp <subcommand>` command handlers.
2//!
3//! Thin glue between the clap argument structs and the `installers`
4//! module. Stays here (not inside `installers/`) to keep the installer
5//! crate boundary independent from CLI argument types.
6
7use std::path::Path;
8
9use anyhow::{Context, Result};
10
11use crate::cli::args::{
12    ClientValue, McpDoctorArgs, McpInstallArgs, McpReportFormat, McpUninstallArgs, McpUpdateArgs,
13};
14use crate::installers::{
15    ClientId, DiagnosticReport, DiagnosticStatus, InstallContext, InstallReport, InstallStatus,
16    UninstallReport, UninstallStatus, UpdateReport, UpdateStatus, installer_for,
17};
18
19pub fn execute_install(args: McpInstallArgs) -> Result<()> {
20    let installer = installer_for(client_id(args.client));
21    let ctx = InstallContext {
22        binary_path: args
23            .binary_path
24            .map(absolutize)
25            .transpose()
26            .context("normalizing --binary-path")?,
27        config_path: absolutize(args.config).context("normalizing --config")?,
28        dry_run: args.dry_run,
29        force: args.force,
30    };
31
32    let report = installer.install(&ctx)?;
33    match args.format {
34        McpReportFormat::Text => print!("{}", render_install_text(&report)),
35        McpReportFormat::Json => {
36            println!("{}", serde_json::to_string_pretty(&report)?);
37        }
38    }
39    if matches!(report.status, InstallStatus::Conflict) {
40        anyhow::bail!(
41            "install conflict for client {}: existing entry differs (re-run with --force)",
42            report.client
43        );
44    }
45    Ok(())
46}
47
48pub fn execute_update(args: McpUpdateArgs) -> Result<()> {
49    let installer = installer_for(client_id(args.client));
50    let ctx = InstallContext {
51        binary_path: args
52            .binary_path
53            .map(absolutize)
54            .transpose()
55            .context("normalizing --binary-path")?,
56        config_path: absolutize(args.config).context("normalizing --config")?,
57        dry_run: args.dry_run,
58        force: false,
59    };
60
61    let report = installer.update(&ctx)?;
62    match args.format {
63        McpReportFormat::Text => print!("{}", render_update_text(&report)),
64        McpReportFormat::Json => {
65            println!("{}", serde_json::to_string_pretty(&report)?);
66        }
67    }
68    Ok(())
69}
70
71pub fn execute_uninstall(args: McpUninstallArgs) -> Result<()> {
72    let installer = installer_for(client_id(args.client));
73    let ctx = InstallContext {
74        binary_path: None,
75        config_path: std::env::current_dir().unwrap_or_default(),
76        dry_run: args.dry_run,
77        force: false,
78    };
79
80    let report = installer.uninstall(&ctx)?;
81    match args.format {
82        McpReportFormat::Text => print!("{}", render_uninstall_text(&report)),
83        McpReportFormat::Json => {
84            println!("{}", serde_json::to_string_pretty(&report)?);
85        }
86    }
87    Ok(())
88}
89
90pub fn execute_doctor(args: McpDoctorArgs) -> Result<()> {
91    let installer = installer_for(client_id(args.client));
92    let ctx = InstallContext {
93        binary_path: args
94            .binary_path
95            .map(absolutize)
96            .transpose()
97            .context("normalizing --binary-path")?,
98        config_path: absolutize(args.config).context("normalizing --config")?,
99        dry_run: false,
100        force: false,
101    };
102
103    let report = installer.diagnose(&ctx)?;
104    match args.format {
105        McpReportFormat::Text => print!("{}", render_doctor_text(&report)),
106        McpReportFormat::Json => {
107            println!("{}", serde_json::to_string_pretty(&report)?);
108        }
109    }
110    if has_failure(&report) {
111        anyhow::bail!("doctor reported FAIL checks for client {}", report.client);
112    }
113    Ok(())
114}
115
116fn client_id(value: ClientValue) -> ClientId {
117    match value {
118        ClientValue::Claude => ClientId::Claude,
119        ClientValue::Codex => ClientId::Codex,
120        ClientValue::Cursor => ClientId::Cursor,
121        ClientValue::Opencode => ClientId::OpenCode,
122    }
123}
124
125fn absolutize(p: std::path::PathBuf) -> Result<std::path::PathBuf> {
126    if p.is_absolute() {
127        return Ok(p);
128    }
129    let cwd = std::env::current_dir().context("current_dir() failed")?;
130    Ok(cwd.join(p))
131}
132
133fn render_install_text(report: &InstallReport) -> String {
134    let mut out = String::new();
135    out.push_str(&format!("[install] client={}\n", report.client));
136    out.push_str(&format!(
137        "  status: {}\n",
138        install_status_label(&report.status)
139    ));
140    out.push_str(&format!("  binary: {}\n", report.binary_path.display()));
141    out.push_str(&format!("  config: {}\n", report.config_path.display()));
142    if !report.planned_writes.is_empty() {
143        out.push_str("  writes:\n");
144        for p in &report.planned_writes {
145            out.push_str(&format!("    - {}\n", p.display()));
146        }
147    }
148    if !report.backups.is_empty() {
149        out.push_str("  backups:\n");
150        for p in &report.backups {
151            out.push_str(&format!("    - {}\n", p.display()));
152        }
153    }
154    if !report.notes.is_empty() {
155        out.push_str("  notes:\n");
156        for n in &report.notes {
157            out.push_str(&format!("    - {}\n", n));
158        }
159    }
160    out
161}
162
163fn render_update_text(report: &UpdateReport) -> String {
164    let mut out = String::new();
165    out.push_str(&format!("[update] client={}\n", report.client));
166    out.push_str(&format!(
167        "  status: {}\n",
168        update_status_label(&report.status)
169    ));
170    if !report.updated_paths.is_empty() {
171        out.push_str("  updated:\n");
172        for p in &report.updated_paths {
173            out.push_str(&format!("    - {}\n", p.display()));
174        }
175    }
176    if !report.notes.is_empty() {
177        out.push_str("  notes:\n");
178        for n in &report.notes {
179            out.push_str(&format!("    - {}\n", n));
180        }
181    }
182    out
183}
184
185fn render_uninstall_text(report: &UninstallReport) -> String {
186    let mut out = String::new();
187    out.push_str(&format!("[uninstall] client={}\n", report.client));
188    out.push_str(&format!(
189        "  status: {}\n",
190        uninstall_status_label(&report.status)
191    ));
192    if !report.removed_paths.is_empty() {
193        out.push_str("  removed:\n");
194        for p in &report.removed_paths {
195            out.push_str(&format!("    - {}\n", p.display()));
196        }
197    }
198    if !report.backups.is_empty() {
199        out.push_str("  backups:\n");
200        for p in &report.backups {
201            out.push_str(&format!("    - {}\n", p.display()));
202        }
203    }
204    if !report.notes.is_empty() {
205        out.push_str("  notes:\n");
206        for n in &report.notes {
207            out.push_str(&format!("    - {}\n", n));
208        }
209    }
210    out
211}
212
213fn render_doctor_text(report: &DiagnosticReport) -> String {
214    let mut out = String::new();
215    out.push_str(&format!("[doctor] client={}\n", report.client));
216    for c in &report.checks {
217        out.push_str(&format!(
218            "  [{}] {} — {}\n",
219            diag_label(&c.status),
220            c.name,
221            c.detail
222        ));
223    }
224    out
225}
226
227fn install_status_label(status: &InstallStatus) -> &'static str {
228    match status {
229        InstallStatus::Installed => "installed",
230        InstallStatus::Unchanged => "unchanged",
231        InstallStatus::Conflict => "conflict",
232        InstallStatus::DryRun => "dry-run",
233    }
234}
235
236fn uninstall_status_label(status: &UninstallStatus) -> &'static str {
237    match status {
238        UninstallStatus::Removed => "removed",
239        UninstallStatus::NotInstalled => "not-installed",
240        UninstallStatus::DryRun => "dry-run",
241    }
242}
243
244fn update_status_label(status: &UpdateStatus) -> &'static str {
245    match status {
246        UpdateStatus::Updated => "updated",
247        UpdateStatus::Unchanged => "unchanged",
248        UpdateStatus::NotInstalled => "not-installed",
249        UpdateStatus::DryRun => "dry-run",
250    }
251}
252
253fn diag_label(status: &DiagnosticStatus) -> &'static str {
254    match status {
255        DiagnosticStatus::Ok => " ok ",
256        DiagnosticStatus::Warn => "warn",
257        DiagnosticStatus::Fail => "FAIL",
258        DiagnosticStatus::NotApplicable => "n/a ",
259    }
260}
261
262fn has_failure(report: &DiagnosticReport) -> bool {
263    report
264        .checks
265        .iter()
266        .any(|c| matches!(c.status, DiagnosticStatus::Fail))
267}
268
269// Touch path import to silence unused warning on platforms where Path
270// isn't used after refactors.
271#[allow(dead_code)]
272fn _path_anchor(_p: &Path) {}
273
274#[cfg(test)]
275mod tests {
276    use super::*;
277    use crate::installers::{
278        DiagnosticCheck, DiagnosticReport, DiagnosticStatus, InstallReport, InstallStatus,
279        UninstallReport, UninstallStatus,
280    };
281
282    fn fake_install_report() -> InstallReport {
283        InstallReport {
284            client: "claude".into(),
285            binary_path: "/abs/spool-mcp".into(),
286            config_path: "/abs/spool.toml".into(),
287            status: InstallStatus::Installed,
288            planned_writes: vec!["/abs/.claude.json".into()],
289            backups: vec!["/abs/.claude.json.bak-spool-1".into()],
290            notes: vec!["binary missing".into()],
291        }
292    }
293
294    #[test]
295    fn render_install_text_includes_all_sections() {
296        let text = render_install_text(&fake_install_report());
297        assert!(text.contains("[install] client=claude"));
298        assert!(text.contains("status: installed"));
299        assert!(text.contains("binary: /abs/spool-mcp"));
300        assert!(text.contains("writes:"));
301        assert!(text.contains("backups:"));
302        assert!(text.contains("notes:"));
303    }
304
305    #[test]
306    fn render_uninstall_text_handles_empty_sections() {
307        let report = UninstallReport {
308            client: "claude".into(),
309            status: UninstallStatus::NotInstalled,
310            removed_paths: vec![],
311            backups: vec![],
312            notes: vec![],
313        };
314        let text = render_uninstall_text(&report);
315        assert!(text.contains("[uninstall]"));
316        assert!(text.contains("status: not-installed"));
317        assert!(!text.contains("removed:"));
318        assert!(!text.contains("backups:"));
319    }
320
321    #[test]
322    fn render_doctor_text_lists_checks() {
323        let report = DiagnosticReport {
324            client: "claude".into(),
325            checks: vec![
326                DiagnosticCheck {
327                    name: "claude_config_exists".into(),
328                    status: DiagnosticStatus::Ok,
329                    detail: "/abs/.claude.json".into(),
330                },
331                DiagnosticCheck {
332                    name: "spool_mcp_binary".into(),
333                    status: DiagnosticStatus::Fail,
334                    detail: "/abs/spool-mcp".into(),
335                },
336            ],
337        };
338        let text = render_doctor_text(&report);
339        assert!(text.contains("[ ok ]"));
340        assert!(text.contains("[FAIL]"));
341        assert!(text.contains("claude_config_exists"));
342        assert!(text.contains("spool_mcp_binary"));
343    }
344
345    #[test]
346    fn has_failure_detects_any_fail() {
347        let mut report = DiagnosticReport {
348            client: "claude".into(),
349            checks: vec![DiagnosticCheck {
350                name: "x".into(),
351                status: DiagnosticStatus::Ok,
352                detail: "".into(),
353            }],
354        };
355        assert!(!has_failure(&report));
356        report.checks.push(DiagnosticCheck {
357            name: "y".into(),
358            status: DiagnosticStatus::Fail,
359            detail: "".into(),
360        });
361        assert!(has_failure(&report));
362    }
363
364    #[test]
365    fn absolutize_keeps_absolute_unchanged() {
366        let path = if cfg!(windows) {
367            std::path::PathBuf::from("C:\\abs\\path")
368        } else {
369            std::path::PathBuf::from("/abs/path")
370        };
371        let abs = absolutize(path.clone()).unwrap();
372        assert_eq!(abs, path);
373    }
374
375    #[test]
376    fn absolutize_resolves_relative_against_cwd() {
377        let cwd = std::env::current_dir().unwrap();
378        let abs = absolutize(std::path::PathBuf::from("foo")).unwrap();
379        assert_eq!(abs, cwd.join("foo"));
380    }
381
382    #[test]
383    fn render_update_text_includes_all_sections() {
384        let report = UpdateReport {
385            client: "claude".into(),
386            status: UpdateStatus::Updated,
387            updated_paths: vec!["/abs/.claude/hooks/spool-Stop.sh".into()],
388            notes: vec!["1 file(s) updated to latest templates.".into()],
389        };
390        let text = render_update_text(&report);
391        assert!(text.contains("[update] client=claude"));
392        assert!(text.contains("status: updated"));
393        assert!(text.contains("updated:"));
394        assert!(text.contains("spool-Stop.sh"));
395        assert!(text.contains("notes:"));
396    }
397
398    #[test]
399    fn render_update_text_handles_empty_sections() {
400        let report = UpdateReport {
401            client: "claude".into(),
402            status: UpdateStatus::Unchanged,
403            updated_paths: vec![],
404            notes: vec![],
405        };
406        let text = render_update_text(&report);
407        assert!(text.contains("[update]"));
408        assert!(text.contains("status: unchanged"));
409        assert!(!text.contains("updated:"));
410        assert!(!text.contains("notes:"));
411    }
412}