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