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 project_root.canonicalize().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 project_root.canonicalize().unwrap_or(project_root.clone())
63 } else {
64 project_root.clone()
65 };
66 let managed_canon = if managed_root.exists() {
67 managed_root.canonicalize().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 = find_agents_root(cli.root.as_deref())?;
191 dispatch_with_root(cmd, &ctx, cli.json)
192 }
193 }
194}
195
196fn dispatch_with_root(cmd: &Command, ctx: &MarsContext, json: bool) -> Result<i32, MarsError> {
197 match cmd {
198 Command::Add(args) => add::run(args, ctx, json),
199 Command::Adopt(args) => adopt::run(args, ctx, json),
200 Command::Remove(args) => remove::run(args, ctx, json),
201 Command::Sync(args) => sync::run(args, ctx, json),
202 Command::Upgrade(args) => upgrade::run(args, ctx, json),
203 Command::Outdated(args) => outdated::run(args, ctx, json),
204 Command::Version(args) => version::run(args, ctx, json),
205 Command::List(args) => list::run(args, ctx, json),
206 Command::Why(args) => why::run(args, ctx, json),
207 Command::Rename(args) => rename::run(args, ctx, json),
208 Command::Resolve(args) => resolve_cmd::run(args, ctx, json),
209 Command::Override(args) => override_cmd::run(args, ctx, json),
210 Command::Link(args) => link::run(args, ctx, json),
211 Command::Doctor(args) => doctor::run(args, ctx, json),
212 Command::Repair(args) => repair::run(args, ctx, json),
213 Command::Models(args) => models::run(args, ctx, json),
214 Command::Init(_) | Command::Check(_) | Command::Cache(_) => unreachable!(),
216 }
217}
218
219pub fn is_symlink(path: &Path) -> bool {
221 path.symlink_metadata()
222 .map(|m| m.file_type().is_symlink())
223 .unwrap_or(false)
224}
225
226fn detect_managed_root(project_root: &Path) -> Result<PathBuf, MarsError> {
227 match crate::config::load(project_root) {
229 Ok(config) => {
230 if let Some(name) = &config.settings.managed_root {
231 return Ok(project_root.join(name));
232 }
233 }
234 Err(MarsError::Config(ConfigError::NotFound { .. })) => {}
236 Err(e) => return Err(e),
238 }
239
240 let default_root = project_root.join(WELL_KNOWN[0]);
242 if default_root.exists() || is_symlink(&default_root) {
243 return Ok(default_root);
244 }
245
246 let mut marked_roots: Vec<PathBuf> = Vec::new();
248 if let Ok(entries) = std::fs::read_dir(project_root) {
249 for entry in entries.flatten() {
250 let path = entry.path();
251 if path.join(".mars").exists() {
252 marked_roots.push(path);
253 }
254 }
255 }
256
257 if marked_roots.len() == 1 {
258 return Ok(marked_roots.remove(0));
259 }
260
261 for subdir in TOOL_DIRS {
262 let candidate = project_root.join(subdir);
263 if marked_roots.iter().any(|p| p == &candidate) {
264 return Ok(candidate);
265 }
266 }
267
268 marked_roots.sort();
269 if let Some(first) = marked_roots.into_iter().next() {
270 return Ok(first);
271 }
272
273 Ok(default_root)
274}
275
276pub fn default_project_root() -> Result<PathBuf, MarsError> {
278 let cwd = std::env::current_dir()?;
279 let mut dir = cwd.as_path();
280 loop {
281 if dir.join(".git").exists() {
282 return Ok(dir.to_path_buf());
283 }
284 match dir.parent() {
285 Some(parent) => dir = parent,
286 None => return Ok(cwd),
287 }
288 }
289}
290
291pub fn find_agents_root(explicit: Option<&Path>) -> Result<MarsContext, MarsError> {
296 if let Some(root) = explicit {
297 if let Some(basename) = root.file_name().and_then(|f| f.to_str())
299 && (WELL_KNOWN.contains(&basename) || TOOL_DIRS.contains(&basename))
300 {
301 return Err(MarsError::Config(ConfigError::Invalid {
302 message: format!(
303 "`--root {basename}` looks like a managed output directory.\n \
304 --root takes the project root (containing mars.toml), not the output directory.\n \
305 Try: mars init (auto-detects project root)\n \
306 Or: mars init {basename} (specify output directory name)"
307 ),
308 }));
309 }
310
311 let config_path = root.join("mars.toml");
312 if !config_path.exists() {
313 return Err(MarsError::Config(ConfigError::Invalid {
314 message: format!(
315 "{} does not contain mars.toml. Run `mars init` first.",
316 root.display()
317 ),
318 }));
319 }
320 return MarsContext::new(root.to_path_buf());
321 }
322
323 find_agents_root_from(None, &std::env::current_dir()?)
324}
325
326fn find_agents_root_from(_explicit: Option<&Path>, start: &Path) -> Result<MarsContext, MarsError> {
327 let cwd_canon = start.canonicalize().unwrap_or_else(|_| start.to_path_buf());
328 let mut dir = cwd_canon.as_path();
329
330 loop {
331 let config_path = dir.join("mars.toml");
332 if config_path.exists() {
333 return MarsContext::new(dir.to_path_buf());
334 }
335
336 if dir.join(".git").exists() {
338 break;
339 }
340
341 match dir.parent() {
342 Some(parent) => dir = parent,
343 None => break,
344 }
345 }
346
347 Err(MarsError::Config(ConfigError::Invalid {
348 message: format!(
349 "no mars.toml found from {} up to repository root. Run `mars init` first.",
350 start.display()
351 ),
352 }))
353}
354
355#[cfg(test)]
356mod tests {
357 use super::*;
358 use tempfile::TempDir;
359
360 #[test]
361 fn find_root_with_explicit_path() {
362 let dir = TempDir::new().unwrap();
363 std::fs::write(dir.path().join("mars.toml"), "[dependencies]\n").unwrap();
364
365 let ctx = find_agents_root(Some(dir.path())).unwrap();
366 assert_eq!(ctx.project_root, dir.path().canonicalize().unwrap());
367 assert_eq!(ctx.managed_root, dir.path().join(".agents"));
368 }
369
370 #[test]
371 fn package_manifest_without_dependencies_is_valid_project_root() {
372 let dir = TempDir::new().unwrap();
373 std::fs::write(
374 dir.path().join("mars.toml"),
375 "[package]\nname = \"pkg\"\nversion = \"0.1.0\"\n",
376 )
377 .unwrap();
378
379 let ctx = find_agents_root(Some(dir.path())).unwrap();
380 assert_eq!(ctx.project_root, dir.path().canonicalize().unwrap());
381 }
382
383 #[test]
384 fn find_root_with_default_managed_dir() {
385 let dir = TempDir::new().unwrap();
386 std::fs::write(dir.path().join("mars.toml"), "[dependencies]\n").unwrap();
387 std::fs::create_dir_all(dir.path().join(".agents")).unwrap();
388
389 let ctx = MarsContext::new(dir.path().to_path_buf()).unwrap();
390 assert_eq!(ctx.project_root, dir.path().canonicalize().unwrap());
391 assert_eq!(
392 ctx.managed_root,
393 dir.path().join(".agents").canonicalize().unwrap()
394 );
395 }
396
397 #[test]
398 fn find_root_with_custom_managed_dir_from_settings() {
399 let dir = TempDir::new().unwrap();
400 std::fs::write(
401 dir.path().join("mars.toml"),
402 "[dependencies]\n\n[settings]\nmanaged_root = \".claude\"\n",
403 )
404 .unwrap();
405 std::fs::create_dir_all(dir.path().join(".claude")).unwrap();
406
407 let ctx = MarsContext::new(dir.path().to_path_buf()).unwrap();
408 assert_eq!(
409 ctx.managed_root,
410 dir.path().join(".claude").canonicalize().unwrap()
411 );
412 }
413
414 #[test]
415 fn find_root_with_custom_managed_dir_marker() {
416 let dir = TempDir::new().unwrap();
417 std::fs::write(dir.path().join("mars.toml"), "[dependencies]\n").unwrap();
418 std::fs::create_dir_all(dir.path().join(".claude/.mars")).unwrap();
419
420 let ctx = MarsContext::new(dir.path().to_path_buf()).unwrap();
421 assert_eq!(
422 ctx.managed_root,
423 dir.path().join(".claude").canonicalize().unwrap()
424 );
425 }
426
427 #[test]
428 fn context_rejects_symlinked_managed_root_outside_project() {
429 let project_dir = TempDir::new().unwrap();
430 let external_dir = TempDir::new().unwrap();
431 std::fs::write(project_dir.path().join("mars.toml"), "[dependencies]\n").unwrap();
432
433 let external_agents = external_dir.path().join(".agents");
434 std::fs::create_dir_all(&external_agents).unwrap();
435
436 let project_agents = project_dir.path().join(".agents");
437 std::os::unix::fs::symlink(&external_agents, &project_agents).unwrap();
438
439 let result = MarsContext::new(project_dir.path().to_path_buf());
440 assert!(result.is_err());
441 }
442
443 #[test]
444 fn detect_managed_root_reads_settings() {
445 let dir = TempDir::new().unwrap();
446 std::fs::write(
447 dir.path().join("mars.toml"),
448 "[dependencies]\n\n[settings]\nmanaged_root = \".claude\"\n",
449 )
450 .unwrap();
451 let result = detect_managed_root(dir.path()).unwrap();
452 assert_eq!(result, dir.path().join(".claude"));
453 }
454
455 #[test]
456 fn detect_managed_root_falls_through_on_missing_config() {
457 let dir = TempDir::new().unwrap();
458 let result = detect_managed_root(dir.path()).unwrap();
459 assert_eq!(result, dir.path().join(".agents"));
460 }
461
462 #[test]
463 fn detect_managed_root_surfaces_parse_errors() {
464 let dir = TempDir::new().unwrap();
465 std::fs::write(dir.path().join("mars.toml"), "invalid toml {{{").unwrap();
466 let result = detect_managed_root(dir.path());
467 assert!(result.is_err());
468 }
469
470 #[test]
471 fn init_rejects_root_that_looks_like_managed_dir() {
472 let result = find_agents_root(Some(Path::new(".agents")));
473 assert!(result.is_err());
474 let err = result.unwrap_err().to_string();
475 assert!(
476 err.contains("managed output directory"),
477 "should reject .agents as --root: {err}"
478 );
479 }
480
481 #[test]
484 fn walk_up_stops_at_git_boundary() {
485 let dir = TempDir::new().unwrap();
488 let outer = dir.path().join("outer");
489 std::fs::create_dir_all(outer.join(".git")).unwrap();
490 std::fs::create_dir_all(outer.join(".agents")).unwrap();
491 std::fs::write(outer.join("mars.toml"), "[dependencies]\n").unwrap();
492
493 let inner = outer.join("inner");
494 std::fs::create_dir_all(inner.join(".git")).unwrap();
495
496 let result = find_agents_root_from(None, &inner);
497 assert!(
498 result.is_err(),
499 "should not find outer config when inner has .git"
500 );
501 }
502
503 #[test]
504 fn walk_up_finds_config_at_git_root() {
505 let dir = TempDir::new().unwrap();
506 let root = dir.path().join("project");
507 std::fs::create_dir_all(root.join(".git")).unwrap();
508 std::fs::create_dir_all(root.join(".agents")).unwrap();
509 std::fs::write(root.join("mars.toml"), "[dependencies]\n").unwrap();
510
511 let subdir = root.join("src").join("lib");
512 std::fs::create_dir_all(&subdir).unwrap();
513
514 let ctx = find_agents_root_from(None, &subdir).unwrap();
515 assert_eq!(ctx.project_root, root.canonicalize().unwrap());
516 }
517
518 #[test]
519 fn walk_up_prefers_nearest_mars_toml() {
520 let dir = TempDir::new().unwrap();
522 let parent = dir.path().join("parent");
523 std::fs::create_dir_all(parent.join(".git")).unwrap();
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(None, &child).unwrap();
536 assert_eq!(ctx.project_root, child.canonicalize().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(".git")).unwrap();
544 std::fs::create_dir_all(root.join(".agents")).unwrap();
545 std::fs::write(root.join("mars.toml"), "[dependencies]\n").unwrap();
546
547 let deep = root.join("src").join("foo").join("bar");
548 std::fs::create_dir_all(&deep).unwrap();
549
550 let ctx = find_agents_root_from(None, &deep).unwrap();
551 assert_eq!(ctx.project_root, root.canonicalize().unwrap());
552 }
553
554 #[test]
555 fn submodule_isolation() {
556 let dir = TempDir::new().unwrap();
559 let outer = dir.path().join("outer");
560 std::fs::create_dir_all(outer.join(".git")).unwrap();
561 std::fs::create_dir_all(outer.join(".agents")).unwrap();
562 std::fs::write(outer.join("mars.toml"), "[dependencies]\n").unwrap();
563
564 let submodule = outer.join("submodule");
565 std::fs::create_dir_all(&submodule).unwrap();
566 std::fs::write(
568 submodule.join(".git"),
569 "gitdir: ../../.git/modules/submodule\n",
570 )
571 .unwrap();
572
573 let result = find_agents_root_from(None, &submodule);
574 assert!(
575 result.is_err(),
576 "should not find outer config through submodule .git file boundary"
577 );
578 }
579}