1pub mod add;
11pub mod adopt;
12pub mod cache;
13pub mod check;
14pub mod doctor;
15pub mod init;
16pub mod link;
17pub mod list;
18pub mod models;
19pub mod outdated;
20pub mod output;
21pub mod override_cmd;
22pub mod remove;
23pub mod rename;
24pub mod repair;
25pub mod resolve_cmd;
26pub mod sync;
27pub mod upgrade;
28pub mod version;
29pub mod why;
30
31use std::path::{Path, PathBuf};
32
33use clap::{Parser, Subcommand};
34
35use crate::error::{ConfigError, LockError, MarsError};
36pub use crate::types::MarsContext;
37
38pub const WELL_KNOWN: &[&str] = &[".agents"];
41
42pub const TOOL_DIRS: &[&str] = &[".claude", ".cursor"];
45
46impl MarsContext {
47 pub fn new(project_root: PathBuf) -> Result<Self, MarsError> {
49 let project_canon = if project_root.exists() {
50 dunce::canonicalize(&project_root).unwrap_or(project_root.clone())
51 } else {
52 project_root.clone()
53 };
54
55 let managed_root = detect_managed_root(&project_canon)?;
56 Self::from_roots(project_canon, managed_root)
57 }
58
59 pub fn from_roots(project_root: PathBuf, managed_root: PathBuf) -> Result<Self, MarsError> {
61 let project_canon = if project_root.exists() {
62 dunce::canonicalize(&project_root).unwrap_or(project_root.clone())
63 } else {
64 project_root.clone()
65 };
66 let managed_canon = if managed_root.exists() {
67 dunce::canonicalize(&managed_root).unwrap_or(managed_root.clone())
68 } else {
69 managed_root.clone()
70 };
71
72 if !managed_canon.starts_with(&project_canon) {
73 return Err(MarsError::Config(ConfigError::Invalid {
74 message: format!(
75 "{} resolves to {} which is outside {}. \
76 The managed root may be a symlink. Use --root to override.",
77 managed_root.display(),
78 managed_canon.display(),
79 project_canon.display(),
80 ),
81 }));
82 }
83
84 Ok(MarsContext {
85 managed_root: managed_canon,
86 project_root: project_canon,
87 })
88 }
89}
90
91#[derive(Debug, Parser)]
93#[command(name = "mars", version, about = "Agent package manager for .agents/")]
94pub struct Cli {
95 #[command(subcommand)]
96 pub command: Command,
97
98 #[arg(long, global = true)]
100 pub root: Option<PathBuf>,
101
102 #[arg(long, global = true)]
104 pub json: bool,
105}
106
107#[derive(Debug, Subcommand)]
108pub enum Command {
109 Init(init::InitArgs),
111
112 Add(add::AddArgs),
114
115 Adopt(adopt::AdoptArgs),
117
118 Remove(remove::RemoveArgs),
120
121 Sync(sync::SyncArgs),
123
124 Upgrade(upgrade::UpgradeArgs),
126
127 Outdated(outdated::OutdatedArgs),
129
130 Version(version::VersionArgs),
132
133 List(list::ListArgs),
135
136 Why(why::WhyArgs),
138
139 Rename(rename::RenameArgs),
141
142 Resolve(resolve_cmd::ResolveArgs),
144
145 Override(override_cmd::OverrideArgs),
147
148 Link(link::LinkArgs),
150
151 Check(check::CheckArgs),
153
154 Doctor(doctor::DoctorArgs),
156
157 Repair(repair::RepairArgs),
159
160 Cache(cache::CacheArgs),
162
163 Models(models::ModelsArgs),
165}
166
167pub fn dispatch(cli: Cli) -> i32 {
170 match dispatch_result(cli) {
171 Ok(code) => code,
172 Err(err) => {
173 eprintln!("error: {err}");
174 if matches!(err, MarsError::Lock(LockError::Corrupt { .. })) {
175 eprintln!("hint: run `mars repair` to rebuild from mars.toml + dependencies");
176 }
177 err.exit_code()
178 }
179 }
180}
181
182fn dispatch_result(cli: Cli) -> Result<i32, MarsError> {
183 match &cli.command {
184 Command::Init(args) => init::run(args, cli.root.as_deref(), cli.json),
186 Command::Check(args) => check::run(args, cli.json),
187 Command::Cache(args) => cache::run(args, cli.json),
188 cmd => {
190 let ctx = match find_agents_root(cli.root.as_deref()) {
191 Ok(ctx) => ctx,
192 Err(err) if should_auto_init_project(cmd, &err) => {
193 let initialized = init::initialize_project(cli.root.as_deref(), None)?;
194 if !cli.json {
195 output::print_info(&format!(
196 "auto-initialized {} with mars.toml",
197 initialized.project_root.display()
198 ));
199 }
200 MarsContext::from_roots(
201 initialized.project_root.clone(),
202 initialized.managed_root.clone(),
203 )?
204 }
205 Err(err) => return Err(err),
206 };
207 dispatch_with_root(cmd, &ctx, cli.json)
208 }
209 }
210}
211
212fn should_auto_init_project(cmd: &Command, err: &MarsError) -> bool {
213 matches!(cmd, Command::Add(_) | Command::Link(_))
214 && matches!(
215 err,
216 MarsError::Config(ConfigError::ProjectRootNotFound { .. })
217 )
218}
219
220fn dispatch_with_root(cmd: &Command, ctx: &MarsContext, json: bool) -> Result<i32, MarsError> {
221 match cmd {
222 Command::Add(args) => add::run(args, ctx, json),
223 Command::Adopt(args) => adopt::run(args, ctx, json),
224 Command::Remove(args) => remove::run(args, ctx, json),
225 Command::Sync(args) => sync::run(args, ctx, json),
226 Command::Upgrade(args) => upgrade::run(args, ctx, json),
227 Command::Outdated(args) => outdated::run(args, ctx, json),
228 Command::Version(args) => version::run(args, ctx, json),
229 Command::List(args) => list::run(args, ctx, json),
230 Command::Why(args) => why::run(args, ctx, json),
231 Command::Rename(args) => rename::run(args, ctx, json),
232 Command::Resolve(args) => resolve_cmd::run(args, ctx, json),
233 Command::Override(args) => override_cmd::run(args, ctx, json),
234 Command::Link(args) => link::run(args, ctx, json),
235 Command::Doctor(args) => doctor::run(args, ctx, json),
236 Command::Repair(args) => repair::run(args, ctx, json),
237 Command::Models(args) => models::run(args, ctx, json),
238 Command::Init(_) | Command::Check(_) | Command::Cache(_) => unreachable!(),
240 }
241}
242
243pub fn is_symlink(path: &Path) -> bool {
245 path.symlink_metadata()
246 .map(|m| m.file_type().is_symlink())
247 .unwrap_or(false)
248}
249
250fn detect_managed_root(project_root: &Path) -> Result<PathBuf, MarsError> {
251 match crate::config::load(project_root) {
253 Ok(config) => {
254 if let Some(name) = &config.settings.managed_root {
255 return Ok(project_root.join(name));
256 }
257 }
258 Err(MarsError::Config(ConfigError::NotFound { .. })) => {}
260 Err(e) => return Err(e),
262 }
263
264 let default_root = project_root.join(WELL_KNOWN[0]);
266 if default_root.exists() || is_symlink(&default_root) {
267 return Ok(default_root);
268 }
269
270 let mut marked_roots: Vec<PathBuf> = Vec::new();
272 if let Ok(entries) = std::fs::read_dir(project_root) {
273 for entry in entries.flatten() {
274 let path = entry.path();
275 if path.join(".mars").exists() {
276 marked_roots.push(path);
277 }
278 }
279 }
280
281 if marked_roots.len() == 1 {
282 return Ok(marked_roots.remove(0));
283 }
284
285 for subdir in TOOL_DIRS {
286 let candidate = project_root.join(subdir);
287 if marked_roots.iter().any(|p| p == &candidate) {
288 return Ok(candidate);
289 }
290 }
291
292 marked_roots.sort();
293 if let Some(first) = marked_roots.into_iter().next() {
294 return Ok(first);
295 }
296
297 Ok(default_root)
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(".agents"));
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_with_default_managed_dir() {
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!(
398 ctx.managed_root,
399 dunce::canonicalize(dir.path().join(".agents")).unwrap()
400 );
401 }
402
403 #[test]
404 fn find_root_with_custom_managed_dir_from_settings() {
405 let dir = TempDir::new().unwrap();
406 std::fs::write(
407 dir.path().join("mars.toml"),
408 "[dependencies]\n\n[settings]\nmanaged_root = \".claude\"\n",
409 )
410 .unwrap();
411 std::fs::create_dir_all(dir.path().join(".claude")).unwrap();
412
413 let ctx = MarsContext::new(dir.path().to_path_buf()).unwrap();
414 assert_eq!(
415 ctx.managed_root,
416 dunce::canonicalize(dir.path().join(".claude")).unwrap()
417 );
418 }
419
420 #[test]
421 fn find_root_with_custom_managed_dir_marker() {
422 let dir = TempDir::new().unwrap();
423 std::fs::write(dir.path().join("mars.toml"), "[dependencies]\n").unwrap();
424 std::fs::create_dir_all(dir.path().join(".claude/.mars")).unwrap();
425
426 let ctx = MarsContext::new(dir.path().to_path_buf()).unwrap();
427 assert_eq!(
428 ctx.managed_root,
429 dunce::canonicalize(dir.path().join(".claude")).unwrap()
430 );
431 }
432
433 #[cfg(unix)]
434 #[test]
435 fn context_rejects_symlinked_managed_root_outside_project() {
436 let project_dir = TempDir::new().unwrap();
437 let external_dir = TempDir::new().unwrap();
438 std::fs::write(project_dir.path().join("mars.toml"), "[dependencies]\n").unwrap();
439
440 let external_agents = external_dir.path().join(".agents");
441 std::fs::create_dir_all(&external_agents).unwrap();
442
443 let project_agents = project_dir.path().join(".agents");
444 std::os::unix::fs::symlink(&external_agents, &project_agents).unwrap();
445
446 let result = MarsContext::new(project_dir.path().to_path_buf());
447 assert!(result.is_err());
448 }
449
450 #[test]
451 fn detect_managed_root_reads_settings() {
452 let dir = TempDir::new().unwrap();
453 std::fs::write(
454 dir.path().join("mars.toml"),
455 "[dependencies]\n\n[settings]\nmanaged_root = \".claude\"\n",
456 )
457 .unwrap();
458 let result = detect_managed_root(dir.path()).unwrap();
459 assert_eq!(result, dir.path().join(".claude"));
460 }
461
462 #[test]
463 fn detect_managed_root_falls_through_on_missing_config() {
464 let dir = TempDir::new().unwrap();
465 let result = detect_managed_root(dir.path()).unwrap();
466 assert_eq!(result, dir.path().join(".agents"));
467 }
468
469 #[test]
470 fn detect_managed_root_surfaces_parse_errors() {
471 let dir = TempDir::new().unwrap();
472 std::fs::write(dir.path().join("mars.toml"), "invalid toml {{{").unwrap();
473 let result = detect_managed_root(dir.path());
474 assert!(result.is_err());
475 }
476
477 #[test]
478 fn init_rejects_root_that_looks_like_managed_dir() {
479 let result = find_agents_root(Some(Path::new(".agents")));
480 assert!(result.is_err());
481 let err = result.unwrap_err().to_string();
482 assert!(
483 err.contains("managed output directory"),
484 "should reject .agents as --root: {err}"
485 );
486 }
487
488 #[test]
491 fn walk_up_crosses_git_boundary_to_find_config() {
492 let dir = TempDir::new().unwrap();
495 let outer = dir.path().join("outer");
496 std::fs::create_dir_all(outer.join(".agents")).unwrap();
497 std::fs::write(outer.join("mars.toml"), "[dependencies]\n").unwrap();
498
499 let inner = outer.join("inner");
500 std::fs::create_dir_all(inner.join(".git")).unwrap();
501
502 let ctx = find_agents_root_from(&inner).unwrap();
503 assert_eq!(
504 ctx.project_root,
505 dunce::canonicalize(&outer).unwrap(),
506 "should find outer config even when inner has .git"
507 );
508 }
509
510 #[test]
511 fn walk_up_finds_config_in_ancestor() {
512 let dir = TempDir::new().unwrap();
513 let root = dir.path().join("project");
514 std::fs::create_dir_all(root.join(".agents")).unwrap();
515 std::fs::write(root.join("mars.toml"), "[dependencies]\n").unwrap();
516
517 let subdir = root.join("src").join("lib");
518 std::fs::create_dir_all(&subdir).unwrap();
519
520 let ctx = find_agents_root_from(&subdir).unwrap();
521 assert_eq!(ctx.project_root, dunce::canonicalize(&root).unwrap());
522 }
523
524 #[test]
525 fn walk_up_prefers_nearest_mars_toml() {
526 let dir = TempDir::new().unwrap();
528 let parent = dir.path().join("parent");
529 std::fs::create_dir_all(parent.join(".agents")).unwrap();
530 std::fs::write(parent.join("mars.toml"), "[dependencies]\n").unwrap();
531
532 let child = parent.join("child");
533 std::fs::create_dir_all(&child).unwrap();
534 std::fs::write(
535 child.join("mars.toml"),
536 "[package]\nname = \"pkg\"\nversion = \"0.1.0\"\n",
537 )
538 .unwrap();
539
540 let ctx = find_agents_root_from(&child).unwrap();
541 assert_eq!(ctx.project_root, dunce::canonicalize(&child).unwrap());
542 }
543
544 #[test]
545 fn walk_up_from_deep_subdirectory() {
546 let dir = TempDir::new().unwrap();
547 let root = dir.path().join("repo");
548 std::fs::create_dir_all(root.join(".agents")).unwrap();
549 std::fs::write(root.join("mars.toml"), "[dependencies]\n").unwrap();
550
551 let deep = root.join("src").join("foo").join("bar");
552 std::fs::create_dir_all(&deep).unwrap();
553
554 let ctx = find_agents_root_from(&deep).unwrap();
555 assert_eq!(ctx.project_root, dunce::canonicalize(&root).unwrap());
556 }
557
558 #[test]
559 fn walk_up_crosses_submodule_boundary() {
560 let dir = TempDir::new().unwrap();
563 let outer = dir.path().join("outer");
564 std::fs::create_dir_all(outer.join(".agents")).unwrap();
565 std::fs::write(outer.join("mars.toml"), "[dependencies]\n").unwrap();
566
567 let submodule = outer.join("submodule");
568 std::fs::create_dir_all(&submodule).unwrap();
569 std::fs::write(
571 submodule.join(".git"),
572 "gitdir: ../../.git/modules/submodule\n",
573 )
574 .unwrap();
575
576 let ctx = find_agents_root_from(&submodule).unwrap();
577 assert_eq!(
578 ctx.project_root,
579 dunce::canonicalize(&outer).unwrap(),
580 "should find outer config through submodule .git file boundary"
581 );
582 }
583
584 #[test]
585 fn walk_up_errors_when_no_config_found() {
586 let dir = TempDir::new().unwrap();
587 let deep = dir.path().join("a").join("b").join("c");
588 std::fs::create_dir_all(&deep).unwrap();
589
590 let result = find_agents_root_from(&deep);
591 assert!(result.is_err());
592 let err = result.unwrap_err().to_string();
593 assert!(
594 err.contains("no mars.toml found"),
595 "should report no config found: {err}"
596 );
597 assert!(
598 err.contains("filesystem root"),
599 "should mention filesystem root: {err}"
600 );
601 }
602
603 #[test]
604 fn walk_up_with_root_flag_starts_from_specified_path() {
605 let dir = TempDir::new().unwrap();
606 let project = dir.path().join("project");
607 std::fs::create_dir_all(project.join(".agents")).unwrap();
608 std::fs::write(project.join("mars.toml"), "[dependencies]\n").unwrap();
609
610 let subdir = project.join("src");
612 std::fs::create_dir_all(&subdir).unwrap();
613
614 let ctx = find_agents_root(Some(&subdir)).unwrap();
615 assert_eq!(ctx.project_root, dunce::canonicalize(&project).unwrap());
616 }
617}