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