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