Skip to main content

rust_doctor/setup/
mod.rs

1//! Interactive setup wizard for configuring rust-doctor with AI coding agents.
2//!
3//! Supports two installation modes:
4//! - **MCP Server**: configures the `rust-doctor --mcp` stdio server in each agent's config
5//! - **CLI + Skills**: installs a `SKILL.md` that teaches the agent to use the CLI
6
7mod detect;
8mod mcp_config;
9mod skill;
10
11use crate::error::SetupError;
12use detect::DetectedAgent;
13use dialoguer::theme::ColorfulTheme;
14use dialoguer::{Confirm, MultiSelect, Select};
15use owo_colors::{OwoColorize, Stream};
16use std::io::IsTerminal;
17use std::process::{Command, Stdio};
18
19/// Installation mode selected by the user.
20enum Mode {
21    /// Configure the MCP stdio server in the agent's config file.
22    Mcp,
23    /// Install a SKILL.md that teaches the agent to use the CLI.
24    CliSkills,
25}
26
27/// A file that was successfully installed.
28struct InstalledFile {
29    path: String,
30    kind: &'static str,
31}
32
33/// Run the interactive setup wizard.
34///
35/// # Errors
36///
37/// Returns an error if interactive prompts fail (e.g., stdin is not a TTY)
38/// or if file I/O fails during installation.
39pub fn run_setup() -> Result<(), SetupError> {
40    if !std::io::stderr().is_terminal() {
41        return Err(SetupError::NotInteractive(
42            "`rust-doctor setup` requires an interactive terminal.\n\
43             Hint: run this command directly in your shell, not via a script or pipe."
44                .to_string(),
45        ));
46    }
47
48    print_banner();
49
50    // Step 1: Choose installation mode
51    let mode = select_mode()?;
52
53    // Step 2: Detect installed agents
54    let agents = detect::detect_agents();
55    if agents.is_empty() {
56        eprintln!(
57            "\n{}",
58            "No supported AI agents detected on this system."
59                .if_supports_color(Stream::Stderr, |t| t.yellow())
60        );
61        eprintln!("Supported agents: Claude Code, Cursor, Windsurf");
62        eprintln!("Install one of these and run `rust-doctor setup` again.");
63        return Ok(());
64    }
65
66    eprintln!(
67        "\n  Detected {} agent(s):\n",
68        agents.len().if_supports_color(Stream::Stderr, |t| t.bold())
69    );
70    for agent in &agents {
71        let status = if agent.mcp_already_configured {
72            " (MCP already configured)"
73        } else {
74            ""
75        };
76        eprintln!(
77            "    {} {} — {}{}",
78            "✓".if_supports_color(Stream::Stderr, |t| t.green()),
79            agent.name.if_supports_color(Stream::Stderr, |t| t.bold()),
80            agent
81                .description
82                .if_supports_color(Stream::Stderr, |t| t.dimmed()),
83            status.if_supports_color(Stream::Stderr, |t| t.dimmed()),
84        );
85    }
86    eprintln!();
87
88    // Step 3: Select which agents to configure
89    let selected = select_agents(&agents, &mode)?;
90    if selected.is_empty() {
91        eprintln!("No agents selected. Exiting.");
92        return Ok(());
93    }
94
95    // Step 4: Confirm
96    let agent_names: Vec<&str> = selected.iter().map(|a| a.name).collect();
97    let mode_label = match mode {
98        Mode::Mcp => "MCP server",
99        Mode::CliSkills => "CLI + Skills",
100    };
101    eprintln!(
102        "\n  Will install {} for: {}",
103        mode_label.if_supports_color(Stream::Stderr, |t| t.bold()),
104        agent_names
105            .join(", ")
106            .if_supports_color(Stream::Stderr, |t| t.cyan())
107    );
108
109    if !Confirm::with_theme(&ColorfulTheme::default())
110        .with_prompt("  Proceed?")
111        .default(true)
112        .interact()?
113    {
114        eprintln!("Cancelled.");
115        return Ok(());
116    }
117
118    eprintln!();
119
120    // Step 5: Install
121    let installed = match mode {
122        Mode::Mcp => install_mcp(&selected),
123        Mode::CliSkills => install_skills(&selected),
124    };
125
126    // Step 6: Recap
127    print_recap(&installed, &mode);
128
129    Ok(())
130}
131
132fn print_banner() {
133    eprintln!(
134        "\n  {}",
135        "rust-doctor setup".if_supports_color(Stream::Stderr, |t| t.bold())
136    );
137    eprintln!(
138        "  {}",
139        "Configure rust-doctor for your AI coding agent"
140            .if_supports_color(Stream::Stderr, |t| t.dimmed())
141    );
142}
143
144fn select_mode() -> Result<Mode, dialoguer::Error> {
145    let items = &[
146        "CLI + Skills \u{2014} Installs a skill file that guides your agent to use the CLI (recommended)",
147        "MCP Server \u{2014} Agent calls rust-doctor tools via MCP protocol",
148    ];
149
150    let selection = Select::with_theme(&ColorfulTheme::default())
151        .with_prompt("  How should your agent access rust-doctor?")
152        .items(items)
153        .default(0)
154        .interact()?;
155
156    Ok(if selection == 0 {
157        Mode::CliSkills
158    } else {
159        Mode::Mcp
160    })
161}
162
163fn select_agents<'a>(
164    agents: &'a [DetectedAgent],
165    mode: &Mode,
166) -> Result<Vec<&'a DetectedAgent>, dialoguer::Error> {
167    // If only one agent detected, skip the selection
168    if agents.len() == 1 {
169        return Ok(agents.iter().collect());
170    }
171
172    // Ask: all agents or pick specific ones?
173    let scope_items = &[
174        format!("All detected agents ({})", agents.len()),
175        "Select specific agents...".to_string(),
176    ];
177
178    let scope = Select::with_theme(&ColorfulTheme::default())
179        .with_prompt("  Install for which agents?")
180        .items(scope_items)
181        .default(0)
182        .interact()?;
183
184    if scope == 0 {
185        return Ok(agents.iter().collect());
186    }
187
188    // Specific selection: space to toggle, enter to confirm
189    let labels: Vec<String> = agents
190        .iter()
191        .map(|a| {
192            let status = match mode {
193                Mode::Mcp if a.mcp_already_configured => " (will overwrite)",
194                Mode::CliSkills if a.skill_already_installed => " (will overwrite)",
195                _ => "",
196            };
197            format!("{} \u{2014} {}{status}", a.name, a.description)
198        })
199        .collect();
200
201    let selections = MultiSelect::with_theme(&ColorfulTheme::default())
202        .with_prompt("  Select agents (space to toggle, enter to confirm)")
203        .items(&labels)
204        .interact()?;
205
206    Ok(selections
207        .into_iter()
208        .filter_map(|i| agents.get(i))
209        .collect())
210}
211
212fn install_mcp(agents: &[&DetectedAgent]) -> Vec<InstalledFile> {
213    let (cmd, args) = detect_command();
214    let mut installed = Vec::new();
215
216    for agent in agents {
217        if agent.mcp_already_configured {
218            eprintln!(
219                "  {} already has rust-doctor MCP configured.",
220                agent.name.if_supports_color(Stream::Stderr, |t| t.cyan())
221            );
222            let replace = Confirm::with_theme(&ColorfulTheme::default())
223                .with_prompt("  Replace with new configuration? (recommended)")
224                .default(true)
225                .interact()
226                .unwrap_or(false);
227            if !replace {
228                eprintln!("  Skipped.");
229                continue;
230            }
231        }
232
233        eprint!(
234            "  Configuring {} ... ",
235            agent.name.if_supports_color(Stream::Stderr, |t| t.cyan())
236        );
237
238        match mcp_config::write_mcp_config(&agent.mcp_config_path, &cmd, &args) {
239            Ok(()) => {
240                eprintln!(
241                    "{}",
242                    "done".if_supports_color(Stream::Stderr, |t| t.green())
243                );
244                installed.push(InstalledFile {
245                    path: agent.mcp_config_path.display().to_string(),
246                    kind: "MCP config",
247                });
248            }
249            Err(e) => {
250                eprintln!(
251                    "{}",
252                    format!("failed: {e}").if_supports_color(Stream::Stderr, |t| t.red())
253                );
254            }
255        }
256    }
257
258    installed
259}
260
261fn install_skills(agents: &[&DetectedAgent]) -> Vec<InstalledFile> {
262    let mut installed = Vec::new();
263
264    for agent in agents {
265        let Some(ref skills_dir) = agent.skills_dir else {
266            eprintln!(
267                "  {} \u{2014} {}",
268                agent.name.if_supports_color(Stream::Stderr, |t| t.cyan()),
269                "no skills support, skipping".if_supports_color(Stream::Stderr, |t| t.yellow())
270            );
271            continue;
272        };
273
274        if agent.skill_already_installed {
275            eprintln!(
276                "  {} already has the rust-doctor skill installed.",
277                agent.name.if_supports_color(Stream::Stderr, |t| t.cyan())
278            );
279            let replace = Confirm::with_theme(&ColorfulTheme::default())
280                .with_prompt("  Replace with latest version? (recommended)")
281                .default(true)
282                .interact()
283                .unwrap_or(false);
284            if !replace {
285                eprintln!("  Skipped.");
286                continue;
287            }
288        }
289
290        eprint!(
291            "  Installing skill for {} ... ",
292            agent.name.if_supports_color(Stream::Stderr, |t| t.cyan())
293        );
294
295        match skill::write_skill(skills_dir) {
296            Ok(path) => {
297                eprintln!(
298                    "{}",
299                    "done".if_supports_color(Stream::Stderr, |t| t.green())
300                );
301                installed.push(InstalledFile {
302                    path: path.display().to_string(),
303                    kind: "Skill file",
304                });
305            }
306            Err(e) => {
307                eprintln!(
308                    "{}",
309                    format!("failed: {e}").if_supports_color(Stream::Stderr, |t| t.red())
310                );
311            }
312        }
313    }
314
315    installed
316}
317
318/// Detect whether `rust-doctor` is directly available in PATH,
319/// or whether we should use `npx` as a fallback for the MCP command.
320fn detect_command() -> (String, Vec<String>) {
321    let is_available = Command::new("rust-doctor")
322        .arg("--version")
323        .stdout(Stdio::null())
324        .stderr(Stdio::null())
325        .status()
326        .is_ok_and(|s| s.success());
327
328    if is_available {
329        ("rust-doctor".into(), vec!["--mcp".into()])
330    } else {
331        (
332            "npx".into(),
333            vec!["-y".into(), "rust-doctor@latest".into(), "--mcp".into()],
334        )
335    }
336}
337
338fn print_recap(installed: &[InstalledFile], mode: &Mode) {
339    eprintln!();
340
341    if installed.is_empty() {
342        eprintln!(
343            "  {}",
344            "No files were installed.".if_supports_color(Stream::Stderr, |t| t.yellow())
345        );
346        return;
347    }
348
349    eprintln!(
350        "  {}",
351        "Setup complete!".if_supports_color(Stream::Stderr, |t| t.green())
352    );
353    eprintln!();
354    eprintln!("  Installed files:");
355    for file in installed {
356        eprintln!(
357            "    {} {} ({})",
358            "\u{2713}".if_supports_color(Stream::Stderr, |t| t.green()),
359            file.path.if_supports_color(Stream::Stderr, |t| t.dimmed()),
360            file.kind,
361        );
362    }
363
364    eprintln!();
365    match mode {
366        Mode::Mcp => {
367            eprintln!("  Restart your AI agent to activate the MCP server.");
368            eprintln!(
369                "  The agent will have access to: {}, {}, {}, {}",
370                "scan".if_supports_color(Stream::Stderr, |t| t.bold()),
371                "score".if_supports_color(Stream::Stderr, |t| t.bold()),
372                "explain_rule".if_supports_color(Stream::Stderr, |t| t.bold()),
373                "list_rules".if_supports_color(Stream::Stderr, |t| t.bold()),
374            );
375        }
376        Mode::CliSkills => {
377            eprintln!("  Your agent can now use rust-doctor via CLI commands.");
378            eprintln!(
379                "  Try asking: {}",
380                "\"Run rust-doctor on this project\""
381                    .if_supports_color(Stream::Stderr, |t| t.dimmed())
382            );
383        }
384    }
385    eprintln!();
386}