Skip to main content

mars_agents/cli/
mod.rs

1//! CLI layer — clap definitions + command dispatch.
2//!
3//! Each subcommand is a separate module. The CLI layer:
4//! - Parses args into typed commands
5//! - Locates project root (walk up from cwd, or `--root` flag)
6//! - Calls library functions
7//! - Formats output (human-readable by default, `--json` for machine)
8//! - Maps `MarsError` to exit codes and stderr messages
9
10pub 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
38/// Directories where mars materializes agents/skills output.
39/// `.agents/` remains the default target for `mars init`.
40pub const WELL_KNOWN: &[&str] = &[".agents"];
41
42/// Tool-specific directories that commonly need linking.
43/// `mars link` warns if the target isn't in TOOL_DIRS or WELL_KNOWN.
44pub const TOOL_DIRS: &[&str] = &[".claude", ".cursor"];
45
46impl MarsContext {
47    /// Build context from project root (directory containing mars.toml).
48    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    /// Build context from explicit project and managed roots.
60    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/// mars — agent package manager for .agents/
92#[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    /// Path to project root containing mars.toml (default: auto-detect).
99    #[arg(long, global = true)]
100    pub root: Option<PathBuf>,
101
102    /// Output in JSON format.
103    #[arg(long, global = true)]
104    pub json: bool,
105}
106
107#[derive(Debug, Subcommand)]
108pub enum Command {
109    /// Initialize project-level mars.toml (managed dir default: .agents/).
110    Init(init::InitArgs),
111
112    /// Add a dependency (git URL, GitHub shorthand, or local path).
113    Add(add::AddArgs),
114
115    /// Adopt an unmanaged target item into `.mars-src/`, then sync.
116    Adopt(adopt::AdoptArgs),
117
118    /// Remove a dependency.
119    Remove(remove::RemoveArgs),
120
121    /// Sync: resolve + install (make reality match config).
122    Sync(sync::SyncArgs),
123
124    /// Upgrade dependencies to newest compatible versions.
125    Upgrade(upgrade::UpgradeArgs),
126
127    /// Show available updates without applying.
128    Outdated(outdated::OutdatedArgs),
129
130    /// Bump package version in mars.toml, commit, and tag.
131    Version(version::VersionArgs),
132
133    /// List managed items with status.
134    List(list::ListArgs),
135
136    /// Explain why an item is installed.
137    Why(why::WhyArgs),
138
139    /// Rename a managed item.
140    Rename(rename::RenameArgs),
141
142    /// Mark conflicts as resolved.
143    Resolve(resolve_cmd::ResolveArgs),
144
145    /// Set a local dev override for a source.
146    Override(override_cmd::OverrideArgs),
147
148    /// Add/remove managed target directories (e.g. .claude).
149    Link(link::LinkArgs),
150
151    /// Validate a source package before publishing (structure, frontmatter, deps).
152    Check(check::CheckArgs),
153
154    /// Diagnose problems in an installed mars project (config, lock, files, targets).
155    Doctor(doctor::DoctorArgs),
156
157    /// Rebuild state from lock + sources.
158    Repair(repair::RepairArgs),
159
160    /// Manage the global source cache.
161    Cache(cache::CacheArgs),
162
163    /// Manage model aliases and the models cache.
164    Models(models::ModelsArgs),
165}
166
167/// Dispatch a parsed CLI command to the appropriate handler and map errors to
168/// the final exit code.
169pub 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        // Root-free commands
185        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        // All other commands require context
189        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        // Root-free commands handled in dispatch_result — unreachable here
239        Command::Init(_) | Command::Check(_) | Command::Cache(_) => unreachable!(),
240    }
241}
242
243/// Check if a path is a symlink (uses symlink_metadata, doesn't follow).
244pub 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    // 1. Check settings in mars.toml
252    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        // Config doesn't exist yet (before mars init) — expected, fall through
259        Err(MarsError::Config(ConfigError::NotFound { .. })) => {}
260        // Config exists but has parse errors — surface the real error
261        Err(e) => return Err(e),
262    }
263
264    // 2. Default: .agents
265    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    // 3. Fallback: scan for .mars/ marker (legacy compat)
271    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
300/// Find mars project root by walking up from start path to filesystem root.
301///
302/// For context commands (`add`, `sync`, etc.), this walks from the start path
303/// (cwd or `--root`) until it finds a `mars.toml`. Walk-up continues to filesystem
304/// root — git boundaries do not stop the walk.
305///
306/// If `--root` is provided, it sets the walk-up start path, not a direct target.
307pub fn find_agents_root(explicit: Option<&Path>) -> Result<MarsContext, MarsError> {
308    let start = if let Some(root) = explicit {
309        // Reject --root values that look like managed output directories
310        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
331/// Walk up from `start` to filesystem root searching for `mars.toml`.
332///
333/// Uses `Path::parent()` which returns `None` at filesystem root on all platforms:
334/// - Unix: `/` has no parent
335/// - Windows: `C:\` or UNC roots like `\\server\share` have no parent
336fn find_agents_root_from(start: &Path) -> Result<MarsContext, MarsError> {
337    let start_canon = start.canonicalize().unwrap_or_else(|_| start.to_path_buf());
338    let mut dir = start_canon.as_path();
339
340    // Walk up to filesystem root (Path::parent() returns None at root)
341    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        std::fs::write(dir.path().join("mars.toml"), "[dependencies]\n").unwrap();
367
368        // --root points to a dir with mars.toml — should find it via walk-up
369        let ctx = find_agents_root(Some(dir.path())).unwrap();
370        assert_eq!(ctx.project_root, dir.path().canonicalize().unwrap());
371        assert_eq!(ctx.managed_root, dir.path().join(".agents"));
372    }
373
374    #[test]
375    fn package_manifest_without_dependencies_is_valid_project_root() {
376        let dir = TempDir::new().unwrap();
377        std::fs::write(
378            dir.path().join("mars.toml"),
379            "[package]\nname = \"pkg\"\nversion = \"0.1.0\"\n",
380        )
381        .unwrap();
382
383        let ctx = find_agents_root(Some(dir.path())).unwrap();
384        assert_eq!(ctx.project_root, dir.path().canonicalize().unwrap());
385    }
386
387    #[test]
388    fn find_root_with_default_managed_dir() {
389        let dir = TempDir::new().unwrap();
390        std::fs::write(dir.path().join("mars.toml"), "[dependencies]\n").unwrap();
391        std::fs::create_dir_all(dir.path().join(".agents")).unwrap();
392
393        let ctx = MarsContext::new(dir.path().to_path_buf()).unwrap();
394        assert_eq!(ctx.project_root, dir.path().canonicalize().unwrap());
395        assert_eq!(
396            ctx.managed_root,
397            dir.path().join(".agents").canonicalize().unwrap()
398        );
399    }
400
401    #[test]
402    fn find_root_with_custom_managed_dir_from_settings() {
403        let dir = TempDir::new().unwrap();
404        std::fs::write(
405            dir.path().join("mars.toml"),
406            "[dependencies]\n\n[settings]\nmanaged_root = \".claude\"\n",
407        )
408        .unwrap();
409        std::fs::create_dir_all(dir.path().join(".claude")).unwrap();
410
411        let ctx = MarsContext::new(dir.path().to_path_buf()).unwrap();
412        assert_eq!(
413            ctx.managed_root,
414            dir.path().join(".claude").canonicalize().unwrap()
415        );
416    }
417
418    #[test]
419    fn find_root_with_custom_managed_dir_marker() {
420        let dir = TempDir::new().unwrap();
421        std::fs::write(dir.path().join("mars.toml"), "[dependencies]\n").unwrap();
422        std::fs::create_dir_all(dir.path().join(".claude/.mars")).unwrap();
423
424        let ctx = MarsContext::new(dir.path().to_path_buf()).unwrap();
425        assert_eq!(
426            ctx.managed_root,
427            dir.path().join(".claude").canonicalize().unwrap()
428        );
429    }
430
431    #[cfg(unix)]
432    #[test]
433    fn context_rejects_symlinked_managed_root_outside_project() {
434        let project_dir = TempDir::new().unwrap();
435        let external_dir = TempDir::new().unwrap();
436        std::fs::write(project_dir.path().join("mars.toml"), "[dependencies]\n").unwrap();
437
438        let external_agents = external_dir.path().join(".agents");
439        std::fs::create_dir_all(&external_agents).unwrap();
440
441        let project_agents = project_dir.path().join(".agents");
442        std::os::unix::fs::symlink(&external_agents, &project_agents).unwrap();
443
444        let result = MarsContext::new(project_dir.path().to_path_buf());
445        assert!(result.is_err());
446    }
447
448    #[test]
449    fn detect_managed_root_reads_settings() {
450        let dir = TempDir::new().unwrap();
451        std::fs::write(
452            dir.path().join("mars.toml"),
453            "[dependencies]\n\n[settings]\nmanaged_root = \".claude\"\n",
454        )
455        .unwrap();
456        let result = detect_managed_root(dir.path()).unwrap();
457        assert_eq!(result, dir.path().join(".claude"));
458    }
459
460    #[test]
461    fn detect_managed_root_falls_through_on_missing_config() {
462        let dir = TempDir::new().unwrap();
463        let result = detect_managed_root(dir.path()).unwrap();
464        assert_eq!(result, dir.path().join(".agents"));
465    }
466
467    #[test]
468    fn detect_managed_root_surfaces_parse_errors() {
469        let dir = TempDir::new().unwrap();
470        std::fs::write(dir.path().join("mars.toml"), "invalid toml {{{").unwrap();
471        let result = detect_managed_root(dir.path());
472        assert!(result.is_err());
473    }
474
475    #[test]
476    fn init_rejects_root_that_looks_like_managed_dir() {
477        let result = find_agents_root(Some(Path::new(".agents")));
478        assert!(result.is_err());
479        let err = result.unwrap_err().to_string();
480        assert!(
481            err.contains("managed output directory"),
482            "should reject .agents as --root: {err}"
483        );
484    }
485
486    // ── Walk-up discovery tests (filesystem root boundary) ──────────────────────────
487
488    #[test]
489    fn walk_up_crosses_git_boundary_to_find_config() {
490        // Outer has mars.toml, inner has .git but no mars.toml
491        // Starting from inner SHOULD find outer's config (git is irrelevant)
492        let dir = TempDir::new().unwrap();
493        let outer = dir.path().join("outer");
494        std::fs::create_dir_all(outer.join(".agents")).unwrap();
495        std::fs::write(outer.join("mars.toml"), "[dependencies]\n").unwrap();
496
497        let inner = outer.join("inner");
498        std::fs::create_dir_all(inner.join(".git")).unwrap();
499
500        let ctx = find_agents_root_from(&inner).unwrap();
501        assert_eq!(
502            ctx.project_root,
503            outer.canonicalize().unwrap(),
504            "should find outer config even when inner has .git"
505        );
506    }
507
508    #[test]
509    fn walk_up_finds_config_in_ancestor() {
510        let dir = TempDir::new().unwrap();
511        let root = dir.path().join("project");
512        std::fs::create_dir_all(root.join(".agents")).unwrap();
513        std::fs::write(root.join("mars.toml"), "[dependencies]\n").unwrap();
514
515        let subdir = root.join("src").join("lib");
516        std::fs::create_dir_all(&subdir).unwrap();
517
518        let ctx = find_agents_root_from(&subdir).unwrap();
519        assert_eq!(ctx.project_root, root.canonicalize().unwrap());
520    }
521
522    #[test]
523    fn walk_up_prefers_nearest_mars_toml() {
524        // child has package-only mars.toml, parent also has mars.toml
525        let dir = TempDir::new().unwrap();
526        let parent = dir.path().join("parent");
527        std::fs::create_dir_all(parent.join(".agents")).unwrap();
528        std::fs::write(parent.join("mars.toml"), "[dependencies]\n").unwrap();
529
530        let child = parent.join("child");
531        std::fs::create_dir_all(&child).unwrap();
532        std::fs::write(
533            child.join("mars.toml"),
534            "[package]\nname = \"pkg\"\nversion = \"0.1.0\"\n",
535        )
536        .unwrap();
537
538        let ctx = find_agents_root_from(&child).unwrap();
539        assert_eq!(ctx.project_root, child.canonicalize().unwrap());
540    }
541
542    #[test]
543    fn walk_up_from_deep_subdirectory() {
544        let dir = TempDir::new().unwrap();
545        let root = dir.path().join("repo");
546        std::fs::create_dir_all(root.join(".agents")).unwrap();
547        std::fs::write(root.join("mars.toml"), "[dependencies]\n").unwrap();
548
549        let deep = root.join("src").join("foo").join("bar");
550        std::fs::create_dir_all(&deep).unwrap();
551
552        let ctx = find_agents_root_from(&deep).unwrap();
553        assert_eq!(ctx.project_root, root.canonicalize().unwrap());
554    }
555
556    #[test]
557    fn walk_up_crosses_submodule_boundary() {
558        // Outer repo has mars.toml
559        // Inner dir has .git FILE (submodule marker) — walk-up should still find outer config
560        let dir = TempDir::new().unwrap();
561        let outer = dir.path().join("outer");
562        std::fs::create_dir_all(outer.join(".agents")).unwrap();
563        std::fs::write(outer.join("mars.toml"), "[dependencies]\n").unwrap();
564
565        let submodule = outer.join("submodule");
566        std::fs::create_dir_all(&submodule).unwrap();
567        // .git FILE (not dir) marks a submodule
568        std::fs::write(
569            submodule.join(".git"),
570            "gitdir: ../../.git/modules/submodule\n",
571        )
572        .unwrap();
573
574        let ctx = find_agents_root_from(&submodule).unwrap();
575        assert_eq!(
576            ctx.project_root,
577            outer.canonicalize().unwrap(),
578            "should find outer config through submodule .git file boundary"
579        );
580    }
581
582    #[test]
583    fn walk_up_errors_when_no_config_found() {
584        let dir = TempDir::new().unwrap();
585        let deep = dir.path().join("a").join("b").join("c");
586        std::fs::create_dir_all(&deep).unwrap();
587
588        let result = find_agents_root_from(&deep);
589        assert!(result.is_err());
590        let err = result.unwrap_err().to_string();
591        assert!(
592            err.contains("no mars.toml found"),
593            "should report no config found: {err}"
594        );
595        assert!(
596            err.contains("filesystem root"),
597            "should mention filesystem root: {err}"
598        );
599    }
600
601    #[test]
602    fn walk_up_with_root_flag_starts_from_specified_path() {
603        let dir = TempDir::new().unwrap();
604        let project = dir.path().join("project");
605        std::fs::create_dir_all(project.join(".agents")).unwrap();
606        std::fs::write(project.join("mars.toml"), "[dependencies]\n").unwrap();
607
608        // --root points to subdirectory — walk up should find mars.toml in parent
609        let subdir = project.join("src");
610        std::fs::create_dir_all(&subdir).unwrap();
611
612        let ctx = find_agents_root(Some(&subdir)).unwrap();
613        assert_eq!(ctx.project_root, project.canonicalize().unwrap());
614    }
615}