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