1pub mod add;
11pub mod adopt;
12pub mod agents;
13pub mod build;
14pub mod cache;
15pub mod check;
16pub(crate) mod conflict_markers;
17pub mod doctor;
18pub mod export;
19pub mod init;
20pub mod link;
21pub mod list;
22pub mod models;
23mod models_common;
24mod models_prompting;
25pub mod outdated;
26pub mod output;
27pub mod override_cmd;
28pub mod remove;
29pub mod rename;
30pub mod repair;
31pub mod resolve_cmd;
32pub mod skills;
33pub mod sync;
34pub mod target;
35pub mod unlink;
36pub mod upgrade;
37pub mod validate;
38pub mod version;
39pub mod why;
40
41use std::path::{Path, PathBuf};
42
43use clap::{Parser, Subcommand};
44
45use crate::error::{ConfigError, LockError, MarsError};
46pub use crate::types::MarsContext;
47use crate::types::managed_cmd;
48
49pub const WELL_KNOWN: &[&str] = &[".agents"];
51
52pub const TOOL_DIRS: &[&str] = &[".claude", ".codex", ".opencode", ".cursor", ".pi"];
55
56impl MarsContext {
57 pub fn new(project_root: PathBuf) -> Result<Self, MarsError> {
59 let project_canon = if project_root.exists() {
60 dunce::canonicalize(&project_root).unwrap_or(project_root.clone())
61 } else {
62 project_root.clone()
63 };
64
65 let managed_root = detect_managed_root(&project_canon)?;
66 Self::from_roots(project_canon, managed_root)
67 }
68
69 pub fn from_roots(project_root: PathBuf, managed_root: PathBuf) -> Result<Self, MarsError> {
71 let project_canon = if project_root.exists() {
72 dunce::canonicalize(&project_root).unwrap_or(project_root.clone())
73 } else {
74 project_root.clone()
75 };
76 let managed_canon = if managed_root.exists() {
77 dunce::canonicalize(&managed_root).unwrap_or(managed_root.clone())
78 } else if let Ok(relative_managed) = managed_root.strip_prefix(&project_root) {
79 project_canon.join(relative_managed)
80 } else {
81 managed_root.clone()
82 };
83
84 if !managed_canon.starts_with(&project_canon) {
85 return Err(MarsError::Config(ConfigError::Invalid {
86 message: format!(
87 "{} resolves to {} which is outside {}. \
88 The managed root may be a symlink. Use --root to override.",
89 managed_root.display(),
90 managed_canon.display(),
91 project_canon.display(),
92 ),
93 }));
94 }
95
96 Ok(MarsContext {
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 #[test]
403 fn package_manifest_without_dependencies_is_valid_project_root() {
404 let dir = TempDir::new().unwrap();
405 std::fs::write(
406 dir.path().join("mars.toml"),
407 "[package]\nname = \"pkg\"\nversion = \"0.1.0\"\n",
408 )
409 .unwrap();
410
411 let ctx = find_agents_root(Some(dir.path())).unwrap();
412 assert_eq!(ctx.project_root, dunce::canonicalize(dir.path()).unwrap());
413 }
414 #[cfg(unix)]
415 #[test]
416 fn context_rejects_symlinked_managed_root_outside_project() {
417 let project_dir = TempDir::new().unwrap();
418 let external_dir = TempDir::new().unwrap();
419 std::fs::write(
420 project_dir.path().join("mars.toml"),
421 "[dependencies]\n\n[settings]\nmanaged_root = \".agents\"\n",
422 )
423 .unwrap();
424
425 let external_agents = external_dir.path().join(".agents");
426 std::fs::create_dir_all(&external_agents).unwrap();
427
428 let project_agents = project_dir.path().join(".agents");
429 std::os::unix::fs::symlink(&external_agents, &project_agents).unwrap();
430
431 let result = MarsContext::new(project_dir.path().to_path_buf());
432 assert!(result.is_err());
433 }
434
435 #[test]
436 fn detect_managed_root_reads_settings() {
437 let dir = TempDir::new().unwrap();
438 std::fs::write(
439 dir.path().join("mars.toml"),
440 "[dependencies]\n\n[settings]\nmanaged_root = \".claude\"\n",
441 )
442 .unwrap();
443 let result = detect_managed_root(dir.path()).unwrap();
444 assert_eq!(result, dir.path().join(".claude"));
445 }
446
447 #[test]
448 fn detect_managed_root_falls_through_on_missing_config() {
449 let dir = TempDir::new().unwrap();
450 let result = detect_managed_root(dir.path()).unwrap();
451 assert_eq!(result, dir.path().join(".mars"));
452 }
453
454 #[test]
455 fn detect_managed_root_ignores_agents_dir_without_explicit_config() {
456 let dir = TempDir::new().unwrap();
457 std::fs::write(dir.path().join("mars.toml"), "[dependencies]\n").unwrap();
458 std::fs::create_dir_all(dir.path().join(".agents")).unwrap();
459
460 let result = detect_managed_root(dir.path()).unwrap();
461 assert_eq!(result, dir.path().join(".mars"));
462 }
463
464 #[test]
465 fn detect_managed_root_surfaces_parse_errors() {
466 let dir = TempDir::new().unwrap();
467 std::fs::write(dir.path().join("mars.toml"), "invalid toml {{{").unwrap();
468 let result = detect_managed_root(dir.path());
469 assert!(result.is_err());
470 }
471
472 #[test]
473 fn init_rejects_root_that_looks_like_managed_dir() {
474 let result = find_agents_root(Some(Path::new(".agents")));
475 assert!(result.is_err());
476 let err = result.unwrap_err().to_string();
477 assert!(
478 err.contains("managed output directory"),
479 "should reject .agents as --root: {err}"
480 );
481 }
482
483 #[test]
486 fn walk_up_crosses_git_boundary_to_find_config() {
487 let dir = TempDir::new().unwrap();
490 let outer = dir.path().join("outer");
491 std::fs::create_dir_all(outer.join(".agents")).unwrap();
492 std::fs::write(outer.join("mars.toml"), "[dependencies]\n").unwrap();
493
494 let inner = outer.join("inner");
495 std::fs::create_dir_all(inner.join(".git")).unwrap();
496
497 let ctx = find_agents_root_from(&inner).unwrap();
498 assert_eq!(
499 ctx.project_root,
500 dunce::canonicalize(&outer).unwrap(),
501 "should find outer config even when inner has .git"
502 );
503 }
504
505 #[test]
506 fn walk_up_finds_config_in_ancestor() {
507 let dir = TempDir::new().unwrap();
508 let root = dir.path().join("project");
509 std::fs::create_dir_all(root.join(".agents")).unwrap();
510 std::fs::write(root.join("mars.toml"), "[dependencies]\n").unwrap();
511
512 let subdir = root.join("src").join("lib");
513 std::fs::create_dir_all(&subdir).unwrap();
514
515 let ctx = find_agents_root_from(&subdir).unwrap();
516 assert_eq!(ctx.project_root, dunce::canonicalize(&root).unwrap());
517 }
518
519 #[test]
520 fn walk_up_prefers_nearest_mars_toml() {
521 let dir = TempDir::new().unwrap();
523 let parent = dir.path().join("parent");
524 std::fs::create_dir_all(parent.join(".agents")).unwrap();
525 std::fs::write(parent.join("mars.toml"), "[dependencies]\n").unwrap();
526
527 let child = parent.join("child");
528 std::fs::create_dir_all(&child).unwrap();
529 std::fs::write(
530 child.join("mars.toml"),
531 "[package]\nname = \"pkg\"\nversion = \"0.1.0\"\n",
532 )
533 .unwrap();
534
535 let ctx = find_agents_root_from(&child).unwrap();
536 assert_eq!(ctx.project_root, dunce::canonicalize(&child).unwrap());
537 }
538
539 #[test]
540 fn walk_up_from_deep_subdirectory() {
541 let dir = TempDir::new().unwrap();
542 let root = dir.path().join("repo");
543 std::fs::create_dir_all(root.join(".agents")).unwrap();
544 std::fs::write(root.join("mars.toml"), "[dependencies]\n").unwrap();
545
546 let deep = root.join("src").join("foo").join("bar");
547 std::fs::create_dir_all(&deep).unwrap();
548
549 let ctx = find_agents_root_from(&deep).unwrap();
550 assert_eq!(ctx.project_root, dunce::canonicalize(&root).unwrap());
551 }
552
553 #[test]
554 fn walk_up_crosses_submodule_boundary() {
555 let dir = TempDir::new().unwrap();
558 let outer = dir.path().join("outer");
559 std::fs::create_dir_all(outer.join(".agents")).unwrap();
560 std::fs::write(outer.join("mars.toml"), "[dependencies]\n").unwrap();
561
562 let submodule = outer.join("submodule");
563 std::fs::create_dir_all(&submodule).unwrap();
564 std::fs::write(
566 submodule.join(".git"),
567 "gitdir: ../../.git/modules/submodule\n",
568 )
569 .unwrap();
570
571 let ctx = find_agents_root_from(&submodule).unwrap();
572 assert_eq!(
573 ctx.project_root,
574 dunce::canonicalize(&outer).unwrap(),
575 "should find outer config through submodule .git file boundary"
576 );
577 }
578
579 #[test]
580 fn walk_up_errors_when_no_config_found() {
581 let dir = TempDir::new().unwrap();
582 let deep = dir.path().join("a").join("b").join("c");
583 std::fs::create_dir_all(&deep).unwrap();
584
585 let result = find_agents_root_from(&deep);
586 assert!(result.is_err());
587 let err = result.unwrap_err().to_string();
588 assert!(
589 err.contains("no mars.toml found"),
590 "should report no config found: {err}"
591 );
592 assert!(
593 err.contains("filesystem root"),
594 "should mention filesystem root: {err}"
595 );
596 }
597
598 #[test]
599 fn walk_up_with_root_flag_starts_from_specified_path() {
600 let dir = TempDir::new().unwrap();
601 let project = dir.path().join("project");
602 std::fs::create_dir_all(project.join(".agents")).unwrap();
603 std::fs::write(project.join("mars.toml"), "[dependencies]\n").unwrap();
604
605 let subdir = project.join("src");
607 std::fs::create_dir_all(&subdir).unwrap();
608
609 let ctx = find_agents_root(Some(&subdir)).unwrap();
610 assert_eq!(ctx.project_root, dunce::canonicalize(&project).unwrap());
611 }
612}