pub mod add;
pub mod cache;
pub mod check;
pub mod doctor;
pub mod init;
pub mod link;
pub mod list;
pub mod outdated;
pub mod output;
pub mod override_cmd;
pub mod remove;
pub mod rename;
pub mod repair;
pub mod resolve_cmd;
pub mod sync;
pub mod upgrade;
pub mod why;
use std::path::{Path, PathBuf};
use clap::{Parser, Subcommand};
use crate::error::{ConfigError, LockError, MarsError};
pub const WELL_KNOWN: &[&str] = &[".agents"];
pub const TOOL_DIRS: &[&str] = &[".claude", ".cursor"];
pub struct MarsContext {
pub managed_root: PathBuf,
pub project_root: PathBuf,
}
impl MarsContext {
pub fn new(managed_root: PathBuf) -> Result<Self, MarsError> {
let canonical = if managed_root.exists() {
managed_root.canonicalize().unwrap_or(managed_root.clone())
} else {
managed_root.clone()
};
let project_root = canonical
.parent()
.ok_or_else(|| {
MarsError::Config(ConfigError::Invalid {
message: format!(
"managed root {} has no parent directory — the managed root must be \
a subdirectory (e.g., /project/.agents, not /project)",
managed_root.display()
),
})
})?
.to_path_buf();
Ok(MarsContext {
managed_root: canonical,
project_root,
})
}
}
#[derive(Debug, Parser)]
#[command(name = "mars", version, about = "Agent package manager for .agents/")]
pub struct Cli {
#[command(subcommand)]
pub command: Command,
#[arg(long, global = true)]
pub root: Option<PathBuf>,
#[arg(long, global = true)]
pub json: bool,
}
#[derive(Debug, Subcommand)]
pub enum Command {
Init(init::InitArgs),
Add(add::AddArgs),
Remove(remove::RemoveArgs),
Sync(sync::SyncArgs),
Upgrade(upgrade::UpgradeArgs),
Outdated(outdated::OutdatedArgs),
List(list::ListArgs),
Why(why::WhyArgs),
Rename(rename::RenameArgs),
Resolve(resolve_cmd::ResolveArgs),
Override(override_cmd::OverrideArgs),
Link(link::LinkArgs),
Check(check::CheckArgs),
Doctor(doctor::DoctorArgs),
Repair(repair::RepairArgs),
Cache(cache::CacheArgs),
}
pub fn dispatch(cli: Cli) -> i32 {
match dispatch_result(cli) {
Ok(code) => code,
Err(err) => {
eprintln!("error: {err}");
if matches!(err, MarsError::Lock(LockError::Corrupt { .. })) {
eprintln!("hint: run `mars repair` to rebuild from mars.toml + sources");
}
err.exit_code()
}
}
}
fn dispatch_result(cli: Cli) -> Result<i32, MarsError> {
match &cli.command {
Command::Init(args) => init::run(args, cli.root.as_deref(), cli.json),
Command::Check(args) => check::run(args, cli.json),
Command::Cache(args) => cache::run(args, cli.json),
cmd => {
let ctx = find_agents_root(cli.root.as_deref())?;
dispatch_with_root(cmd, &ctx, cli.json)
}
}
}
fn dispatch_with_root(cmd: &Command, ctx: &MarsContext, json: bool) -> Result<i32, MarsError> {
match cmd {
Command::Add(args) => add::run(args, ctx, json),
Command::Remove(args) => remove::run(args, ctx, json),
Command::Sync(args) => sync::run(args, ctx, json),
Command::Upgrade(args) => upgrade::run(args, ctx, json),
Command::Outdated(args) => outdated::run(args, ctx, json),
Command::List(args) => list::run(args, ctx, json),
Command::Why(args) => why::run(args, ctx, json),
Command::Rename(args) => rename::run(args, ctx, json),
Command::Resolve(args) => resolve_cmd::run(args, ctx, json),
Command::Override(args) => override_cmd::run(args, ctx, json),
Command::Link(args) => link::run(args, ctx, json),
Command::Doctor(args) => doctor::run(args, ctx, json),
Command::Repair(args) => repair::run(args, ctx, json),
Command::Init(_) | Command::Check(_) | Command::Cache(_) => unreachable!(),
}
}
pub fn is_symlink(path: &Path) -> bool {
path.symlink_metadata()
.map(|m| m.file_type().is_symlink())
.unwrap_or(false)
}
pub fn find_agents_root(explicit: Option<&Path>) -> Result<MarsContext, MarsError> {
if let Some(root) = explicit {
return MarsContext::new(root.to_path_buf());
}
let cwd = std::env::current_dir()?;
let cwd_canon = cwd.canonicalize().unwrap_or_else(|_| cwd.clone());
let mut dir = cwd_canon.as_path();
loop {
for subdir in WELL_KNOWN.iter().chain(TOOL_DIRS.iter()) {
let candidate = dir.join(subdir);
if candidate.join("mars.toml").exists() {
let ctx = MarsContext::new(candidate)?;
if !ctx.managed_root.starts_with(dir) {
return Err(MarsError::Config(ConfigError::Invalid {
message: format!(
"{}/{} resolves to {} which is outside {}. \
The managed root may be a symlink. Use --root to override.",
dir.display(),
subdir,
ctx.managed_root.display(),
dir.display(),
),
}));
}
return Ok(ctx);
}
}
if dir.join("mars.toml").exists() {
return MarsContext::new(dir.to_path_buf());
}
match dir.parent() {
Some(parent) => dir = parent,
None => break,
}
}
Err(MarsError::Config(ConfigError::Invalid {
message: format!(
"no mars.toml found from {} to /. Run `mars init` first.",
cwd.display()
),
}))
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
#[test]
fn find_root_with_explicit_path() {
let dir = TempDir::new().unwrap();
let ctx = find_agents_root(Some(dir.path())).unwrap();
assert_eq!(ctx.managed_root, dir.path().canonicalize().unwrap());
}
#[test]
fn find_root_walks_up() {
let dir = TempDir::new().unwrap();
let agents_dir = dir.path().join(".agents");
std::fs::create_dir_all(&agents_dir).unwrap();
std::fs::write(agents_dir.join("mars.toml"), "[sources]\n").unwrap();
let sub = dir.path().join("subdir").join("deep");
std::fs::create_dir_all(&sub).unwrap();
let ctx = find_agents_root(Some(&agents_dir)).unwrap();
assert_eq!(ctx.managed_root, agents_dir.canonicalize().unwrap());
assert_eq!(ctx.project_root, dir.path().canonicalize().unwrap());
}
#[test]
fn find_root_symlink_outside_project_detected() {
let project_dir = TempDir::new().unwrap();
let external_dir = TempDir::new().unwrap();
let external_agents = external_dir.path().join(".agents");
std::fs::create_dir_all(&external_agents).unwrap();
std::fs::write(external_agents.join("mars.toml"), "[sources]\n").unwrap();
let project_agents = project_dir.path().join(".agents");
std::os::unix::fs::symlink(&external_agents, &project_agents).unwrap();
let ctx = MarsContext::new(project_agents).unwrap();
let project_canon = project_dir.path().canonicalize().unwrap();
assert!(
!ctx.managed_root.starts_with(&project_canon),
"symlinked managed_root should resolve outside project"
);
}
#[test]
fn find_root_explicit_bypasses_containment() {
let dir = TempDir::new().unwrap();
let agents = dir.path().join("agents");
std::fs::create_dir_all(&agents).unwrap();
let ctx = find_agents_root(Some(&agents)).unwrap();
assert_eq!(ctx.managed_root, agents.canonicalize().unwrap());
}
#[test]
fn mars_context_new_errors_on_root_path() {
let result = MarsContext::new(std::path::PathBuf::from("/"));
assert!(result.is_err());
}
}