Skip to main content

spool/installers/
mod.rs

1//! Multi-platform installer registry for spool hook runtime.
2//!
3//! Borrowed from Trellis' Configurator pattern but slimmed down: each AI
4//! client (Claude Code, Codex, Cursor, …) implements [`Installer`] and is
5//! routed through the unified `spool mcp install/uninstall/doctor` CLI.
6//!
7//! R1 scope: only [`claude::ClaudeInstaller`] is wired. The trait + the
8//! [`shared`] helper module are designed so that adding a new client
9//! becomes "copy `claude.rs`, swap config path / hook layout".
10//!
11//! ## Boundaries
12//! - Installers MUST be idempotent — re-running `install` on an already
13//!   installed client either no-ops or reports a recoverable conflict.
14//! - Installers MUST stay side-effect-free in `dry_run` mode: only build
15//!   a [`InstallReport`] / [`UninstallReport`] without touching disk.
16//! - Installers MUST keep all transport / API key concerns out of band:
17//!   they only stitch local config files. Hooks are shipped as inert
18//!   shell scripts that shell out to `spool`.
19
20pub mod claude;
21pub mod codex;
22pub mod cursor;
23pub mod opencode;
24pub mod shared;
25pub mod templates;
26
27use serde::{Deserialize, Serialize};
28use std::path::PathBuf;
29
30/// Stable identifier for an AI client target.
31#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
32#[serde(rename_all = "kebab-case")]
33pub enum ClientId {
34    Claude,
35    Codex,
36    Cursor,
37    OpenCode,
38}
39
40impl ClientId {
41    pub fn as_str(self) -> &'static str {
42        match self {
43            ClientId::Claude => "claude",
44            ClientId::Codex => "codex",
45            ClientId::Cursor => "cursor",
46            ClientId::OpenCode => "opencode",
47        }
48    }
49}
50
51/// Inputs shared by all installer entry points.
52#[derive(Debug, Clone)]
53pub struct InstallContext {
54    /// Optional override for the spool-mcp binary path. When `None`, the
55    /// installer is responsible for resolving a stable path (e.g. via
56    /// `cargo install`). When set, it must be an absolute path.
57    pub binary_path: Option<PathBuf>,
58    /// spool config TOML used by the registered MCP entry.
59    pub config_path: PathBuf,
60    /// When true, installer must NOT write to disk; instead populate the
61    /// returned report's `planned_writes` list.
62    pub dry_run: bool,
63    /// When true, installer is allowed to overwrite an existing client
64    /// entry in mcpServers. Default behavior on conflict is to refuse
65    /// and report `Conflict`.
66    pub force: bool,
67}
68
69impl InstallContext {
70    pub fn new(config_path: PathBuf) -> Self {
71        Self {
72            binary_path: None,
73            config_path,
74            dry_run: false,
75            force: false,
76        }
77    }
78}
79
80/// Outcome of an `install` call.
81#[derive(Debug, Clone, Serialize)]
82pub struct InstallReport {
83    pub client: String,
84    pub binary_path: PathBuf,
85    pub config_path: PathBuf,
86    pub status: InstallStatus,
87    /// Files the installer wrote (or, in dry_run, would have written).
88    pub planned_writes: Vec<PathBuf>,
89    /// Backup files actually created during install. Empty in dry_run.
90    pub backups: Vec<PathBuf>,
91    pub notes: Vec<String>,
92}
93
94#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
95#[serde(rename_all = "snake_case")]
96pub enum InstallStatus {
97    /// Fresh install; nothing previously registered.
98    Installed,
99    /// Already installed and matched the desired state — no writes.
100    Unchanged,
101    /// Already installed but with different command/args. With `force`
102    /// the entry is rewritten; without it the installer refuses.
103    Conflict,
104    /// Dry-run — nothing touched. `planned_writes` describes the diff.
105    DryRun,
106}
107
108/// Outcome of an `uninstall` call.
109#[derive(Debug, Clone, Serialize)]
110pub struct UninstallReport {
111    pub client: String,
112    pub status: UninstallStatus,
113    pub removed_paths: Vec<PathBuf>,
114    pub backups: Vec<PathBuf>,
115    pub notes: Vec<String>,
116}
117
118#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
119#[serde(rename_all = "snake_case")]
120pub enum UninstallStatus {
121    Removed,
122    NotInstalled,
123    DryRun,
124}
125
126/// Outcome of an `update` call.
127#[derive(Debug, Clone, Serialize)]
128pub struct UpdateReport {
129    pub client: String,
130    pub status: UpdateStatus,
131    /// Files the installer wrote (or, in dry_run, would have written).
132    pub updated_paths: Vec<PathBuf>,
133    pub notes: Vec<String>,
134}
135
136#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
137#[serde(rename_all = "snake_case")]
138pub enum UpdateStatus {
139    /// At least one template file was re-rendered and written.
140    Updated,
141    /// All template files already match the current version — no writes.
142    Unchanged,
143    /// spool is not installed for this client (no mcpServers entry).
144    NotInstalled,
145    /// Dry-run — nothing touched. `updated_paths` describes what would change.
146    DryRun,
147}
148
149/// Outcome of a `diagnose` call (used by `spool mcp doctor`).
150#[derive(Debug, Clone, Serialize)]
151pub struct DiagnosticReport {
152    pub client: String,
153    pub checks: Vec<DiagnosticCheck>,
154}
155
156#[derive(Debug, Clone, Serialize)]
157pub struct DiagnosticCheck {
158    pub name: String,
159    pub status: DiagnosticStatus,
160    pub detail: String,
161}
162
163#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
164#[serde(rename_all = "snake_case")]
165pub enum DiagnosticStatus {
166    Ok,
167    Warn,
168    Fail,
169    NotApplicable,
170}
171
172/// Single per-client installer surface.
173///
174/// All methods MUST be safe to call multiple times. `install` and
175/// `uninstall` are responsible for backing up any file they touch.
176pub trait Installer {
177    /// Stable identifier matching `ClientId::as_str()`.
178    fn id(&self) -> ClientId;
179
180    /// Returns true when the local environment looks like the client is
181    /// installed (e.g. `~/.claude/` exists). Used to power doctor.
182    fn detect(&self) -> anyhow::Result<bool>;
183
184    fn install(&self, ctx: &InstallContext) -> anyhow::Result<InstallReport>;
185    fn update(&self, ctx: &InstallContext) -> anyhow::Result<UpdateReport>;
186    fn uninstall(&self, ctx: &InstallContext) -> anyhow::Result<UninstallReport>;
187    fn diagnose(&self, ctx: &InstallContext) -> anyhow::Result<DiagnosticReport>;
188}
189
190/// Resolve a [`ClientId`] to a concrete installer.
191pub fn installer_for(id: ClientId) -> Box<dyn Installer> {
192    match id {
193        ClientId::Claude => Box::new(claude::ClaudeInstaller::new()),
194        ClientId::Codex => Box::new(codex::CodexInstaller::new()),
195        ClientId::Cursor => Box::new(cursor::CursorInstaller::new()),
196        ClientId::OpenCode => Box::new(opencode::OpenCodeInstaller::new()),
197    }
198}