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 export;
16pub mod init;
17pub mod link;
18pub mod list;
19pub mod models;
20pub mod outdated;
21pub mod output;
22pub mod override_cmd;
23pub mod remove;
24pub mod rename;
25pub mod repair;
26pub mod resolve_cmd;
27pub mod sync;
28pub mod upgrade;
29pub mod validate;
30pub mod version;
31pub mod why;
32
33use std::path::{Path, PathBuf};
34
35use clap::{Parser, Subcommand};
36
37use crate::error::{ConfigError, LockError, MarsError};
38pub use crate::types::MarsContext;
39
40/// Deprecated generic output directories still recognized for migration hints.
41pub const WELL_KNOWN: &[&str] = &[".agents"];
42
43/// Tool-specific directories that commonly need linking.
44/// `mars link` warns if the target isn't in TOOL_DIRS or WELL_KNOWN.
45pub const TOOL_DIRS: &[&str] = &[".claude", ".cursor"];
46
47impl MarsContext {
48    /// Build context from project root (directory containing mars.toml).
49    pub fn new(project_root: PathBuf) -> Result<Self, MarsError> {
50        let project_canon = if project_root.exists() {
51            dunce::canonicalize(&project_root).unwrap_or(project_root.clone())
52        } else {
53            project_root.clone()
54        };
55
56        let managed_root = detect_managed_root(&project_canon)?;
57        Self::from_roots(project_canon, managed_root)
58    }
59
60    /// Build context from explicit project and managed roots.
61    pub fn from_roots(project_root: PathBuf, managed_root: PathBuf) -> Result<Self, MarsError> {
62        let project_canon = if project_root.exists() {
63            dunce::canonicalize(&project_root).unwrap_or(project_root.clone())
64        } else {
65            project_root.clone()
66        };
67        let managed_canon = if managed_root.exists() {
68            dunce::canonicalize(&managed_root).unwrap_or(managed_root.clone())
69        } else {
70            managed_root.clone()
71        };
72
73        if !managed_canon.starts_with(&project_canon) {
74            return Err(MarsError::Config(ConfigError::Invalid {
75                message: format!(
76                    "{} resolves to {} which is outside {}. \
77                     The managed root may be a symlink. Use --root to override.",
78                    managed_root.display(),
79                    managed_canon.display(),
80                    project_canon.display(),
81                ),
82            }));
83        }
84
85        Ok(MarsContext {
86            managed_root: managed_canon,
87            project_root: project_canon,
88            meridian_managed: crate::types::meridian_managed_from_env(),
89        })
90    }
91}
92
93/// mars — agent package manager for agent and skill packages.
94#[derive(Debug, Parser)]
95#[command(name = "mars", version, about = "Agent package manager")]
96pub struct Cli {
97    #[command(subcommand)]
98    pub command: Command,
99
100    /// Path to project root containing mars.toml (default: auto-detect).
101    #[arg(long, global = true)]
102    pub root: Option<PathBuf>,
103
104    /// Output in JSON format.
105    #[arg(long, global = true)]
106    pub json: bool,
107}
108
109#[derive(Debug, Subcommand)]
110pub enum Command {
111    /// Initialize project-level mars.toml and .mars/ compiled store.
112    Init(init::InitArgs),
113
114    /// Add a dependency (git URL, GitHub shorthand, or local path).
115    Add(add::AddArgs),
116
117    /// Adopt an unmanaged target item into `.mars-src/`, then sync.
118    Adopt(adopt::AdoptArgs),
119
120    /// Remove a dependency.
121    Remove(remove::RemoveArgs),
122
123    /// Sync: resolve + install (make reality match config).
124    Sync(sync::SyncArgs),
125
126    /// Upgrade dependencies to newest compatible versions.
127    Upgrade(upgrade::UpgradeArgs),
128
129    /// Show available updates without applying.
130    Outdated(outdated::OutdatedArgs),
131
132    /// Bump package version in mars.toml, commit, and tag.
133    Version(version::VersionArgs),
134
135    /// List managed items with status.
136    List(list::ListArgs),
137
138    /// Explain why an item is installed.
139    Why(why::WhyArgs),
140
141    /// Rename a managed item.
142    Rename(rename::RenameArgs),
143
144    /// Mark conflicts as resolved.
145    Resolve(resolve_cmd::ResolveArgs),
146
147    /// Set a local dev override for a source.
148    Override(override_cmd::OverrideArgs),
149
150    /// Add/remove managed target directories (e.g. .claude).
151    Link(link::LinkArgs),
152
153    /// Dry-run the compiler pipeline and report diagnostics without writing.
154    Validate(validate::ValidateArgs),
155
156    /// Export the compile plan as JSON (dry-run, no writes).
157    Export(export::ExportArgs),
158
159    /// Validate a source package before publishing (structure, frontmatter, deps).
160    Check(check::CheckArgs),
161
162    /// Diagnose problems in an installed mars project (config, lock, files, targets).
163    Doctor(doctor::DoctorArgs),
164
165    /// Rebuild state from lock + sources.
166    Repair(repair::RepairArgs),
167
168    /// Manage the global source cache.
169    Cache(cache::CacheArgs),
170
171    /// Manage model aliases and the models cache.
172    Models(models::ModelsArgs),
173}
174
175/// Dispatch a parsed CLI command to the appropriate handler and map errors to
176/// the final exit code.
177pub fn dispatch(cli: Cli) -> i32 {
178    match dispatch_result(cli) {
179        Ok(code) => code,
180        Err(err) => {
181            eprintln!("error: {err}");
182            if matches!(err, MarsError::Lock(LockError::Corrupt { .. })) {
183                eprintln!("hint: run `mars repair` to rebuild from mars.toml + dependencies");
184            }
185            err.exit_code()
186        }
187    }
188}
189
190fn dispatch_result(cli: Cli) -> Result<i32, MarsError> {
191    match &cli.command {
192        // Root-free commands
193        Command::Init(args) => init::run(args, cli.root.as_deref(), cli.json),
194        Command::Check(args) => check::run(args, cli.json),
195        Command::Cache(args) => cache::run(args, cli.json),
196        // All other commands require context
197        cmd => {
198            let ctx = match find_agents_root(cli.root.as_deref()) {
199                Ok(ctx) => ctx,
200                Err(err) if should_auto_init_project(cmd, &err) => {
201                    let initialized = init::initialize_project(cli.root.as_deref(), None)?;
202                    if !cli.json {
203                        output::print_info(&format!(
204                            "auto-initialized {} with mars.toml",
205                            initialized.project_root.display()
206                        ));
207                    }
208                    MarsContext::from_roots(
209                        initialized.project_root.clone(),
210                        initialized
211                            .managed_root
212                            .clone()
213                            .unwrap_or_else(|| initialized.project_root.join(".mars")),
214                    )?
215                }
216                Err(err) => return Err(err),
217            };
218            dispatch_with_root(cmd, &ctx, cli.json)
219        }
220    }
221}
222
223fn should_auto_init_project(cmd: &Command, err: &MarsError) -> bool {
224    matches!(cmd, Command::Add(_) | Command::Link(_))
225        && matches!(
226            err,
227            MarsError::Config(ConfigError::ProjectRootNotFound { .. })
228        )
229}
230
231fn dispatch_with_root(cmd: &Command, ctx: &MarsContext, json: bool) -> Result<i32, MarsError> {
232    match cmd {
233        Command::Validate(args) => validate::run(args, ctx, json),
234        Command::Export(args) => export::run(args, ctx, json),
235        Command::Add(args) => add::run(args, ctx, json),
236        Command::Adopt(args) => adopt::run(args, ctx, json),
237        Command::Remove(args) => remove::run(args, ctx, json),
238        Command::Sync(args) => sync::run(args, ctx, json),
239        Command::Upgrade(args) => upgrade::run(args, ctx, json),
240        Command::Outdated(args) => outdated::run(args, ctx, json),
241        Command::Version(args) => version::run(args, ctx, json),
242        Command::List(args) => list::run(args, ctx, json),
243        Command::Why(args) => why::run(args, ctx, json),
244        Command::Rename(args) => rename::run(args, ctx, json),
245        Command::Resolve(args) => resolve_cmd::run(args, ctx, json),
246        Command::Override(args) => override_cmd::run(args, ctx, json),
247        Command::Link(args) => link::run(args, ctx, json),
248        Command::Doctor(args) => doctor::run(args, ctx, json),
249        Command::Repair(args) => repair::run(args, ctx, json),
250        Command::Models(args) => models::run(args, ctx, json),
251        // Root-free commands handled in dispatch_result — unreachable here
252        Command::Init(_) | Command::Check(_) | Command::Cache(_) => unreachable!(),
253    }
254}
255
256/// Check if a path is a symlink (uses symlink_metadata, doesn't follow).
257pub fn is_symlink(path: &Path) -> bool {
258    path.symlink_metadata()
259        .map(|m| m.file_type().is_symlink())
260        .unwrap_or(false)
261}
262
263fn detect_managed_root(project_root: &Path) -> Result<PathBuf, MarsError> {
264    // 1. Check explicit settings in mars.toml.
265    match crate::config::load(project_root) {
266        Ok(config) => {
267            if let Some(name) = &config.settings.managed_root {
268                return Ok(project_root.join(name));
269            }
270            if config
271                .settings
272                .targets
273                .as_ref()
274                .is_some_and(|targets| targets.iter().any(|target| target == WELL_KNOWN[0]))
275            {
276                return Ok(project_root.join(WELL_KNOWN[0]));
277            }
278        }
279        // Config doesn't exist yet (before mars init) — expected, fall through
280        Err(MarsError::Config(ConfigError::NotFound { .. })) => {}
281        // Config exists but has parse errors — surface the real error
282        Err(e) => return Err(e),
283    }
284
285    // 2. Canonical store default. Do not infer legacy `.agents/` ownership from
286    // disk presence; doctor reports leftover target migration hints separately.
287    Ok(project_root.join(".mars"))
288}
289
290/// Find mars project root by walking up from start path to filesystem root.
291///
292/// For context commands (`add`, `sync`, etc.), this walks from the start path
293/// (cwd or `--root`) until it finds a `mars.toml`. Walk-up continues to filesystem
294/// root — git boundaries do not stop the walk.
295///
296/// If `--root` is provided, it sets the walk-up start path, not a direct target.
297pub fn find_agents_root(explicit: Option<&Path>) -> Result<MarsContext, MarsError> {
298    let start = if let Some(root) = explicit {
299        // Reject --root values that look like managed output directories
300        if let Some(basename) = root.file_name().and_then(|f| f.to_str())
301            && (WELL_KNOWN.contains(&basename) || TOOL_DIRS.contains(&basename))
302        {
303            return Err(MarsError::Config(ConfigError::Invalid {
304                message: format!(
305                    "`--root {basename}` looks like a managed output directory.\n  \
306                     --root takes the project root (containing mars.toml), not the output directory.\n  \
307                     Try: mars init  (auto-detects project root)\n  \
308                     Or:  mars init {basename}  (specify output directory name)"
309                ),
310            }));
311        }
312
313        root.to_path_buf()
314    } else {
315        std::env::current_dir()?
316    };
317
318    find_agents_root_from(&start)
319}
320
321/// Walk up from `start` to filesystem root searching for `mars.toml`.
322///
323/// Uses `Path::parent()` which returns `None` at filesystem root on all platforms:
324/// - Unix: `/` has no parent
325/// - Windows: `C:\` or UNC roots like `\\server\share` have no parent
326fn find_agents_root_from(start: &Path) -> Result<MarsContext, MarsError> {
327    let start_canon = dunce::canonicalize(start).unwrap_or_else(|_| start.to_path_buf());
328    let mut dir = start_canon.as_path();
329
330    // Walk up to filesystem root (Path::parent() returns None at root)
331    loop {
332        let config_path = dir.join("mars.toml");
333        if config_path.exists() {
334            return MarsContext::new(dir.to_path_buf());
335        }
336
337        match dir.parent() {
338            Some(parent) => dir = parent,
339            None => break,
340        }
341    }
342
343    Err(MarsError::Config(ConfigError::ProjectRootNotFound {
344        start: start.to_path_buf(),
345    }))
346}
347
348#[cfg(test)]
349mod tests {
350    use super::*;
351    use tempfile::TempDir;
352
353    #[test]
354    fn find_root_with_explicit_path() {
355        let dir = TempDir::new().unwrap();
356        // Canonicalize once and use everywhere to avoid Windows 8.3 short-name mismatches
357        let canonical_dir = dunce::canonicalize(dir.path()).unwrap();
358        std::fs::write(canonical_dir.join("mars.toml"), "[dependencies]\n").unwrap();
359
360        // --root points to a dir with mars.toml — should find it via walk-up
361        let ctx = find_agents_root(Some(&canonical_dir)).unwrap();
362        assert_eq!(ctx.project_root, canonical_dir);
363        assert_eq!(ctx.managed_root, ctx.project_root.join(".mars"));
364    }
365
366    #[test]
367    fn package_manifest_without_dependencies_is_valid_project_root() {
368        let dir = TempDir::new().unwrap();
369        std::fs::write(
370            dir.path().join("mars.toml"),
371            "[package]\nname = \"pkg\"\nversion = \"0.1.0\"\n",
372        )
373        .unwrap();
374
375        let ctx = find_agents_root(Some(dir.path())).unwrap();
376        assert_eq!(ctx.project_root, dunce::canonicalize(dir.path()).unwrap());
377    }
378
379    #[test]
380    fn find_root_ignores_leftover_agents_dir_without_explicit_config() {
381        let dir = TempDir::new().unwrap();
382        std::fs::write(dir.path().join("mars.toml"), "[dependencies]\n").unwrap();
383        std::fs::create_dir_all(dir.path().join(".agents")).unwrap();
384
385        let ctx = MarsContext::new(dir.path().to_path_buf()).unwrap();
386        assert_eq!(ctx.project_root, dunce::canonicalize(dir.path()).unwrap());
387        assert_eq!(ctx.managed_root, ctx.project_root.join(".mars"));
388    }
389
390    #[test]
391    fn find_root_with_custom_managed_dir_from_settings() {
392        let dir = TempDir::new().unwrap();
393        std::fs::write(
394            dir.path().join("mars.toml"),
395            "[dependencies]\n\n[settings]\nmanaged_root = \".claude\"\n",
396        )
397        .unwrap();
398        std::fs::create_dir_all(dir.path().join(".claude")).unwrap();
399
400        let ctx = MarsContext::new(dir.path().to_path_buf()).unwrap();
401        assert_eq!(
402            ctx.managed_root,
403            dunce::canonicalize(dir.path().join(".claude")).unwrap()
404        );
405    }
406
407    #[test]
408    fn find_root_with_agents_target_from_settings_targets() {
409        let dir = TempDir::new().unwrap();
410        std::fs::write(
411            dir.path().join("mars.toml"),
412            "[dependencies]\n\n[settings]\ntargets = [\".agents\"]\n",
413        )
414        .unwrap();
415        std::fs::create_dir_all(dir.path().join(".agents")).unwrap();
416
417        let ctx = MarsContext::new(dir.path().to_path_buf()).unwrap();
418        assert_eq!(
419            ctx.managed_root,
420            dunce::canonicalize(dir.path().join(".agents")).unwrap()
421        );
422    }
423
424    #[cfg(unix)]
425    #[test]
426    fn context_rejects_symlinked_managed_root_outside_project() {
427        let project_dir = TempDir::new().unwrap();
428        let external_dir = TempDir::new().unwrap();
429        std::fs::write(
430            project_dir.path().join("mars.toml"),
431            "[dependencies]\n\n[settings]\nmanaged_root = \".agents\"\n",
432        )
433        .unwrap();
434
435        let external_agents = external_dir.path().join(".agents");
436        std::fs::create_dir_all(&external_agents).unwrap();
437
438        let project_agents = project_dir.path().join(".agents");
439        std::os::unix::fs::symlink(&external_agents, &project_agents).unwrap();
440
441        let result = MarsContext::new(project_dir.path().to_path_buf());
442        assert!(result.is_err());
443    }
444
445    #[test]
446    fn detect_managed_root_reads_settings() {
447        let dir = TempDir::new().unwrap();
448        std::fs::write(
449            dir.path().join("mars.toml"),
450            "[dependencies]\n\n[settings]\nmanaged_root = \".claude\"\n",
451        )
452        .unwrap();
453        let result = detect_managed_root(dir.path()).unwrap();
454        assert_eq!(result, dir.path().join(".claude"));
455    }
456
457    #[test]
458    fn detect_managed_root_falls_through_on_missing_config() {
459        let dir = TempDir::new().unwrap();
460        let result = detect_managed_root(dir.path()).unwrap();
461        assert_eq!(result, dir.path().join(".mars"));
462    }
463
464    #[test]
465    fn detect_managed_root_ignores_agents_dir_without_explicit_config() {
466        let dir = TempDir::new().unwrap();
467        std::fs::write(dir.path().join("mars.toml"), "[dependencies]\n").unwrap();
468        std::fs::create_dir_all(dir.path().join(".agents")).unwrap();
469
470        let result = detect_managed_root(dir.path()).unwrap();
471        assert_eq!(result, dir.path().join(".mars"));
472    }
473
474    #[test]
475    fn detect_managed_root_surfaces_parse_errors() {
476        let dir = TempDir::new().unwrap();
477        std::fs::write(dir.path().join("mars.toml"), "invalid toml {{{").unwrap();
478        let result = detect_managed_root(dir.path());
479        assert!(result.is_err());
480    }
481
482    #[test]
483    fn init_rejects_root_that_looks_like_managed_dir() {
484        let result = find_agents_root(Some(Path::new(".agents")));
485        assert!(result.is_err());
486        let err = result.unwrap_err().to_string();
487        assert!(
488            err.contains("managed output directory"),
489            "should reject .agents as --root: {err}"
490        );
491    }
492
493    // ── Walk-up discovery tests (filesystem root boundary) ──────────────────────────
494
495    #[test]
496    fn walk_up_crosses_git_boundary_to_find_config() {
497        // Outer has mars.toml, inner has .git but no mars.toml
498        // Starting from inner SHOULD find outer's config (git is irrelevant)
499        let dir = TempDir::new().unwrap();
500        let outer = dir.path().join("outer");
501        std::fs::create_dir_all(outer.join(".agents")).unwrap();
502        std::fs::write(outer.join("mars.toml"), "[dependencies]\n").unwrap();
503
504        let inner = outer.join("inner");
505        std::fs::create_dir_all(inner.join(".git")).unwrap();
506
507        let ctx = find_agents_root_from(&inner).unwrap();
508        assert_eq!(
509            ctx.project_root,
510            dunce::canonicalize(&outer).unwrap(),
511            "should find outer config even when inner has .git"
512        );
513    }
514
515    #[test]
516    fn walk_up_finds_config_in_ancestor() {
517        let dir = TempDir::new().unwrap();
518        let root = dir.path().join("project");
519        std::fs::create_dir_all(root.join(".agents")).unwrap();
520        std::fs::write(root.join("mars.toml"), "[dependencies]\n").unwrap();
521
522        let subdir = root.join("src").join("lib");
523        std::fs::create_dir_all(&subdir).unwrap();
524
525        let ctx = find_agents_root_from(&subdir).unwrap();
526        assert_eq!(ctx.project_root, dunce::canonicalize(&root).unwrap());
527    }
528
529    #[test]
530    fn walk_up_prefers_nearest_mars_toml() {
531        // child has package-only mars.toml, parent also has mars.toml
532        let dir = TempDir::new().unwrap();
533        let parent = dir.path().join("parent");
534        std::fs::create_dir_all(parent.join(".agents")).unwrap();
535        std::fs::write(parent.join("mars.toml"), "[dependencies]\n").unwrap();
536
537        let child = parent.join("child");
538        std::fs::create_dir_all(&child).unwrap();
539        std::fs::write(
540            child.join("mars.toml"),
541            "[package]\nname = \"pkg\"\nversion = \"0.1.0\"\n",
542        )
543        .unwrap();
544
545        let ctx = find_agents_root_from(&child).unwrap();
546        assert_eq!(ctx.project_root, dunce::canonicalize(&child).unwrap());
547    }
548
549    #[test]
550    fn walk_up_from_deep_subdirectory() {
551        let dir = TempDir::new().unwrap();
552        let root = dir.path().join("repo");
553        std::fs::create_dir_all(root.join(".agents")).unwrap();
554        std::fs::write(root.join("mars.toml"), "[dependencies]\n").unwrap();
555
556        let deep = root.join("src").join("foo").join("bar");
557        std::fs::create_dir_all(&deep).unwrap();
558
559        let ctx = find_agents_root_from(&deep).unwrap();
560        assert_eq!(ctx.project_root, dunce::canonicalize(&root).unwrap());
561    }
562
563    #[test]
564    fn walk_up_crosses_submodule_boundary() {
565        // Outer repo has mars.toml
566        // Inner dir has .git FILE (submodule marker) — walk-up should still find outer config
567        let dir = TempDir::new().unwrap();
568        let outer = dir.path().join("outer");
569        std::fs::create_dir_all(outer.join(".agents")).unwrap();
570        std::fs::write(outer.join("mars.toml"), "[dependencies]\n").unwrap();
571
572        let submodule = outer.join("submodule");
573        std::fs::create_dir_all(&submodule).unwrap();
574        // .git FILE (not dir) marks a submodule
575        std::fs::write(
576            submodule.join(".git"),
577            "gitdir: ../../.git/modules/submodule\n",
578        )
579        .unwrap();
580
581        let ctx = find_agents_root_from(&submodule).unwrap();
582        assert_eq!(
583            ctx.project_root,
584            dunce::canonicalize(&outer).unwrap(),
585            "should find outer config through submodule .git file boundary"
586        );
587    }
588
589    #[test]
590    fn walk_up_errors_when_no_config_found() {
591        let dir = TempDir::new().unwrap();
592        let deep = dir.path().join("a").join("b").join("c");
593        std::fs::create_dir_all(&deep).unwrap();
594
595        let result = find_agents_root_from(&deep);
596        assert!(result.is_err());
597        let err = result.unwrap_err().to_string();
598        assert!(
599            err.contains("no mars.toml found"),
600            "should report no config found: {err}"
601        );
602        assert!(
603            err.contains("filesystem root"),
604            "should mention filesystem root: {err}"
605        );
606    }
607
608    #[test]
609    fn walk_up_with_root_flag_starts_from_specified_path() {
610        let dir = TempDir::new().unwrap();
611        let project = dir.path().join("project");
612        std::fs::create_dir_all(project.join(".agents")).unwrap();
613        std::fs::write(project.join("mars.toml"), "[dependencies]\n").unwrap();
614
615        // --root points to subdirectory — walk up should find mars.toml in parent
616        let subdir = project.join("src");
617        std::fs::create_dir_all(&subdir).unwrap();
618
619        let ctx = find_agents_root(Some(&subdir)).unwrap();
620        assert_eq!(ctx.project_root, dunce::canonicalize(&project).unwrap());
621    }
622}