1use std::io::{self, BufRead, Write};
8use std::path::Path;
9
10use anyhow::{Context, Result};
11
12use crate::config_writer::{self, ConfigWriteResult, RemoveResult, TransportMode};
13use crate::tool_detection::{self, DetectedTool, DetectionResult, MagConfigStatus};
14
15pub 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#[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
40pub async fn run_setup(args: SetupArgs) -> Result<()> {
46 if args.uninstall {
47 return run_uninstall(None);
48 }
49
50 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 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 let summary = configure_tools(&tools_to_configure, args.transport)?;
68 present_summary(&summary);
69
70 #[cfg(feature = "daemon-http")]
72 maybe_start_daemon(args.port, args.no_start)?;
73
74 Ok(())
75}
76
77fn detect_phase(project_root: Option<&Path>) -> DetectionResult {
82 tool_detection::detect_all_tools(project_root)
83}
84
85fn 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}", MagConfigStatus::NotConfigured => "\u{2717}", MagConfigStatus::Misconfigured(_) => "\u{26a0}", MagConfigStatus::Unreadable(_) => "\u{26a0}", };
103 let status_label = match &dt.mag_status {
104 MagConfigStatus::Configured => "configured",
105 MagConfigStatus::NotConfigured => "not configured",
106 MagConfigStatus::Misconfigured(reason) => reason.as_str(),
107 MagConfigStatus::Unreadable(reason) => reason.as_str(),
108 };
109 println!(
110 " {status_icon} {name:<20} {status_label}",
111 name = dt.tool.display_name(),
112 );
113 tracing::debug!(
114 tool = %dt.tool.display_name(),
115 path = %dt.config_path.display(),
116 "detected tool"
117 );
118 }
119
120 if !result.not_found.is_empty() {
121 println!();
122 let not_found_names: Vec<&str> = result
123 .not_found
124 .iter()
125 .map(|t: &tool_detection::AiTool| t.display_name())
126 .collect();
127 tracing::debug!(tools = ?not_found_names, "tools not found");
128 }
129 println!();
130}
131
132fn present_summary(summary: &ConfigureSummary) {
133 println!(" Configuration summary:\n");
134
135 for name in &summary.written {
136 println!(" \u{2713} {name} — configured");
137 }
138 for name in &summary.already_current {
139 println!(" \u{2713} {name} — already current");
140 }
141 for name in &summary.deferred {
142 println!(" - {name} — deferred (format not yet supported)");
143 }
144 for (name, reason) in &summary.unsupported {
145 println!(" - {name} — skipped ({reason})");
146 }
147 for (name, err) in &summary.errors {
148 println!(" \u{2717} {name} — error: {err}");
149 }
150 println!();
151}
152
153fn select_tools<'a>(
158 result: &'a DetectionResult,
159 args: &SetupArgs,
160) -> Result<Vec<&'a DetectedTool>> {
161 let candidates: Vec<&DetectedTool> = if let Some(ref tool_names) = args.tools {
163 let lower_names: Vec<String> = tool_names.iter().map(|n| n.to_lowercase()).collect();
164 result
165 .detected
166 .iter()
167 .filter(|dt| {
168 let display_lower = dt.tool.display_name().to_lowercase();
169 let variant_lower = format!("{:?}", dt.tool).to_lowercase();
170 lower_names.iter().any(|n| {
171 display_lower.contains(n.as_str()) || variant_lower.contains(n.as_str())
172 })
173 })
174 .collect()
175 } else {
176 result.detected.iter().collect()
177 };
178
179 if args.force {
181 return Ok(candidates);
182 }
183
184 let actionable: Vec<&DetectedTool> = candidates
186 .into_iter()
187 .filter(|dt| !matches!(dt.mag_status, MagConfigStatus::Configured))
188 .collect();
189
190 if actionable.is_empty() {
191 return Ok(vec![]);
192 }
193
194 if args.non_interactive || is_ci() || !is_tty() {
196 return Ok(actionable);
197 }
198
199 select_tools_interactive(&actionable)
201}
202
203fn select_tools_interactive<'a>(tools: &[&'a DetectedTool]) -> Result<Vec<&'a DetectedTool>> {
204 println!(
205 " Configure {} tool{}? [Y/n] ",
206 tools.len(),
207 if tools.len() == 1 { "" } else { "s" }
208 );
209 io::stdout().flush().context("flushing stdout")?;
210
211 let stdin = io::stdin();
212 let mut line = String::new();
213 stdin
214 .lock()
215 .read_line(&mut line)
216 .context("reading user input")?;
217
218 let trimmed = line.trim().to_lowercase();
219 if trimmed.is_empty() || trimmed == "y" || trimmed == "yes" {
220 Ok(tools.to_vec())
221 } else {
222 Ok(vec![])
223 }
224}
225
226fn configure_tools(tools: &[&DetectedTool], mode: TransportMode) -> Result<ConfigureSummary> {
231 let mut summary = ConfigureSummary::default();
232
233 for tool in tools {
234 let name = tool.tool.display_name().to_string();
235 match config_writer::write_config(tool, mode) {
236 Ok(ConfigWriteResult::Written { backup_path }) => {
237 if let Some(ref bak) = backup_path {
238 tracing::debug!(tool = %name, backup = %bak.display(), "config backed up");
239 }
240 let _ = backup_path; summary.written.push(name);
242 }
243 Ok(ConfigWriteResult::AlreadyCurrent) => {
244 summary.already_current.push(name);
245 }
246 Ok(ConfigWriteResult::UnsupportedFormat { reason }) => {
247 summary.unsupported.push((name, reason));
248 }
249 Ok(ConfigWriteResult::Deferred { tool: ai_tool }) => {
250 summary.deferred.push(ai_tool.display_name().to_string());
251 }
252 Err(e) => {
253 summary.errors.push((name, format!("{e:#}")));
254 }
255 }
256 }
257
258 Ok(summary)
259}
260
261fn run_uninstall(project_root: Option<&Path>) -> Result<()> {
266 println!("\n Removing MAG from all detected tools...\n");
267
268 let result = detect_phase(project_root);
269
270 if result.detected.is_empty() {
271 println!(" No tools detected — nothing to remove.");
272 return Ok(());
273 }
274
275 let mut removed = Vec::new();
276 let mut not_present = Vec::new();
277 let mut errors = Vec::new();
278
279 for dt in &result.detected {
280 let name = dt.tool.display_name().to_string();
281 match config_writer::remove_config(dt) {
282 Ok(RemoveResult::Removed) => removed.push(name),
283 Ok(RemoveResult::NotPresent | RemoveResult::NoConfigFile) => {
284 not_present.push(name);
285 }
286 Ok(RemoveResult::UnsupportedFormat { reason }) => {
287 not_present.push(format!("{name} (skipped: {reason})"));
288 }
289 Err(e) => errors.push((name, format!("{e:#}"))),
290 }
291 }
292
293 println!(" Uninstall summary:\n");
294 for name in &removed {
295 println!(" \u{2713} {name} — removed");
296 }
297 for name in ¬_present {
298 println!(" - {name} — was not configured");
299 }
300 for (name, err) in &errors {
301 println!(" \u{2717} {name} — error: {err}");
302 }
303 println!();
304
305 Ok(())
306}
307
308#[cfg(feature = "daemon-http")]
313fn maybe_start_daemon(port: u16, no_start: bool) -> Result<()> {
314 if no_start {
315 tracing::debug!("--no-start: skipping daemon check");
316 return Ok(());
317 }
318
319 match crate::daemon::DaemonInfo::read() {
321 Ok(Some(info)) if !info.is_stale() => {
322 println!(
323 " MAG daemon already running (pid {}, port {}).\n",
324 info.pid, info.port
325 );
326 return Ok(());
327 }
328 _ => {}
329 }
330
331 println!(" Tip: start the MAG daemon with `mag serve` (port {port}).\n");
332
333 Ok(())
334}
335
336pub fn parse_transport(s: &str, port: u16) -> Result<TransportMode> {
342 match s.to_lowercase().as_str() {
343 "command" | "cmd" => Ok(TransportMode::Command),
344 "http" => Ok(TransportMode::Http { port }),
345 "stdio" => Ok(TransportMode::Stdio),
346 other => {
347 anyhow::bail!("unknown transport mode: '{other}' (expected command, http, or stdio)")
348 }
349 }
350}
351
352fn is_ci() -> bool {
354 std::env::var_os("CI").is_some() || std::env::var_os("GITHUB_ACTIONS").is_some()
355}
356
357fn is_tty() -> bool {
359 use std::io::IsTerminal;
360 io::stdin().is_terminal()
361}
362
363#[cfg(test)]
368mod tests {
369 use super::*;
370 use crate::test_helpers::with_temp_home;
371 use crate::tool_detection::{AiTool, ConfigScope, DetectedTool, MagConfigStatus};
372 use std::path::PathBuf;
373
374 #[test]
379 fn parse_transport_command() {
380 let mode = parse_transport("command", 4242).unwrap();
381 assert_eq!(mode, TransportMode::Command);
382 }
383
384 #[test]
385 fn parse_transport_cmd_alias() {
386 let mode = parse_transport("cmd", 4242).unwrap();
387 assert_eq!(mode, TransportMode::Command);
388 }
389
390 #[test]
391 fn parse_transport_http() {
392 let mode = parse_transport("http", 9090).unwrap();
393 assert_eq!(mode, TransportMode::Http { port: 9090 });
394 }
395
396 #[test]
397 fn parse_transport_stdio() {
398 let mode = parse_transport("stdio", 4242).unwrap();
399 assert_eq!(mode, TransportMode::Stdio);
400 }
401
402 #[test]
403 fn parse_transport_case_insensitive() {
404 let mode = parse_transport("HTTP", 8080).unwrap();
405 assert_eq!(mode, TransportMode::Http { port: 8080 });
406 }
407
408 #[test]
409 fn parse_transport_unknown_errors() {
410 let result = parse_transport("grpc", 4242);
411 assert!(result.is_err());
412 let msg = result.unwrap_err().to_string();
413 assert!(
414 msg.contains("grpc"),
415 "error should mention the bad input: {msg}"
416 );
417 }
418
419 #[test]
424 fn setup_args_defaults() {
425 let args = SetupArgs {
426 non_interactive: false,
427 tools: None,
428 transport: TransportMode::Command,
429 port: 4242,
430 no_start: false,
431 uninstall: false,
432 force: false,
433 };
434 assert!(!args.non_interactive);
435 assert!(args.tools.is_none());
436 assert_eq!(args.port, 4242);
437 }
438
439 fn make_detected(tool: AiTool, status: MagConfigStatus) -> DetectedTool {
444 DetectedTool {
445 tool,
446 config_path: PathBuf::from("/fake/config.json"),
447 scope: ConfigScope::Global,
448 mag_status: status,
449 }
450 }
451
452 #[test]
453 fn select_tools_non_interactive_configures_unconfigured() {
454 let result = DetectionResult {
455 detected: vec![
456 make_detected(AiTool::ClaudeCode, MagConfigStatus::NotConfigured),
457 make_detected(AiTool::Cursor, MagConfigStatus::Configured),
458 make_detected(AiTool::Windsurf, MagConfigStatus::NotConfigured),
459 ],
460 not_found: vec![],
461 };
462 let args = SetupArgs {
463 non_interactive: true,
464 tools: None,
465 transport: TransportMode::Command,
466 port: 4242,
467 no_start: true,
468 uninstall: false,
469 force: false,
470 };
471
472 let selected = select_tools(&result, &args).unwrap();
473 assert_eq!(selected.len(), 2);
474 assert_eq!(selected[0].tool, AiTool::ClaudeCode);
475 assert_eq!(selected[1].tool, AiTool::Windsurf);
476 }
477
478 #[test]
479 fn select_tools_with_filter() {
480 let result = DetectionResult {
481 detected: vec![
482 make_detected(AiTool::ClaudeCode, MagConfigStatus::NotConfigured),
483 make_detected(AiTool::Cursor, MagConfigStatus::NotConfigured),
484 make_detected(AiTool::Windsurf, MagConfigStatus::NotConfigured),
485 ],
486 not_found: vec![],
487 };
488 let args = SetupArgs {
489 non_interactive: true,
490 tools: Some(vec!["cursor".to_string()]),
491 transport: TransportMode::Command,
492 port: 4242,
493 no_start: true,
494 uninstall: false,
495 force: false,
496 };
497
498 let selected = select_tools(&result, &args).unwrap();
499 assert_eq!(selected.len(), 1);
500 assert_eq!(selected[0].tool, AiTool::Cursor);
501 }
502
503 #[test]
504 fn select_tools_force_includes_configured() {
505 let result = DetectionResult {
506 detected: vec![
507 make_detected(AiTool::ClaudeCode, MagConfigStatus::Configured),
508 make_detected(AiTool::Cursor, MagConfigStatus::NotConfigured),
509 ],
510 not_found: vec![],
511 };
512 let args = SetupArgs {
513 non_interactive: true,
514 tools: None,
515 transport: TransportMode::Command,
516 port: 4242,
517 no_start: true,
518 uninstall: false,
519 force: true,
520 };
521
522 let selected = select_tools(&result, &args).unwrap();
523 assert_eq!(selected.len(), 2);
524 }
525
526 #[test]
527 fn select_tools_all_configured_returns_empty() {
528 let result = DetectionResult {
529 detected: vec![make_detected(
530 AiTool::ClaudeCode,
531 MagConfigStatus::Configured,
532 )],
533 not_found: vec![],
534 };
535 let args = SetupArgs {
536 non_interactive: true,
537 tools: None,
538 transport: TransportMode::Command,
539 port: 4242,
540 no_start: true,
541 uninstall: false,
542 force: false,
543 };
544
545 let selected = select_tools(&result, &args).unwrap();
546 assert!(selected.is_empty());
547 }
548
549 #[test]
554 fn is_ci_checks_env_vars() {
555 let _ = is_ci();
558 }
559
560 #[test]
565 fn present_detection_empty() {
566 let result = DetectionResult {
567 detected: vec![],
568 not_found: vec![AiTool::ClaudeCode],
569 };
570 present_detection(&result);
571 }
572
573 #[test]
574 fn present_detection_with_tools() {
575 let result = DetectionResult {
576 detected: vec![
577 make_detected(AiTool::ClaudeCode, MagConfigStatus::Configured),
578 make_detected(AiTool::Cursor, MagConfigStatus::NotConfigured),
579 make_detected(
580 AiTool::Zed,
581 MagConfigStatus::Misconfigured("missing source".to_string()),
582 ),
583 ],
584 not_found: vec![AiTool::Windsurf],
585 };
586 present_detection(&result);
587 }
588
589 #[test]
590 fn present_summary_all_variants() {
591 let summary = ConfigureSummary {
592 written: vec!["Claude Code".to_string()],
593 already_current: vec!["Cursor".to_string()],
594 unsupported: vec![("Zed".to_string(), "manual editing required".to_string())],
595 deferred: vec!["Codex".to_string()],
596 errors: vec![("Windsurf".to_string(), "permission denied".to_string())],
597 };
598 present_summary(&summary);
599 }
600
601 #[test]
606 fn configure_tools_writes_config() {
607 with_temp_home(|home| {
608 let config_path = home.join(".claude.json");
610 std::fs::write(&config_path, "{}").unwrap();
611
612 let dt = DetectedTool {
613 tool: AiTool::ClaudeCode,
614 config_path: config_path.clone(),
615 scope: ConfigScope::Global,
616 mag_status: MagConfigStatus::NotConfigured,
617 };
618
619 let tools: Vec<&DetectedTool> = vec![&dt];
620 let summary = configure_tools(&tools, TransportMode::Command).unwrap();
621
622 assert_eq!(summary.written.len(), 1);
623 assert_eq!(summary.written[0], "Claude Code");
624 assert!(summary.errors.is_empty());
625
626 let content = std::fs::read_to_string(&config_path).unwrap();
628 let parsed: serde_json::Value = serde_json::from_str(&content).unwrap();
629 assert!(parsed["mcpServers"]["mag"].is_object());
630 });
631 }
632
633 #[test]
634 fn configure_tools_idempotent() {
635 with_temp_home(|home| {
636 let config_path = home.join(".cursor/mcp.json");
638 std::fs::create_dir_all(config_path.parent().unwrap()).unwrap();
639 std::fs::write(
640 &config_path,
641 r#"{"mcpServers":{"mag":{"command":"mag","args":["serve"]}}}"#,
642 )
643 .unwrap();
644
645 let dt = DetectedTool {
646 tool: AiTool::Cursor,
647 config_path: config_path.clone(),
648 scope: ConfigScope::Global,
649 mag_status: MagConfigStatus::Configured,
650 };
651
652 let tools: Vec<&DetectedTool> = vec![&dt];
653 let summary = configure_tools(&tools, TransportMode::Command).unwrap();
654
655 assert_eq!(summary.already_current.len(), 1);
656 assert!(summary.written.is_empty());
657 });
658 }
659
660 #[test]
661 fn configure_tools_zed_unsupported() {
662 let dt = DetectedTool {
663 tool: AiTool::Zed,
664 config_path: PathBuf::from("/fake/zed/settings.json"),
665 scope: ConfigScope::Global,
666 mag_status: MagConfigStatus::NotConfigured,
667 };
668
669 let tools: Vec<&DetectedTool> = vec![&dt];
670 let summary = configure_tools(&tools, TransportMode::Command).unwrap();
671
672 assert_eq!(summary.unsupported.len(), 1);
673 }
674
675 #[test]
676 fn configure_tools_codex_deferred() {
677 let dt = DetectedTool {
678 tool: AiTool::Codex,
679 config_path: PathBuf::from("/fake/codex/config.toml"),
680 scope: ConfigScope::Global,
681 mag_status: MagConfigStatus::NotConfigured,
682 };
683
684 let tools: Vec<&DetectedTool> = vec![&dt];
685 let summary = configure_tools(&tools, TransportMode::Command).unwrap();
686
687 assert_eq!(summary.deferred.len(), 1);
688 }
689
690 #[test]
695 fn full_non_interactive_setup() {
696 with_temp_home(|home| {
697 let cursor_dir = home.join(".cursor");
699 std::fs::create_dir_all(&cursor_dir).unwrap();
700 std::fs::write(cursor_dir.join("mcp.json"), "{}").unwrap();
701
702 let result = detect_phase(None);
704 assert!(
705 result.detected.iter().any(|d| d.tool == AiTool::Cursor),
706 "expected Cursor to be detected"
707 );
708
709 let args = SetupArgs {
711 non_interactive: true,
712 tools: None,
713 transport: TransportMode::Command,
714 port: 4242,
715 no_start: true,
716 uninstall: false,
717 force: false,
718 };
719
720 let selected = select_tools(&result, &args).unwrap();
721 assert!(!selected.is_empty(), "expected at least one tool selected");
722
723 let summary = configure_tools(&selected, TransportMode::Command).unwrap();
725 assert!(
726 !summary.written.is_empty() || !summary.already_current.is_empty(),
727 "expected at least one tool configured"
728 );
729 });
730 }
731
732 #[test]
737 fn uninstall_removes_configured_tools() {
738 with_temp_home(|home| {
739 let config_path = home.join(".claude.json");
741 std::fs::write(
742 &config_path,
743 r#"{"mcpServers":{"mag":{"command":"mag","args":["serve"]},"other":{}}}"#,
744 )
745 .unwrap();
746
747 run_uninstall(None).unwrap();
749
750 let content = std::fs::read_to_string(&config_path).unwrap();
752 let parsed: serde_json::Value = serde_json::from_str(&content).unwrap();
753 assert!(parsed["mcpServers"]["mag"].is_null());
754 assert!(parsed["mcpServers"]["other"].is_object());
755 });
756 }
757
758 #[test]
759 fn uninstall_no_tools_detected() {
760 with_temp_home(|_home| {
761 run_uninstall(None).unwrap();
763 });
764 }
765
766 #[test]
771 fn filter_matches_partial_name() {
772 let result = DetectionResult {
773 detected: vec![
774 make_detected(AiTool::VSCodeCopilot, MagConfigStatus::NotConfigured),
775 make_detected(AiTool::ClaudeCode, MagConfigStatus::NotConfigured),
776 ],
777 not_found: vec![],
778 };
779 let args = SetupArgs {
780 non_interactive: true,
781 tools: Some(vec!["vscode".to_string()]),
782 transport: TransportMode::Command,
783 port: 4242,
784 no_start: true,
785 uninstall: false,
786 force: false,
787 };
788
789 let selected = select_tools(&result, &args).unwrap();
790 assert_eq!(selected.len(), 1);
791 assert_eq!(selected[0].tool, AiTool::VSCodeCopilot);
792 }
793
794 #[test]
795 fn filter_matches_multiple_tools() {
796 let result = DetectionResult {
797 detected: vec![
798 make_detected(AiTool::Cursor, MagConfigStatus::NotConfigured),
799 make_detected(AiTool::Windsurf, MagConfigStatus::NotConfigured),
800 make_detected(AiTool::ClaudeCode, MagConfigStatus::NotConfigured),
801 ],
802 not_found: vec![],
803 };
804 let args = SetupArgs {
805 non_interactive: true,
806 tools: Some(vec!["cursor".to_string(), "windsurf".to_string()]),
807 transport: TransportMode::Command,
808 port: 4242,
809 no_start: true,
810 uninstall: false,
811 force: false,
812 };
813
814 let selected = select_tools(&result, &args).unwrap();
815 assert_eq!(selected.len(), 2);
816 let tool_names: Vec<_> = selected.iter().map(|d| d.tool).collect();
817 assert!(tool_names.contains(&AiTool::Cursor));
818 assert!(tool_names.contains(&AiTool::Windsurf));
819 }
820}