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