1pub 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
34pub const WELL_KNOWN: &[&str] = &[".agents"];
37
38pub const TOOL_DIRS: &[&str] = &[".claude", ".cursor"];
42
43pub struct MarsContext {
46 pub managed_root: PathBuf,
48 pub project_root: PathBuf,
50}
51
52impl MarsContext {
53 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#[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 #[arg(long, global = true)]
83 pub root: Option<PathBuf>,
84
85 #[arg(long, global = true)]
87 pub json: bool,
88}
89
90#[derive(Debug, Subcommand)]
91pub enum Command {
92 Init(init::InitArgs),
94
95 Add(add::AddArgs),
97
98 Remove(remove::RemoveArgs),
100
101 Sync(sync::SyncArgs),
103
104 Upgrade(upgrade::UpgradeArgs),
106
107 Outdated(outdated::OutdatedArgs),
109
110 List(list::ListArgs),
112
113 Why(why::WhyArgs),
115
116 Rename(rename::RenameArgs),
118
119 Resolve(resolve_cmd::ResolveArgs),
121
122 Override(override_cmd::OverrideArgs),
124
125 Link(link::LinkArgs),
127
128 Check(check::CheckArgs),
130
131 Doctor(doctor::DoctorArgs),
133
134 Repair(repair::RepairArgs),
136
137 Cache(cache::CacheArgs),
139}
140
141pub 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 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 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 Command::Init(_) | Command::Check(_) | Command::Cache(_) => unreachable!(),
187 }
188}
189
190pub fn is_symlink(path: &Path) -> bool {
192 path.symlink_metadata()
193 .map(|m| m.file_type().is_symlink())
194 .unwrap_or(false)
195}
196
197pub fn find_agents_root(explicit: Option<&Path>) -> Result<MarsContext, MarsError> {
208 if let Some(root) = explicit {
209 return MarsContext::new(root.to_path_buf());
211 }
212
213 let cwd = std::env::current_dir()?;
214 let cwd_canon = cwd.canonicalize().unwrap_or_else(|_| cwd.clone());
218 let mut dir = cwd_canon.as_path();
219
220 loop {
221 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 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 if dir.join("mars.toml").exists() {
245 return MarsContext::new(dir.to_path_buf());
246 }
247
248 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 let sub = dir.path().join("subdir").join("deep");
284 std::fs::create_dir_all(&sub).unwrap();
285
286 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 let project_dir = TempDir::new().unwrap();
298 let external_dir = TempDir::new().unwrap();
299
300 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 let project_agents = project_dir.path().join(".agents");
307 std::os::unix::fs::symlink(&external_agents, &project_agents).unwrap();
308
309 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 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 let result = MarsContext::new(std::path::PathBuf::from("/"));
333 assert!(result.is_err());
334 }
335}