use std::{
env, fs,
path::{Path, PathBuf},
};
use anyhow::Context;
const MC_HOME_ENV: &str = "MC_HOME";
#[derive(Debug, Clone, Copy)]
enum RootResolutionMode {
Runtime,
ReadOnly,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct McPaths {
pub root: PathBuf,
pub cache: PathBuf,
pub state: PathBuf,
pub sessions: PathBuf,
pub checkpoints: PathBuf,
pub skills: PathBuf,
pub prompts: PathBuf,
pub subagents: PathBuf,
pub primary_agents: PathBuf,
pub user_agents: PathBuf,
pub settings_file: PathBuf,
pub project_settings_file: PathBuf,
pub local_settings_file: Option<PathBuf>,
pub auth_file: PathBuf,
}
impl McPaths {
pub fn resolve() -> anyhow::Result<Self> {
let root = resolve_storage_root(RootResolutionMode::Runtime)?;
let project_dir = current_project_dir();
Ok(Self::from_root_and_project_dir(root, project_dir))
}
pub(crate) fn resolve_read_only() -> anyhow::Result<Self> {
let root = resolve_storage_root(RootResolutionMode::ReadOnly)?;
let project_dir = current_project_dir();
Ok(Self::from_root_and_project_dir(root, project_dir))
}
pub fn from_root_and_project_dir(root: PathBuf, project_dir: PathBuf) -> Self {
let project_settings_file = project_settings_file_for(&project_dir);
let local_settings_file = project_settings_file
.exists()
.then_some(project_settings_file.clone());
Self::from_parts(root, project_settings_file, local_settings_file)
}
#[cfg(test)]
pub(crate) fn from_root(root: PathBuf) -> Self {
let project_settings_file = project_settings_file_for(¤t_project_dir());
Self::from_parts(root, project_settings_file, None)
}
fn from_parts(
root: PathBuf,
project_settings_file: PathBuf,
local_settings_file: Option<PathBuf>,
) -> Self {
Self {
cache: root.join("cache"),
state: root.join("state"),
sessions: root.join("sessions"),
checkpoints: root.join("checkpoints"),
skills: root.join("skills"),
prompts: root.join("prompts"),
subagents: root.join("subagents"),
primary_agents: root.join("agents"),
user_agents: root.join("AGENTS.md"),
settings_file: root.join("settings.json"),
project_settings_file,
local_settings_file,
auth_file: root.join("auth.json"),
root,
}
}
pub fn ensure_runtime_dirs(&self) -> anyhow::Result<()> {
fs::create_dir_all(&self.cache)?;
fs::create_dir_all(&self.state)?;
crate::sessions::prepare_session_root(&self.sessions)?;
fs::create_dir_all(&self.checkpoints)?;
Ok(())
}
}
fn current_project_dir() -> PathBuf {
env::current_dir().unwrap_or_else(|_| PathBuf::from("."))
}
fn project_settings_file_for(project_dir: &Path) -> PathBuf {
project_dir.join(".magi-code").join("settings.json")
}
fn resolve_storage_root(mode: RootResolutionMode) -> anyhow::Result<PathBuf> {
let root = match env::var_os(MC_HOME_ENV) {
Some(value) => resolve_explicit_mc_home(value, mode)?,
None => {
let home = dirs::home_dir().ok_or_else(|| {
anyhow::anyhow!("could not resolve home directory for ~/.magi-code")
})?;
match mode {
RootResolutionMode::Runtime => default_root_with_migration(&home)?,
RootResolutionMode::ReadOnly => home.join(".magi-code"),
}
}
};
validate_storage_root(&root, mode)?;
if matches!(mode, RootResolutionMode::Runtime) {
migrate_primary_agents_directory(&root)?;
}
Ok(root)
}
fn resolve_explicit_mc_home(
value: std::ffi::OsString,
mode: RootResolutionMode,
) -> anyhow::Result<PathBuf> {
if value.is_empty() {
anyhow::bail!("{MC_HOME_ENV} must be an absolute directory path, not empty");
}
let root = PathBuf::from(value);
if !root.is_absolute() {
match mode {
RootResolutionMode::Runtime => anyhow::bail!(
"{MC_HOME_ENV} must be an absolute directory path: {}",
root.display()
),
RootResolutionMode::ReadOnly => {
anyhow::bail!("{MC_HOME_ENV} must be an absolute directory path");
}
}
}
Ok(root)
}
fn validate_storage_root(root: &Path, mode: RootResolutionMode) -> anyhow::Result<()> {
match mode {
RootResolutionMode::Runtime => {
if root.exists() && !root.is_dir() {
anyhow::bail!(
"{MC_HOME_ENV} must point to a directory, not a file: {}",
root.display()
);
}
Ok(())
}
RootResolutionMode::ReadOnly => validate_read_only_mc_home(root),
}
}
fn validate_read_only_mc_home(root: &Path) -> anyhow::Result<()> {
match fs::symlink_metadata(root) {
Ok(metadata) if metadata.file_type().is_dir() => Ok(()),
Ok(_) => anyhow::bail!("{MC_HOME_ENV} must point to a directory"),
Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()),
Err(_) => anyhow::bail!("{MC_HOME_ENV} could not be inspected"),
}
}
fn migrate_primary_agents_directory(root: &Path) -> anyhow::Result<()> {
let legacy = root.join("primary-agents");
let destination = root.join("agents");
match fs::symlink_metadata(&legacy) {
Ok(metadata) if metadata.is_dir() => {}
Ok(_) => return Ok(()), Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(()),
Err(error) => return Err(error).context("could not inspect primary-agents directory"),
}
match fs::symlink_metadata(&destination) {
Ok(_) => return Ok(()), Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
Err(error) => return Err(error).context("could not inspect agents directory"),
}
match fs::rename(&legacy, &destination) {
Ok(()) => Ok(()),
Err(_) if migration_already_completed(&legacy, &destination) => Ok(()),
Err(error) => Err(error).context("could not migrate primary-agents directory to agents"),
}
}
fn default_root_with_migration(home: &Path) -> anyhow::Result<PathBuf> {
let new_root = home.join(".magi-code");
let legacy_root = home.join(".mc");
if new_root.exists() || !legacy_root.is_dir() {
return Ok(new_root);
}
migrate_legacy_default_root(&legacy_root, &new_root)?;
Ok(new_root)
}
fn migrate_legacy_default_root(legacy_root: &Path, new_root: &Path) -> anyhow::Result<()> {
match fs::rename(legacy_root, new_root) {
Ok(()) => Ok(()),
Err(_) if migration_already_completed(legacy_root, new_root) => Ok(()),
Err(rename_error) => copy_legacy_root_via_temp(legacy_root, new_root).with_context(|| {
format!(
"failed to rename legacy config root {} to {}; fallback copy also failed after rename error: {rename_error}",
legacy_root.display(),
new_root.display()
)
}),
}
}
fn migration_already_completed(legacy_root: &Path, new_root: &Path) -> bool {
new_root.is_dir() && !legacy_root.exists()
}
fn copy_legacy_root_via_temp(legacy_root: &Path, new_root: &Path) -> anyhow::Result<()> {
let parent = new_root.parent().ok_or_else(|| {
anyhow::anyhow!(
"new config root has no parent directory: {}",
new_root.display()
)
})?;
let temp_root = parent.join(format!(".magi-code.tmp-{}", std::process::id()));
if temp_root.exists() {
fs::remove_dir_all(&temp_root).with_context(|| {
format!(
"failed to remove stale temp config dir: {}",
temp_root.display()
)
})?;
}
if let Err(error) = copy_dir_all(legacy_root, &temp_root) {
let _ = fs::remove_dir_all(&temp_root);
return Err(error).with_context(|| {
format!(
"failed to copy legacy config root {} to temp dir {}",
legacy_root.display(),
temp_root.display()
)
});
}
if new_root.exists() {
let _ = fs::remove_dir_all(&temp_root);
if migration_already_completed(legacy_root, new_root) {
return Ok(());
}
anyhow::bail!(
"new config root appeared during migration; refusing to overwrite: {}",
new_root.display()
);
}
if let Err(error) = fs::rename(&temp_root, new_root) {
let _ = fs::remove_dir_all(&temp_root);
return Err(error).with_context(|| {
format!(
"failed to finalize migrated config root from {} to {}",
temp_root.display(),
new_root.display()
)
});
}
fs::remove_dir_all(legacy_root).with_context(|| {
format!(
"migrated config root to {} but failed to remove legacy config root {}; credentials may be duplicated",
new_root.display(),
legacy_root.display()
)
})?;
Ok(())
}
fn copy_dir_all(src: &Path, dst: &Path) -> std::io::Result<()> {
fs::create_dir_all(dst)?;
for entry in fs::read_dir(src)? {
let entry = entry?;
let file_type = entry.file_type()?;
let from = entry.path();
let to = dst.join(entry.file_name());
if file_type.is_dir() {
copy_dir_all(&from, &to)?;
} else {
fs::copy(&from, &to)?;
}
}
Ok(())
}