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", ".cursor"];
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 {
74 managed_root.clone()
75 };
76
77 if !managed_canon.starts_with(&project_canon) {
78 return Err(MarsError::Config(ConfigError::Invalid {
79 message: format!(
80 "{} resolves to {} which is outside {}. \
81 The managed root may be a symlink. Use --root to override.",
82 managed_root.display(),
83 managed_canon.display(),
84 project_canon.display(),
85 ),
86 }));
87 }
88
89 Ok(MarsContext {
90 managed_root: managed_canon,
91 project_root: project_canon,
92 meridian_managed: crate::types::meridian_managed_from_env(),
93 })
94 }
95}
96
97#[derive(Debug, Parser)]
99#[command(name = "mars", version, about = "Agent package manager")]
100pub struct Cli {
101 #[command(subcommand)]
102 pub command: Command,
103
104 #[arg(long, global = true)]
106 pub root: Option<PathBuf>,
107
108 #[arg(long, global = true)]
110 pub json: bool,
111}
112
113#[derive(Debug, Subcommand)]
114pub enum Command {
115 Init(init::InitArgs),
117
118 Add(add::AddArgs),
120
121 Adopt(adopt::AdoptArgs),
123
124 Remove(remove::RemoveArgs),
126
127 Sync(sync::SyncArgs),
129
130 Upgrade(upgrade::UpgradeArgs),
132
133 Outdated(outdated::OutdatedArgs),
135
136 Version(version::VersionArgs),
138
139 List(list::ListArgs),
141
142 Why(why::WhyArgs),
144
145 Rename(rename::RenameArgs),
147
148 Resolve(resolve_cmd::ResolveArgs),
150
151 Override(override_cmd::OverrideArgs),
153
154 Link(link::LinkArgs),
156
157 Unlink(unlink::UnlinkArgs),
159
160 Validate(validate::ValidateArgs),
162
163 Export(export::ExportArgs),
165
166 Check(check::CheckArgs),
168
169 Doctor(doctor::DoctorArgs),
171
172 Repair(repair::RepairArgs),
174
175 Cache(cache::CacheArgs),
177
178 Models(models::ModelsArgs),
180
181 Build(build::BuildArgs),
183}
184
185pub fn dispatch(cli: Cli) -> i32 {
188 match dispatch_result(cli) {
189 Ok(code) => code,
190 Err(err) => {
191 eprintln!("error: {err}");
192 if matches!(err, MarsError::Lock(LockError::Corrupt { .. })) {
193 eprintln!(
194 "hint: run `{}` to rebuild from mars.toml + dependencies",
195 managed_cmd("mars repair")
196 );
197 }
198 err.exit_code()
199 }
200 }
201}
202
203fn dispatch_result(cli: Cli) -> Result<i32, MarsError> {
204 match &cli.command {
205 Command::Init(args) => init::run(args, cli.root.as_deref(), cli.json),
207 Command::Check(args) => check::run(args, cli.json),
208 Command::Cache(args) => cache::run(args, cli.json),
209 cmd => {
211 let ctx = match find_agents_root(cli.root.as_deref()) {
212 Ok(ctx) => ctx,
213 Err(err) if should_auto_init_project(cmd, &err) => {
214 let initialized = init::initialize_project(cli.root.as_deref(), None)?;
215 if !cli.json {
216 output::print_info(&format!(
217 "auto-initialized {} with mars.toml",
218 initialized.project_root.display()
219 ));
220 }
221 MarsContext::from_roots(
222 initialized.project_root.clone(),
223 initialized
224 .managed_root
225 .clone()
226 .unwrap_or_else(|| initialized.project_root.join(".mars")),
227 )?
228 }
229 Err(err) => return Err(err),
230 };
231 dispatch_with_root(cmd, &ctx, cli.json)
232 }
233 }
234}
235
236fn should_auto_init_project(cmd: &Command, err: &MarsError) -> bool {
237 matches!(cmd, Command::Add(_) | Command::Link(_))
238 && matches!(
239 err,
240 MarsError::Config(ConfigError::ProjectRootNotFound { .. })
241 )
242}
243
244fn dispatch_with_root(cmd: &Command, ctx: &MarsContext, json: bool) -> Result<i32, MarsError> {
245 match cmd {
246 Command::Validate(args) => validate::run(args, ctx, json),
247 Command::Export(args) => export::run(args, ctx, json),
248 Command::Add(args) => add::run(args, ctx, json),
249 Command::Adopt(args) => adopt::run(args, ctx, json),
250 Command::Remove(args) => remove::run(args, ctx, json),
251 Command::Sync(args) => sync::run(args, ctx, json),
252 Command::Upgrade(args) => upgrade::run(args, ctx, json),
253 Command::Outdated(args) => outdated::run(args, ctx, json),
254 Command::Version(args) => version::run(args, ctx, json),
255 Command::List(args) => list::run(args, ctx, json),
256 Command::Why(args) => why::run(args, ctx, json),
257 Command::Rename(args) => rename::run(args, ctx, json),
258 Command::Resolve(args) => resolve_cmd::run(args, ctx, json),
259 Command::Override(args) => override_cmd::run(args, ctx, json),
260 Command::Link(args) => link::run(args, ctx, json),
261 Command::Unlink(args) => unlink::run(args, ctx, json),
262 Command::Doctor(args) => doctor::run(args, ctx, json),
263 Command::Repair(args) => repair::run(args, ctx, json),
264 Command::Models(args) => models::run(args, ctx, json),
265 Command::Build(args) => build::run(args, ctx, json),
266 Command::Init(_) | Command::Check(_) | Command::Cache(_) => unreachable!(),
268 }
269}
270
271pub fn is_symlink(path: &Path) -> bool {
273 path.symlink_metadata()
274 .map(|m| m.file_type().is_symlink())
275 .unwrap_or(false)
276}
277
278fn detect_managed_root(project_root: &Path) -> Result<PathBuf, MarsError> {
279 match crate::config::load(project_root) {
281 Ok(config) => {
282 if let Some(name) = &config.settings.managed_root {
283 return Ok(project_root.join(name));
284 }
285 if config
286 .settings
287 .targets
288 .as_ref()
289 .is_some_and(|targets| targets.iter().any(|target| target == WELL_KNOWN[0]))
290 {
291 return Ok(project_root.join(WELL_KNOWN[0]));
292 }
293 }
294 Err(MarsError::Config(ConfigError::NotFound { .. })) => {}
296 Err(e) => return Err(e),
298 }
299
300 Ok(project_root.join(".mars"))
303}
304
305pub fn find_agents_root(explicit: Option<&Path>) -> Result<MarsContext, MarsError> {
313 let start = if let Some(root) = explicit {
314 if let Some(basename) = root.file_name().and_then(|f| f.to_str())
316 && (WELL_KNOWN.contains(&basename) || TOOL_DIRS.contains(&basename))
317 {
318 return Err(MarsError::Config(ConfigError::Invalid {
319 message: format!(
320 "`--root {basename}` looks like a managed output directory.\n \
321 --root takes the project root (containing mars.toml), not the output directory.\n \
322 Try: mars init (auto-detects project root)\n \
323 Or: mars init {basename} (specify output directory name)"
324 ),
325 }));
326 }
327
328 root.to_path_buf()
329 } else {
330 std::env::current_dir()?
331 };
332
333 find_agents_root_from(&start)
334}
335
336fn find_agents_root_from(start: &Path) -> Result<MarsContext, MarsError> {
342 let start_canon = dunce::canonicalize(start).unwrap_or_else(|_| start.to_path_buf());
343 let mut dir = start_canon.as_path();
344
345 loop {
347 let config_path = dir.join("mars.toml");
348 if config_path.exists() {
349 return MarsContext::new(dir.to_path_buf());
350 }
351
352 match dir.parent() {
353 Some(parent) => dir = parent,
354 None => break,
355 }
356 }
357
358 Err(MarsError::Config(ConfigError::ProjectRootNotFound {
359 start: start.to_path_buf(),
360 }))
361}
362
363#[cfg(test)]
364mod tests {
365 use super::*;
366 use tempfile::TempDir;
367
368 #[test]
369 fn find_root_with_explicit_path() {
370 let dir = TempDir::new().unwrap();
371 let canonical_dir = dunce::canonicalize(dir.path()).unwrap();
373 std::fs::write(canonical_dir.join("mars.toml"), "[dependencies]\n").unwrap();
374
375 let ctx = find_agents_root(Some(&canonical_dir)).unwrap();
377 assert_eq!(ctx.project_root, canonical_dir);
378 assert_eq!(ctx.managed_root, ctx.project_root.join(".mars"));
379 }
380
381 #[test]
382 fn package_manifest_without_dependencies_is_valid_project_root() {
383 let dir = TempDir::new().unwrap();
384 std::fs::write(
385 dir.path().join("mars.toml"),
386 "[package]\nname = \"pkg\"\nversion = \"0.1.0\"\n",
387 )
388 .unwrap();
389
390 let ctx = find_agents_root(Some(dir.path())).unwrap();
391 assert_eq!(ctx.project_root, dunce::canonicalize(dir.path()).unwrap());
392 }
393
394 #[test]
395 fn find_root_ignores_leftover_agents_dir_without_explicit_config() {
396 let dir = TempDir::new().unwrap();
397 std::fs::write(dir.path().join("mars.toml"), "[dependencies]\n").unwrap();
398 std::fs::create_dir_all(dir.path().join(".agents")).unwrap();
399
400 let ctx = MarsContext::new(dir.path().to_path_buf()).unwrap();
401 assert_eq!(ctx.project_root, dunce::canonicalize(dir.path()).unwrap());
402 assert_eq!(ctx.managed_root, ctx.project_root.join(".mars"));
403 }
404
405 #[test]
406 fn find_root_with_custom_managed_dir_from_settings() {
407 let dir = TempDir::new().unwrap();
408 std::fs::write(
409 dir.path().join("mars.toml"),
410 "[dependencies]\n\n[settings]\nmanaged_root = \".claude\"\n",
411 )
412 .unwrap();
413 std::fs::create_dir_all(dir.path().join(".claude")).unwrap();
414
415 let ctx = MarsContext::new(dir.path().to_path_buf()).unwrap();
416 assert_eq!(
417 ctx.managed_root,
418 dunce::canonicalize(dir.path().join(".claude")).unwrap()
419 );
420 }
421
422 #[test]
423 fn find_root_with_agents_target_from_settings_targets() {
424 let dir = TempDir::new().unwrap();
425 std::fs::write(
426 dir.path().join("mars.toml"),
427 "[dependencies]\n\n[settings]\ntargets = [\".agents\"]\n",
428 )
429 .unwrap();
430 std::fs::create_dir_all(dir.path().join(".agents")).unwrap();
431
432 let ctx = MarsContext::new(dir.path().to_path_buf()).unwrap();
433 assert_eq!(
434 ctx.managed_root,
435 dunce::canonicalize(dir.path().join(".agents")).unwrap()
436 );
437 }
438
439 #[cfg(unix)]
440 #[test]
441 fn context_rejects_symlinked_managed_root_outside_project() {
442 let project_dir = TempDir::new().unwrap();
443 let external_dir = TempDir::new().unwrap();
444 std::fs::write(
445 project_dir.path().join("mars.toml"),
446 "[dependencies]\n\n[settings]\nmanaged_root = \".agents\"\n",
447 )
448 .unwrap();
449
450 let external_agents = external_dir.path().join(".agents");
451 std::fs::create_dir_all(&external_agents).unwrap();
452
453 let project_agents = project_dir.path().join(".agents");
454 std::os::unix::fs::symlink(&external_agents, &project_agents).unwrap();
455
456 let result = MarsContext::new(project_dir.path().to_path_buf());
457 assert!(result.is_err());
458 }
459
460 #[test]
461 fn detect_managed_root_reads_settings() {
462 let dir = TempDir::new().unwrap();
463 std::fs::write(
464 dir.path().join("mars.toml"),
465 "[dependencies]\n\n[settings]\nmanaged_root = \".claude\"\n",
466 )
467 .unwrap();
468 let result = detect_managed_root(dir.path()).unwrap();
469 assert_eq!(result, dir.path().join(".claude"));
470 }
471
472 #[test]
473 fn detect_managed_root_falls_through_on_missing_config() {
474 let dir = TempDir::new().unwrap();
475 let result = detect_managed_root(dir.path()).unwrap();
476 assert_eq!(result, dir.path().join(".mars"));
477 }
478
479 #[test]
480 fn detect_managed_root_ignores_agents_dir_without_explicit_config() {
481 let dir = TempDir::new().unwrap();
482 std::fs::write(dir.path().join("mars.toml"), "[dependencies]\n").unwrap();
483 std::fs::create_dir_all(dir.path().join(".agents")).unwrap();
484
485 let result = detect_managed_root(dir.path()).unwrap();
486 assert_eq!(result, dir.path().join(".mars"));
487 }
488
489 #[test]
490 fn detect_managed_root_surfaces_parse_errors() {
491 let dir = TempDir::new().unwrap();
492 std::fs::write(dir.path().join("mars.toml"), "invalid toml {{{").unwrap();
493 let result = detect_managed_root(dir.path());
494 assert!(result.is_err());
495 }
496
497 #[test]
498 fn init_rejects_root_that_looks_like_managed_dir() {
499 let result = find_agents_root(Some(Path::new(".agents")));
500 assert!(result.is_err());
501 let err = result.unwrap_err().to_string();
502 assert!(
503 err.contains("managed output directory"),
504 "should reject .agents as --root: {err}"
505 );
506 }
507
508 #[test]
511 fn walk_up_crosses_git_boundary_to_find_config() {
512 let dir = TempDir::new().unwrap();
515 let outer = dir.path().join("outer");
516 std::fs::create_dir_all(outer.join(".agents")).unwrap();
517 std::fs::write(outer.join("mars.toml"), "[dependencies]\n").unwrap();
518
519 let inner = outer.join("inner");
520 std::fs::create_dir_all(inner.join(".git")).unwrap();
521
522 let ctx = find_agents_root_from(&inner).unwrap();
523 assert_eq!(
524 ctx.project_root,
525 dunce::canonicalize(&outer).unwrap(),
526 "should find outer config even when inner has .git"
527 );
528 }
529
530 #[test]
531 fn walk_up_finds_config_in_ancestor() {
532 let dir = TempDir::new().unwrap();
533 let root = dir.path().join("project");
534 std::fs::create_dir_all(root.join(".agents")).unwrap();
535 std::fs::write(root.join("mars.toml"), "[dependencies]\n").unwrap();
536
537 let subdir = root.join("src").join("lib");
538 std::fs::create_dir_all(&subdir).unwrap();
539
540 let ctx = find_agents_root_from(&subdir).unwrap();
541 assert_eq!(ctx.project_root, dunce::canonicalize(&root).unwrap());
542 }
543
544 #[test]
545 fn walk_up_prefers_nearest_mars_toml() {
546 let dir = TempDir::new().unwrap();
548 let parent = dir.path().join("parent");
549 std::fs::create_dir_all(parent.join(".agents")).unwrap();
550 std::fs::write(parent.join("mars.toml"), "[dependencies]\n").unwrap();
551
552 let child = parent.join("child");
553 std::fs::create_dir_all(&child).unwrap();
554 std::fs::write(
555 child.join("mars.toml"),
556 "[package]\nname = \"pkg\"\nversion = \"0.1.0\"\n",
557 )
558 .unwrap();
559
560 let ctx = find_agents_root_from(&child).unwrap();
561 assert_eq!(ctx.project_root, dunce::canonicalize(&child).unwrap());
562 }
563
564 #[test]
565 fn walk_up_from_deep_subdirectory() {
566 let dir = TempDir::new().unwrap();
567 let root = dir.path().join("repo");
568 std::fs::create_dir_all(root.join(".agents")).unwrap();
569 std::fs::write(root.join("mars.toml"), "[dependencies]\n").unwrap();
570
571 let deep = root.join("src").join("foo").join("bar");
572 std::fs::create_dir_all(&deep).unwrap();
573
574 let ctx = find_agents_root_from(&deep).unwrap();
575 assert_eq!(ctx.project_root, dunce::canonicalize(&root).unwrap());
576 }
577
578 #[test]
579 fn walk_up_crosses_submodule_boundary() {
580 let dir = TempDir::new().unwrap();
583 let outer = dir.path().join("outer");
584 std::fs::create_dir_all(outer.join(".agents")).unwrap();
585 std::fs::write(outer.join("mars.toml"), "[dependencies]\n").unwrap();
586
587 let submodule = outer.join("submodule");
588 std::fs::create_dir_all(&submodule).unwrap();
589 std::fs::write(
591 submodule.join(".git"),
592 "gitdir: ../../.git/modules/submodule\n",
593 )
594 .unwrap();
595
596 let ctx = find_agents_root_from(&submodule).unwrap();
597 assert_eq!(
598 ctx.project_root,
599 dunce::canonicalize(&outer).unwrap(),
600 "should find outer config through submodule .git file boundary"
601 );
602 }
603
604 #[test]
605 fn walk_up_errors_when_no_config_found() {
606 let dir = TempDir::new().unwrap();
607 let deep = dir.path().join("a").join("b").join("c");
608 std::fs::create_dir_all(&deep).unwrap();
609
610 let result = find_agents_root_from(&deep);
611 assert!(result.is_err());
612 let err = result.unwrap_err().to_string();
613 assert!(
614 err.contains("no mars.toml found"),
615 "should report no config found: {err}"
616 );
617 assert!(
618 err.contains("filesystem root"),
619 "should mention filesystem root: {err}"
620 );
621 }
622
623 #[test]
624 fn walk_up_with_root_flag_starts_from_specified_path() {
625 let dir = TempDir::new().unwrap();
626 let project = dir.path().join("project");
627 std::fs::create_dir_all(project.join(".agents")).unwrap();
628 std::fs::write(project.join("mars.toml"), "[dependencies]\n").unwrap();
629
630 let subdir = project.join("src");
632 std::fs::create_dir_all(&subdir).unwrap();
633
634 let ctx = find_agents_root(Some(&subdir)).unwrap();
635 assert_eq!(ctx.project_root, dunce::canonicalize(&project).unwrap());
636 }
637}