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, PathBuf};
9
10use anyhow::{Context, Result};
11
12use crate::config_writer::{self, ConfigWriteResult, TransportMode};
13use crate::tool_detection::{self, ContentTier, 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 scoped_tools = invocation_scoped_tools(&result, &args);
60    let tools_to_configure = select_tools(&result, &args)?;
61
62    if tools_to_configure.is_empty() {
63        println!("  No tools to configure.");
64        // Still refresh connector content (AGENTS.md / SKILL.md) so that
65        // already-configured tools stay up to date even on subsequent runs.
66        let (connector_successes, connector_warnings) = install_connector_content(&scoped_tools);
67        if !connector_successes.is_empty() || !connector_warnings.is_empty() {
68            let summary = ConfigureSummary {
69                written: connector_successes,
70                errors: connector_warnings,
71                ..Default::default()
72            };
73            present_summary(&summary);
74        }
75    } else {
76        let summary = configure_tools(&tools_to_configure, args.transport, &scoped_tools)?;
77        present_summary(&summary);
78    }
79
80    // Model download phase — always runs; daemon must start after models are ready.
81    #[cfg(feature = "real-embeddings")]
82    {
83        println!("\n  Downloading models (first run only)...\n");
84        match crate::memory_core::embedder::download_bge_small_model().await {
85            Ok(path) => println!("    \u{2713} Embedding model ready at {}", path.display()),
86            Err(e) => eprintln!(
87                "    \u{26a0} Embedding model download failed: {e}\n      Run 'mag download-model' to retry."
88            ),
89        }
90        match crate::memory_core::reranker::download_cross_encoder_model().await {
91            Ok(path) => println!(
92                "    \u{2713} Cross-encoder model ready at {}",
93                path.display()
94            ),
95            Err(e) => eprintln!(
96                "    \u{26a0} Cross-encoder model download failed: {e}\n      Run 'mag download-cross-encoder' to retry."
97            ),
98        }
99    }
100
101    // Daemon phase — starts after models are downloaded.
102    #[cfg(feature = "daemon-http")]
103    maybe_start_daemon(args.port, args.no_start)?;
104
105    Ok(())
106}
107
108// ---------------------------------------------------------------------------
109// Detection phase
110// ---------------------------------------------------------------------------
111
112fn detect_phase(project_root: Option<&Path>) -> DetectionResult {
113    tool_detection::detect_all_tools(project_root)
114}
115
116// ---------------------------------------------------------------------------
117// Presentation
118// ---------------------------------------------------------------------------
119
120fn present_detection(result: &DetectionResult) {
121    if result.detected.is_empty() {
122        println!("  No AI coding tools detected.\n");
123        return;
124    }
125
126    println!("  Detected tools:\n");
127    for dt in &result.detected {
128        let status_icon = match &dt.mag_status {
129            MagConfigStatus::Configured => "\u{2713}", // check mark
130            MagConfigStatus::InstalledAsPlugin => "\u{2713}", // check mark
131            MagConfigStatus::NotConfigured => "\u{2717}", // X mark
132            MagConfigStatus::Misconfigured(_) => "\u{26a0}", // warning
133            MagConfigStatus::Unreadable(_) => "\u{26a0}", // warning
134        };
135        println!(
136            "    {status_icon} {name:<20} {status_label}",
137            name = dt.tool.display_name(),
138            status_label = status_short_label(&dt.mag_status),
139        );
140        tracing::debug!(
141            tool = %dt.tool.display_name(),
142            path = %dt.config_path.display(),
143            "detected tool"
144        );
145    }
146
147    if !result.not_found.is_empty() {
148        println!();
149        let not_found_names: Vec<&str> = result
150            .not_found
151            .iter()
152            .map(|t: &tool_detection::AiTool| t.display_name())
153            .collect();
154        tracing::debug!(tools = ?not_found_names, "tools not found");
155    }
156    println!();
157}
158
159fn present_summary(summary: &ConfigureSummary) {
160    println!("  Configuration summary:\n");
161
162    for name in &summary.written {
163        println!("    \u{2713} {name} — configured");
164    }
165    for name in &summary.already_current {
166        println!("    \u{2713} {name} — already current");
167    }
168    for name in &summary.deferred {
169        println!("    - {name} — deferred (format not yet supported)");
170    }
171    for (name, reason) in &summary.unsupported {
172        println!("    - {name} — skipped ({reason})");
173    }
174    for (name, err) in &summary.errors {
175        println!("    \u{2717} {name} — error: {err}");
176    }
177    println!();
178}
179
180// ---------------------------------------------------------------------------
181// Tool selection
182// ---------------------------------------------------------------------------
183
184/// Returns the invocation-scoped tool set: all detected tools that match the
185/// `--tools` filter (or all detected tools when no filter is given).  This is
186/// the set used as the connector-content target so that AGENTS.md / SKILL.md
187/// is written only for tools the user explicitly asked about, while still
188/// including already-configured tools within that scope.
189fn invocation_scoped_tools<'a>(
190    result: &'a DetectionResult,
191    args: &SetupArgs,
192) -> Vec<&'a DetectedTool> {
193    if let Some(ref tool_names) = args.tools {
194        let lower_names: Vec<String> = tool_names.iter().map(|n| n.to_lowercase()).collect();
195        result
196            .detected
197            .iter()
198            .filter(|dt| {
199                let display_lower = dt.tool.display_name().to_lowercase();
200                let variant_lower = format!("{:?}", dt.tool).to_lowercase();
201                lower_names.iter().any(|n| {
202                    display_lower.contains(n.as_str()) || variant_lower.contains(n.as_str())
203                })
204            })
205            .collect()
206    } else {
207        result.detected.iter().collect()
208    }
209}
210
211fn select_tools<'a>(
212    result: &'a DetectionResult,
213    args: &SetupArgs,
214) -> Result<Vec<&'a DetectedTool>> {
215    let candidates = invocation_scoped_tools(result, args);
216
217    // In force mode, configure all matched tools regardless of status
218    if args.force {
219        return Ok(candidates);
220    }
221
222    // Filter to only unconfigured/misconfigured tools
223    let actionable: Vec<&DetectedTool> = candidates
224        .into_iter()
225        .filter(|dt| {
226            !matches!(
227                dt.mag_status,
228                MagConfigStatus::Configured | MagConfigStatus::InstalledAsPlugin
229            )
230        })
231        .collect();
232
233    if actionable.is_empty() {
234        return Ok(vec![]);
235    }
236
237    // Non-interactive: configure all actionable tools
238    if args.non_interactive || is_ci() || !is_tty() {
239        return Ok(actionable);
240    }
241
242    // Interactive: prompt user
243    select_tools_interactive(&actionable)
244}
245
246fn status_short_label(status: &MagConfigStatus) -> &str {
247    match status {
248        MagConfigStatus::Configured => "configured",
249        MagConfigStatus::InstalledAsPlugin => "installed as plugin",
250        MagConfigStatus::NotConfigured => "not configured",
251        MagConfigStatus::Misconfigured(r) => r.as_str(),
252        MagConfigStatus::Unreadable(r) => r.as_str(),
253    }
254}
255
256fn select_tools_interactive<'a>(tools: &[&'a DetectedTool]) -> Result<Vec<&'a DetectedTool>> {
257    if tools.len() == 1 {
258        let tool = tools[0];
259        print!(
260            "  Configure {} ({})? [Y/n] ",
261            tool.tool.display_name(),
262            status_short_label(&tool.mag_status),
263        );
264        io::stdout().flush().context("flushing stdout")?;
265        let mut line = String::new();
266        io::stdin()
267            .lock()
268            .read_line(&mut line)
269            .context("reading user input")?;
270        let trimmed = line.trim().to_lowercase();
271        return if trimmed.is_empty() || trimmed == "y" || trimmed == "yes" {
272            Ok(tools.to_vec())
273        } else {
274            Ok(vec![])
275        };
276    }
277
278    // Multiple tools: show numbered list and accept Y/n or a selection like "1,3"
279    println!("  Tools to configure:");
280    for (i, dt) in tools.iter().enumerate() {
281        println!(
282            "    {}. {:<20} ({})",
283            i + 1,
284            dt.tool.display_name(),
285            status_short_label(&dt.mag_status),
286        );
287    }
288    println!();
289    print!("  Configure all {}? [Y/n or e.g. 1,3] ", tools.len());
290    io::stdout().flush().context("flushing stdout")?;
291
292    let mut line = String::new();
293    io::stdin()
294        .lock()
295        .read_line(&mut line)
296        .context("reading user input")?;
297    let trimmed = line.trim().to_lowercase();
298
299    if trimmed.is_empty() || trimmed == "y" || trimmed == "yes" {
300        Ok(tools.to_vec())
301    } else if trimmed == "n" || trimmed == "no" {
302        Ok(vec![])
303    } else {
304        // Parse comma/space-separated numbers like "1,3" or "1 3"
305        let selected: Vec<&DetectedTool> = trimmed
306            .split(|c: char| c == ',' || c.is_whitespace())
307            .filter(|s| !s.is_empty())
308            .filter_map(|s| s.parse::<usize>().ok())
309            .filter(|&n| n >= 1 && n <= tools.len())
310            .map(|n| tools[n - 1])
311            .collect();
312        Ok(selected)
313    }
314}
315
316// ---------------------------------------------------------------------------
317// Configuration phase
318// ---------------------------------------------------------------------------
319
320fn configure_tools(
321    tools: &[&DetectedTool],
322    mode: TransportMode,
323    all_detected: &[&DetectedTool],
324) -> Result<ConfigureSummary> {
325    let mut summary = ConfigureSummary::default();
326
327    for tool in tools {
328        let name = tool.tool.display_name().to_string();
329
330        // For Claude Code, prefer the plugin marketplace install.
331        // The plugin's .mcp.json uses sh -c to resolve the binary via
332        // $MAG_INSTALL_DIR or $HOME/.mag/bin without needing mag on PATH.
333        // Fall back to writing .claude.json only when the plugin install fails.
334        if tool.tool == tool_detection::AiTool::ClaudeCode {
335            match config_writer::install_claude_plugin() {
336                Ok(ConfigWriteResult::Plugin) => {
337                    summary.written.push(format!("{name} (plugin)"));
338                    continue;
339                }
340                Err(e) => {
341                    tracing::debug!(error = %e, "plugin install failed, falling back to MCP config");
342                    // Fall through to regular write_config below
343                }
344                Ok(other) => {
345                    tracing::debug!(result = ?other, "unexpected plugin install result, falling back");
346                    // Fall through
347                }
348            }
349        }
350
351        match config_writer::write_config(tool, mode) {
352            Ok(ConfigWriteResult::Written { backup_path }) => {
353                if let Some(ref bak) = backup_path {
354                    tracing::debug!(tool = %name, backup = %bak.display(), "config backed up");
355                }
356                let _ = backup_path; // suppress unused warning
357                summary.written.push(name);
358            }
359            Ok(ConfigWriteResult::AlreadyCurrent) => {
360                summary.already_current.push(name);
361            }
362            Ok(ConfigWriteResult::UnsupportedFormat { reason }) => {
363                summary.unsupported.push((name, reason));
364            }
365            Ok(ConfigWriteResult::Deferred { tool: ai_tool }) => {
366                summary.deferred.push(ai_tool.display_name().to_string());
367            }
368            Ok(ConfigWriteResult::Plugin) => {
369                // Shouldn't reach here for non-Claude tools, but handle it
370                summary.written.push(format!("{name} (plugin)"));
371            }
372            Err(e) => {
373                summary.errors.push((name, format!("{e:#}")));
374            }
375        }
376    }
377
378    // Phase 2: Install connector content (AGENTS.md, SKILL.md, etc.) for the
379    // full invocation scope, not just the newly-configured subset.
380    let (connector_successes, connector_warnings) = install_connector_content(all_detected);
381    summary.written.extend(connector_successes);
382    summary.errors.extend(connector_warnings);
383
384    Ok(summary)
385}
386
387// ---------------------------------------------------------------------------
388// Helpers: atomic write, XDG config
389// ---------------------------------------------------------------------------
390
391/// Atomically writes `content` to `path` by writing to a temporary file in the
392/// same directory, flushing, syncing, then renaming. This prevents partial writes
393/// if the process is interrupted.
394///
395/// The temp file includes the process ID in its name to avoid races when
396/// multiple `mag setup` invocations run concurrently. If any step fails, the
397/// temp file is removed before the error is returned.
398fn atomic_write(path: &Path, content: &str) -> Result<()> {
399    if let Some(parent) = path.parent() {
400        std::fs::create_dir_all(parent)
401            .with_context(|| format!("creating directory {}", parent.display()))?;
402    }
403    let tmp = path.with_extension(format!("mag-tmp.{}", std::process::id()));
404    let result = (|| -> Result<()> {
405        let mut f = std::fs::File::create(&tmp)
406            .with_context(|| format!("creating temp file {}", tmp.display()))?;
407        f.write_all(content.as_bytes())
408            .with_context(|| format!("writing to {}", tmp.display()))?;
409        f.sync_all()
410            .with_context(|| format!("syncing {}", tmp.display()))?;
411        drop(f);
412        std::fs::rename(&tmp, path)
413            .with_context(|| format!("renaming {} -> {}", tmp.display(), path.display()))
414    })();
415    if result.is_err() {
416        let _ = std::fs::remove_file(&tmp);
417    }
418    result
419}
420
421// ---------------------------------------------------------------------------
422// Connector content installation
423// ---------------------------------------------------------------------------
424
425/// Sentinel marking the start of the MAG section in AGENTS.md files.
426const MAG_SENTINEL_START: &str = "<!-- MAG_MEMORY_START -->";
427/// Sentinel marking the end of the MAG section in AGENTS.md files.
428const MAG_SENTINEL_END: &str = "<!-- MAG_MEMORY_END -->";
429
430/// Shared AGENTS.md template for all AgentsMd-tier tools.
431/// The `{{MAG_VERSION}}` placeholder is replaced at install time with the
432/// running binary's version so stale installs can be detected after upgrades.
433const AGENTS_MD_TEMPLATE: &str = include_str!("../connectors/shared/AGENTS.md");
434
435fn agents_md_content() -> String {
436    AGENTS_MD_TEMPLATE.replace("{{MAG_VERSION}}", env!("CARGO_PKG_VERSION"))
437}
438
439/// OpenCode skill definitions: (directory name, embedded content).
440const OPENCODE_SKILLS: &[(&str, &str)] = &[
441    (
442        "memory-store",
443        include_str!("../connectors/opencode/skills/memory-store/SKILL.md"),
444    ),
445    (
446        "memory-recall",
447        include_str!("../connectors/opencode/skills/memory-recall/SKILL.md"),
448    ),
449    (
450        "memory-checkpoint",
451        include_str!("../connectors/opencode/skills/memory-checkpoint/SKILL.md"),
452    ),
453    (
454        "memory-health",
455        include_str!("../connectors/opencode/skills/memory-health/SKILL.md"),
456    ),
457];
458
459/// Installs connector content (AGENTS.md / SKILL.md) for each tool based on
460/// its content tier. Returns `(successes, warnings)` — human-readable messages
461/// for successful installs and `(tool_label, message)` pairs for any failures.
462fn install_connector_content(tools: &[&DetectedTool]) -> (Vec<String>, Vec<(String, String)>) {
463    let mut successes = Vec::new();
464    let mut warnings: Vec<(String, String)> = Vec::new();
465
466    let home = match crate::app_paths::home_dir() {
467        Ok(h) => h,
468        Err(e) => {
469            let msg = format!("Cannot resolve HOME for connector content: {e}");
470            tracing::warn!("{msg}");
471            warnings.push(("connector".to_string(), msg));
472            return (successes, warnings);
473        }
474    };
475
476    for tool in tools {
477        let name = tool.tool.display_name();
478        match tool.tool.content_tier() {
479            ContentTier::AgentsMd => match install_agents_md(tool.tool, &home) {
480                Ok(true) => {
481                    let msg = format!("Installed AGENTS.md for {name}");
482                    tracing::debug!(tool = %name, "AGENTS.md installed/updated");
483                    successes.push(msg);
484                }
485                Ok(false) => {
486                    tracing::debug!(tool = %name, "AGENTS.md already current");
487                }
488                Err(e) => {
489                    let msg = format!("Failed to install AGENTS.md: {e}");
490                    tracing::warn!(tool = %name, "{msg}");
491                    warnings.push((name.to_string(), msg));
492                }
493            },
494            ContentTier::Skills => match install_skills(tool.tool, &home) {
495                Ok(n) if n > 0 => {
496                    let msg = format!("Installed {n} skill(s) for {name}");
497                    tracing::debug!(tool = %name, count = n, "installed SKILL.md files");
498                    successes.push(msg);
499                }
500                Ok(_) => {
501                    tracing::debug!(tool = %name, "skills already current");
502                }
503                Err(e) => {
504                    let msg = format!("Failed to install SKILL.md files: {e}");
505                    tracing::warn!(tool = %name, "{msg}");
506                    warnings.push((name.to_string(), msg));
507                }
508            },
509            ContentTier::Rules => tracing::debug!(tool = %name, "rules connector deferred"),
510            ContentTier::Mcp | ContentTier::Plugin => {}
511        }
512    }
513
514    (successes, warnings)
515}
516
517/// Returns the raw template and target path for a tool's AGENTS.md, if applicable.
518/// The template contains a `{{MAG_VERSION}}` placeholder replaced at install time.
519pub(crate) fn agents_md_target(
520    tool: tool_detection::AiTool,
521    home: &Path,
522) -> Option<(&'static str, PathBuf)> {
523    match tool {
524        tool_detection::AiTool::Codex => Some((AGENTS_MD_TEMPLATE, home.join(".codex/AGENTS.md"))),
525        tool_detection::AiTool::GeminiCli => {
526            Some((AGENTS_MD_TEMPLATE, home.join(".gemini/AGENTS.md")))
527        }
528        _ => None,
529    }
530}
531
532/// Appends the MAG section to `existing` content after a blank line separator,
533/// then writes it atomically to `path`. Returns `Ok(true)` (content always changed).
534fn install_agents_md_append(existing: &str, content: &str, path: &Path) -> Result<bool> {
535    let mut result = existing.to_string();
536    if !result.ends_with('\n') {
537        result.push('\n');
538    }
539    result.push('\n');
540    result.push_str(content);
541    if !content.ends_with('\n') {
542        result.push('\n');
543    }
544    atomic_write(path, &result)?;
545    Ok(true)
546}
547
548/// Installs or updates the MAG section in a tool's AGENTS.md file.
549///
550/// Uses sentinel comments (`<!-- MAG_MEMORY_START -->` / `<!-- MAG_MEMORY_END -->`)
551/// for idempotent append/replace.
552///
553/// Returns `Ok(true)` if the file was created or updated, `Ok(false)` if already
554/// up-to-date (identical content).
555pub(crate) fn install_agents_md(tool: tool_detection::AiTool, home: &Path) -> Result<bool> {
556    let Some((_, target_path)) = agents_md_target(tool, home) else {
557        return Ok(false);
558    };
559    // Substitute {{MAG_VERSION}} so stale installs are detectable after upgrades.
560    let content_owned = agents_md_content();
561    let content = content_owned.as_str();
562
563    if let Some(parent) = target_path.parent() {
564        std::fs::create_dir_all(parent)
565            .with_context(|| format!("creating directory {}", parent.display()))?;
566    }
567
568    let existing = match std::fs::read_to_string(&target_path) {
569        Ok(s) => s,
570        Err(e) if e.kind() == io::ErrorKind::NotFound => String::new(),
571        Err(e) => return Err(e).context("reading existing AGENTS.md"),
572    };
573
574    let has_start = existing.find(MAG_SENTINEL_START);
575    // Search for END only after START to avoid matching an orphan END before a
576    // valid START/END pair.
577    let has_end = has_start
578        .and_then(|si| existing[si..].find(MAG_SENTINEL_END).map(|i| si + i))
579        .or_else(|| existing.find(MAG_SENTINEL_END));
580
581    let new_content = if existing.is_empty() {
582        content.to_string()
583    } else if let Some(start_idx) = has_start {
584        // START sentinel found — END must also be present and after START.
585        let end_raw = match has_end {
586            Some(i) if i >= start_idx => i,
587            Some(_) => {
588                // END appears before START — malformed. Treat as "no sentinel" and append.
589                return install_agents_md_append(&existing, content, &target_path);
590            }
591            None => {
592                anyhow::bail!(
593                    "corrupt AGENTS.md: found MAG_MEMORY_START but no matching MAG_MEMORY_END in {}",
594                    target_path.display()
595                );
596            }
597        };
598
599        // Replace the existing MAG section in place.
600        let end_idx = end_raw + MAG_SENTINEL_END.len();
601        let end_idx = if existing[end_idx..].starts_with('\n') {
602            end_idx + 1
603        } else {
604            end_idx
605        };
606
607        let mut result = String::with_capacity(existing.len());
608        result.push_str(&existing[..start_idx]);
609        result.push_str(content);
610        if !content.ends_with('\n') {
611            result.push('\n');
612        }
613        result.push_str(&existing[end_idx..]);
614        result
615    } else {
616        // No valid START sentinel (either no sentinels, or orphan END only) — append.
617        return install_agents_md_append(&existing, content, &target_path);
618    };
619
620    if new_content == existing {
621        return Ok(false);
622    }
623
624    atomic_write(&target_path, &new_content)?;
625
626    Ok(true)
627}
628
629/// Installs SKILL.md files for OpenCode (ContentTier::Skills tools).
630///
631/// Creates `~/.config/opencode/skills/{skill_name}/SKILL.md` for each skill.
632/// Returns the number of files created or updated, or `0` if this tool does
633/// not use the Skills content tier.
634pub(crate) fn install_skills(tool: tool_detection::AiTool, home: &Path) -> Result<usize> {
635    if tool.content_tier() != ContentTier::Skills {
636        return Ok(0);
637    }
638
639    let skills_root = crate::app_paths::xdg_config_home(home).join("opencode/skills");
640    let mut errors: Vec<String> = Vec::new();
641    let mut count = 0usize;
642
643    for &(skill_name, skill_content) in OPENCODE_SKILLS {
644        let skill_dir = skills_root.join(skill_name);
645        let skill_path = skill_dir.join("SKILL.md");
646
647        if let Ok(existing) = std::fs::read_to_string(&skill_path)
648            && existing == skill_content
649        {
650            continue;
651        }
652
653        if let Err(e) = atomic_write(&skill_path, skill_content) {
654            errors.push(format!("{}: {}", skill_name, e));
655            continue;
656        }
657        count += 1;
658    }
659
660    if !errors.is_empty() {
661        anyhow::bail!(
662            "Failed to install {} skill(s): {}",
663            errors.len(),
664            errors.join("; ")
665        );
666    }
667
668    Ok(count)
669}
670
671/// Removes the MAG sentinel section from an AGENTS.md file at the given path.
672///
673/// Returns `Ok(true)` if the section was removed, `Ok(false)` if not present.
674pub(crate) fn remove_agents_md_section(path: &Path) -> Result<bool> {
675    let existing = match std::fs::read_to_string(path) {
676        Ok(s) => s,
677        Err(e) if e.kind() == io::ErrorKind::NotFound => return Ok(false),
678        Err(e) => return Err(e).context("reading AGENTS.md for removal"),
679    };
680
681    let Some(start_idx) = existing.find(MAG_SENTINEL_START) else {
682        return Ok(false);
683    };
684
685    // Search for END starting from START to avoid matching an orphan END
686    // that appears before the START sentinel.
687    let Some(end_raw) = existing[start_idx..]
688        .find(MAG_SENTINEL_END)
689        .map(|i| start_idx + i)
690    else {
691        anyhow::bail!(
692            "AGENTS.md has MAG_MEMORY_START but no matching MAG_MEMORY_END: {}",
693            path.display()
694        );
695    };
696
697    let end_idx = end_raw + MAG_SENTINEL_END.len();
698
699    // Consume the newline immediately after the END sentinel.
700    let end_idx = if existing[end_idx..].starts_with('\n') {
701        end_idx + 1
702    } else {
703        end_idx
704    };
705
706    // If the text before the sentinel ends with a blank line (the separator
707    // written by install_agents_md), consume the extra newline.
708    let start_idx = if existing[..start_idx].ends_with("\n\n") {
709        start_idx - 1
710    } else {
711        start_idx
712    };
713
714    let mut result = String::with_capacity(existing.len());
715    result.push_str(&existing[..start_idx]);
716    result.push_str(&existing[end_idx..]);
717
718    if result.trim().is_empty() {
719        std::fs::remove_file(path).with_context(|| format!("removing empty {}", path.display()))?;
720    } else {
721        atomic_write(path, &result)?;
722    }
723
724    Ok(true)
725}
726
727/// Removes OpenCode skill files created by MAG.
728///
729/// Only removes the managed SKILL.md file from each skill directory. The
730/// directory itself is removed only if it is empty afterwards, so any
731/// user-added files are preserved.
732pub(crate) fn remove_opencode_skills(home: &Path) -> Result<usize> {
733    let skills_root = crate::app_paths::xdg_config_home(home).join("opencode/skills");
734    let mut count = 0;
735
736    for &(skill_name, _) in OPENCODE_SKILLS {
737        let skill_dir = skills_root.join(skill_name);
738        let skill_path = skill_dir.join("SKILL.md");
739        match std::fs::remove_file(&skill_path) {
740            Ok(()) => {}
741            Err(e) if e.kind() == std::io::ErrorKind::NotFound => continue,
742            Err(e) => {
743                return Err(e).with_context(|| format!("removing {}", skill_path.display()));
744            }
745        }
746        count += 1;
747        // Only remove directory if empty (don't delete user files)
748        if std::fs::read_dir(&skill_dir)
749            .with_context(|| format!("reading {}", skill_dir.display()))?
750            .next()
751            .is_none()
752            && let Err(e) = std::fs::remove_dir(&skill_dir)
753        {
754            tracing::warn!(
755                path = %skill_dir.display(),
756                error = %e,
757                "failed to remove empty skill directory"
758            );
759        }
760    }
761
762    Ok(count)
763}
764
765// ---------------------------------------------------------------------------
766// Daemon management
767// ---------------------------------------------------------------------------
768
769#[cfg(feature = "daemon-http")]
770fn maybe_start_daemon(port: u16, no_start: bool) -> Result<()> {
771    if no_start {
772        tracing::debug!("--no-start: skipping daemon check");
773        return Ok(());
774    }
775
776    // Check if daemon is already running via daemon.json
777    match crate::daemon::DaemonInfo::read() {
778        Ok(Some(info)) if !info.is_stale() => {
779            println!(
780                "  MAG daemon already running (pid {}, port {}).\n",
781                info.pid, info.port
782            );
783            return Ok(());
784        }
785        Err(e) => {
786            tracing::debug!(error = %e, "failed to read daemon info; assuming not running");
787        }
788        _ => {}
789    }
790
791    println!("  Tip: start the MAG daemon with `mag serve` (port {port}).\n");
792
793    Ok(())
794}
795
796// ---------------------------------------------------------------------------
797// Helpers
798// ---------------------------------------------------------------------------
799
800/// Parses a CLI transport string into a `TransportMode`.
801pub fn parse_transport(s: &str, port: u16) -> Result<TransportMode> {
802    match s.to_lowercase().as_str() {
803        "command" | "cmd" => Ok(TransportMode::Command),
804        "http" => Ok(TransportMode::Http { port }),
805        "stdio" => Ok(TransportMode::Stdio),
806        other => {
807            anyhow::bail!("unknown transport mode: '{other}' (expected command, http, or stdio)")
808        }
809    }
810}
811
812/// Returns `true` if we detect a CI environment.
813fn is_ci() -> bool {
814    std::env::var_os("CI").is_some() || std::env::var_os("GITHUB_ACTIONS").is_some()
815}
816
817/// Returns `true` if stdin appears to be a TTY.
818fn is_tty() -> bool {
819    use std::io::IsTerminal;
820    io::stdin().is_terminal()
821}
822
823// ---------------------------------------------------------------------------
824// Tests
825// ---------------------------------------------------------------------------
826
827#[cfg(test)]
828mod tests {
829    use super::*;
830    use crate::test_helpers::with_temp_home;
831    use crate::tool_detection::{AiTool, ConfigScope, DetectedTool, MagConfigStatus};
832    use std::path::PathBuf;
833
834    // -----------------------------------------------------------------------
835    // Transport parsing
836    // -----------------------------------------------------------------------
837
838    #[test]
839    fn parse_transport_command() {
840        let mode = parse_transport("command", 4242).unwrap();
841        assert_eq!(mode, TransportMode::Command);
842    }
843
844    #[test]
845    fn parse_transport_cmd_alias() {
846        let mode = parse_transport("cmd", 4242).unwrap();
847        assert_eq!(mode, TransportMode::Command);
848    }
849
850    #[test]
851    fn parse_transport_http() {
852        let mode = parse_transport("http", 9090).unwrap();
853        assert_eq!(mode, TransportMode::Http { port: 9090 });
854    }
855
856    #[test]
857    fn parse_transport_stdio() {
858        let mode = parse_transport("stdio", 4242).unwrap();
859        assert_eq!(mode, TransportMode::Stdio);
860    }
861
862    #[test]
863    fn parse_transport_case_insensitive() {
864        let mode = parse_transport("HTTP", 8080).unwrap();
865        assert_eq!(mode, TransportMode::Http { port: 8080 });
866    }
867
868    #[test]
869    fn parse_transport_unknown_errors() {
870        let result = parse_transport("grpc", 4242);
871        assert!(result.is_err());
872        let msg = result.unwrap_err().to_string();
873        assert!(
874            msg.contains("grpc"),
875            "error should mention the bad input: {msg}"
876        );
877    }
878
879    // -----------------------------------------------------------------------
880    // SetupArgs construction
881    // -----------------------------------------------------------------------
882
883    #[test]
884    fn setup_args_defaults() {
885        let args = SetupArgs {
886            non_interactive: false,
887            tools: None,
888            transport: TransportMode::Command,
889            port: 4242,
890            no_start: false,
891            uninstall: false,
892            force: false,
893        };
894        assert!(!args.non_interactive);
895        assert!(args.tools.is_none());
896        assert_eq!(args.port, 4242);
897    }
898
899    // -----------------------------------------------------------------------
900    // Tool selection helpers
901    // -----------------------------------------------------------------------
902
903    fn make_detected(tool: AiTool, status: MagConfigStatus) -> DetectedTool {
904        DetectedTool {
905            tool,
906            config_path: PathBuf::from("/fake/config.json"),
907            scope: ConfigScope::Global,
908            mag_status: status,
909        }
910    }
911
912    #[test]
913    fn select_tools_non_interactive_configures_unconfigured() {
914        let result = DetectionResult {
915            detected: vec![
916                make_detected(AiTool::ClaudeCode, MagConfigStatus::NotConfigured),
917                make_detected(AiTool::Cursor, MagConfigStatus::Configured),
918                make_detected(AiTool::Windsurf, MagConfigStatus::NotConfigured),
919            ],
920            not_found: vec![],
921        };
922        let args = SetupArgs {
923            non_interactive: true,
924            tools: None,
925            transport: TransportMode::Command,
926            port: 4242,
927            no_start: true,
928            uninstall: false,
929            force: false,
930        };
931
932        let selected = select_tools(&result, &args).unwrap();
933        assert_eq!(selected.len(), 2);
934        assert_eq!(selected[0].tool, AiTool::ClaudeCode);
935        assert_eq!(selected[1].tool, AiTool::Windsurf);
936    }
937
938    #[test]
939    fn select_tools_with_filter() {
940        let result = DetectionResult {
941            detected: vec![
942                make_detected(AiTool::ClaudeCode, MagConfigStatus::NotConfigured),
943                make_detected(AiTool::Cursor, MagConfigStatus::NotConfigured),
944                make_detected(AiTool::Windsurf, MagConfigStatus::NotConfigured),
945            ],
946            not_found: vec![],
947        };
948        let args = SetupArgs {
949            non_interactive: true,
950            tools: Some(vec!["cursor".to_string()]),
951            transport: TransportMode::Command,
952            port: 4242,
953            no_start: true,
954            uninstall: false,
955            force: false,
956        };
957
958        let selected = select_tools(&result, &args).unwrap();
959        assert_eq!(selected.len(), 1);
960        assert_eq!(selected[0].tool, AiTool::Cursor);
961    }
962
963    #[test]
964    fn select_tools_force_includes_configured() {
965        let result = DetectionResult {
966            detected: vec![
967                make_detected(AiTool::ClaudeCode, MagConfigStatus::Configured),
968                make_detected(AiTool::Cursor, MagConfigStatus::NotConfigured),
969            ],
970            not_found: vec![],
971        };
972        let args = SetupArgs {
973            non_interactive: true,
974            tools: None,
975            transport: TransportMode::Command,
976            port: 4242,
977            no_start: true,
978            uninstall: false,
979            force: true,
980        };
981
982        let selected = select_tools(&result, &args).unwrap();
983        assert_eq!(selected.len(), 2);
984    }
985
986    #[test]
987    fn select_tools_all_configured_returns_empty() {
988        let result = DetectionResult {
989            detected: vec![make_detected(
990                AiTool::ClaudeCode,
991                MagConfigStatus::Configured,
992            )],
993            not_found: vec![],
994        };
995        let args = SetupArgs {
996            non_interactive: true,
997            tools: None,
998            transport: TransportMode::Command,
999            port: 4242,
1000            no_start: true,
1001            uninstall: false,
1002            force: false,
1003        };
1004
1005        let selected = select_tools(&result, &args).unwrap();
1006        assert!(selected.is_empty());
1007    }
1008
1009    // -----------------------------------------------------------------------
1010    // CI / TTY detection
1011    // -----------------------------------------------------------------------
1012
1013    #[test]
1014    fn is_ci_checks_env_vars() {
1015        // In test environment, CI may or may not be set, but the function
1016        // should not panic.
1017        let _ = is_ci();
1018    }
1019
1020    // -----------------------------------------------------------------------
1021    // Presentation (smoke tests — ensure no panics)
1022    // -----------------------------------------------------------------------
1023
1024    #[test]
1025    fn present_detection_empty() {
1026        let result = DetectionResult {
1027            detected: vec![],
1028            not_found: vec![AiTool::ClaudeCode],
1029        };
1030        present_detection(&result);
1031    }
1032
1033    #[test]
1034    fn present_detection_with_tools() {
1035        let result = DetectionResult {
1036            detected: vec![
1037                make_detected(AiTool::ClaudeCode, MagConfigStatus::Configured),
1038                make_detected(AiTool::Cursor, MagConfigStatus::NotConfigured),
1039                make_detected(
1040                    AiTool::Zed,
1041                    MagConfigStatus::Misconfigured("missing source".to_string()),
1042                ),
1043            ],
1044            not_found: vec![AiTool::Windsurf],
1045        };
1046        present_detection(&result);
1047    }
1048
1049    #[test]
1050    fn present_summary_all_variants() {
1051        let summary = ConfigureSummary {
1052            written: vec!["Claude Code".to_string()],
1053            already_current: vec!["Cursor".to_string()],
1054            unsupported: vec![("Zed".to_string(), "manual editing required".to_string())],
1055            deferred: vec!["Codex".to_string()],
1056            errors: vec![("Windsurf".to_string(), "permission denied".to_string())],
1057        };
1058        present_summary(&summary);
1059    }
1060
1061    // -----------------------------------------------------------------------
1062    // Integration: configure_tools with temp home
1063    // -----------------------------------------------------------------------
1064
1065    #[test]
1066    fn configure_tools_writes_config() {
1067        with_temp_home(|home| {
1068            // Create a Claude Code config file
1069            let config_path = home.join(".claude.json");
1070            std::fs::write(&config_path, "{}").unwrap();
1071
1072            let dt = DetectedTool {
1073                tool: AiTool::ClaudeCode,
1074                config_path: config_path.clone(),
1075                scope: ConfigScope::Global,
1076                mag_status: MagConfigStatus::NotConfigured,
1077            };
1078
1079            let tools: Vec<&DetectedTool> = vec![&dt];
1080            let summary = configure_tools(&tools, TransportMode::Command, &tools).unwrap();
1081
1082            assert_eq!(summary.written.len(), 1);
1083            assert!(summary.errors.is_empty());
1084
1085            // Claude Code may be configured via plugin (if `claude` CLI is available)
1086            // or via MCP config (if not). Both are valid outcomes.
1087            let name = &summary.written[0];
1088            assert!(
1089                name == "Claude Code" || name == "Claude Code (plugin)",
1090                "unexpected written entry: {name}"
1091            );
1092
1093            if name == "Claude Code" {
1094                // Verify the MCP config was written (fallback path)
1095                let content = std::fs::read_to_string(&config_path).unwrap();
1096                let parsed: serde_json::Value = serde_json::from_str(&content).unwrap();
1097                assert!(parsed["mcpServers"]["mag"].is_object());
1098            }
1099        });
1100    }
1101
1102    #[test]
1103    fn configure_tools_idempotent() {
1104        with_temp_home(|home| {
1105            // Create a config that already has MAG configured with the absolute binary path
1106            // that resolve_mag_binary() produces for this temp HOME.
1107            let config_path = home.join(".cursor/mcp.json");
1108            std::fs::create_dir_all(config_path.parent().unwrap()).unwrap();
1109            let mag_binary = home.join(".mag").join("bin").join("mag");
1110            let mag_binary_str = mag_binary.to_string_lossy();
1111            let initial = format!(
1112                r#"{{"mcpServers":{{"mag":{{"command":"{mag_binary_str}","args":["serve"]}}}}}}"#
1113            );
1114            std::fs::write(&config_path, &initial).unwrap();
1115
1116            let dt = DetectedTool {
1117                tool: AiTool::Cursor,
1118                config_path: config_path.clone(),
1119                scope: ConfigScope::Global,
1120                mag_status: MagConfigStatus::Configured,
1121            };
1122
1123            let tools: Vec<&DetectedTool> = vec![&dt];
1124            let summary = configure_tools(&tools, TransportMode::Command, &tools).unwrap();
1125
1126            assert_eq!(summary.already_current.len(), 1);
1127            assert!(summary.written.is_empty());
1128        });
1129    }
1130
1131    #[test]
1132    fn configure_tools_zed_unsupported() {
1133        let dt = DetectedTool {
1134            tool: AiTool::Zed,
1135            config_path: PathBuf::from("/fake/zed/settings.json"),
1136            scope: ConfigScope::Global,
1137            mag_status: MagConfigStatus::NotConfigured,
1138        };
1139
1140        let tools: Vec<&DetectedTool> = vec![&dt];
1141        let summary = configure_tools(&tools, TransportMode::Command, &tools).unwrap();
1142
1143        assert_eq!(summary.unsupported.len(), 1);
1144    }
1145
1146    #[test]
1147    fn configure_tools_codex_writes_toml() {
1148        with_temp_home(|home| {
1149            let config_path = home.join(".codex/config.toml");
1150            let dt = DetectedTool {
1151                tool: AiTool::Codex,
1152                config_path: config_path.clone(),
1153                scope: ConfigScope::Global,
1154                mag_status: MagConfigStatus::NotConfigured,
1155            };
1156
1157            let tools: Vec<&DetectedTool> = vec![&dt];
1158            let summary = configure_tools(&tools, TransportMode::Command, &tools).unwrap();
1159
1160            assert!(summary.deferred.is_empty());
1161            // At least the TOML config is written; connector content (AGENTS.md)
1162            // may add additional entries to summary.written.
1163            assert!(
1164                summary.written.iter().any(|s| s.contains("Codex")),
1165                "expected Codex to appear in written entries, got: {:?}",
1166                summary.written
1167            );
1168            assert!(config_path.exists(), "expected config.toml to be created");
1169        });
1170    }
1171
1172    // -----------------------------------------------------------------------
1173    // Integration: full non-interactive setup flow
1174    // -----------------------------------------------------------------------
1175
1176    #[test]
1177    fn full_non_interactive_setup() {
1178        with_temp_home(|home| {
1179            // Set up a Cursor config file
1180            let cursor_dir = home.join(".cursor");
1181            std::fs::create_dir_all(&cursor_dir).unwrap();
1182            std::fs::write(cursor_dir.join("mcp.json"), "{}").unwrap();
1183
1184            // Detect
1185            let result = detect_phase(None);
1186            assert!(
1187                result.detected.iter().any(|d| d.tool == AiTool::Cursor),
1188                "expected Cursor to be detected"
1189            );
1190
1191            // Select non-interactively
1192            let args = SetupArgs {
1193                non_interactive: true,
1194                tools: None,
1195                transport: TransportMode::Command,
1196                port: 4242,
1197                no_start: true,
1198                uninstall: false,
1199                force: false,
1200            };
1201
1202            let selected = select_tools(&result, &args).unwrap();
1203            assert!(!selected.is_empty(), "expected at least one tool selected");
1204
1205            // Configure — pass invocation-scoped tools for connector content
1206            let scope = invocation_scoped_tools(&result, &args);
1207            let summary = configure_tools(&selected, TransportMode::Command, &scope).unwrap();
1208            assert!(
1209                !summary.written.is_empty() || !summary.already_current.is_empty(),
1210                "expected at least one tool configured"
1211            );
1212        });
1213    }
1214
1215    // -----------------------------------------------------------------------
1216    // Uninstall flow
1217    // -----------------------------------------------------------------------
1218
1219    #[test]
1220    fn uninstall_removes_configured_tools() {
1221        with_temp_home(|home| {
1222            // Set up a Claude Code config with MAG
1223            let config_path = home.join(".claude.json");
1224            std::fs::write(
1225                &config_path,
1226                r#"{"mcpServers":{"mag":{"command":"mag","args":["serve"]},"other":{}}}"#,
1227            )
1228            .unwrap();
1229
1230            // Run uninstall
1231            let rt = tokio::runtime::Runtime::new().unwrap();
1232            rt.block_on(crate::uninstall::run_uninstall(false, true))
1233                .unwrap();
1234
1235            // Verify MAG was removed but other config preserved
1236            let content = std::fs::read_to_string(&config_path).unwrap();
1237            let parsed: serde_json::Value = serde_json::from_str(&content).unwrap();
1238            assert!(parsed["mcpServers"]["mag"].is_null());
1239            assert!(parsed["mcpServers"]["other"].is_object());
1240        });
1241    }
1242
1243    #[test]
1244    fn uninstall_no_tools_detected() {
1245        with_temp_home(|_home| {
1246            // No config files exist — should not error
1247            let rt = tokio::runtime::Runtime::new().unwrap();
1248            rt.block_on(crate::uninstall::run_uninstall(false, true))
1249                .unwrap();
1250        });
1251    }
1252
1253    // -----------------------------------------------------------------------
1254    // Tool filter matching
1255    // -----------------------------------------------------------------------
1256
1257    #[test]
1258    fn filter_matches_partial_name() {
1259        let result = DetectionResult {
1260            detected: vec![
1261                make_detected(AiTool::VSCodeCopilot, MagConfigStatus::NotConfigured),
1262                make_detected(AiTool::ClaudeCode, MagConfigStatus::NotConfigured),
1263            ],
1264            not_found: vec![],
1265        };
1266        let args = SetupArgs {
1267            non_interactive: true,
1268            tools: Some(vec!["vscode".to_string()]),
1269            transport: TransportMode::Command,
1270            port: 4242,
1271            no_start: true,
1272            uninstall: false,
1273            force: false,
1274        };
1275
1276        let selected = select_tools(&result, &args).unwrap();
1277        assert_eq!(selected.len(), 1);
1278        assert_eq!(selected[0].tool, AiTool::VSCodeCopilot);
1279    }
1280
1281    #[test]
1282    fn filter_matches_multiple_tools() {
1283        let result = DetectionResult {
1284            detected: vec![
1285                make_detected(AiTool::Cursor, MagConfigStatus::NotConfigured),
1286                make_detected(AiTool::Windsurf, MagConfigStatus::NotConfigured),
1287                make_detected(AiTool::ClaudeCode, MagConfigStatus::NotConfigured),
1288            ],
1289            not_found: vec![],
1290        };
1291        let args = SetupArgs {
1292            non_interactive: true,
1293            tools: Some(vec!["cursor".to_string(), "windsurf".to_string()]),
1294            transport: TransportMode::Command,
1295            port: 4242,
1296            no_start: true,
1297            uninstall: false,
1298            force: false,
1299        };
1300
1301        let selected = select_tools(&result, &args).unwrap();
1302        assert_eq!(selected.len(), 2);
1303        let tool_names: Vec<_> = selected.iter().map(|d| d.tool).collect();
1304        assert!(tool_names.contains(&AiTool::Cursor));
1305        assert!(tool_names.contains(&AiTool::Windsurf));
1306    }
1307
1308    // -----------------------------------------------------------------------
1309    // Plugin-related tests
1310    // -----------------------------------------------------------------------
1311
1312    #[test]
1313    fn select_tools_skips_installed_as_plugin() {
1314        let result = DetectionResult {
1315            detected: vec![
1316                make_detected(AiTool::ClaudeCode, MagConfigStatus::InstalledAsPlugin),
1317                make_detected(AiTool::Cursor, MagConfigStatus::NotConfigured),
1318            ],
1319            not_found: vec![],
1320        };
1321        let args = SetupArgs {
1322            non_interactive: true,
1323            tools: None,
1324            transport: TransportMode::Command,
1325            port: 4242,
1326            no_start: true,
1327            uninstall: false,
1328            force: false,
1329        };
1330
1331        let selected = select_tools(&result, &args).unwrap();
1332        assert_eq!(selected.len(), 1);
1333        assert_eq!(selected[0].tool, AiTool::Cursor);
1334    }
1335
1336    #[test]
1337    fn select_tools_force_includes_plugin_installed() {
1338        let result = DetectionResult {
1339            detected: vec![make_detected(
1340                AiTool::ClaudeCode,
1341                MagConfigStatus::InstalledAsPlugin,
1342            )],
1343            not_found: vec![],
1344        };
1345        let args = SetupArgs {
1346            non_interactive: true,
1347            tools: None,
1348            transport: TransportMode::Command,
1349            port: 4242,
1350            no_start: true,
1351            uninstall: false,
1352            force: true,
1353        };
1354
1355        let selected = select_tools(&result, &args).unwrap();
1356        assert_eq!(selected.len(), 1);
1357    }
1358
1359    #[test]
1360    fn present_detection_shows_plugin_status() {
1361        let result = DetectionResult {
1362            detected: vec![
1363                make_detected(AiTool::ClaudeCode, MagConfigStatus::InstalledAsPlugin),
1364                make_detected(AiTool::Cursor, MagConfigStatus::NotConfigured),
1365            ],
1366            not_found: vec![],
1367        };
1368        // Smoke test — should not panic
1369        present_detection(&result);
1370    }
1371
1372    #[test]
1373    fn present_summary_with_plugin_entry() {
1374        let summary = ConfigureSummary {
1375            written: vec!["Claude Code (plugin)".to_string()],
1376            already_current: vec![],
1377            unsupported: vec![],
1378            deferred: vec![],
1379            errors: vec![],
1380        };
1381        // Smoke test — should not panic
1382        present_summary(&summary);
1383    }
1384
1385    // -----------------------------------------------------------------------
1386    // Connector: install_agents_md
1387    // -----------------------------------------------------------------------
1388
1389    #[test]
1390    fn install_agents_md_creates_file_for_codex() {
1391        with_temp_home(|home| {
1392            let result = install_agents_md(AiTool::Codex, home).unwrap();
1393            assert!(result, "expected file to be created");
1394
1395            let path = home.join(".codex/AGENTS.md");
1396            assert!(path.exists());
1397            let content = std::fs::read_to_string(&path).unwrap();
1398            assert!(content.contains("<!-- MAG_MEMORY_START -->"));
1399            assert!(content.contains("<!-- MAG_MEMORY_END -->"));
1400            assert!(content.contains("mag process"));
1401        });
1402    }
1403
1404    #[test]
1405    fn install_agents_md_creates_file_for_gemini() {
1406        with_temp_home(|home| {
1407            let result = install_agents_md(AiTool::GeminiCli, home).unwrap();
1408            assert!(result, "expected file to be created");
1409
1410            let path = home.join(".gemini/AGENTS.md");
1411            assert!(path.exists());
1412            let content = std::fs::read_to_string(&path).unwrap();
1413            assert!(content.contains("<!-- MAG_MEMORY_START -->"));
1414            assert!(content.contains("<!-- MAG_MEMORY_END -->"));
1415        });
1416    }
1417
1418    #[test]
1419    fn install_agents_md_is_idempotent() {
1420        with_temp_home(|home| {
1421            // First install
1422            let first = install_agents_md(AiTool::Codex, home).unwrap();
1423            assert!(first, "first install should return true");
1424
1425            // Second install — content is identical
1426            let second = install_agents_md(AiTool::Codex, home).unwrap();
1427            assert!(
1428                !second,
1429                "second install should return false (already current)"
1430            );
1431        });
1432    }
1433
1434    #[test]
1435    fn install_agents_md_replaces_existing_section() {
1436        with_temp_home(|home| {
1437            let path = home.join(".codex/AGENTS.md");
1438            std::fs::create_dir_all(path.parent().unwrap()).unwrap();
1439
1440            // Write a file with existing user content + old MAG section
1441            std::fs::write(
1442                &path,
1443                "# My Agent\n\nSome user content.\n\n<!-- MAG_MEMORY_START -->\nOLD MAG CONTENT\n<!-- MAG_MEMORY_END -->\n",
1444            ).unwrap();
1445
1446            let result = install_agents_md(AiTool::Codex, home).unwrap();
1447            assert!(result, "should update the MAG section");
1448
1449            let content = std::fs::read_to_string(&path).unwrap();
1450            // User content should be preserved
1451            assert!(content.contains("# My Agent"));
1452            assert!(content.contains("Some user content."));
1453            // Old content should be gone
1454            assert!(!content.contains("OLD MAG CONTENT"));
1455            // New content should be present
1456            assert!(content.contains("mag process"));
1457        });
1458    }
1459
1460    #[test]
1461    fn install_agents_md_appends_to_existing_file() {
1462        with_temp_home(|home| {
1463            let path = home.join(".codex/AGENTS.md");
1464            std::fs::create_dir_all(path.parent().unwrap()).unwrap();
1465            std::fs::write(&path, "# Existing AGENTS.md\n\nSome guidance.\n").unwrap();
1466
1467            let result = install_agents_md(AiTool::Codex, home).unwrap();
1468            assert!(result, "should append MAG section");
1469
1470            let content = std::fs::read_to_string(&path).unwrap();
1471            assert!(content.starts_with("# Existing AGENTS.md"));
1472            assert!(content.contains("<!-- MAG_MEMORY_START -->"));
1473            assert!(content.contains("<!-- MAG_MEMORY_END -->"));
1474        });
1475    }
1476
1477    #[test]
1478    fn install_agents_md_returns_false_for_non_agents_md_tool() {
1479        with_temp_home(|home| {
1480            let result = install_agents_md(AiTool::Cursor, home).unwrap();
1481            assert!(!result, "Cursor is not an AgentsMd tier tool");
1482        });
1483    }
1484
1485    // -----------------------------------------------------------------------
1486    // Connector: install_skills
1487    // -----------------------------------------------------------------------
1488
1489    #[test]
1490    fn install_skills_returns_zero_for_non_skills_tier() {
1491        with_temp_home(|home| {
1492            // Codex is AgentsMd, not Skills
1493            let count = install_skills(AiTool::Codex, home).unwrap();
1494            assert_eq!(count, 0);
1495
1496            // ClaudeCode is Plugin, not Skills
1497            let count = install_skills(AiTool::ClaudeCode, home).unwrap();
1498            assert_eq!(count, 0);
1499        });
1500    }
1501
1502    // -----------------------------------------------------------------------
1503    // Connector: remove_agents_md_section
1504    // -----------------------------------------------------------------------
1505
1506    #[test]
1507    fn remove_agents_md_section_removes_mag_content() {
1508        with_temp_home(|home| {
1509            // Install first
1510            install_agents_md(AiTool::Codex, home).unwrap();
1511            let path = home.join(".codex/AGENTS.md");
1512            assert!(path.exists());
1513
1514            // Remove
1515            let removed = remove_agents_md_section(&path).unwrap();
1516            assert!(removed, "should return true when section is removed");
1517
1518            // File should be gone (was MAG-only content)
1519            assert!(!path.exists(), "empty file should be deleted");
1520        });
1521    }
1522
1523    #[test]
1524    fn remove_agents_md_section_preserves_other_content() {
1525        with_temp_home(|home| {
1526            let path = home.join(".codex/AGENTS.md");
1527            std::fs::create_dir_all(path.parent().unwrap()).unwrap();
1528            std::fs::write(
1529                &path,
1530                "# User content\n\n<!-- MAG_MEMORY_START -->\nMAG stuff\n<!-- MAG_MEMORY_END -->\n",
1531            )
1532            .unwrap();
1533
1534            let removed = remove_agents_md_section(&path).unwrap();
1535            assert!(removed);
1536
1537            let content = std::fs::read_to_string(&path).unwrap();
1538            assert!(content.contains("# User content"));
1539            assert!(!content.contains("MAG_MEMORY_START"));
1540        });
1541    }
1542
1543    #[test]
1544    fn remove_agents_md_section_returns_false_when_no_sentinel() {
1545        with_temp_home(|home| {
1546            let path = home.join(".codex/AGENTS.md");
1547            std::fs::create_dir_all(path.parent().unwrap()).unwrap();
1548            std::fs::write(&path, "# No MAG content here\n").unwrap();
1549
1550            let removed = remove_agents_md_section(&path).unwrap();
1551            assert!(!removed);
1552        });
1553    }
1554
1555    #[test]
1556    fn remove_agents_md_section_returns_false_for_missing_file() {
1557        with_temp_home(|home| {
1558            let path = home.join(".codex/AGENTS.md");
1559            let removed = remove_agents_md_section(&path).unwrap();
1560            assert!(!removed);
1561        });
1562    }
1563
1564    // -----------------------------------------------------------------------
1565    // Connector: remove_opencode_skills
1566    // -----------------------------------------------------------------------
1567
1568    #[test]
1569    fn remove_opencode_skills_returns_zero_when_none_exist() {
1570        with_temp_home(|home| {
1571            let count = remove_opencode_skills(home).unwrap();
1572            assert_eq!(count, 0);
1573        });
1574    }
1575
1576    #[test]
1577    fn remove_opencode_skills_removes_existing_dirs() {
1578        with_temp_home(|home| {
1579            // Create some skill directories
1580            let skills_root = home.join(".config/opencode/skills");
1581            let skill_dir = skills_root.join("memory-store");
1582            std::fs::create_dir_all(&skill_dir).unwrap();
1583            std::fs::write(skill_dir.join("SKILL.md"), "test").unwrap();
1584
1585            let skill_dir2 = skills_root.join("memory-health");
1586            std::fs::create_dir_all(&skill_dir2).unwrap();
1587            std::fs::write(skill_dir2.join("SKILL.md"), "test").unwrap();
1588
1589            let count = remove_opencode_skills(home).unwrap();
1590            assert_eq!(count, 2);
1591            assert!(!skills_root.join("memory-store").exists());
1592            assert!(!skills_root.join("memory-health").exists());
1593        });
1594    }
1595
1596    // -----------------------------------------------------------------------
1597    // Connector: uninstall integration
1598    // -----------------------------------------------------------------------
1599
1600    #[test]
1601    fn uninstall_removes_agents_md_sections() {
1602        with_temp_home(|home| {
1603            // Install MAG AGENTS.md for Codex
1604            install_agents_md(AiTool::Codex, home).unwrap();
1605            let codex_path = home.join(".codex/AGENTS.md");
1606            assert!(codex_path.exists());
1607
1608            // Also create a Codex config.toml so it's detected
1609            std::fs::write(
1610                home.join(".codex/config.toml"),
1611                "[mcp_servers.mag]\ncommand = \"mag\"\n",
1612            )
1613            .unwrap();
1614
1615            // Run uninstall
1616            let rt = tokio::runtime::Runtime::new().unwrap();
1617            rt.block_on(crate::uninstall::run_uninstall(false, true))
1618                .unwrap();
1619
1620            // The AGENTS.md should be cleaned up (file removed since MAG-only)
1621            assert!(
1622                !codex_path.exists(),
1623                "MAG-only AGENTS.md should be removed by uninstall"
1624            );
1625        });
1626    }
1627
1628    // -----------------------------------------------------------------------
1629    // FIX 5: Additional tests for review findings
1630    // -----------------------------------------------------------------------
1631
1632    #[test]
1633    fn install_agents_md_append_then_idempotent() {
1634        with_temp_home(|home| {
1635            // Create an existing file with non-MAG content
1636            let path = home.join(".codex/AGENTS.md");
1637            std::fs::create_dir_all(path.parent().unwrap()).unwrap();
1638            std::fs::write(&path, "# Pre-existing content\n\nSome other guidance.\n").unwrap();
1639
1640            // First call: should append MAG section
1641            let first = install_agents_md(AiTool::Codex, home).unwrap();
1642            assert!(first, "first install should return true (content changed)");
1643
1644            let content = std::fs::read_to_string(&path).unwrap();
1645            assert!(content.contains("# Pre-existing content"));
1646            assert!(content.contains("<!-- MAG_MEMORY_START -->"));
1647            assert!(content.contains("<!-- MAG_MEMORY_END -->"));
1648
1649            // Second call: content is identical, should return false
1650            let second = install_agents_md(AiTool::Codex, home).unwrap();
1651            assert!(
1652                !second,
1653                "second install should return false (already current)"
1654            );
1655        });
1656    }
1657
1658    #[test]
1659    fn install_skills_creates_skill_files_for_opencode() {
1660        with_temp_home(|home| {
1661            let count = install_skills(AiTool::OpenCode, home).unwrap();
1662            assert_eq!(count, 4, "expected 4 SKILL.md files to be created");
1663
1664            let skills_root = home.join(".config/opencode/skills");
1665            for dir_name in &[
1666                "memory-store",
1667                "memory-recall",
1668                "memory-checkpoint",
1669                "memory-health",
1670            ] {
1671                let skill_path = skills_root.join(dir_name).join("SKILL.md");
1672                assert!(skill_path.exists(), "expected {dir_name}/SKILL.md to exist");
1673                let content = std::fs::read_to_string(&skill_path).unwrap();
1674                assert!(!content.is_empty(), "SKILL.md for {dir_name} is empty");
1675            }
1676
1677            // Idempotent: second call should create 0 new files
1678            let count2 = install_skills(AiTool::OpenCode, home).unwrap();
1679            assert_eq!(count2, 0, "second install should be idempotent");
1680        });
1681    }
1682
1683    #[test]
1684    fn install_agents_md_errors_on_start_without_end_sentinel() {
1685        with_temp_home(|home| {
1686            let path = home.join(".codex/AGENTS.md");
1687            std::fs::create_dir_all(path.parent().unwrap()).unwrap();
1688            // Write a file with START but no END sentinel — corrupt state
1689            std::fs::write(
1690                &path,
1691                "# Content\n\n<!-- MAG_MEMORY_START -->\nOrphaned content\n",
1692            )
1693            .unwrap();
1694
1695            let result = install_agents_md(AiTool::Codex, home);
1696            assert!(result.is_err(), "expected error for missing END sentinel");
1697            let err_msg = result.unwrap_err().to_string();
1698            assert!(
1699                err_msg.contains("MAG_MEMORY_START") && err_msg.contains("MAG_MEMORY_END"),
1700                "error message should mention both sentinels: {err_msg}"
1701            );
1702        });
1703    }
1704
1705    #[test]
1706    fn remove_agents_md_returns_error_when_start_without_end_sentinel() {
1707        with_temp_home(|home| {
1708            let path = home.join(".codex/AGENTS.md");
1709            std::fs::create_dir_all(path.parent().unwrap()).unwrap();
1710            std::fs::write(
1711                &path,
1712                "# Content\n\n<!-- MAG_MEMORY_START -->\nOrphaned content\n",
1713            )
1714            .unwrap();
1715
1716            // START without END is malformed — returns an error so the user
1717            // knows the file needs manual repair (mirrors install-path behaviour).
1718            let result = remove_agents_md_section(&path);
1719            assert!(result.is_err(), "expected error for missing END sentinel");
1720            let err_msg = result.unwrap_err().to_string();
1721            assert!(
1722                err_msg.contains("MAG_MEMORY_START") && err_msg.contains("MAG_MEMORY_END"),
1723                "error message should mention both sentinels: {err_msg}"
1724            );
1725        });
1726    }
1727
1728    #[test]
1729    fn install_agents_md_appends_when_end_before_start() {
1730        with_temp_home(|home| {
1731            let path = home.join(".codex/AGENTS.md");
1732            std::fs::create_dir_all(path.parent().unwrap()).unwrap();
1733            // Malformed: END before START
1734            std::fs::write(
1735                &path,
1736                "# Content\n<!-- MAG_MEMORY_END -->\n<!-- MAG_MEMORY_START -->\n",
1737            )
1738            .unwrap();
1739
1740            // Should treat as "no sentinel" and append
1741            let result = install_agents_md(AiTool::Codex, home).unwrap();
1742            assert!(result, "should append when sentinels are reversed");
1743        });
1744    }
1745
1746    #[test]
1747    fn remove_agents_md_returns_error_when_end_before_start() {
1748        with_temp_home(|home| {
1749            let path = home.join(".codex/AGENTS.md");
1750            std::fs::create_dir_all(path.parent().unwrap()).unwrap();
1751            // Malformed: END before START — START exists but END is not found
1752            // after START, so the same "no matching END" error fires.
1753            std::fs::write(
1754                &path,
1755                "# Content\n<!-- MAG_MEMORY_END -->\n<!-- MAG_MEMORY_START -->\n",
1756            )
1757            .unwrap();
1758
1759            let result = remove_agents_md_section(&path);
1760            assert!(result.is_err(), "expected error for reversed sentinels");
1761            let err_msg = result.unwrap_err().to_string();
1762            assert!(
1763                err_msg.contains("MAG_MEMORY_START") && err_msg.contains("MAG_MEMORY_END"),
1764                "error message should mention both sentinels: {err_msg}"
1765            );
1766        });
1767    }
1768}