magi-code 0.96.1

Repository-aware CLI coding agent for terminal work
Documentation
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))
    }

    /// Resolves the storage root without migration or runtime directory creation.
    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))
    }

    /// Constructs paths from an explicit storage root and project directory.
    ///
    /// The project settings target is always `<project_dir>/.magi-code/settings.json`.
    /// `local_settings_file` is populated only when that target exists when this method runs.
    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)
    }

    /// Test fixture root: uses the current process directory as the project directory and never
    /// detects a local settings file, so tests stay hermetic.
    #[cfg(test)]
    pub(crate) fn from_root(root: PathBuf) -> Self {
        let project_settings_file = project_settings_file_for(&current_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"),
    }
}

// Temporary compatibility for the primary-agents -> agents transition (introduced in 0.79).
// Keep for the next few releases, then remove this helper and its runtime call.
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(()), // Never follow a legacy directory symlink.
        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(()), // Preserve both paths, including dangling symlinks.
        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(())
}