1pub mod add;
11pub mod adopt;
12pub mod build;
13pub mod cache;
14pub mod check;
15pub mod doctor;
16pub mod export;
17pub mod init;
18pub mod link;
19pub mod list;
20pub mod models;
21pub mod outdated;
22pub mod output;
23pub mod override_cmd;
24pub mod remove;
25pub mod rename;
26pub mod repair;
27pub mod resolve_cmd;
28pub mod sync;
29pub mod target;
30pub mod unlink;
31pub mod upgrade;
32pub mod validate;
33pub mod version;
34pub mod why;
35
36use std::path::{Path, PathBuf};
37
38use clap::{Parser, Subcommand};
39
40use crate::error::{ConfigError, LockError, MarsError};
41pub use crate::types::MarsContext;
42use crate::types::managed_cmd;
43
44pub const WELL_KNOWN: &[&str] = &[".agents"];
46
47pub const TOOL_DIRS: &[&str] = &[".claude", ".codex", ".opencode", ".cursor", ".pi"];
50
51impl MarsContext {
52 pub fn new(project_root: PathBuf) -> Result<Self, MarsError> {
54 let project_canon = if project_root.exists() {
55 dunce::canonicalize(&project_root).unwrap_or(project_root.clone())
56 } else {
57 project_root.clone()
58 };
59
60 let managed_root = detect_managed_root(&project_canon)?;
61 Self::from_roots(project_canon, managed_root)
62 }
63
64 pub fn from_roots(project_root: PathBuf, managed_root: PathBuf) -> Result<Self, MarsError> {
66 let project_canon = if project_root.exists() {
67 dunce::canonicalize(&project_root).unwrap_or(project_root.clone())
68 } else {
69 project_root.clone()
70 };
71 let managed_canon = if managed_root.exists() {
72 dunce::canonicalize(&managed_root).unwrap_or(managed_root.clone())
73 } else if let Ok(relative_managed) = managed_root.strip_prefix(&project_root) {
74 project_canon.join(relative_managed)
75 } else {
76 managed_root.clone()
77 };
78
79 if !managed_canon.starts_with(&project_canon) {
80 return Err(MarsError::Config(ConfigError::Invalid {
81 message: format!(
82 "{} resolves to {} which is outside {}. \
83 The managed root may be a symlink. Use --root to override.",
84 managed_root.display(),
85 managed_canon.display(),
86 project_canon.display(),
87 ),
88 }));
89 }
90
91 Ok(MarsContext {
92 managed_root: managed_canon,
93 project_root: project_canon,
94 meridian_managed: crate::types::meridian_managed_from_env(),
95 })
96 }
97}
98
99#[derive(Debug, Parser)]
101#[command(name = "mars", version, about = "Agent package manager")]
102pub struct Cli {
103 #[command(subcommand)]
104 pub command: Command,
105
106 #[arg(long, global = true)]
108 pub root: Option<PathBuf>,
109
110 #[arg(long, global = true)]
112 pub json: bool,
113}
114
115#[derive(Debug, Subcommand)]
116pub enum Command {
117 Init(init::InitArgs),
119
120 Add(add::AddArgs),
122
123 Adopt(adopt::AdoptArgs),
125
126 Remove(remove::RemoveArgs),
128
129 Sync(sync::SyncArgs),
131
132 Upgrade(upgrade::UpgradeArgs),
134
135 Outdated(outdated::OutdatedArgs),
137
138 Version(version::VersionArgs),
140
141 List(list::ListArgs),
143
144 Why(why::WhyArgs),
146
147 Rename(rename::RenameArgs),
149
150 Resolve(resolve_cmd::ResolveArgs),
152
153 Override(override_cmd::OverrideArgs),
155
156 Link(link::LinkArgs),
158
159 Unlink(unlink::UnlinkArgs),
161
162 Validate(validate::ValidateArgs),
164
165 Export(export::ExportArgs),
167
168 Check(check::CheckArgs),
170
171 Doctor(doctor::DoctorArgs),
173
174 Repair(repair::RepairArgs),
176
177 Cache(cache::CacheArgs),
179
180 Models(models::ModelsArgs),
182
183 Build(build::BuildArgs),
185}
186
187pub fn dispatch(cli: Cli) -> i32 {
190 match dispatch_result(cli) {
191 Ok(code) => code,
192 Err(err) => {
193 eprintln!("error: {err}");
194 if matches!(err, MarsError::Lock(LockError::Corrupt { .. })) {
195 eprintln!(
196 "hint: run `{}` to rebuild from mars.toml + dependencies",
197 managed_cmd("mars repair")
198 );
199 }
200 err.exit_code()
201 }
202 }
203}
204
205fn dispatch_result(cli: Cli) -> Result<i32, MarsError> {
206 match &cli.command {
207 Command::Init(args) => init::run(args, cli.root.as_deref(), cli.json),
209 Command::Check(args) => check::run(args, cli.json),
210 Command::Cache(args) => cache::run(args, cli.json),
211 cmd => {
213 let ctx = match find_agents_root(cli.root.as_deref()) {
214 Ok(ctx) => ctx,
215 Err(err) if should_auto_init_project(cmd, &err) => {
216 let initialized = init::initialize_project(cli.root.as_deref(), None)?;
217 if !cli.json {
218 output::print_info(&format!(
219 "auto-initialized {} with mars.toml",
220 initialized.project_root.display()
221 ));
222 }
223 MarsContext::from_roots(
224 initialized.project_root.clone(),
225 initialized
226 .managed_root
227 .clone()
228 .unwrap_or_else(|| initialized.project_root.join(".mars")),
229 )?
230 }
231 Err(err) if can_run_without_project(cmd, &err) => {
232 let project_root = cli.root.clone().unwrap_or(std::env::current_dir()?);
233 MarsContext::from_roots(project_root.clone(), project_root.join(".mars"))?
234 }
235 Err(err) => return Err(err),
236 };
237 dispatch_with_root(cmd, &ctx, cli.json)
238 }
239 }
240}
241
242fn should_auto_init_project(cmd: &Command, err: &MarsError) -> bool {
243 matches!(cmd, Command::Add(_) | Command::Link(_))
244 && matches!(
245 err,
246 MarsError::Config(ConfigError::ProjectRootNotFound { .. })
247 )
248}
249
250fn can_run_without_project(cmd: &Command, err: &MarsError) -> bool {
251 matches!(
252 (cmd, err),
253 (
254 Command::Build(build::BuildArgs {
255 command: build::BuildCommand::LaunchBundle(build::LaunchBundleArgs {
256 agent: None,
257 ..
258 })
259 }),
260 MarsError::Config(ConfigError::ProjectRootNotFound { .. })
261 )
262 )
263}
264
265fn dispatch_with_root(cmd: &Command, ctx: &MarsContext, json: bool) -> Result<i32, MarsError> {
266 match cmd {
267 Command::Validate(args) => validate::run(args, ctx, json),
268 Command::Export(args) => export::run(args, ctx, json),
269 Command::Add(args) => add::run(args, ctx, json),
270 Command::Adopt(args) => adopt::run(args, ctx, json),
271 Command::Remove(args) => remove::run(args, ctx, json),
272 Command::Sync(args) => sync::run(args, ctx, json),
273 Command::Upgrade(args) => upgrade::run(args, ctx, json),
274 Command::Outdated(args) => outdated::run(args, ctx, json),
275 Command::Version(args) => version::run(args, ctx, json),
276 Command::List(args) => list::run(args, ctx, json),
277 Command::Why(args) => why::run(args, ctx, json),
278 Command::Rename(args) => rename::run(args, ctx, json),
279 Command::Resolve(args) => resolve_cmd::run(args, ctx, json),
280 Command::Override(args) => override_cmd::run(args, ctx, json),
281 Command::Link(args) => link::run(args, ctx, json),
282 Command::Unlink(args) => unlink::run(args, ctx, json),
283 Command::Doctor(args) => doctor::run(args, ctx, json),
284 Command::Repair(args) => repair::run(args, ctx, json),
285 Command::Models(args) => models::run(args, ctx, json),
286 Command::Build(args) => build::run(args, ctx, json),
287 Command::Init(_) | Command::Check(_) | Command::Cache(_) => unreachable!(),
289 }
290}
291
292pub fn is_symlink(path: &Path) -> bool {
294 path.symlink_metadata()
295 .map(|m| m.file_type().is_symlink())
296 .unwrap_or(false)
297}
298
299fn detect_managed_root(project_root: &Path) -> Result<PathBuf, MarsError> {
300 match crate::config::load(project_root) {
302 Ok(config) => {
303 if config.settings.managed_root.is_some()
304 && let Some(name) = config.settings.managed_targets().first()
305 {
306 return Ok(project_root.join(name));
307 }
308 if config
309 .settings
310 .managed_targets()
311 .iter()
312 .any(|target| target == WELL_KNOWN[0])
313 {
314 return Ok(project_root.join(WELL_KNOWN[0]));
315 }
316 }
317 Err(MarsError::Config(ConfigError::NotFound { .. })) => {}
319 Err(e) => return Err(e),
321 }
322
323 Ok(project_root.join(".mars"))
326}
327
328pub fn find_agents_root(explicit: Option<&Path>) -> Result<MarsContext, MarsError> {
336 let start = if let Some(root) = explicit {
337 if let Some(basename) = root.file_name().and_then(|f| f.to_str())
339 && (WELL_KNOWN.contains(&basename) || TOOL_DIRS.contains(&basename))
340 {
341 return Err(MarsError::Config(ConfigError::Invalid {
342 message: format!(
343 "`--root {basename}` looks like a managed output directory.\n \
344 --root takes the project root (containing mars.toml), not the output directory.\n \
345 Try: mars init (auto-detects project root)\n \
346 Or: mars init {basename} (specify output directory name)"
347 ),
348 }));
349 }
350
351 root.to_path_buf()
352 } else {
353 std::env::current_dir()?
354 };
355
356 find_agents_root_from(&start)
357}
358
359fn find_agents_root_from(start: &Path) -> Result<MarsContext, MarsError> {
365 let start_canon = dunce::canonicalize(start).unwrap_or_else(|_| start.to_path_buf());
366 let mut dir = start_canon.as_path();
367
368 loop {
370 let config_path = dir.join("mars.toml");
371 if config_path.exists() {
372 return MarsContext::new(dir.to_path_buf());
373 }
374
375 match dir.parent() {
376 Some(parent) => dir = parent,
377 None => break,
378 }
379 }
380
381 Err(MarsError::Config(ConfigError::ProjectRootNotFound {
382 start: start.to_path_buf(),
383 }))
384}
385
386#[cfg(test)]
387mod tests {
388 use super::*;
389 use tempfile::TempDir;
390
391 #[test]
392 fn find_root_with_explicit_path() {
393 let dir = TempDir::new().unwrap();
394 let canonical_dir = dunce::canonicalize(dir.path()).unwrap();
396 std::fs::write(canonical_dir.join("mars.toml"), "[dependencies]\n").unwrap();
397
398 let ctx = find_agents_root(Some(&canonical_dir)).unwrap();
400 assert_eq!(ctx.project_root, canonical_dir);
401 assert_eq!(ctx.managed_root, ctx.project_root.join(".mars"));
402 }
403
404 #[test]
405 fn package_manifest_without_dependencies_is_valid_project_root() {
406 let dir = TempDir::new().unwrap();
407 std::fs::write(
408 dir.path().join("mars.toml"),
409 "[package]\nname = \"pkg\"\nversion = \"0.1.0\"\n",
410 )
411 .unwrap();
412
413 let ctx = find_agents_root(Some(dir.path())).unwrap();
414 assert_eq!(ctx.project_root, dunce::canonicalize(dir.path()).unwrap());
415 }
416
417 #[test]
418 fn find_root_ignores_leftover_agents_dir_without_explicit_config() {
419 let dir = TempDir::new().unwrap();
420 std::fs::write(dir.path().join("mars.toml"), "[dependencies]\n").unwrap();
421 std::fs::create_dir_all(dir.path().join(".agents")).unwrap();
422
423 let ctx = MarsContext::new(dir.path().to_path_buf()).unwrap();
424 assert_eq!(ctx.project_root, dunce::canonicalize(dir.path()).unwrap());
425 assert_eq!(ctx.managed_root, ctx.project_root.join(".mars"));
426 }
427
428 #[test]
429 fn find_root_with_custom_managed_dir_from_settings() {
430 let dir = TempDir::new().unwrap();
431 std::fs::write(
432 dir.path().join("mars.toml"),
433 "[dependencies]\n\n[settings]\nmanaged_root = \".claude\"\n",
434 )
435 .unwrap();
436 std::fs::create_dir_all(dir.path().join(".claude")).unwrap();
437
438 let ctx = MarsContext::new(dir.path().to_path_buf()).unwrap();
439 assert_eq!(
440 ctx.managed_root,
441 dunce::canonicalize(dir.path().join(".claude")).unwrap()
442 );
443 }
444
445 #[test]
446 fn find_root_with_agents_target_from_settings_targets() {
447 let dir = TempDir::new().unwrap();
448 std::fs::write(
449 dir.path().join("mars.toml"),
450 "[dependencies]\n\n[settings]\ntargets = [\".agents\"]\n",
451 )
452 .unwrap();
453 std::fs::create_dir_all(dir.path().join(".agents")).unwrap();
454
455 let ctx = MarsContext::new(dir.path().to_path_buf()).unwrap();
456 assert_eq!(
457 ctx.managed_root,
458 dunce::canonicalize(dir.path().join(".agents")).unwrap()
459 );
460 }
461
462 #[cfg(unix)]
463 #[test]
464 fn context_rejects_symlinked_managed_root_outside_project() {
465 let project_dir = TempDir::new().unwrap();
466 let external_dir = TempDir::new().unwrap();
467 std::fs::write(
468 project_dir.path().join("mars.toml"),
469 "[dependencies]\n\n[settings]\nmanaged_root = \".agents\"\n",
470 )
471 .unwrap();
472
473 let external_agents = external_dir.path().join(".agents");
474 std::fs::create_dir_all(&external_agents).unwrap();
475
476 let project_agents = project_dir.path().join(".agents");
477 std::os::unix::fs::symlink(&external_agents, &project_agents).unwrap();
478
479 let result = MarsContext::new(project_dir.path().to_path_buf());
480 assert!(result.is_err());
481 }
482
483 #[test]
484 fn detect_managed_root_reads_settings() {
485 let dir = TempDir::new().unwrap();
486 std::fs::write(
487 dir.path().join("mars.toml"),
488 "[dependencies]\n\n[settings]\nmanaged_root = \".claude\"\n",
489 )
490 .unwrap();
491 let result = detect_managed_root(dir.path()).unwrap();
492 assert_eq!(result, dir.path().join(".claude"));
493 }
494
495 #[test]
496 fn detect_managed_root_falls_through_on_missing_config() {
497 let dir = TempDir::new().unwrap();
498 let result = detect_managed_root(dir.path()).unwrap();
499 assert_eq!(result, dir.path().join(".mars"));
500 }
501
502 #[test]
503 fn detect_managed_root_ignores_agents_dir_without_explicit_config() {
504 let dir = TempDir::new().unwrap();
505 std::fs::write(dir.path().join("mars.toml"), "[dependencies]\n").unwrap();
506 std::fs::create_dir_all(dir.path().join(".agents")).unwrap();
507
508 let result = detect_managed_root(dir.path()).unwrap();
509 assert_eq!(result, dir.path().join(".mars"));
510 }
511
512 #[test]
513 fn detect_managed_root_surfaces_parse_errors() {
514 let dir = TempDir::new().unwrap();
515 std::fs::write(dir.path().join("mars.toml"), "invalid toml {{{").unwrap();
516 let result = detect_managed_root(dir.path());
517 assert!(result.is_err());
518 }
519
520 #[test]
521 fn init_rejects_root_that_looks_like_managed_dir() {
522 let result = find_agents_root(Some(Path::new(".agents")));
523 assert!(result.is_err());
524 let err = result.unwrap_err().to_string();
525 assert!(
526 err.contains("managed output directory"),
527 "should reject .agents as --root: {err}"
528 );
529 }
530
531 #[test]
534 fn walk_up_crosses_git_boundary_to_find_config() {
535 let dir = TempDir::new().unwrap();
538 let outer = dir.path().join("outer");
539 std::fs::create_dir_all(outer.join(".agents")).unwrap();
540 std::fs::write(outer.join("mars.toml"), "[dependencies]\n").unwrap();
541
542 let inner = outer.join("inner");
543 std::fs::create_dir_all(inner.join(".git")).unwrap();
544
545 let ctx = find_agents_root_from(&inner).unwrap();
546 assert_eq!(
547 ctx.project_root,
548 dunce::canonicalize(&outer).unwrap(),
549 "should find outer config even when inner has .git"
550 );
551 }
552
553 #[test]
554 fn walk_up_finds_config_in_ancestor() {
555 let dir = TempDir::new().unwrap();
556 let root = dir.path().join("project");
557 std::fs::create_dir_all(root.join(".agents")).unwrap();
558 std::fs::write(root.join("mars.toml"), "[dependencies]\n").unwrap();
559
560 let subdir = root.join("src").join("lib");
561 std::fs::create_dir_all(&subdir).unwrap();
562
563 let ctx = find_agents_root_from(&subdir).unwrap();
564 assert_eq!(ctx.project_root, dunce::canonicalize(&root).unwrap());
565 }
566
567 #[test]
568 fn walk_up_prefers_nearest_mars_toml() {
569 let dir = TempDir::new().unwrap();
571 let parent = dir.path().join("parent");
572 std::fs::create_dir_all(parent.join(".agents")).unwrap();
573 std::fs::write(parent.join("mars.toml"), "[dependencies]\n").unwrap();
574
575 let child = parent.join("child");
576 std::fs::create_dir_all(&child).unwrap();
577 std::fs::write(
578 child.join("mars.toml"),
579 "[package]\nname = \"pkg\"\nversion = \"0.1.0\"\n",
580 )
581 .unwrap();
582
583 let ctx = find_agents_root_from(&child).unwrap();
584 assert_eq!(ctx.project_root, dunce::canonicalize(&child).unwrap());
585 }
586
587 #[test]
588 fn walk_up_from_deep_subdirectory() {
589 let dir = TempDir::new().unwrap();
590 let root = dir.path().join("repo");
591 std::fs::create_dir_all(root.join(".agents")).unwrap();
592 std::fs::write(root.join("mars.toml"), "[dependencies]\n").unwrap();
593
594 let deep = root.join("src").join("foo").join("bar");
595 std::fs::create_dir_all(&deep).unwrap();
596
597 let ctx = find_agents_root_from(&deep).unwrap();
598 assert_eq!(ctx.project_root, dunce::canonicalize(&root).unwrap());
599 }
600
601 #[test]
602 fn walk_up_crosses_submodule_boundary() {
603 let dir = TempDir::new().unwrap();
606 let outer = dir.path().join("outer");
607 std::fs::create_dir_all(outer.join(".agents")).unwrap();
608 std::fs::write(outer.join("mars.toml"), "[dependencies]\n").unwrap();
609
610 let submodule = outer.join("submodule");
611 std::fs::create_dir_all(&submodule).unwrap();
612 std::fs::write(
614 submodule.join(".git"),
615 "gitdir: ../../.git/modules/submodule\n",
616 )
617 .unwrap();
618
619 let ctx = find_agents_root_from(&submodule).unwrap();
620 assert_eq!(
621 ctx.project_root,
622 dunce::canonicalize(&outer).unwrap(),
623 "should find outer config through submodule .git file boundary"
624 );
625 }
626
627 #[test]
628 fn walk_up_errors_when_no_config_found() {
629 let dir = TempDir::new().unwrap();
630 let deep = dir.path().join("a").join("b").join("c");
631 std::fs::create_dir_all(&deep).unwrap();
632
633 let result = find_agents_root_from(&deep);
634 assert!(result.is_err());
635 let err = result.unwrap_err().to_string();
636 assert!(
637 err.contains("no mars.toml found"),
638 "should report no config found: {err}"
639 );
640 assert!(
641 err.contains("filesystem root"),
642 "should mention filesystem root: {err}"
643 );
644 }
645
646 #[test]
647 fn walk_up_with_root_flag_starts_from_specified_path() {
648 let dir = TempDir::new().unwrap();
649 let project = dir.path().join("project");
650 std::fs::create_dir_all(project.join(".agents")).unwrap();
651 std::fs::write(project.join("mars.toml"), "[dependencies]\n").unwrap();
652
653 let subdir = project.join("src");
655 std::fs::create_dir_all(&subdir).unwrap();
656
657 let ctx = find_agents_root(Some(&subdir)).unwrap();
658 assert_eq!(ctx.project_root, dunce::canonicalize(&project).unwrap());
659 }
660}