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