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 model: Some(_),
258 ..
259 })
260 }),
261 MarsError::Config(ConfigError::ProjectRootNotFound { .. })
262 )
263 )
264}
265
266fn dispatch_with_root(cmd: &Command, ctx: &MarsContext, json: bool) -> Result<i32, MarsError> {
267 match cmd {
268 Command::Validate(args) => validate::run(args, ctx, json),
269 Command::Export(args) => export::run(args, ctx, json),
270 Command::Add(args) => add::run(args, ctx, json),
271 Command::Adopt(args) => adopt::run(args, ctx, json),
272 Command::Remove(args) => remove::run(args, ctx, json),
273 Command::Sync(args) => sync::run(args, ctx, json),
274 Command::Upgrade(args) => upgrade::run(args, ctx, json),
275 Command::Outdated(args) => outdated::run(args, ctx, json),
276 Command::Version(args) => version::run(args, ctx, json),
277 Command::List(args) => list::run(args, ctx, json),
278 Command::Why(args) => why::run(args, ctx, json),
279 Command::Rename(args) => rename::run(args, ctx, json),
280 Command::Resolve(args) => resolve_cmd::run(args, ctx, json),
281 Command::Override(args) => override_cmd::run(args, ctx, json),
282 Command::Link(args) => link::run(args, ctx, json),
283 Command::Unlink(args) => unlink::run(args, ctx, json),
284 Command::Doctor(args) => doctor::run(args, ctx, json),
285 Command::Repair(args) => repair::run(args, ctx, json),
286 Command::Models(args) => models::run(args, ctx, json),
287 Command::Build(args) => build::run(args, ctx, json),
288 Command::Init(_) | Command::Check(_) | Command::Cache(_) => unreachable!(),
290 }
291}
292
293pub fn is_symlink(path: &Path) -> bool {
295 path.symlink_metadata()
296 .map(|m| m.file_type().is_symlink())
297 .unwrap_or(false)
298}
299
300fn detect_managed_root(project_root: &Path) -> Result<PathBuf, MarsError> {
301 match crate::config::load(project_root) {
303 Ok(config) => {
304 if config.settings.managed_root.is_some()
305 && let Some(name) = config.settings.managed_targets().first()
306 {
307 return Ok(project_root.join(name));
308 }
309 if config
310 .settings
311 .managed_targets()
312 .iter()
313 .any(|target| target == WELL_KNOWN[0])
314 {
315 return Ok(project_root.join(WELL_KNOWN[0]));
316 }
317 }
318 Err(MarsError::Config(ConfigError::NotFound { .. })) => {}
320 Err(e) => return Err(e),
322 }
323
324 Ok(project_root.join(".mars"))
327}
328
329pub fn find_agents_root(explicit: Option<&Path>) -> Result<MarsContext, MarsError> {
337 let start = if let Some(root) = explicit {
338 if let Some(basename) = root.file_name().and_then(|f| f.to_str())
340 && (WELL_KNOWN.contains(&basename) || TOOL_DIRS.contains(&basename))
341 {
342 return Err(MarsError::Config(ConfigError::Invalid {
343 message: format!(
344 "`--root {basename}` looks like a managed output directory.\n \
345 --root takes the project root (containing mars.toml), not the output directory.\n \
346 Try: mars init (auto-detects project root)\n \
347 Or: mars init {basename} (specify output directory name)"
348 ),
349 }));
350 }
351
352 root.to_path_buf()
353 } else {
354 std::env::current_dir()?
355 };
356
357 find_agents_root_from(&start)
358}
359
360fn find_agents_root_from(start: &Path) -> Result<MarsContext, MarsError> {
366 let start_canon = dunce::canonicalize(start).unwrap_or_else(|_| start.to_path_buf());
367 let mut dir = start_canon.as_path();
368
369 loop {
371 let config_path = dir.join("mars.toml");
372 if config_path.exists() {
373 return MarsContext::new(dir.to_path_buf());
374 }
375
376 match dir.parent() {
377 Some(parent) => dir = parent,
378 None => break,
379 }
380 }
381
382 Err(MarsError::Config(ConfigError::ProjectRootNotFound {
383 start: start.to_path_buf(),
384 }))
385}
386
387#[cfg(test)]
388mod tests {
389 use super::*;
390 use tempfile::TempDir;
391
392 #[test]
393 fn find_root_with_explicit_path() {
394 let dir = TempDir::new().unwrap();
395 let canonical_dir = dunce::canonicalize(dir.path()).unwrap();
397 std::fs::write(canonical_dir.join("mars.toml"), "[dependencies]\n").unwrap();
398
399 let ctx = find_agents_root(Some(&canonical_dir)).unwrap();
401 assert_eq!(ctx.project_root, canonical_dir);
402 assert_eq!(ctx.managed_root, ctx.project_root.join(".mars"));
403 }
404
405 #[test]
406 fn package_manifest_without_dependencies_is_valid_project_root() {
407 let dir = TempDir::new().unwrap();
408 std::fs::write(
409 dir.path().join("mars.toml"),
410 "[package]\nname = \"pkg\"\nversion = \"0.1.0\"\n",
411 )
412 .unwrap();
413
414 let ctx = find_agents_root(Some(dir.path())).unwrap();
415 assert_eq!(ctx.project_root, dunce::canonicalize(dir.path()).unwrap());
416 }
417
418 #[test]
419 fn find_root_ignores_leftover_agents_dir_without_explicit_config() {
420 let dir = TempDir::new().unwrap();
421 std::fs::write(dir.path().join("mars.toml"), "[dependencies]\n").unwrap();
422 std::fs::create_dir_all(dir.path().join(".agents")).unwrap();
423
424 let ctx = MarsContext::new(dir.path().to_path_buf()).unwrap();
425 assert_eq!(ctx.project_root, dunce::canonicalize(dir.path()).unwrap());
426 assert_eq!(ctx.managed_root, ctx.project_root.join(".mars"));
427 }
428
429 #[test]
430 fn find_root_with_custom_managed_dir_from_settings() {
431 let dir = TempDir::new().unwrap();
432 std::fs::write(
433 dir.path().join("mars.toml"),
434 "[dependencies]\n\n[settings]\nmanaged_root = \".claude\"\n",
435 )
436 .unwrap();
437 std::fs::create_dir_all(dir.path().join(".claude")).unwrap();
438
439 let ctx = MarsContext::new(dir.path().to_path_buf()).unwrap();
440 assert_eq!(
441 ctx.managed_root,
442 dunce::canonicalize(dir.path().join(".claude")).unwrap()
443 );
444 }
445
446 #[test]
447 fn find_root_with_agents_target_from_settings_targets() {
448 let dir = TempDir::new().unwrap();
449 std::fs::write(
450 dir.path().join("mars.toml"),
451 "[dependencies]\n\n[settings]\ntargets = [\".agents\"]\n",
452 )
453 .unwrap();
454 std::fs::create_dir_all(dir.path().join(".agents")).unwrap();
455
456 let ctx = MarsContext::new(dir.path().to_path_buf()).unwrap();
457 assert_eq!(
458 ctx.managed_root,
459 dunce::canonicalize(dir.path().join(".agents")).unwrap()
460 );
461 }
462
463 #[cfg(unix)]
464 #[test]
465 fn context_rejects_symlinked_managed_root_outside_project() {
466 let project_dir = TempDir::new().unwrap();
467 let external_dir = TempDir::new().unwrap();
468 std::fs::write(
469 project_dir.path().join("mars.toml"),
470 "[dependencies]\n\n[settings]\nmanaged_root = \".agents\"\n",
471 )
472 .unwrap();
473
474 let external_agents = external_dir.path().join(".agents");
475 std::fs::create_dir_all(&external_agents).unwrap();
476
477 let project_agents = project_dir.path().join(".agents");
478 std::os::unix::fs::symlink(&external_agents, &project_agents).unwrap();
479
480 let result = MarsContext::new(project_dir.path().to_path_buf());
481 assert!(result.is_err());
482 }
483
484 #[test]
485 fn detect_managed_root_reads_settings() {
486 let dir = TempDir::new().unwrap();
487 std::fs::write(
488 dir.path().join("mars.toml"),
489 "[dependencies]\n\n[settings]\nmanaged_root = \".claude\"\n",
490 )
491 .unwrap();
492 let result = detect_managed_root(dir.path()).unwrap();
493 assert_eq!(result, dir.path().join(".claude"));
494 }
495
496 #[test]
497 fn detect_managed_root_falls_through_on_missing_config() {
498 let dir = TempDir::new().unwrap();
499 let result = detect_managed_root(dir.path()).unwrap();
500 assert_eq!(result, dir.path().join(".mars"));
501 }
502
503 #[test]
504 fn detect_managed_root_ignores_agents_dir_without_explicit_config() {
505 let dir = TempDir::new().unwrap();
506 std::fs::write(dir.path().join("mars.toml"), "[dependencies]\n").unwrap();
507 std::fs::create_dir_all(dir.path().join(".agents")).unwrap();
508
509 let result = detect_managed_root(dir.path()).unwrap();
510 assert_eq!(result, dir.path().join(".mars"));
511 }
512
513 #[test]
514 fn detect_managed_root_surfaces_parse_errors() {
515 let dir = TempDir::new().unwrap();
516 std::fs::write(dir.path().join("mars.toml"), "invalid toml {{{").unwrap();
517 let result = detect_managed_root(dir.path());
518 assert!(result.is_err());
519 }
520
521 #[test]
522 fn init_rejects_root_that_looks_like_managed_dir() {
523 let result = find_agents_root(Some(Path::new(".agents")));
524 assert!(result.is_err());
525 let err = result.unwrap_err().to_string();
526 assert!(
527 err.contains("managed output directory"),
528 "should reject .agents as --root: {err}"
529 );
530 }
531
532 #[test]
535 fn walk_up_crosses_git_boundary_to_find_config() {
536 let dir = TempDir::new().unwrap();
539 let outer = dir.path().join("outer");
540 std::fs::create_dir_all(outer.join(".agents")).unwrap();
541 std::fs::write(outer.join("mars.toml"), "[dependencies]\n").unwrap();
542
543 let inner = outer.join("inner");
544 std::fs::create_dir_all(inner.join(".git")).unwrap();
545
546 let ctx = find_agents_root_from(&inner).unwrap();
547 assert_eq!(
548 ctx.project_root,
549 dunce::canonicalize(&outer).unwrap(),
550 "should find outer config even when inner has .git"
551 );
552 }
553
554 #[test]
555 fn walk_up_finds_config_in_ancestor() {
556 let dir = TempDir::new().unwrap();
557 let root = dir.path().join("project");
558 std::fs::create_dir_all(root.join(".agents")).unwrap();
559 std::fs::write(root.join("mars.toml"), "[dependencies]\n").unwrap();
560
561 let subdir = root.join("src").join("lib");
562 std::fs::create_dir_all(&subdir).unwrap();
563
564 let ctx = find_agents_root_from(&subdir).unwrap();
565 assert_eq!(ctx.project_root, dunce::canonicalize(&root).unwrap());
566 }
567
568 #[test]
569 fn walk_up_prefers_nearest_mars_toml() {
570 let dir = TempDir::new().unwrap();
572 let parent = dir.path().join("parent");
573 std::fs::create_dir_all(parent.join(".agents")).unwrap();
574 std::fs::write(parent.join("mars.toml"), "[dependencies]\n").unwrap();
575
576 let child = parent.join("child");
577 std::fs::create_dir_all(&child).unwrap();
578 std::fs::write(
579 child.join("mars.toml"),
580 "[package]\nname = \"pkg\"\nversion = \"0.1.0\"\n",
581 )
582 .unwrap();
583
584 let ctx = find_agents_root_from(&child).unwrap();
585 assert_eq!(ctx.project_root, dunce::canonicalize(&child).unwrap());
586 }
587
588 #[test]
589 fn walk_up_from_deep_subdirectory() {
590 let dir = TempDir::new().unwrap();
591 let root = dir.path().join("repo");
592 std::fs::create_dir_all(root.join(".agents")).unwrap();
593 std::fs::write(root.join("mars.toml"), "[dependencies]\n").unwrap();
594
595 let deep = root.join("src").join("foo").join("bar");
596 std::fs::create_dir_all(&deep).unwrap();
597
598 let ctx = find_agents_root_from(&deep).unwrap();
599 assert_eq!(ctx.project_root, dunce::canonicalize(&root).unwrap());
600 }
601
602 #[test]
603 fn walk_up_crosses_submodule_boundary() {
604 let dir = TempDir::new().unwrap();
607 let outer = dir.path().join("outer");
608 std::fs::create_dir_all(outer.join(".agents")).unwrap();
609 std::fs::write(outer.join("mars.toml"), "[dependencies]\n").unwrap();
610
611 let submodule = outer.join("submodule");
612 std::fs::create_dir_all(&submodule).unwrap();
613 std::fs::write(
615 submodule.join(".git"),
616 "gitdir: ../../.git/modules/submodule\n",
617 )
618 .unwrap();
619
620 let ctx = find_agents_root_from(&submodule).unwrap();
621 assert_eq!(
622 ctx.project_root,
623 dunce::canonicalize(&outer).unwrap(),
624 "should find outer config through submodule .git file boundary"
625 );
626 }
627
628 #[test]
629 fn walk_up_errors_when_no_config_found() {
630 let dir = TempDir::new().unwrap();
631 let deep = dir.path().join("a").join("b").join("c");
632 std::fs::create_dir_all(&deep).unwrap();
633
634 let result = find_agents_root_from(&deep);
635 assert!(result.is_err());
636 let err = result.unwrap_err().to_string();
637 assert!(
638 err.contains("no mars.toml found"),
639 "should report no config found: {err}"
640 );
641 assert!(
642 err.contains("filesystem root"),
643 "should mention filesystem root: {err}"
644 );
645 }
646
647 #[test]
648 fn walk_up_with_root_flag_starts_from_specified_path() {
649 let dir = TempDir::new().unwrap();
650 let project = dir.path().join("project");
651 std::fs::create_dir_all(project.join(".agents")).unwrap();
652 std::fs::write(project.join("mars.toml"), "[dependencies]\n").unwrap();
653
654 let subdir = project.join("src");
656 std::fs::create_dir_all(&subdir).unwrap();
657
658 let ctx = find_agents_root(Some(&subdir)).unwrap();
659 assert_eq!(ctx.project_root, dunce::canonicalize(&project).unwrap());
660 }
661}