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