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