1use 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
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 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 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 #[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 #[cfg(feature = "daemon-http")]
103 maybe_start_daemon(args.port, args.no_start)?;
104
105 Ok(())
106}
107
108fn detect_phase(project_root: Option<&Path>) -> DetectionResult {
113 tool_detection::detect_all_tools(project_root)
114}
115
116fn 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}", MagConfigStatus::InstalledAsPlugin => "\u{2713}", MagConfigStatus::NotConfigured => "\u{2717}", MagConfigStatus::Misconfigured(_) => "\u{26a0}", MagConfigStatus::Unreadable(_) => "\u{26a0}", };
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
180fn 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 if args.force {
219 return Ok(candidates);
220 }
221
222 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 if args.non_interactive || is_ci() || !is_tty() {
239 return Ok(actionable);
240 }
241
242 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 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 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
316fn 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 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 }
344 Ok(other) => {
345 tracing::debug!(result = ?other, "unexpected plugin install result, falling back");
346 }
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; 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 summary.written.push(format!("{name} (plugin)"));
371 }
372 Err(e) => {
373 summary.errors.push((name, format!("{e:#}")));
374 }
375 }
376 }
377
378 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
387fn 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
421const MAG_SENTINEL_START: &str = "<!-- MAG_MEMORY_START -->";
427const MAG_SENTINEL_END: &str = "<!-- MAG_MEMORY_END -->";
429
430const 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
439const 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
459fn 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
517pub(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
532fn 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
548pub(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 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 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 let end_raw = match has_end {
586 Some(i) if i >= start_idx => i,
587 Some(_) => {
588 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 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 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
629pub(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
671pub(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 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 let end_idx = if existing[end_idx..].starts_with('\n') {
701 end_idx + 1
702 } else {
703 end_idx
704 };
705
706 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
727pub(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 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#[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 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
796pub 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
812fn is_ci() -> bool {
814 std::env::var_os("CI").is_some() || std::env::var_os("GITHUB_ACTIONS").is_some()
815}
816
817fn is_tty() -> bool {
819 use std::io::IsTerminal;
820 io::stdin().is_terminal()
821}
822
823#[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 #[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 #[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 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 #[test]
1014 fn is_ci_checks_env_vars() {
1015 let _ = is_ci();
1018 }
1019
1020 #[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 #[test]
1066 fn configure_tools_writes_config() {
1067 with_temp_home(|home| {
1068 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 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 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 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 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 #[test]
1177 fn full_non_interactive_setup() {
1178 with_temp_home(|home| {
1179 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 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 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 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 #[test]
1220 fn uninstall_removes_configured_tools() {
1221 with_temp_home(|home| {
1222 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 let rt = tokio::runtime::Runtime::new().unwrap();
1232 rt.block_on(crate::uninstall::run_uninstall(false, true))
1233 .unwrap();
1234
1235 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 let rt = tokio::runtime::Runtime::new().unwrap();
1248 rt.block_on(crate::uninstall::run_uninstall(false, true))
1249 .unwrap();
1250 });
1251 }
1252
1253 #[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 #[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 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 present_summary(&summary);
1383 }
1384
1385 #[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 let first = install_agents_md(AiTool::Codex, home).unwrap();
1423 assert!(first, "first install should return true");
1424
1425 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 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 assert!(content.contains("# My Agent"));
1452 assert!(content.contains("Some user content."));
1453 assert!(!content.contains("OLD MAG CONTENT"));
1455 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 #[test]
1490 fn install_skills_returns_zero_for_non_skills_tier() {
1491 with_temp_home(|home| {
1492 let count = install_skills(AiTool::Codex, home).unwrap();
1494 assert_eq!(count, 0);
1495
1496 let count = install_skills(AiTool::ClaudeCode, home).unwrap();
1498 assert_eq!(count, 0);
1499 });
1500 }
1501
1502 #[test]
1507 fn remove_agents_md_section_removes_mag_content() {
1508 with_temp_home(|home| {
1509 install_agents_md(AiTool::Codex, home).unwrap();
1511 let path = home.join(".codex/AGENTS.md");
1512 assert!(path.exists());
1513
1514 let removed = remove_agents_md_section(&path).unwrap();
1516 assert!(removed, "should return true when section is removed");
1517
1518 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 #[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 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 #[test]
1601 fn uninstall_removes_agents_md_sections() {
1602 with_temp_home(|home| {
1603 install_agents_md(AiTool::Codex, home).unwrap();
1605 let codex_path = home.join(".codex/AGENTS.md");
1606 assert!(codex_path.exists());
1607
1608 std::fs::write(
1610 home.join(".codex/config.toml"),
1611 "[mcp_servers.mag]\ncommand = \"mag\"\n",
1612 )
1613 .unwrap();
1614
1615 let rt = tokio::runtime::Runtime::new().unwrap();
1617 rt.block_on(crate::uninstall::run_uninstall(false, true))
1618 .unwrap();
1619
1620 assert!(
1622 !codex_path.exists(),
1623 "MAG-only AGENTS.md should be removed by uninstall"
1624 );
1625 });
1626 }
1627
1628 #[test]
1633 fn install_agents_md_append_then_idempotent() {
1634 with_temp_home(|home| {
1635 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 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 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 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 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 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 std::fs::write(
1735 &path,
1736 "# Content\n<!-- MAG_MEMORY_END -->\n<!-- MAG_MEMORY_START -->\n",
1737 )
1738 .unwrap();
1739
1740 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 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}