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 `.agents/` 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};
33
34/// Directories where mars manages mars.toml as the primary root.
35/// These are the default target for `mars init`.
36pub const WELL_KNOWN: &[&str] = &[".agents"];
37
38/// Tool-specific directories that commonly need linking.
39/// Root detection searches these in addition to WELL_KNOWN.
40/// `mars link` warns if the target isn't in TOOL_DIRS or WELL_KNOWN.
41pub const TOOL_DIRS: &[&str] = &[".claude", ".cursor"];
42
43/// Resolved context for a mars command — both the managed root
44/// and its parent project root.
45pub struct MarsContext {
46    /// The directory containing mars.toml (e.g. /project/.agents)
47    pub managed_root: PathBuf,
48    /// The project directory (managed_root's parent, e.g. /project)
49    pub project_root: PathBuf,
50}
51
52impl MarsContext {
53    /// Build from a managed root path. Enforces the invariant that
54    /// managed_root must have a parent (i.e., is always a subdirectory).
55    pub fn new(managed_root: PathBuf) -> Result<Self, MarsError> {
56        let canonical = if managed_root.exists() {
57            managed_root.canonicalize().unwrap_or(managed_root.clone())
58        } else {
59            managed_root.clone()
60        };
61        let project_root = canonical.parent()
62            .ok_or_else(|| MarsError::Config(ConfigError::Invalid {
63                message: format!(
64                    "managed root {} has no parent directory — the managed root must be \
65                     a subdirectory (e.g., /project/.agents, not /project)",
66                    managed_root.display()
67                ),
68            }))?
69            .to_path_buf();
70        Ok(MarsContext { managed_root: canonical, project_root })
71    }
72}
73
74/// mars — agent package manager for .agents/
75#[derive(Debug, Parser)]
76#[command(name = "mars", version, about = "Agent package manager for .agents/")]
77pub struct Cli {
78    #[command(subcommand)]
79    pub command: Command,
80
81    /// Path to managed root containing mars.toml (default: auto-detect).
82    #[arg(long, global = true)]
83    pub root: Option<PathBuf>,
84
85    /// Output in JSON format.
86    #[arg(long, global = true)]
87    pub json: bool,
88}
89
90#[derive(Debug, Subcommand)]
91pub enum Command {
92    /// Initialize a managed root with mars.toml (default: .agents/).
93    Init(init::InitArgs),
94
95    /// Add a source (git URL, GitHub shorthand, or local path).
96    Add(add::AddArgs),
97
98    /// Remove a source.
99    Remove(remove::RemoveArgs),
100
101    /// Sync: resolve + install (make reality match config).
102    Sync(sync::SyncArgs),
103
104    /// Upgrade sources to newest compatible versions.
105    Upgrade(upgrade::UpgradeArgs),
106
107    /// Show available updates without applying.
108    Outdated(outdated::OutdatedArgs),
109
110    /// List managed items with status.
111    List(list::ListArgs),
112
113    /// Explain why an item is installed.
114    Why(why::WhyArgs),
115
116    /// Rename a managed item.
117    Rename(rename::RenameArgs),
118
119    /// Mark conflicts as resolved.
120    Resolve(resolve_cmd::ResolveArgs),
121
122    /// Set a local dev override for a source.
123    Override(override_cmd::OverrideArgs),
124
125    /// Symlink agents/ and skills/ into another directory (e.g. .claude).
126    Link(link::LinkArgs),
127
128    /// Validate a source package before publishing (structure, frontmatter, deps).
129    Check(check::CheckArgs),
130
131    /// Diagnose problems in an installed mars project (config, lock, files, links).
132    Doctor(doctor::DoctorArgs),
133
134    /// Rebuild state from lock + sources.
135    Repair(repair::RepairArgs),
136
137    /// Manage the global source cache.
138    Cache(cache::CacheArgs),
139}
140
141/// Dispatch a parsed CLI command to the appropriate handler and map errors to
142/// the final exit code.
143pub fn dispatch(cli: Cli) -> i32 {
144    match dispatch_result(cli) {
145        Ok(code) => code,
146        Err(err) => {
147            eprintln!("error: {err}");
148            if matches!(err, MarsError::Lock(LockError::Corrupt { .. })) {
149                eprintln!("hint: run `mars repair` to rebuild from mars.toml + sources");
150            }
151            err.exit_code()
152        }
153    }
154}
155
156fn dispatch_result(cli: Cli) -> Result<i32, MarsError> {
157    match &cli.command {
158        // Root-free commands
159        Command::Init(args) => init::run(args, cli.root.as_deref(), cli.json),
160        Command::Check(args) => check::run(args, cli.json),
161        Command::Cache(args) => cache::run(args, cli.json),
162        // All other commands require a managed root
163        cmd => {
164            let ctx = find_agents_root(cli.root.as_deref())?;
165            dispatch_with_root(cmd, &ctx, cli.json)
166        }
167    }
168}
169
170fn dispatch_with_root(cmd: &Command, ctx: &MarsContext, json: bool) -> Result<i32, MarsError> {
171    match cmd {
172        Command::Add(args) => add::run(args, ctx, json),
173        Command::Remove(args) => remove::run(args, ctx, json),
174        Command::Sync(args) => sync::run(args, ctx, json),
175        Command::Upgrade(args) => upgrade::run(args, ctx, json),
176        Command::Outdated(args) => outdated::run(args, ctx, json),
177        Command::List(args) => list::run(args, ctx, json),
178        Command::Why(args) => why::run(args, ctx, json),
179        Command::Rename(args) => rename::run(args, ctx, json),
180        Command::Resolve(args) => resolve_cmd::run(args, ctx, json),
181        Command::Override(args) => override_cmd::run(args, ctx, json),
182        Command::Link(args) => link::run(args, ctx, json),
183        Command::Doctor(args) => doctor::run(args, ctx, json),
184        Command::Repair(args) => repair::run(args, ctx, json),
185        // Root-free commands handled in dispatch_result — unreachable here
186        Command::Init(_) | Command::Check(_) | Command::Cache(_) => unreachable!(),
187    }
188}
189
190/// Check if a path is a symlink (uses symlink_metadata, doesn't follow).
191pub fn is_symlink(path: &Path) -> bool {
192    path.symlink_metadata()
193        .map(|m| m.file_type().is_symlink())
194        .unwrap_or(false)
195}
196
197/// Find the mars-managed root by walking up from cwd, or use `--root` flag.
198///
199/// Walk up the directory tree looking for a directory containing `mars.toml`.
200/// The managed root can be any directory (`.agents/`, `.claude/`, etc.) —
201/// mars doesn't impose a specific name.
202///
203/// Search order at each level:
204/// 1. `.agents/mars.toml` (convention default)
205/// 2. `.claude/mars.toml` (Claude Code projects)
206/// 3. If cwd itself contains `mars.toml`, use it directly
207pub fn find_agents_root(explicit: Option<&Path>) -> Result<MarsContext, MarsError> {
208    if let Some(root) = explicit {
209        // User explicitly chose this root — trust it (no containment check)
210        return MarsContext::new(root.to_path_buf());
211    }
212
213    let cwd = std::env::current_dir()?;
214    // Canonicalize cwd to resolve ancestor symlinks so the walk-up operates
215    // on real paths and containment checks catch .agents/ symlinks pointing
216    // outside the real cwd tree.
217    let cwd_canon = cwd.canonicalize().unwrap_or_else(|_| cwd.clone());
218    let mut dir = cwd_canon.as_path();
219
220    loop {
221        // Check well-known subdirectories + tool dirs
222        for subdir in WELL_KNOWN.iter().chain(TOOL_DIRS.iter()) {
223            let candidate = dir.join(subdir);
224            if candidate.join("mars.toml").exists() {
225                let ctx = MarsContext::new(candidate)?;
226                // Validate: canonical managed_root should be under the
227                // directory we found it in. A symlinked .agents/ pointing
228                // outside the project tree would fail this check.
229                if !ctx.managed_root.starts_with(dir) {
230                    return Err(MarsError::Config(ConfigError::Invalid {
231                        message: format!(
232                            "{}/{} resolves to {} which is outside {}. \
233                             The managed root may be a symlink. Use --root to override.",
234                            dir.display(), subdir,
235                            ctx.managed_root.display(), dir.display(),
236                        ),
237                    }));
238                }
239                return Ok(ctx);
240            }
241        }
242
243        // Check if we're already inside a mars-managed directory
244        if dir.join("mars.toml").exists() {
245            return MarsContext::new(dir.to_path_buf());
246        }
247
248        // Walk up
249        match dir.parent() {
250            Some(parent) => dir = parent,
251            None => break,
252        }
253    }
254
255    Err(MarsError::Config(ConfigError::Invalid {
256        message: format!(
257            "no mars.toml found from {} to /. Run `mars init` first.",
258            cwd.display()
259        ),
260    }))
261}
262
263#[cfg(test)]
264mod tests {
265    use super::*;
266    use tempfile::TempDir;
267
268    #[test]
269    fn find_root_with_explicit_path() {
270        let dir = TempDir::new().unwrap();
271        let ctx = find_agents_root(Some(dir.path())).unwrap();
272        assert_eq!(ctx.managed_root, dir.path().canonicalize().unwrap());
273    }
274
275    #[test]
276    fn find_root_walks_up() {
277        let dir = TempDir::new().unwrap();
278        let agents_dir = dir.path().join(".agents");
279        std::fs::create_dir_all(&agents_dir).unwrap();
280        std::fs::write(agents_dir.join("mars.toml"), "[sources]\n").unwrap();
281
282        // Create a subdirectory
283        let sub = dir.path().join("subdir").join("deep");
284        std::fs::create_dir_all(&sub).unwrap();
285
286        // find_agents_root uses cwd, so we test with explicit
287        // The actual walk-up requires changing cwd which isn't safe in tests
288        let ctx = find_agents_root(Some(&agents_dir)).unwrap();
289        assert_eq!(ctx.managed_root, agents_dir.canonicalize().unwrap());
290        assert_eq!(ctx.project_root, dir.path().canonicalize().unwrap());
291    }
292
293    #[test]
294    fn find_root_symlink_outside_project_detected() {
295        // Verify the containment invariant: a symlinked .agents/ resolving
296        // outside the project tree should be detectable.
297        let project_dir = TempDir::new().unwrap();
298        let external_dir = TempDir::new().unwrap();
299
300        // Create the external agents dir with mars.toml
301        let external_agents = external_dir.path().join(".agents");
302        std::fs::create_dir_all(&external_agents).unwrap();
303        std::fs::write(external_agents.join("mars.toml"), "[sources]\n").unwrap();
304
305        // Symlink project/.agents -> external/.agents
306        let project_agents = project_dir.path().join(".agents");
307        std::os::unix::fs::symlink(&external_agents, &project_agents).unwrap();
308
309        // MarsContext::new canonicalizes, so managed_root resolves outside project
310        let ctx = MarsContext::new(project_agents).unwrap();
311        let project_canon = project_dir.path().canonicalize().unwrap();
312        assert!(
313            !ctx.managed_root.starts_with(&project_canon),
314            "symlinked managed_root should resolve outside project"
315        );
316    }
317
318    #[test]
319    fn find_root_explicit_bypasses_containment() {
320        // --root flag should work even for paths that would fail containment
321        let dir = TempDir::new().unwrap();
322        let agents = dir.path().join("agents");
323        std::fs::create_dir_all(&agents).unwrap();
324
325        let ctx = find_agents_root(Some(&agents)).unwrap();
326        assert_eq!(ctx.managed_root, agents.canonicalize().unwrap());
327    }
328
329    #[test]
330    fn mars_context_new_errors_on_root_path() {
331        // "/" has no parent — should error
332        let result = MarsContext::new(std::path::PathBuf::from("/"));
333        assert!(result.is_err());
334    }
335}