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