Skip to main content

mag/
setup.rs

1//! Interactive setup wizard for configuring AI coding tools to use MAG.
2//!
3//! The `mag setup` subcommand detects installed AI tools, presents their
4//! configuration status, and writes MCP config entries so that each tool
5//! can communicate with the MAG daemon.
6
7use std::io::{self, BufRead, Write};
8use std::path::Path;
9
10use anyhow::{Context, Result};
11
12use crate::config_writer::{self, ConfigWriteResult, TransportMode};
13use crate::tool_detection::{self, DetectedTool, DetectionResult, MagConfigStatus};
14
15// ---------------------------------------------------------------------------
16// Public types
17// ---------------------------------------------------------------------------
18
19/// Arguments for the `mag setup` subcommand, mapped from the CLI layer.
20pub struct SetupArgs {
21    pub non_interactive: bool,
22    pub tools: Option<Vec<String>>,
23    pub transport: TransportMode,
24    pub port: u16,
25    pub no_start: bool,
26    pub uninstall: bool,
27    pub force: bool,
28}
29
30/// Summary of a configuration run.
31#[derive(Debug, Default)]
32struct ConfigureSummary {
33    written: Vec<String>,
34    already_current: Vec<String>,
35    unsupported: Vec<(String, String)>,
36    deferred: Vec<String>,
37    errors: Vec<(String, String)>,
38}
39
40// ---------------------------------------------------------------------------
41// Public entry point
42// ---------------------------------------------------------------------------
43
44/// Main entry point for `mag setup`.
45pub async fn run_setup(args: SetupArgs) -> Result<()> {
46    if args.uninstall {
47        return crate::uninstall::run_uninstall(false, true).await;
48    }
49
50    // Detect phase
51    println!("\n  Detecting AI coding tools...\n");
52    let result: DetectionResult = tokio::task::spawn_blocking(|| detect_phase(None))
53        .await
54        .context("tool detection task panicked")?;
55
56    present_detection(&result);
57
58    // Determine which tools to configure
59    let tools_to_configure = select_tools(&result, &args)?;
60
61    if tools_to_configure.is_empty() {
62        println!("  No tools to configure.");
63        return Ok(());
64    }
65
66    // Configure phase
67    let summary = configure_tools(&tools_to_configure, args.transport)?;
68    present_summary(&summary);
69
70    // Daemon phase
71    #[cfg(feature = "daemon-http")]
72    maybe_start_daemon(args.port, args.no_start)?;
73
74    Ok(())
75}
76
77// ---------------------------------------------------------------------------
78// Detection phase
79// ---------------------------------------------------------------------------
80
81fn detect_phase(project_root: Option<&Path>) -> DetectionResult {
82    tool_detection::detect_all_tools(project_root)
83}
84
85// ---------------------------------------------------------------------------
86// Presentation
87// ---------------------------------------------------------------------------
88
89fn present_detection(result: &DetectionResult) {
90    if result.detected.is_empty() {
91        println!("  No AI coding tools detected.\n");
92        return;
93    }
94
95    println!("  Detected tools:\n");
96    for dt in &result.detected {
97        let status_icon = match &dt.mag_status {
98            MagConfigStatus::Configured => "\u{2713}", // check mark
99            MagConfigStatus::InstalledAsPlugin => "\u{2713}", // check mark
100            MagConfigStatus::NotConfigured => "\u{2717}", // X mark
101            MagConfigStatus::Misconfigured(_) => "\u{26a0}", // warning
102            MagConfigStatus::Unreadable(_) => "\u{26a0}", // warning
103        };
104        let status_label = match &dt.mag_status {
105            MagConfigStatus::Configured => "configured",
106            MagConfigStatus::InstalledAsPlugin => "installed as plugin",
107            MagConfigStatus::NotConfigured => "not configured",
108            MagConfigStatus::Misconfigured(reason) => reason.as_str(),
109            MagConfigStatus::Unreadable(reason) => reason.as_str(),
110        };
111        println!(
112            "    {status_icon} {name:<20} {status_label}",
113            name = dt.tool.display_name(),
114        );
115        tracing::debug!(
116            tool = %dt.tool.display_name(),
117            path = %dt.config_path.display(),
118            "detected tool"
119        );
120    }
121
122    if !result.not_found.is_empty() {
123        println!();
124        let not_found_names: Vec<&str> = result
125            .not_found
126            .iter()
127            .map(|t: &tool_detection::AiTool| t.display_name())
128            .collect();
129        tracing::debug!(tools = ?not_found_names, "tools not found");
130    }
131    println!();
132}
133
134fn present_summary(summary: &ConfigureSummary) {
135    println!("  Configuration summary:\n");
136
137    for name in &summary.written {
138        println!("    \u{2713} {name} — configured");
139    }
140    for name in &summary.already_current {
141        println!("    \u{2713} {name} — already current");
142    }
143    for name in &summary.deferred {
144        println!("    - {name} — deferred (format not yet supported)");
145    }
146    for (name, reason) in &summary.unsupported {
147        println!("    - {name} — skipped ({reason})");
148    }
149    for (name, err) in &summary.errors {
150        println!("    \u{2717} {name} — error: {err}");
151    }
152    println!();
153}
154
155// ---------------------------------------------------------------------------
156// Tool selection
157// ---------------------------------------------------------------------------
158
159fn select_tools<'a>(
160    result: &'a DetectionResult,
161    args: &SetupArgs,
162) -> Result<Vec<&'a DetectedTool>> {
163    // Filter by --tools if provided
164    let candidates: Vec<&DetectedTool> = if let Some(ref tool_names) = args.tools {
165        let lower_names: Vec<String> = tool_names.iter().map(|n| n.to_lowercase()).collect();
166        result
167            .detected
168            .iter()
169            .filter(|dt| {
170                let display_lower = dt.tool.display_name().to_lowercase();
171                let variant_lower = format!("{:?}", dt.tool).to_lowercase();
172                lower_names.iter().any(|n| {
173                    display_lower.contains(n.as_str()) || variant_lower.contains(n.as_str())
174                })
175            })
176            .collect()
177    } else {
178        result.detected.iter().collect()
179    };
180
181    // In force mode, configure all matched tools regardless of status
182    if args.force {
183        return Ok(candidates);
184    }
185
186    // Filter to only unconfigured/misconfigured tools
187    let actionable: Vec<&DetectedTool> = candidates
188        .into_iter()
189        .filter(|dt| {
190            !matches!(
191                dt.mag_status,
192                MagConfigStatus::Configured | MagConfigStatus::InstalledAsPlugin
193            )
194        })
195        .collect();
196
197    if actionable.is_empty() {
198        return Ok(vec![]);
199    }
200
201    // Non-interactive: configure all actionable tools
202    if args.non_interactive || is_ci() || !is_tty() {
203        return Ok(actionable);
204    }
205
206    // Interactive: prompt user
207    select_tools_interactive(&actionable)
208}
209
210fn select_tools_interactive<'a>(tools: &[&'a DetectedTool]) -> Result<Vec<&'a DetectedTool>> {
211    println!(
212        "  Configure {} tool{}? [Y/n] ",
213        tools.len(),
214        if tools.len() == 1 { "" } else { "s" }
215    );
216    io::stdout().flush().context("flushing stdout")?;
217
218    let stdin = io::stdin();
219    let mut line = String::new();
220    stdin
221        .lock()
222        .read_line(&mut line)
223        .context("reading user input")?;
224
225    let trimmed = line.trim().to_lowercase();
226    if trimmed.is_empty() || trimmed == "y" || trimmed == "yes" {
227        Ok(tools.to_vec())
228    } else {
229        Ok(vec![])
230    }
231}
232
233// ---------------------------------------------------------------------------
234// Configuration phase
235// ---------------------------------------------------------------------------
236
237fn configure_tools(tools: &[&DetectedTool], mode: TransportMode) -> Result<ConfigureSummary> {
238    let mut summary = ConfigureSummary::default();
239
240    for tool in tools {
241        let name = tool.tool.display_name().to_string();
242
243        // For Claude Code, prefer the plugin marketplace install.
244        // Fall back to MCP config if `claude` CLI is not available.
245        if tool.tool == tool_detection::AiTool::ClaudeCode {
246            match config_writer::install_claude_plugin() {
247                Ok(ConfigWriteResult::Plugin) => {
248                    summary.written.push(format!("{name} (plugin)"));
249                    continue;
250                }
251                Err(e) => {
252                    tracing::debug!(error = %e, "plugin install failed, falling back to MCP config");
253                    // Fall through to regular write_config below
254                }
255                Ok(other) => {
256                    tracing::debug!(result = ?other, "unexpected plugin install result, falling back");
257                    // Fall through
258                }
259            }
260        }
261
262        match config_writer::write_config(tool, mode) {
263            Ok(ConfigWriteResult::Written { backup_path }) => {
264                if let Some(ref bak) = backup_path {
265                    tracing::debug!(tool = %name, backup = %bak.display(), "config backed up");
266                }
267                let _ = backup_path; // suppress unused warning
268                summary.written.push(name);
269            }
270            Ok(ConfigWriteResult::AlreadyCurrent) => {
271                summary.already_current.push(name);
272            }
273            Ok(ConfigWriteResult::UnsupportedFormat { reason }) => {
274                summary.unsupported.push((name, reason));
275            }
276            Ok(ConfigWriteResult::Deferred { tool: ai_tool }) => {
277                summary.deferred.push(ai_tool.display_name().to_string());
278            }
279            Ok(ConfigWriteResult::Plugin) => {
280                // Shouldn't reach here for non-Claude tools, but handle it
281                summary.written.push(format!("{name} (plugin)"));
282            }
283            Err(e) => {
284                summary.errors.push((name, format!("{e:#}")));
285            }
286        }
287    }
288
289    Ok(summary)
290}
291
292// ---------------------------------------------------------------------------
293// Daemon management
294// ---------------------------------------------------------------------------
295
296#[cfg(feature = "daemon-http")]
297fn maybe_start_daemon(port: u16, no_start: bool) -> Result<()> {
298    if no_start {
299        tracing::debug!("--no-start: skipping daemon check");
300        return Ok(());
301    }
302
303    // Check if daemon is already running via daemon.json
304    match crate::daemon::DaemonInfo::read() {
305        Ok(Some(info)) if !info.is_stale() => {
306            println!(
307                "  MAG daemon already running (pid {}, port {}).\n",
308                info.pid, info.port
309            );
310            return Ok(());
311        }
312        _ => {}
313    }
314
315    println!("  Tip: start the MAG daemon with `mag serve` (port {port}).\n");
316
317    Ok(())
318}
319
320// ---------------------------------------------------------------------------
321// Helpers
322// ---------------------------------------------------------------------------
323
324/// Parses a CLI transport string into a `TransportMode`.
325pub fn parse_transport(s: &str, port: u16) -> Result<TransportMode> {
326    match s.to_lowercase().as_str() {
327        "command" | "cmd" => Ok(TransportMode::Command),
328        "http" => Ok(TransportMode::Http { port }),
329        "stdio" => Ok(TransportMode::Stdio),
330        other => {
331            anyhow::bail!("unknown transport mode: '{other}' (expected command, http, or stdio)")
332        }
333    }
334}
335
336/// Returns `true` if we detect a CI environment.
337fn is_ci() -> bool {
338    std::env::var_os("CI").is_some() || std::env::var_os("GITHUB_ACTIONS").is_some()
339}
340
341/// Returns `true` if stdin appears to be a TTY.
342fn is_tty() -> bool {
343    use std::io::IsTerminal;
344    io::stdin().is_terminal()
345}
346
347// ---------------------------------------------------------------------------
348// Tests
349// ---------------------------------------------------------------------------
350
351#[cfg(test)]
352mod tests {
353    use super::*;
354    use crate::test_helpers::with_temp_home;
355    use crate::tool_detection::{AiTool, ConfigScope, DetectedTool, MagConfigStatus};
356    use std::path::PathBuf;
357
358    // -----------------------------------------------------------------------
359    // Transport parsing
360    // -----------------------------------------------------------------------
361
362    #[test]
363    fn parse_transport_command() {
364        let mode = parse_transport("command", 4242).unwrap();
365        assert_eq!(mode, TransportMode::Command);
366    }
367
368    #[test]
369    fn parse_transport_cmd_alias() {
370        let mode = parse_transport("cmd", 4242).unwrap();
371        assert_eq!(mode, TransportMode::Command);
372    }
373
374    #[test]
375    fn parse_transport_http() {
376        let mode = parse_transport("http", 9090).unwrap();
377        assert_eq!(mode, TransportMode::Http { port: 9090 });
378    }
379
380    #[test]
381    fn parse_transport_stdio() {
382        let mode = parse_transport("stdio", 4242).unwrap();
383        assert_eq!(mode, TransportMode::Stdio);
384    }
385
386    #[test]
387    fn parse_transport_case_insensitive() {
388        let mode = parse_transport("HTTP", 8080).unwrap();
389        assert_eq!(mode, TransportMode::Http { port: 8080 });
390    }
391
392    #[test]
393    fn parse_transport_unknown_errors() {
394        let result = parse_transport("grpc", 4242);
395        assert!(result.is_err());
396        let msg = result.unwrap_err().to_string();
397        assert!(
398            msg.contains("grpc"),
399            "error should mention the bad input: {msg}"
400        );
401    }
402
403    // -----------------------------------------------------------------------
404    // SetupArgs construction
405    // -----------------------------------------------------------------------
406
407    #[test]
408    fn setup_args_defaults() {
409        let args = SetupArgs {
410            non_interactive: false,
411            tools: None,
412            transport: TransportMode::Command,
413            port: 4242,
414            no_start: false,
415            uninstall: false,
416            force: false,
417        };
418        assert!(!args.non_interactive);
419        assert!(args.tools.is_none());
420        assert_eq!(args.port, 4242);
421    }
422
423    // -----------------------------------------------------------------------
424    // Tool selection helpers
425    // -----------------------------------------------------------------------
426
427    fn make_detected(tool: AiTool, status: MagConfigStatus) -> DetectedTool {
428        DetectedTool {
429            tool,
430            config_path: PathBuf::from("/fake/config.json"),
431            scope: ConfigScope::Global,
432            mag_status: status,
433        }
434    }
435
436    #[test]
437    fn select_tools_non_interactive_configures_unconfigured() {
438        let result = DetectionResult {
439            detected: vec![
440                make_detected(AiTool::ClaudeCode, MagConfigStatus::NotConfigured),
441                make_detected(AiTool::Cursor, MagConfigStatus::Configured),
442                make_detected(AiTool::Windsurf, MagConfigStatus::NotConfigured),
443            ],
444            not_found: vec![],
445        };
446        let args = SetupArgs {
447            non_interactive: true,
448            tools: None,
449            transport: TransportMode::Command,
450            port: 4242,
451            no_start: true,
452            uninstall: false,
453            force: false,
454        };
455
456        let selected = select_tools(&result, &args).unwrap();
457        assert_eq!(selected.len(), 2);
458        assert_eq!(selected[0].tool, AiTool::ClaudeCode);
459        assert_eq!(selected[1].tool, AiTool::Windsurf);
460    }
461
462    #[test]
463    fn select_tools_with_filter() {
464        let result = DetectionResult {
465            detected: vec![
466                make_detected(AiTool::ClaudeCode, MagConfigStatus::NotConfigured),
467                make_detected(AiTool::Cursor, MagConfigStatus::NotConfigured),
468                make_detected(AiTool::Windsurf, MagConfigStatus::NotConfigured),
469            ],
470            not_found: vec![],
471        };
472        let args = SetupArgs {
473            non_interactive: true,
474            tools: Some(vec!["cursor".to_string()]),
475            transport: TransportMode::Command,
476            port: 4242,
477            no_start: true,
478            uninstall: false,
479            force: false,
480        };
481
482        let selected = select_tools(&result, &args).unwrap();
483        assert_eq!(selected.len(), 1);
484        assert_eq!(selected[0].tool, AiTool::Cursor);
485    }
486
487    #[test]
488    fn select_tools_force_includes_configured() {
489        let result = DetectionResult {
490            detected: vec![
491                make_detected(AiTool::ClaudeCode, MagConfigStatus::Configured),
492                make_detected(AiTool::Cursor, MagConfigStatus::NotConfigured),
493            ],
494            not_found: vec![],
495        };
496        let args = SetupArgs {
497            non_interactive: true,
498            tools: None,
499            transport: TransportMode::Command,
500            port: 4242,
501            no_start: true,
502            uninstall: false,
503            force: true,
504        };
505
506        let selected = select_tools(&result, &args).unwrap();
507        assert_eq!(selected.len(), 2);
508    }
509
510    #[test]
511    fn select_tools_all_configured_returns_empty() {
512        let result = DetectionResult {
513            detected: vec![make_detected(
514                AiTool::ClaudeCode,
515                MagConfigStatus::Configured,
516            )],
517            not_found: vec![],
518        };
519        let args = SetupArgs {
520            non_interactive: true,
521            tools: None,
522            transport: TransportMode::Command,
523            port: 4242,
524            no_start: true,
525            uninstall: false,
526            force: false,
527        };
528
529        let selected = select_tools(&result, &args).unwrap();
530        assert!(selected.is_empty());
531    }
532
533    // -----------------------------------------------------------------------
534    // CI / TTY detection
535    // -----------------------------------------------------------------------
536
537    #[test]
538    fn is_ci_checks_env_vars() {
539        // In test environment, CI may or may not be set, but the function
540        // should not panic.
541        let _ = is_ci();
542    }
543
544    // -----------------------------------------------------------------------
545    // Presentation (smoke tests — ensure no panics)
546    // -----------------------------------------------------------------------
547
548    #[test]
549    fn present_detection_empty() {
550        let result = DetectionResult {
551            detected: vec![],
552            not_found: vec![AiTool::ClaudeCode],
553        };
554        present_detection(&result);
555    }
556
557    #[test]
558    fn present_detection_with_tools() {
559        let result = DetectionResult {
560            detected: vec![
561                make_detected(AiTool::ClaudeCode, MagConfigStatus::Configured),
562                make_detected(AiTool::Cursor, MagConfigStatus::NotConfigured),
563                make_detected(
564                    AiTool::Zed,
565                    MagConfigStatus::Misconfigured("missing source".to_string()),
566                ),
567            ],
568            not_found: vec![AiTool::Windsurf],
569        };
570        present_detection(&result);
571    }
572
573    #[test]
574    fn present_summary_all_variants() {
575        let summary = ConfigureSummary {
576            written: vec!["Claude Code".to_string()],
577            already_current: vec!["Cursor".to_string()],
578            unsupported: vec![("Zed".to_string(), "manual editing required".to_string())],
579            deferred: vec!["Codex".to_string()],
580            errors: vec![("Windsurf".to_string(), "permission denied".to_string())],
581        };
582        present_summary(&summary);
583    }
584
585    // -----------------------------------------------------------------------
586    // Integration: configure_tools with temp home
587    // -----------------------------------------------------------------------
588
589    #[test]
590    fn configure_tools_writes_config() {
591        with_temp_home(|home| {
592            // Create a Claude Code config file
593            let config_path = home.join(".claude.json");
594            std::fs::write(&config_path, "{}").unwrap();
595
596            let dt = DetectedTool {
597                tool: AiTool::ClaudeCode,
598                config_path: config_path.clone(),
599                scope: ConfigScope::Global,
600                mag_status: MagConfigStatus::NotConfigured,
601            };
602
603            let tools: Vec<&DetectedTool> = vec![&dt];
604            let summary = configure_tools(&tools, TransportMode::Command).unwrap();
605
606            assert_eq!(summary.written.len(), 1);
607            assert!(summary.errors.is_empty());
608
609            // Claude Code may be configured via plugin (if `claude` CLI is available)
610            // or via MCP config (if not). Both are valid outcomes.
611            let name = &summary.written[0];
612            assert!(
613                name == "Claude Code" || name == "Claude Code (plugin)",
614                "unexpected written entry: {name}"
615            );
616
617            if name == "Claude Code" {
618                // Verify the MCP config was written (fallback path)
619                let content = std::fs::read_to_string(&config_path).unwrap();
620                let parsed: serde_json::Value = serde_json::from_str(&content).unwrap();
621                assert!(parsed["mcpServers"]["mag"].is_object());
622            }
623        });
624    }
625
626    #[test]
627    fn configure_tools_idempotent() {
628        with_temp_home(|home| {
629            // Create a config that already has MAG configured
630            let config_path = home.join(".cursor/mcp.json");
631            std::fs::create_dir_all(config_path.parent().unwrap()).unwrap();
632            std::fs::write(
633                &config_path,
634                r#"{"mcpServers":{"mag":{"command":"mag","args":["serve"]}}}"#,
635            )
636            .unwrap();
637
638            let dt = DetectedTool {
639                tool: AiTool::Cursor,
640                config_path: config_path.clone(),
641                scope: ConfigScope::Global,
642                mag_status: MagConfigStatus::Configured,
643            };
644
645            let tools: Vec<&DetectedTool> = vec![&dt];
646            let summary = configure_tools(&tools, TransportMode::Command).unwrap();
647
648            assert_eq!(summary.already_current.len(), 1);
649            assert!(summary.written.is_empty());
650        });
651    }
652
653    #[test]
654    fn configure_tools_zed_unsupported() {
655        let dt = DetectedTool {
656            tool: AiTool::Zed,
657            config_path: PathBuf::from("/fake/zed/settings.json"),
658            scope: ConfigScope::Global,
659            mag_status: MagConfigStatus::NotConfigured,
660        };
661
662        let tools: Vec<&DetectedTool> = vec![&dt];
663        let summary = configure_tools(&tools, TransportMode::Command).unwrap();
664
665        assert_eq!(summary.unsupported.len(), 1);
666    }
667
668    #[test]
669    fn configure_tools_codex_deferred() {
670        let dt = DetectedTool {
671            tool: AiTool::Codex,
672            config_path: PathBuf::from("/fake/codex/config.toml"),
673            scope: ConfigScope::Global,
674            mag_status: MagConfigStatus::NotConfigured,
675        };
676
677        let tools: Vec<&DetectedTool> = vec![&dt];
678        let summary = configure_tools(&tools, TransportMode::Command).unwrap();
679
680        assert_eq!(summary.deferred.len(), 1);
681    }
682
683    // -----------------------------------------------------------------------
684    // Integration: full non-interactive setup flow
685    // -----------------------------------------------------------------------
686
687    #[test]
688    fn full_non_interactive_setup() {
689        with_temp_home(|home| {
690            // Set up a Cursor config file
691            let cursor_dir = home.join(".cursor");
692            std::fs::create_dir_all(&cursor_dir).unwrap();
693            std::fs::write(cursor_dir.join("mcp.json"), "{}").unwrap();
694
695            // Detect
696            let result = detect_phase(None);
697            assert!(
698                result.detected.iter().any(|d| d.tool == AiTool::Cursor),
699                "expected Cursor to be detected"
700            );
701
702            // Select non-interactively
703            let args = SetupArgs {
704                non_interactive: true,
705                tools: None,
706                transport: TransportMode::Command,
707                port: 4242,
708                no_start: true,
709                uninstall: false,
710                force: false,
711            };
712
713            let selected = select_tools(&result, &args).unwrap();
714            assert!(!selected.is_empty(), "expected at least one tool selected");
715
716            // Configure
717            let summary = configure_tools(&selected, TransportMode::Command).unwrap();
718            assert!(
719                !summary.written.is_empty() || !summary.already_current.is_empty(),
720                "expected at least one tool configured"
721            );
722        });
723    }
724
725    // -----------------------------------------------------------------------
726    // Uninstall flow
727    // -----------------------------------------------------------------------
728
729    #[test]
730    fn uninstall_removes_configured_tools() {
731        with_temp_home(|home| {
732            // Set up a Claude Code config with MAG
733            let config_path = home.join(".claude.json");
734            std::fs::write(
735                &config_path,
736                r#"{"mcpServers":{"mag":{"command":"mag","args":["serve"]},"other":{}}}"#,
737            )
738            .unwrap();
739
740            // Run uninstall
741            let rt = tokio::runtime::Runtime::new().unwrap();
742            rt.block_on(crate::uninstall::run_uninstall(false, true))
743                .unwrap();
744
745            // Verify MAG was removed but other config preserved
746            let content = std::fs::read_to_string(&config_path).unwrap();
747            let parsed: serde_json::Value = serde_json::from_str(&content).unwrap();
748            assert!(parsed["mcpServers"]["mag"].is_null());
749            assert!(parsed["mcpServers"]["other"].is_object());
750        });
751    }
752
753    #[test]
754    fn uninstall_no_tools_detected() {
755        with_temp_home(|_home| {
756            // No config files exist — should not error
757            let rt = tokio::runtime::Runtime::new().unwrap();
758            rt.block_on(crate::uninstall::run_uninstall(false, true))
759                .unwrap();
760        });
761    }
762
763    // -----------------------------------------------------------------------
764    // Tool filter matching
765    // -----------------------------------------------------------------------
766
767    #[test]
768    fn filter_matches_partial_name() {
769        let result = DetectionResult {
770            detected: vec![
771                make_detected(AiTool::VSCodeCopilot, MagConfigStatus::NotConfigured),
772                make_detected(AiTool::ClaudeCode, MagConfigStatus::NotConfigured),
773            ],
774            not_found: vec![],
775        };
776        let args = SetupArgs {
777            non_interactive: true,
778            tools: Some(vec!["vscode".to_string()]),
779            transport: TransportMode::Command,
780            port: 4242,
781            no_start: true,
782            uninstall: false,
783            force: false,
784        };
785
786        let selected = select_tools(&result, &args).unwrap();
787        assert_eq!(selected.len(), 1);
788        assert_eq!(selected[0].tool, AiTool::VSCodeCopilot);
789    }
790
791    #[test]
792    fn filter_matches_multiple_tools() {
793        let result = DetectionResult {
794            detected: vec![
795                make_detected(AiTool::Cursor, MagConfigStatus::NotConfigured),
796                make_detected(AiTool::Windsurf, MagConfigStatus::NotConfigured),
797                make_detected(AiTool::ClaudeCode, MagConfigStatus::NotConfigured),
798            ],
799            not_found: vec![],
800        };
801        let args = SetupArgs {
802            non_interactive: true,
803            tools: Some(vec!["cursor".to_string(), "windsurf".to_string()]),
804            transport: TransportMode::Command,
805            port: 4242,
806            no_start: true,
807            uninstall: false,
808            force: false,
809        };
810
811        let selected = select_tools(&result, &args).unwrap();
812        assert_eq!(selected.len(), 2);
813        let tool_names: Vec<_> = selected.iter().map(|d| d.tool).collect();
814        assert!(tool_names.contains(&AiTool::Cursor));
815        assert!(tool_names.contains(&AiTool::Windsurf));
816    }
817
818    // -----------------------------------------------------------------------
819    // Plugin-related tests
820    // -----------------------------------------------------------------------
821
822    #[test]
823    fn select_tools_skips_installed_as_plugin() {
824        let result = DetectionResult {
825            detected: vec![
826                make_detected(AiTool::ClaudeCode, MagConfigStatus::InstalledAsPlugin),
827                make_detected(AiTool::Cursor, MagConfigStatus::NotConfigured),
828            ],
829            not_found: vec![],
830        };
831        let args = SetupArgs {
832            non_interactive: true,
833            tools: None,
834            transport: TransportMode::Command,
835            port: 4242,
836            no_start: true,
837            uninstall: false,
838            force: false,
839        };
840
841        let selected = select_tools(&result, &args).unwrap();
842        assert_eq!(selected.len(), 1);
843        assert_eq!(selected[0].tool, AiTool::Cursor);
844    }
845
846    #[test]
847    fn select_tools_force_includes_plugin_installed() {
848        let result = DetectionResult {
849            detected: vec![make_detected(
850                AiTool::ClaudeCode,
851                MagConfigStatus::InstalledAsPlugin,
852            )],
853            not_found: vec![],
854        };
855        let args = SetupArgs {
856            non_interactive: true,
857            tools: None,
858            transport: TransportMode::Command,
859            port: 4242,
860            no_start: true,
861            uninstall: false,
862            force: true,
863        };
864
865        let selected = select_tools(&result, &args).unwrap();
866        assert_eq!(selected.len(), 1);
867    }
868
869    #[test]
870    fn present_detection_shows_plugin_status() {
871        let result = DetectionResult {
872            detected: vec![
873                make_detected(AiTool::ClaudeCode, MagConfigStatus::InstalledAsPlugin),
874                make_detected(AiTool::Cursor, MagConfigStatus::NotConfigured),
875            ],
876            not_found: vec![],
877        };
878        // Smoke test — should not panic
879        present_detection(&result);
880    }
881
882    #[test]
883    fn present_summary_with_plugin_entry() {
884        let summary = ConfigureSummary {
885            written: vec!["Claude Code (plugin)".to_string()],
886            already_current: vec![],
887            unsupported: vec![],
888            deferred: vec![],
889            errors: vec![],
890        };
891        // Smoke test — should not panic
892        present_summary(&summary);
893    }
894}