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}