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                    model: Some(_),
258                    ..
259                })
260            }),
261            MarsError::Config(ConfigError::ProjectRootNotFound { .. })
262        )
263    )
264}
265
266fn dispatch_with_root(cmd: &Command, ctx: &MarsContext, json: bool) -> Result<i32, MarsError> {
267    match cmd {
268        Command::Validate(args) => validate::run(args, ctx, json),
269        Command::Export(args) => export::run(args, ctx, json),
270        Command::Add(args) => add::run(args, ctx, json),
271        Command::Adopt(args) => adopt::run(args, ctx, json),
272        Command::Remove(args) => remove::run(args, ctx, json),
273        Command::Sync(args) => sync::run(args, ctx, json),
274        Command::Upgrade(args) => upgrade::run(args, ctx, json),
275        Command::Outdated(args) => outdated::run(args, ctx, json),
276        Command::Version(args) => version::run(args, ctx, json),
277        Command::List(args) => list::run(args, ctx, json),
278        Command::Why(args) => why::run(args, ctx, json),
279        Command::Rename(args) => rename::run(args, ctx, json),
280        Command::Resolve(args) => resolve_cmd::run(args, ctx, json),
281        Command::Override(args) => override_cmd::run(args, ctx, json),
282        Command::Link(args) => link::run(args, ctx, json),
283        Command::Unlink(args) => unlink::run(args, ctx, json),
284        Command::Doctor(args) => doctor::run(args, ctx, json),
285        Command::Repair(args) => repair::run(args, ctx, json),
286        Command::Models(args) => models::run(args, ctx, json),
287        Command::Build(args) => build::run(args, ctx, json),
288        // Root-free commands handled in dispatch_result — unreachable here
289        Command::Init(_) | Command::Check(_) | Command::Cache(_) => unreachable!(),
290    }
291}
292
293/// Check if a path is a symlink (uses symlink_metadata, doesn't follow).
294pub fn is_symlink(path: &Path) -> bool {
295    path.symlink_metadata()
296        .map(|m| m.file_type().is_symlink())
297        .unwrap_or(false)
298}
299
300fn detect_managed_root(project_root: &Path) -> Result<PathBuf, MarsError> {
301    // 1. Check explicit settings in mars.toml.
302    match crate::config::load(project_root) {
303        Ok(config) => {
304            if config.settings.managed_root.is_some()
305                && let Some(name) = config.settings.managed_targets().first()
306            {
307                return Ok(project_root.join(name));
308            }
309            if config
310                .settings
311                .managed_targets()
312                .iter()
313                .any(|target| target == WELL_KNOWN[0])
314            {
315                return Ok(project_root.join(WELL_KNOWN[0]));
316            }
317        }
318        // Config doesn't exist yet (before mars init) — expected, fall through
319        Err(MarsError::Config(ConfigError::NotFound { .. })) => {}
320        // Config exists but has parse errors — surface the real error
321        Err(e) => return Err(e),
322    }
323
324    // 2. Canonical store default. Do not infer legacy `.agents/` ownership from
325    // disk presence; doctor reports leftover target migration hints separately.
326    Ok(project_root.join(".mars"))
327}
328
329/// Find mars project root by walking up from start path to filesystem root.
330///
331/// For context commands (`add`, `sync`, etc.), this walks from the start path
332/// (cwd or `--root`) until it finds a `mars.toml`. Walk-up continues to filesystem
333/// root — git boundaries do not stop the walk.
334///
335/// If `--root` is provided, it sets the walk-up start path, not a direct target.
336pub fn find_agents_root(explicit: Option<&Path>) -> Result<MarsContext, MarsError> {
337    let start = if let Some(root) = explicit {
338        // Reject --root values that look like managed output directories
339        if let Some(basename) = root.file_name().and_then(|f| f.to_str())
340            && (WELL_KNOWN.contains(&basename) || TOOL_DIRS.contains(&basename))
341        {
342            return Err(MarsError::Config(ConfigError::Invalid {
343                message: format!(
344                    "`--root {basename}` looks like a managed output directory.\n  \
345                     --root takes the project root (containing mars.toml), not the output directory.\n  \
346                     Try: mars init  (auto-detects project root)\n  \
347                     Or:  mars init {basename}  (specify output directory name)"
348                ),
349            }));
350        }
351
352        root.to_path_buf()
353    } else {
354        std::env::current_dir()?
355    };
356
357    find_agents_root_from(&start)
358}
359
360/// Walk up from `start` to filesystem root searching for `mars.toml`.
361///
362/// Uses `Path::parent()` which returns `None` at filesystem root on all platforms:
363/// - Unix: `/` has no parent
364/// - Windows: `C:\` or UNC roots like `\\server\share` have no parent
365fn find_agents_root_from(start: &Path) -> Result<MarsContext, MarsError> {
366    let start_canon = dunce::canonicalize(start).unwrap_or_else(|_| start.to_path_buf());
367    let mut dir = start_canon.as_path();
368
369    // Walk up to filesystem root (Path::parent() returns None at root)
370    loop {
371        let config_path = dir.join("mars.toml");
372        if config_path.exists() {
373            return MarsContext::new(dir.to_path_buf());
374        }
375
376        match dir.parent() {
377            Some(parent) => dir = parent,
378            None => break,
379        }
380    }
381
382    Err(MarsError::Config(ConfigError::ProjectRootNotFound {
383        start: start.to_path_buf(),
384    }))
385}
386
387#[cfg(test)]
388mod tests {
389    use super::*;
390    use tempfile::TempDir;
391
392    #[test]
393    fn find_root_with_explicit_path() {
394        let dir = TempDir::new().unwrap();
395        // Canonicalize once and use everywhere to avoid Windows 8.3 short-name mismatches
396        let canonical_dir = dunce::canonicalize(dir.path()).unwrap();
397        std::fs::write(canonical_dir.join("mars.toml"), "[dependencies]\n").unwrap();
398
399        // --root points to a dir with mars.toml — should find it via walk-up
400        let ctx = find_agents_root(Some(&canonical_dir)).unwrap();
401        assert_eq!(ctx.project_root, canonical_dir);
402        assert_eq!(ctx.managed_root, ctx.project_root.join(".mars"));
403    }
404
405    #[test]
406    fn package_manifest_without_dependencies_is_valid_project_root() {
407        let dir = TempDir::new().unwrap();
408        std::fs::write(
409            dir.path().join("mars.toml"),
410            "[package]\nname = \"pkg\"\nversion = \"0.1.0\"\n",
411        )
412        .unwrap();
413
414        let ctx = find_agents_root(Some(dir.path())).unwrap();
415        assert_eq!(ctx.project_root, dunce::canonicalize(dir.path()).unwrap());
416    }
417
418    #[test]
419    fn find_root_ignores_leftover_agents_dir_without_explicit_config() {
420        let dir = TempDir::new().unwrap();
421        std::fs::write(dir.path().join("mars.toml"), "[dependencies]\n").unwrap();
422        std::fs::create_dir_all(dir.path().join(".agents")).unwrap();
423
424        let ctx = MarsContext::new(dir.path().to_path_buf()).unwrap();
425        assert_eq!(ctx.project_root, dunce::canonicalize(dir.path()).unwrap());
426        assert_eq!(ctx.managed_root, ctx.project_root.join(".mars"));
427    }
428
429    #[test]
430    fn find_root_with_custom_managed_dir_from_settings() {
431        let dir = TempDir::new().unwrap();
432        std::fs::write(
433            dir.path().join("mars.toml"),
434            "[dependencies]\n\n[settings]\nmanaged_root = \".claude\"\n",
435        )
436        .unwrap();
437        std::fs::create_dir_all(dir.path().join(".claude")).unwrap();
438
439        let ctx = MarsContext::new(dir.path().to_path_buf()).unwrap();
440        assert_eq!(
441            ctx.managed_root,
442            dunce::canonicalize(dir.path().join(".claude")).unwrap()
443        );
444    }
445
446    #[test]
447    fn find_root_with_agents_target_from_settings_targets() {
448        let dir = TempDir::new().unwrap();
449        std::fs::write(
450            dir.path().join("mars.toml"),
451            "[dependencies]\n\n[settings]\ntargets = [\".agents\"]\n",
452        )
453        .unwrap();
454        std::fs::create_dir_all(dir.path().join(".agents")).unwrap();
455
456        let ctx = MarsContext::new(dir.path().to_path_buf()).unwrap();
457        assert_eq!(
458            ctx.managed_root,
459            dunce::canonicalize(dir.path().join(".agents")).unwrap()
460        );
461    }
462
463    #[cfg(unix)]
464    #[test]
465    fn context_rejects_symlinked_managed_root_outside_project() {
466        let project_dir = TempDir::new().unwrap();
467        let external_dir = TempDir::new().unwrap();
468        std::fs::write(
469            project_dir.path().join("mars.toml"),
470            "[dependencies]\n\n[settings]\nmanaged_root = \".agents\"\n",
471        )
472        .unwrap();
473
474        let external_agents = external_dir.path().join(".agents");
475        std::fs::create_dir_all(&external_agents).unwrap();
476
477        let project_agents = project_dir.path().join(".agents");
478        std::os::unix::fs::symlink(&external_agents, &project_agents).unwrap();
479
480        let result = MarsContext::new(project_dir.path().to_path_buf());
481        assert!(result.is_err());
482    }
483
484    #[test]
485    fn detect_managed_root_reads_settings() {
486        let dir = TempDir::new().unwrap();
487        std::fs::write(
488            dir.path().join("mars.toml"),
489            "[dependencies]\n\n[settings]\nmanaged_root = \".claude\"\n",
490        )
491        .unwrap();
492        let result = detect_managed_root(dir.path()).unwrap();
493        assert_eq!(result, dir.path().join(".claude"));
494    }
495
496    #[test]
497    fn detect_managed_root_falls_through_on_missing_config() {
498        let dir = TempDir::new().unwrap();
499        let result = detect_managed_root(dir.path()).unwrap();
500        assert_eq!(result, dir.path().join(".mars"));
501    }
502
503    #[test]
504    fn detect_managed_root_ignores_agents_dir_without_explicit_config() {
505        let dir = TempDir::new().unwrap();
506        std::fs::write(dir.path().join("mars.toml"), "[dependencies]\n").unwrap();
507        std::fs::create_dir_all(dir.path().join(".agents")).unwrap();
508
509        let result = detect_managed_root(dir.path()).unwrap();
510        assert_eq!(result, dir.path().join(".mars"));
511    }
512
513    #[test]
514    fn detect_managed_root_surfaces_parse_errors() {
515        let dir = TempDir::new().unwrap();
516        std::fs::write(dir.path().join("mars.toml"), "invalid toml {{{").unwrap();
517        let result = detect_managed_root(dir.path());
518        assert!(result.is_err());
519    }
520
521    #[test]
522    fn init_rejects_root_that_looks_like_managed_dir() {
523        let result = find_agents_root(Some(Path::new(".agents")));
524        assert!(result.is_err());
525        let err = result.unwrap_err().to_string();
526        assert!(
527            err.contains("managed output directory"),
528            "should reject .agents as --root: {err}"
529        );
530    }
531
532    // ── Walk-up discovery tests (filesystem root boundary) ──────────────────────────
533
534    #[test]
535    fn walk_up_crosses_git_boundary_to_find_config() {
536        // Outer has mars.toml, inner has .git but no mars.toml
537        // Starting from inner SHOULD find outer's config (git is irrelevant)
538        let dir = TempDir::new().unwrap();
539        let outer = dir.path().join("outer");
540        std::fs::create_dir_all(outer.join(".agents")).unwrap();
541        std::fs::write(outer.join("mars.toml"), "[dependencies]\n").unwrap();
542
543        let inner = outer.join("inner");
544        std::fs::create_dir_all(inner.join(".git")).unwrap();
545
546        let ctx = find_agents_root_from(&inner).unwrap();
547        assert_eq!(
548            ctx.project_root,
549            dunce::canonicalize(&outer).unwrap(),
550            "should find outer config even when inner has .git"
551        );
552    }
553
554    #[test]
555    fn walk_up_finds_config_in_ancestor() {
556        let dir = TempDir::new().unwrap();
557        let root = dir.path().join("project");
558        std::fs::create_dir_all(root.join(".agents")).unwrap();
559        std::fs::write(root.join("mars.toml"), "[dependencies]\n").unwrap();
560
561        let subdir = root.join("src").join("lib");
562        std::fs::create_dir_all(&subdir).unwrap();
563
564        let ctx = find_agents_root_from(&subdir).unwrap();
565        assert_eq!(ctx.project_root, dunce::canonicalize(&root).unwrap());
566    }
567
568    #[test]
569    fn walk_up_prefers_nearest_mars_toml() {
570        // child has package-only mars.toml, parent also has mars.toml
571        let dir = TempDir::new().unwrap();
572        let parent = dir.path().join("parent");
573        std::fs::create_dir_all(parent.join(".agents")).unwrap();
574        std::fs::write(parent.join("mars.toml"), "[dependencies]\n").unwrap();
575
576        let child = parent.join("child");
577        std::fs::create_dir_all(&child).unwrap();
578        std::fs::write(
579            child.join("mars.toml"),
580            "[package]\nname = \"pkg\"\nversion = \"0.1.0\"\n",
581        )
582        .unwrap();
583
584        let ctx = find_agents_root_from(&child).unwrap();
585        assert_eq!(ctx.project_root, dunce::canonicalize(&child).unwrap());
586    }
587
588    #[test]
589    fn walk_up_from_deep_subdirectory() {
590        let dir = TempDir::new().unwrap();
591        let root = dir.path().join("repo");
592        std::fs::create_dir_all(root.join(".agents")).unwrap();
593        std::fs::write(root.join("mars.toml"), "[dependencies]\n").unwrap();
594
595        let deep = root.join("src").join("foo").join("bar");
596        std::fs::create_dir_all(&deep).unwrap();
597
598        let ctx = find_agents_root_from(&deep).unwrap();
599        assert_eq!(ctx.project_root, dunce::canonicalize(&root).unwrap());
600    }
601
602    #[test]
603    fn walk_up_crosses_submodule_boundary() {
604        // Outer repo has mars.toml
605        // Inner dir has .git FILE (submodule marker) — walk-up should still find outer config
606        let dir = TempDir::new().unwrap();
607        let outer = dir.path().join("outer");
608        std::fs::create_dir_all(outer.join(".agents")).unwrap();
609        std::fs::write(outer.join("mars.toml"), "[dependencies]\n").unwrap();
610
611        let submodule = outer.join("submodule");
612        std::fs::create_dir_all(&submodule).unwrap();
613        // .git FILE (not dir) marks a submodule
614        std::fs::write(
615            submodule.join(".git"),
616            "gitdir: ../../.git/modules/submodule\n",
617        )
618        .unwrap();
619
620        let ctx = find_agents_root_from(&submodule).unwrap();
621        assert_eq!(
622            ctx.project_root,
623            dunce::canonicalize(&outer).unwrap(),
624            "should find outer config through submodule .git file boundary"
625        );
626    }
627
628    #[test]
629    fn walk_up_errors_when_no_config_found() {
630        let dir = TempDir::new().unwrap();
631        let deep = dir.path().join("a").join("b").join("c");
632        std::fs::create_dir_all(&deep).unwrap();
633
634        let result = find_agents_root_from(&deep);
635        assert!(result.is_err());
636        let err = result.unwrap_err().to_string();
637        assert!(
638            err.contains("no mars.toml found"),
639            "should report no config found: {err}"
640        );
641        assert!(
642            err.contains("filesystem root"),
643            "should mention filesystem root: {err}"
644        );
645    }
646
647    #[test]
648    fn walk_up_with_root_flag_starts_from_specified_path() {
649        let dir = TempDir::new().unwrap();
650        let project = dir.path().join("project");
651        std::fs::create_dir_all(project.join(".agents")).unwrap();
652        std::fs::write(project.join("mars.toml"), "[dependencies]\n").unwrap();
653
654        // --root points to subdirectory — walk up should find mars.toml in parent
655        let subdir = project.join("src");
656        std::fs::create_dir_all(&subdir).unwrap();
657
658        let ctx = find_agents_root(Some(&subdir)).unwrap();
659        assert_eq!(ctx.project_root, dunce::canonicalize(&project).unwrap());
660    }
661}