1use 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
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 crate::uninstall::run_uninstall(false, true).await;
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::InstalledAsPlugin => "\u{2713}", MagConfigStatus::NotConfigured => "\u{2717}", MagConfigStatus::Misconfigured(_) => "\u{26a0}", MagConfigStatus::Unreadable(_) => "\u{26a0}", };
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
155fn select_tools<'a>(
160 result: &'a DetectionResult,
161 args: &SetupArgs,
162) -> Result<Vec<&'a DetectedTool>> {
163 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 if args.force {
183 return Ok(candidates);
184 }
185
186 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 if args.non_interactive || is_ci() || !is_tty() {
203 return Ok(actionable);
204 }
205
206 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
233fn 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 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 }
255 Ok(other) => {
256 tracing::debug!(result = ?other, "unexpected plugin install result, falling back");
257 }
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; 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 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#[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 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
320pub 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
336fn is_ci() -> bool {
338 std::env::var_os("CI").is_some() || std::env::var_os("GITHUB_ACTIONS").is_some()
339}
340
341fn is_tty() -> bool {
343 use std::io::IsTerminal;
344 io::stdin().is_terminal()
345}
346
347#[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 #[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 #[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 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 #[test]
538 fn is_ci_checks_env_vars() {
539 let _ = is_ci();
542 }
543
544 #[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 #[test]
590 fn configure_tools_writes_config() {
591 with_temp_home(|home| {
592 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 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 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 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 #[test]
688 fn full_non_interactive_setup() {
689 with_temp_home(|home| {
690 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 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 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 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 #[test]
730 fn uninstall_removes_configured_tools() {
731 with_temp_home(|home| {
732 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 let rt = tokio::runtime::Runtime::new().unwrap();
742 rt.block_on(crate::uninstall::run_uninstall(false, true))
743 .unwrap();
744
745 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 let rt = tokio::runtime::Runtime::new().unwrap();
758 rt.block_on(crate::uninstall::run_uninstall(false, true))
759 .unwrap();
760 });
761 }
762
763 #[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 #[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 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 present_summary(&summary);
893 }
894}