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
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#[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 #[arg(long, global = true)]
89 pub root: Option<PathBuf>,
90
91 #[arg(long, global = true)]
93 pub json: bool,
94}
95
96#[derive(Debug, Subcommand)]
97pub enum Command {
98 Init(init::InitArgs),
100
101 Add(add::AddArgs),
103
104 Remove(remove::RemoveArgs),
106
107 Sync(sync::SyncArgs),
109
110 Upgrade(upgrade::UpgradeArgs),
112
113 Outdated(outdated::OutdatedArgs),
115
116 List(list::ListArgs),
118
119 Why(why::WhyArgs),
121
122 Rename(rename::RenameArgs),
124
125 Resolve(resolve_cmd::ResolveArgs),
127
128 Override(override_cmd::OverrideArgs),
130
131 Link(link::LinkArgs),
133
134 Check(check::CheckArgs),
136
137 Doctor(doctor::DoctorArgs),
139
140 Repair(repair::RepairArgs),
142
143 Cache(cache::CacheArgs),
145}
146
147pub 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 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 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 Command::Init(_) | Command::Check(_) | Command::Cache(_) => unreachable!(),
193 }
194}
195
196pub fn is_symlink(path: &Path) -> bool {
198 path.symlink_metadata()
199 .map(|m| m.file_type().is_symlink())
200 .unwrap_or(false)
201}
202
203pub fn find_agents_root(explicit: Option<&Path>) -> Result<MarsContext, MarsError> {
214 if let Some(root) = explicit {
215 return MarsContext::new(root.to_path_buf());
217 }
218
219 let cwd = std::env::current_dir()?;
220 let cwd_canon = cwd.canonicalize().unwrap_or_else(|_| cwd.clone());
224 let mut dir = cwd_canon.as_path();
225
226 loop {
227 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 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 if dir.join("mars.toml").exists() {
253 return MarsContext::new(dir.to_path_buf());
254 }
255
256 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 let sub = dir.path().join("subdir").join("deep");
292 std::fs::create_dir_all(&sub).unwrap();
293
294 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 let project_dir = TempDir::new().unwrap();
306 let external_dir = TempDir::new().unwrap();
307
308 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 let project_agents = project_dir.path().join(".agents");
315 std::os::unix::fs::symlink(&external_agents, &project_agents).unwrap();
316
317 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 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 let result = MarsContext::new(std::path::PathBuf::from("/"));
341 assert!(result.is_err());
342 }
343}