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