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