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