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