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