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