1pub mod add;
11pub mod cache;
12pub mod check;
13pub mod doctor;
14pub mod init;
15pub mod link;
16pub mod list;
17pub mod outdated;
18pub mod output;
19pub mod override_cmd;
20pub mod remove;
21pub mod rename;
22pub mod repair;
23pub mod resolve_cmd;
24pub mod sync;
25pub mod upgrade;
26pub mod version;
27pub mod why;
28
29use std::path::{Path, PathBuf};
30
31use clap::{Parser, Subcommand};
32
33use crate::error::{ConfigError, LockError, MarsError};
34pub use crate::types::MarsContext;
35
36pub const WELL_KNOWN: &[&str] = &[".agents"];
39
40pub const TOOL_DIRS: &[&str] = &[".claude", ".cursor"];
43
44impl MarsContext {
45 pub fn new(project_root: PathBuf) -> Result<Self, MarsError> {
47 let project_canon = if project_root.exists() {
48 project_root.canonicalize().unwrap_or(project_root.clone())
49 } else {
50 project_root.clone()
51 };
52
53 let managed_root = detect_managed_root(&project_canon)?;
54 Self::from_roots(project_canon, managed_root)
55 }
56
57 pub fn from_roots(project_root: PathBuf, managed_root: PathBuf) -> Result<Self, MarsError> {
59 let project_canon = if project_root.exists() {
60 project_root.canonicalize().unwrap_or(project_root.clone())
61 } else {
62 project_root.clone()
63 };
64 let managed_canon = if managed_root.exists() {
65 managed_root.canonicalize().unwrap_or(managed_root.clone())
66 } else {
67 managed_root.clone()
68 };
69
70 if !managed_canon.starts_with(&project_canon) {
71 return Err(MarsError::Config(ConfigError::Invalid {
72 message: format!(
73 "{} resolves to {} which is outside {}. \
74 The managed root may be a symlink. Use --root to override.",
75 managed_root.display(),
76 managed_canon.display(),
77 project_canon.display(),
78 ),
79 }));
80 }
81
82 Ok(MarsContext {
83 managed_root: managed_canon,
84 project_root: project_canon,
85 })
86 }
87}
88
89#[derive(Debug, Parser)]
91#[command(name = "mars", version, about = "Agent package manager for .agents/")]
92pub struct Cli {
93 #[command(subcommand)]
94 pub command: Command,
95
96 #[arg(long, global = true)]
98 pub root: Option<PathBuf>,
99
100 #[arg(long, global = true)]
102 pub json: bool,
103}
104
105#[derive(Debug, Subcommand)]
106pub enum Command {
107 Init(init::InitArgs),
109
110 Add(add::AddArgs),
112
113 Remove(remove::RemoveArgs),
115
116 Sync(sync::SyncArgs),
118
119 Upgrade(upgrade::UpgradeArgs),
121
122 Outdated(outdated::OutdatedArgs),
124
125 Version(version::VersionArgs),
127
128 List(list::ListArgs),
130
131 Why(why::WhyArgs),
133
134 Rename(rename::RenameArgs),
136
137 Resolve(resolve_cmd::ResolveArgs),
139
140 Override(override_cmd::OverrideArgs),
142
143 Link(link::LinkArgs),
145
146 Check(check::CheckArgs),
148
149 Doctor(doctor::DoctorArgs),
151
152 Repair(repair::RepairArgs),
154
155 Cache(cache::CacheArgs),
157}
158
159pub fn dispatch(cli: Cli) -> i32 {
162 match dispatch_result(cli) {
163 Ok(code) => code,
164 Err(err) => {
165 eprintln!("error: {err}");
166 if matches!(err, MarsError::Lock(LockError::Corrupt { .. })) {
167 eprintln!("hint: run `mars repair` to rebuild from mars.toml + dependencies");
168 }
169 err.exit_code()
170 }
171 }
172}
173
174fn dispatch_result(cli: Cli) -> Result<i32, MarsError> {
175 match &cli.command {
176 Command::Init(args) => init::run(args, cli.root.as_deref(), cli.json),
178 Command::Check(args) => check::run(args, cli.json),
179 Command::Cache(args) => cache::run(args, cli.json),
180 cmd => {
182 let ctx = find_agents_root(cli.root.as_deref())?;
183 dispatch_with_root(cmd, &ctx, cli.json)
184 }
185 }
186}
187
188fn dispatch_with_root(cmd: &Command, ctx: &MarsContext, json: bool) -> Result<i32, MarsError> {
189 match cmd {
190 Command::Add(args) => add::run(args, ctx, json),
191 Command::Remove(args) => remove::run(args, ctx, json),
192 Command::Sync(args) => sync::run(args, ctx, json),
193 Command::Upgrade(args) => upgrade::run(args, ctx, json),
194 Command::Outdated(args) => outdated::run(args, ctx, json),
195 Command::Version(args) => version::run(args, ctx, json),
196 Command::List(args) => list::run(args, ctx, json),
197 Command::Why(args) => why::run(args, ctx, json),
198 Command::Rename(args) => rename::run(args, ctx, json),
199 Command::Resolve(args) => resolve_cmd::run(args, ctx, json),
200 Command::Override(args) => override_cmd::run(args, ctx, json),
201 Command::Link(args) => link::run(args, ctx, json),
202 Command::Doctor(args) => doctor::run(args, ctx, json),
203 Command::Repair(args) => repair::run(args, ctx, json),
204 Command::Init(_) | Command::Check(_) | Command::Cache(_) => unreachable!(),
206 }
207}
208
209pub fn is_symlink(path: &Path) -> bool {
211 path.symlink_metadata()
212 .map(|m| m.file_type().is_symlink())
213 .unwrap_or(false)
214}
215
216fn detect_managed_root(project_root: &Path) -> Result<PathBuf, MarsError> {
217 match crate::config::load(project_root) {
219 Ok(config) => {
220 if let Some(name) = &config.settings.managed_root {
221 return Ok(project_root.join(name));
222 }
223 }
224 Err(MarsError::Config(ConfigError::NotFound { .. })) => {}
226 Err(e) => return Err(e),
228 }
229
230 let default_root = project_root.join(WELL_KNOWN[0]);
232 if default_root.exists() || is_symlink(&default_root) {
233 return Ok(default_root);
234 }
235
236 let mut marked_roots: Vec<PathBuf> = Vec::new();
238 if let Ok(entries) = std::fs::read_dir(project_root) {
239 for entry in entries.flatten() {
240 let path = entry.path();
241 if path.join(".mars").exists() {
242 marked_roots.push(path);
243 }
244 }
245 }
246
247 if marked_roots.len() == 1 {
248 return Ok(marked_roots.remove(0));
249 }
250
251 for subdir in TOOL_DIRS {
252 let candidate = project_root.join(subdir);
253 if marked_roots.iter().any(|p| p == &candidate) {
254 return Ok(candidate);
255 }
256 }
257
258 marked_roots.sort();
259 if let Some(first) = marked_roots.into_iter().next() {
260 return Ok(first);
261 }
262
263 Ok(default_root)
264}
265
266pub fn default_project_root() -> Result<PathBuf, MarsError> {
268 let cwd = std::env::current_dir()?;
269 let mut dir = cwd.as_path();
270 loop {
271 if dir.join(".git").exists() {
272 return Ok(dir.to_path_buf());
273 }
274 match dir.parent() {
275 Some(parent) => dir = parent,
276 None => return Ok(cwd),
277 }
278 }
279}
280
281pub fn find_agents_root(explicit: Option<&Path>) -> Result<MarsContext, MarsError> {
286 if let Some(root) = explicit {
287 if let Some(basename) = root.file_name().and_then(|f| f.to_str())
289 && (WELL_KNOWN.contains(&basename) || TOOL_DIRS.contains(&basename))
290 {
291 return Err(MarsError::Config(ConfigError::Invalid {
292 message: format!(
293 "`--root {basename}` looks like a managed output directory.\n \
294 --root takes the project root (containing mars.toml), not the output directory.\n \
295 Try: mars init (auto-detects project root)\n \
296 Or: mars init {basename} (specify output directory name)"
297 ),
298 }));
299 }
300
301 let config_path = root.join("mars.toml");
302 if !config_path.exists() {
303 return Err(MarsError::Config(ConfigError::Invalid {
304 message: format!(
305 "{} does not contain mars.toml. Run `mars init` first.",
306 root.display()
307 ),
308 }));
309 }
310 return MarsContext::new(root.to_path_buf());
311 }
312
313 find_agents_root_from(None, &std::env::current_dir()?)
314}
315
316fn find_agents_root_from(_explicit: Option<&Path>, start: &Path) -> Result<MarsContext, MarsError> {
317 let cwd_canon = start.canonicalize().unwrap_or_else(|_| start.to_path_buf());
318 let mut dir = cwd_canon.as_path();
319
320 loop {
321 let config_path = dir.join("mars.toml");
322 if config_path.exists() {
323 return MarsContext::new(dir.to_path_buf());
324 }
325
326 if dir.join(".git").exists() {
328 break;
329 }
330
331 match dir.parent() {
332 Some(parent) => dir = parent,
333 None => break,
334 }
335 }
336
337 Err(MarsError::Config(ConfigError::Invalid {
338 message: format!(
339 "no mars.toml found from {} up to repository root. Run `mars init` first.",
340 start.display()
341 ),
342 }))
343}
344
345#[cfg(test)]
346mod tests {
347 use super::*;
348 use tempfile::TempDir;
349
350 #[test]
351 fn find_root_with_explicit_path() {
352 let dir = TempDir::new().unwrap();
353 std::fs::write(dir.path().join("mars.toml"), "[dependencies]\n").unwrap();
354
355 let ctx = find_agents_root(Some(dir.path())).unwrap();
356 assert_eq!(ctx.project_root, dir.path().canonicalize().unwrap());
357 assert_eq!(ctx.managed_root, dir.path().join(".agents"));
358 }
359
360 #[test]
361 fn package_manifest_without_dependencies_is_valid_project_root() {
362 let dir = TempDir::new().unwrap();
363 std::fs::write(
364 dir.path().join("mars.toml"),
365 "[package]\nname = \"pkg\"\nversion = \"0.1.0\"\n",
366 )
367 .unwrap();
368
369 let ctx = find_agents_root(Some(dir.path())).unwrap();
370 assert_eq!(ctx.project_root, dir.path().canonicalize().unwrap());
371 }
372
373 #[test]
374 fn find_root_with_default_managed_dir() {
375 let dir = TempDir::new().unwrap();
376 std::fs::write(dir.path().join("mars.toml"), "[dependencies]\n").unwrap();
377 std::fs::create_dir_all(dir.path().join(".agents")).unwrap();
378
379 let ctx = MarsContext::new(dir.path().to_path_buf()).unwrap();
380 assert_eq!(ctx.project_root, dir.path().canonicalize().unwrap());
381 assert_eq!(
382 ctx.managed_root,
383 dir.path().join(".agents").canonicalize().unwrap()
384 );
385 }
386
387 #[test]
388 fn find_root_with_custom_managed_dir_from_settings() {
389 let dir = TempDir::new().unwrap();
390 std::fs::write(
391 dir.path().join("mars.toml"),
392 "[dependencies]\n\n[settings]\nmanaged_root = \".claude\"\n",
393 )
394 .unwrap();
395 std::fs::create_dir_all(dir.path().join(".claude")).unwrap();
396
397 let ctx = MarsContext::new(dir.path().to_path_buf()).unwrap();
398 assert_eq!(
399 ctx.managed_root,
400 dir.path().join(".claude").canonicalize().unwrap()
401 );
402 }
403
404 #[test]
405 fn find_root_with_custom_managed_dir_marker() {
406 let dir = TempDir::new().unwrap();
407 std::fs::write(dir.path().join("mars.toml"), "[dependencies]\n").unwrap();
408 std::fs::create_dir_all(dir.path().join(".claude/.mars")).unwrap();
409
410 let ctx = MarsContext::new(dir.path().to_path_buf()).unwrap();
411 assert_eq!(
412 ctx.managed_root,
413 dir.path().join(".claude").canonicalize().unwrap()
414 );
415 }
416
417 #[test]
418 fn context_rejects_symlinked_managed_root_outside_project() {
419 let project_dir = TempDir::new().unwrap();
420 let external_dir = TempDir::new().unwrap();
421 std::fs::write(project_dir.path().join("mars.toml"), "[dependencies]\n").unwrap();
422
423 let external_agents = external_dir.path().join(".agents");
424 std::fs::create_dir_all(&external_agents).unwrap();
425
426 let project_agents = project_dir.path().join(".agents");
427 std::os::unix::fs::symlink(&external_agents, &project_agents).unwrap();
428
429 let result = MarsContext::new(project_dir.path().to_path_buf());
430 assert!(result.is_err());
431 }
432
433 #[test]
434 fn detect_managed_root_reads_settings() {
435 let dir = TempDir::new().unwrap();
436 std::fs::write(
437 dir.path().join("mars.toml"),
438 "[dependencies]\n\n[settings]\nmanaged_root = \".claude\"\n",
439 )
440 .unwrap();
441 let result = detect_managed_root(dir.path()).unwrap();
442 assert_eq!(result, dir.path().join(".claude"));
443 }
444
445 #[test]
446 fn detect_managed_root_falls_through_on_missing_config() {
447 let dir = TempDir::new().unwrap();
448 let result = detect_managed_root(dir.path()).unwrap();
449 assert_eq!(result, dir.path().join(".agents"));
450 }
451
452 #[test]
453 fn detect_managed_root_surfaces_parse_errors() {
454 let dir = TempDir::new().unwrap();
455 std::fs::write(dir.path().join("mars.toml"), "invalid toml {{{").unwrap();
456 let result = detect_managed_root(dir.path());
457 assert!(result.is_err());
458 }
459
460 #[test]
461 fn init_rejects_root_that_looks_like_managed_dir() {
462 let result = find_agents_root(Some(Path::new(".agents")));
463 assert!(result.is_err());
464 let err = result.unwrap_err().to_string();
465 assert!(
466 err.contains("managed output directory"),
467 "should reject .agents as --root: {err}"
468 );
469 }
470
471 #[test]
474 fn walk_up_stops_at_git_boundary() {
475 let dir = TempDir::new().unwrap();
478 let outer = dir.path().join("outer");
479 std::fs::create_dir_all(outer.join(".git")).unwrap();
480 std::fs::create_dir_all(outer.join(".agents")).unwrap();
481 std::fs::write(outer.join("mars.toml"), "[dependencies]\n").unwrap();
482
483 let inner = outer.join("inner");
484 std::fs::create_dir_all(inner.join(".git")).unwrap();
485
486 let result = find_agents_root_from(None, &inner);
487 assert!(
488 result.is_err(),
489 "should not find outer config when inner has .git"
490 );
491 }
492
493 #[test]
494 fn walk_up_finds_config_at_git_root() {
495 let dir = TempDir::new().unwrap();
496 let root = dir.path().join("project");
497 std::fs::create_dir_all(root.join(".git")).unwrap();
498 std::fs::create_dir_all(root.join(".agents")).unwrap();
499 std::fs::write(root.join("mars.toml"), "[dependencies]\n").unwrap();
500
501 let subdir = root.join("src").join("lib");
502 std::fs::create_dir_all(&subdir).unwrap();
503
504 let ctx = find_agents_root_from(None, &subdir).unwrap();
505 assert_eq!(ctx.project_root, root.canonicalize().unwrap());
506 }
507
508 #[test]
509 fn walk_up_prefers_nearest_mars_toml() {
510 let dir = TempDir::new().unwrap();
512 let parent = dir.path().join("parent");
513 std::fs::create_dir_all(parent.join(".git")).unwrap();
514 std::fs::create_dir_all(parent.join(".agents")).unwrap();
515 std::fs::write(parent.join("mars.toml"), "[dependencies]\n").unwrap();
516
517 let child = parent.join("child");
518 std::fs::create_dir_all(&child).unwrap();
519 std::fs::write(
520 child.join("mars.toml"),
521 "[package]\nname = \"pkg\"\nversion = \"0.1.0\"\n",
522 )
523 .unwrap();
524
525 let ctx = find_agents_root_from(None, &child).unwrap();
526 assert_eq!(ctx.project_root, child.canonicalize().unwrap());
527 }
528
529 #[test]
530 fn walk_up_from_deep_subdirectory() {
531 let dir = TempDir::new().unwrap();
532 let root = dir.path().join("repo");
533 std::fs::create_dir_all(root.join(".git")).unwrap();
534 std::fs::create_dir_all(root.join(".agents")).unwrap();
535 std::fs::write(root.join("mars.toml"), "[dependencies]\n").unwrap();
536
537 let deep = root.join("src").join("foo").join("bar");
538 std::fs::create_dir_all(&deep).unwrap();
539
540 let ctx = find_agents_root_from(None, &deep).unwrap();
541 assert_eq!(ctx.project_root, root.canonicalize().unwrap());
542 }
543
544 #[test]
545 fn submodule_isolation() {
546 let dir = TempDir::new().unwrap();
549 let outer = dir.path().join("outer");
550 std::fs::create_dir_all(outer.join(".git")).unwrap();
551 std::fs::create_dir_all(outer.join(".agents")).unwrap();
552 std::fs::write(outer.join("mars.toml"), "[dependencies]\n").unwrap();
553
554 let submodule = outer.join("submodule");
555 std::fs::create_dir_all(&submodule).unwrap();
556 std::fs::write(
558 submodule.join(".git"),
559 "gitdir: ../../.git/modules/submodule\n",
560 )
561 .unwrap();
562
563 let result = find_agents_root_from(None, &submodule);
564 assert!(
565 result.is_err(),
566 "should not find outer config through submodule .git file boundary"
567 );
568 }
569}