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