Ctrl K
ComunidadGuías…

crates/tui/src/doctor_fix.rs

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
//! `codewhale doctor --fix` repair planning and application (#5552, v1).
//!
//! The doctor is a read-only diagnostic by default. `--fix` computes a
//! concrete repair plan first — pure detection, no mutation — shows it, and
//! applies it only after explicit consent (or `--yes`). Every v1 action is
//! narrowly scoped and reversible:
//!
//! - delete stale `.tmp*` files left behind by interrupted atomic writes in
//!   the Codewhale home, only when last modified more than an hour ago;
//! - tighten secret-store file permissions to `0600` on Unix when group or
//!   world bits are set (the secret store writes private files, but a file
//!   restored from backup or moved from another machine may have drifted);
//! - disable MCP entries whose structural check reports `Error` (no command
//!   and no URL, or an empty command) by writing `enabled: false` through the
//!   same atomic save path the MCP editor uses;
//! - scaffold the user-global `skills`, `tools`, and `plugins` directories
//!   when they are missing so discovery surfaces stop reporting them absent.
//!
//! Explicitly out of scope for v1: completion registration, launch-record
//! repair, and config.toml credential scrubbing (each needs its own consent
//! story; the doctor still *reports* the credential-shaped keys).

use std::path::{Path, PathBuf};

use anyhow::{Context, Result};

use crate::McpServerDoctorStatus;
use codewhale_engine::mcp;

/// One concrete, reversible repair the doctor can apply.
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) enum DoctorFixAction {
    /// Delete an interrupted-atomic-write leftover. Only ever a regular file
    /// whose name starts with `.tmp` directly inside the Codewhale home and
    /// whose last modification was more than an hour ago.
    DeleteStaleTempFile {
        path: PathBuf,
    },
    /// Restrict a secret-store file to owner-only on Unix.
    #[cfg(unix)]
    TightenSecretPermissions {
        path: PathBuf,
        from_mode: u32,
    },
    /// Disable a structurally broken MCP entry by name in the given config.
    DisableMcpServer {
        config_path: PathBuf,
        server: String,
    },
    /// Create a missing user-global discovery directory.
    ScaffoldDirectory {
        path: PathBuf,
    },
    MoveInstallLeftover {
        path: PathBuf,
        bytes: u64,
    },
    AlignAlias {
        alias: PathBuf,
        primary: PathBuf,
        sha256: String,
    },
    AdoptInstall {
        dir: PathBuf,
        sha256: String,
    },
    PruneRecovery {
        stamps: Vec<String>,
        bytes: u64,
    },
}

/// The full repair plan for one doctor run.
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub(crate) struct DoctorFixPlan {
    pub(crate) actions: Vec<DoctorFixAction>,
}

impl DoctorFixPlan {
    pub(crate) fn is_empty(&self) -> bool {
        self.actions.is_empty()
    }

    pub(crate) fn len(&self) -> usize {
        self.actions.len()
    }

    /// One human-readable line per action, in a stable order.
    pub(crate) fn describe(&self) -> Vec<String> {
        self.actions
            .iter()
            .map(|action| match action {
                DoctorFixAction::DeleteStaleTempFile { path } => {
                    format!(
                        "delete stale temp file {}",
                        codewhale_util::utils::display_path(path)
                    )
                }
                #[cfg(unix)]
                DoctorFixAction::TightenSecretPermissions { path, from_mode } => format!(
                    "restrict {} to 0600 (currently {:o})",
                    codewhale_util::utils::display_path(path),
                    from_mode & 0o7777
                ),
                DoctorFixAction::DisableMcpServer {
                    config_path,
                    server,
                } => format!(
                    "disable broken MCP server entry '{server}' in {}",
                    codewhale_util::utils::display_path(config_path)
                ),
                DoctorFixAction::ScaffoldDirectory { path } => {
                    format!(
                        "create missing directory {}",
                        codewhale_util::utils::display_path(path)
                    )
                }
                DoctorFixAction::MoveInstallLeftover { path, bytes } => format!(
                    "move old Codewhale backup {} ({}) to the recovery folder (kept {} days)",
                    codewhale_util::utils::display_path(path),
                    codewhale_release::installs::human_bytes(*bytes),
                    codewhale_release::installs::RECOVERY_RETENTION_DAYS
                ),
                DoctorFixAction::AlignAlias { alias, primary, .. } => format!(
                    "make {} match {} (v{}); the old copy goes to the recovery folder",
                    codewhale_util::utils::display_path(alias),
                    codewhale_util::utils::display_path(primary),
                    env!("CARGO_PKG_VERSION")
                ),
                DoctorFixAction::AdoptInstall { dir, .. } => format!(
                    "record an install receipt in {} so `codewhale update` and `codewhale uninstall` own it",
                    codewhale_util::utils::display_path(dir)
                ),
                DoctorFixAction::PruneRecovery { stamps, bytes } => format!(
                    "delete {} recovery folder(s) older than {} days ({}): {}",
                    stamps.len(),
                    codewhale_release::installs::RECOVERY_RETENTION_DAYS,
                    codewhale_release::installs::human_bytes(*bytes),
                    stamps.join(", ")
                ),
            })
            .collect()
    }
}

fn is_stale_temp_file(path: &Path) -> bool {
    path.file_name()
        .and_then(|name| name.to_str())
        .is_some_and(|name| name.starts_with(".tmp"))
        && std::fs::symlink_metadata(path).is_ok_and(|meta| {
            meta.is_file()
                && meta.modified().is_ok_and(|modified| {
                    modified
                        .elapsed()
                        .is_ok_and(|age| age > std::time::Duration::from_secs(60 * 60))
                })
        })
}

/// Stale `.tmp*` regular files directly inside the Codewhale home.
/// Recent files and files with unknown or future modification times are kept.
fn stale_temp_files() -> Vec<PathBuf> {
    let Ok(home) = codewhale_config::codewhale_home() else {
        return Vec::new();
    };
    let Ok(entries) = std::fs::read_dir(&home) else {
        return Vec::new();
    };
    let mut files = entries
        .flatten()
        .map(|entry| entry.path())
        .filter(|path| is_stale_temp_file(path))
        .collect::<Vec<_>>();
    files.sort();
    files
}

/// Secret-store files whose Unix permissions are looser than 0600.
#[cfg(unix)]
fn secret_files_needing_tightening() -> Vec<(PathBuf, u32)> {
    use std::os::unix::fs::PermissionsExt;

    let Ok(paths) = codewhale_secrets::FileKeyringStore::default_paths_read_only() else {
        return Vec::new();
    };
    let candidates = std::iter::once(paths.0).chain(paths.1);
    candidates
        .filter_map(|path| {
            let metadata = std::fs::metadata(&path).ok()?;
            if !metadata.is_file() {
                return None;
            }
            let mode = metadata.permissions().mode();
            (mode & 0o077 != 0).then_some((path, mode))
        })
        .collect()
}

/// MCP entries whose structural check reports `Error`, by config file.
fn broken_mcp_entries(
    config: &codewhale_runtime::config::Config,
    workspace: &Path,
    plugins: &codewhale_plugins::PluginRegistry,
) -> Vec<(PathBuf, Vec<String>)> {
    let global_path = config.mcp_config_path();
    let project_path = mcp::workspace_mcp_config_path(workspace);
    let mut broken = Vec::new();
    for path in [global_path, project_path] {
        let Ok(cfg) = mcp::load_config_with_workspace_and_plugins(&path, workspace, plugins) else {
            continue;
        };
        let names = cfg
            .servers
            .iter()
            .filter(|(_, server)| {
                server.is_enabled()
                    && matches!(
                        crate::doctor_check_mcp_server(server),
                        McpServerDoctorStatus::Error(_)
                    )
            })
            .map(|(name, _)| name.clone())
            .collect::<Vec<_>>();
        if !names.is_empty() {
            broken.push((path, names));
        }
    }
    broken
}

/// User-global discovery directories the product expects to exist.
fn missing_user_directories(config: &codewhale_runtime::config::Config) -> Vec<PathBuf> {
    let candidates = [
        config.skills_dir(),
        crate::default_tools_dir(),
        crate::default_plugins_dir(),
    ];
    let mut missing = candidates
        .into_iter()
        .filter(|dir| !dir.exists())
        .collect::<Vec<_>>();
    missing.sort();
    missing
}

/// Compute the repair plan. Pure: reads state, mutates nothing.
pub(crate) fn plan_fixes(
    config: &codewhale_runtime::config::Config,
    workspace: &Path,
    plugins: &codewhale_plugins::PluginRegistry,
) -> DoctorFixPlan {
    let mut actions = Vec::new();
    actions.extend(
        stale_temp_files()
            .into_iter()
            .map(|path| DoctorFixAction::DeleteStaleTempFile { path }),
    );
    #[cfg(unix)]
    actions.extend(
        secret_files_needing_tightening()
            .into_iter()
            .map(|(path, from_mode)| DoctorFixAction::TightenSecretPermissions { path, from_mode }),
    );
    for (config_path, servers) in broken_mcp_entries(config, workspace, plugins) {
        actions.extend(
            servers
                .into_iter()
                .map(|server| DoctorFixAction::DisableMcpServer {
                    config_path: config_path.clone(),
                    server,
                }),
        );
    }
    actions.extend(
        missing_user_directories(config)
            .into_iter()
            .map(|path| DoctorFixAction::ScaffoldDirectory { path }),
    );
    actions.extend(install_actions());
    DoctorFixPlan { actions }
}

fn install_actions() -> Vec<DoctorFixAction> {
    use codewhale_release::installs::{self, InstallClass};
    let running = std::env::current_exe().ok();
    let inventory = installs::inventory(running.as_deref());
    let mut actions: Vec<DoctorFixAction> = inventory
        .leftovers()
        .map(|entry| DoctorFixAction::MoveInstallLeftover {
            path: entry.path.clone(),
            bytes: entry.bytes,
        })
        .collect();
    let canonical =
        |path: &Path| std::fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf());
    if let Some(exe) = &running
        && codewhale_release::InstallMethod::detect(exe).supports_self_update()
        && let Some(dir) = exe.parent().map(canonical)
        && std::fs::symlink_metadata(dir.join(installs::RECEIPT_FILE)).is_err()
        && let Some(entry) = inventory
            .entries
            .iter()
            .find(|entry| canonical(&entry.path) == canonical(exe))
        && entry.class == InstallClass::OursUnreceipted
        && let Ok(sha256) = installs::file_sha256(exe)
    {
        for alias in inventory.entries.iter().filter(|other| {
            other.class == InstallClass::OursUnreceipted
                && !other.same_as_running
                && other.path.parent().map(canonical).as_deref() == Some(dir.as_path())
                && installs::RECEIPT_COMMANDS
                    .iter()
                    .any(|name| other.path.file_name().and_then(|n| n.to_str()) == Some(name))
        }) {
            actions.push(DoctorFixAction::AlignAlias {
                alias: alias.path.clone(),
                primary: exe.clone(),
                sha256: sha256.clone(),
            });
        }
        if installs::directory_is_private(&dir) {
            actions.push(DoctorFixAction::AdoptInstall { dir, sha256 });
        }
    }
    if let Ok(home) = codewhale_config::codewhale_home() {
        let now = codewhale_release::check::now_unix();
        let expired: Vec<_> = installs::recovery_folders(&home)
            .into_iter()
            .filter(|folder| folder.expires_in_days(now) == Some(0))
            .collect();
        if !expired.is_empty() {
            actions.push(DoctorFixAction::PruneRecovery {
                bytes: expired.iter().map(|folder| folder.bytes()).sum(),
                stamps: expired.into_iter().map(|folder| folder.stamp).collect(),
            });
        }
    }
    actions
}

/// Outcome of applying one action.
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) enum DoctorFixOutcome {
    Applied,
    Failed(String),
}

/// Apply a plan. Each action reports its own outcome; one failure never
/// blocks the rest. Actions are idempotent, so a re-run converges.
pub(crate) fn apply_fixes(plan: &DoctorFixPlan) -> Vec<(DoctorFixAction, DoctorFixOutcome)> {
    let mut batch = None;
    let results = plan
        .actions
        .iter()
        .cloned()
        .map(|action| {
            let outcome = match apply_install_action(&action, &mut batch) {
                Some(Ok(())) => DoctorFixOutcome::Applied,
                Some(Err(error)) => DoctorFixOutcome::Failed(format!("{error:#}")),
                None => apply_one(&action),
            };
            (action, outcome)
        })
        .collect();
    if let Some(batch) = batch {
        let batch: codewhale_release::installs::RecoveryBatch = batch;
        let stamp = batch.stamp.clone();
        let dir = batch.dir.clone();
        let moves = batch.finish();
        if !moves.is_empty() {
            println!(
                "Moved {} item(s) ({}) to {}. Undo all: codewhale doctor --restore {stamp}",
                moves.len(),
                codewhale_release::installs::human_bytes(moves.iter().map(|m| m.bytes).sum()),
                dir.display()
            );
        }
    }
    results
}

fn recovery_batch(
    batch: &mut Option<codewhale_release::installs::RecoveryBatch>,
) -> Result<&mut codewhale_release::installs::RecoveryBatch> {
    if batch.is_none() {
        let home = codewhale_config::codewhale_home()?;
        *batch = Some(codewhale_release::installs::RecoveryBatch::open(&home)?);
    }
    Ok(batch.as_mut().expect("recovery batch was just opened"))
}

fn apply_install_action(
    action: &DoctorFixAction,
    batch: &mut Option<codewhale_release::installs::RecoveryBatch>,
) -> Option<Result<()>> {
    use codewhale_release::installs;
    Some(match action {
        DoctorFixAction::MoveInstallLeftover { path, .. } => (|| {
            let meta = std::fs::symlink_metadata(path)?;
            anyhow::ensure!(!meta.is_symlink(), "it became a symlink; not moved");
            let moved = recovery_batch(batch)?.move_in(path)?;
            println!(
                "  moved {} -> {}\n    undo: {}",
                path.display(),
                moved.to.display(),
                moved.undo_command()
            );
            Ok(())
        })(),
        DoctorFixAction::AlignAlias {
            alias,
            primary,
            sha256,
        } => (|| {
            let bytes = std::fs::read(primary)?;
            anyhow::ensure!(
                &installs::sha256_hex(&bytes) == sha256,
                "{} changed since the plan; not aligned",
                primary.display()
            );
            let dir = alias.parent().context("alias has no parent")?;
            let name = alias
                .file_name()
                .and_then(|name| name.to_str())
                .context("alias name is not UTF-8")?;
            let moved = recovery_batch(batch)?.move_in(alias)?;
            if let Err(error) =
                codewhale_release::installs::write_file_atomically(dir, name, &bytes, true)
            {
                let _ = std::fs::rename(&moved.to, alias);
                return Err(error);
            }
            println!(
                "  replaced {} with v{}; old copy at {}\n    undo: rm {} && {}",
                alias.display(),
                env!("CARGO_PKG_VERSION"),
                moved.to.display(),
                alias.display(),
                moved.undo_command()
            );
            Ok(())
        })(),
        DoctorFixAction::AdoptInstall { dir, sha256 } => (|| {
            let entries: Vec<(String, String)> = installs::RECEIPT_COMMANDS
                .iter()
                .filter(|name| {
                    installs::file_sha256(&dir.join(name)).is_ok_and(|hash| &hash == sha256)
                })
                .map(|name| ((*name).to_string(), sha256.clone()))
                .collect();
            if installs::adopt_receipt(dir, &entries)? {
                println!(
                    "  recorded {}\n    undo: rm {}",
                    dir.join(installs::RECEIPT_FILE).display(),
                    dir.join(installs::RECEIPT_FILE).display()
                );
            }
            Ok(())
        })(),
        DoctorFixAction::PruneRecovery { .. } => (|| {
            let home = codewhale_config::codewhale_home()?;
            for (stamp, bytes) in installs::prune_expired(&home) {
                println!(
                    "  deleted expired recovery folder {stamp} ({})",
                    installs::human_bytes(bytes)
                );
            }
            Ok(())
        })(),
        _ => return None,
    })
}

fn apply_one(action: &DoctorFixAction) -> DoctorFixOutcome {
    match action {
        DoctorFixAction::DeleteStaleTempFile { path } => {
            // Re-verify the deletion guard at apply time: the plan was
            // computed before consent, so the file may have changed.
            if !is_stale_temp_file(path) {
                return DoctorFixOutcome::Applied;
            }
            match std::fs::remove_file(path) {
                Ok(()) => DoctorFixOutcome::Applied,
                Err(error) => DoctorFixOutcome::Failed(error.to_string()),
            }
        }
        #[cfg(unix)]
        DoctorFixAction::TightenSecretPermissions { path, .. } => {
            use std::os::unix::fs::PermissionsExt;
            match std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600)) {
                Ok(()) => DoctorFixOutcome::Applied,
                Err(error) => DoctorFixOutcome::Failed(error.to_string()),
            }
        }
        DoctorFixAction::DisableMcpServer {
            config_path,
            server,
        } => match disable_mcp_server(config_path, server) {
            Ok(()) => DoctorFixOutcome::Applied,
            Err(error) => DoctorFixOutcome::Failed(format!("{error:#}")),
        },
        DoctorFixAction::ScaffoldDirectory { path } => match std::fs::create_dir_all(path) {
            Ok(()) => DoctorFixOutcome::Applied,
            Err(error) => DoctorFixOutcome::Failed(error.to_string()),
        },
        DoctorFixAction::MoveInstallLeftover { .. }
        | DoctorFixAction::AlignAlias { .. }
        | DoctorFixAction::AdoptInstall { .. }
        | DoctorFixAction::PruneRecovery { .. } => DoctorFixOutcome::Applied,
    }
}

/// Disable one server entry in an MCP config file through the atomic save
/// path the MCP editor uses. A missing file or missing server is a no-op
/// (the plan may be stale after consent).
fn disable_mcp_server(config_path: &Path, server_name: &str) -> Result<()> {
    mcp::mutate_config(config_path, None, |cfg| {
        if let Some(server) = cfg.servers.get_mut(server_name) {
            server.enabled = false;
            server.disabled = true;
        }
        Ok(())
    })
    .map(|_| ())
}

/// Print the repair plan the way the human doctor report presents it.
pub(crate) fn print_fix_plan(plan: &DoctorFixPlan) {
    use colored::Colorize;

    let (sky_r, sky_g, sky_b) = codewhale_palette::WHALE_ACTION_RGB;
    println!("{}", "Repair plan (--fix):".bold());
    if plan.is_empty() {
        println!("  {} nothing to repair", "✓".truecolor(sky_r, sky_g, sky_b));
        return;
    }
    for line in plan.describe() {
        println!("  · {line}");
    }
    println!(
        "  {} pass --yes to apply without prompting",
        "!".truecolor(sky_r, sky_g, sky_b)
    );
}

/// Ask for consent on stdin. Only used by the human (non-JSON) doctor path.
pub(crate) fn confirm_fix(plan: &DoctorFixPlan) -> bool {
    use std::io::{BufRead, Write};

    println!();
    print!("Apply these {} repair(s) now? [Y/n] ", plan.len());
    let mut answer = String::new();
    let _ = std::io::stdout().flush();
    match std::io::stdin().lock().read_line(&mut answer) {
        Ok(0) | Err(_) => false,
        Ok(_) => matches!(
            answer.trim().to_ascii_lowercase().as_str(),
            "" | "y" | "yes"
        ),
    }
}

/// Print apply results and return whether every action succeeded.
pub(crate) fn print_apply_results(results: &[(DoctorFixAction, DoctorFixOutcome)]) -> bool {
    use colored::Colorize;

    let (aqua_r, aqua_g, aqua_b) = codewhale_palette::WHALE_ACTION_RGB;
    let (red_r, red_g, red_b) = codewhale_palette::WHALE_ERROR_RGB;
    println!("{}", "Repair results:".bold());
    let mut all_applied = true;
    for (action, outcome) in results {
        match outcome {
            DoctorFixOutcome::Applied => println!(
                "  {} {}",
                "✓".truecolor(aqua_r, aqua_g, aqua_b),
                plan_action_line(action)
            ),
            DoctorFixOutcome::Failed(error) => {
                all_applied = false;
                println!(
                    "  {} {} — {error}",
                    "✗".truecolor(red_r, red_g, red_b),
                    plan_action_line(action)
                );
            }
        }
    }
    all_applied
}

fn plan_action_line(action: &DoctorFixAction) -> String {
    DoctorFixPlan {
        actions: vec![action.clone()],
    }
    .describe()
    .pop()
    .unwrap_or_else(|| "repair".to_string())
}

#[cfg(test)]
mod tests {
    use super::*;

    /// Scoped `CODEWHALE_HOME` override under the process-wide env barrier.
    /// `EnvVarGuard` restores the prior value even on panic.
    struct ScratchHome {
        dir: tempfile::TempDir,
        _env: crate::test_support::EnvVarGuard,
        _lock: crate::test_support::TestEnvLock,
    }

    impl ScratchHome {
        fn new() -> (Self, codewhale_runtime::config::Config) {
            let lock = crate::test_support::lock_test_env();
            let dir = tempfile::tempdir().expect("home tempdir");
            let env = crate::test_support::EnvVarGuard::set("CODEWHALE_HOME", dir.path());
            (
                Self {
                    dir,
                    _env: env,
                    _lock: lock,
                },
                codewhale_runtime::config::Config::default(),
            )
        }

        fn path(&self) -> &Path {
            self.dir.path()
        }
    }

    #[test]
    fn stale_temp_files_only_names_regular_dot_tmp_files_in_home() {
        let (home, config) = ScratchHome::new();
        std::fs::write(home.path().join(".tmpAbC123"), b"orphaned").expect("write stale");
        std::fs::write(home.path().join(".tmpOther"), b"orphaned 2").expect("write stale 2");
        let now = std::time::SystemTime::now();
        for name in [".tmpAbC123", ".tmpOther"] {
            std::fs::File::options()
                .write(true)
                .open(home.path().join(name))
                .unwrap()
                .set_modified(now - std::time::Duration::from_secs(2 * 60 * 60))
                .unwrap();
        }
        for (name, modified) in [
            (".tmpLive", now),
            (".tmpRecent", now - std::time::Duration::from_secs(30 * 60)),
            (".tmpFuture", now + std::time::Duration::from_secs(60 * 60)),
        ] {
            let file = std::fs::File::create(home.path().join(name)).expect("temp file");
            file.set_modified(modified).expect("mtime");
        }
        std::fs::write(home.path().join("keep.txt"), b"keep").expect("write keep");
        std::fs::create_dir_all(home.path().join(".tmpDir")).expect("mkdir .tmpDir");

        let workspace = tempfile::tempdir().expect("workspace");
        let plan = plan_fixes(
            &config,
            workspace.path(),
            &codewhale_plugins::PluginRegistry::default(),
        );
        let temp_deletions = plan
            .actions
            .iter()
            .filter(|a| matches!(a, DoctorFixAction::DeleteStaleTempFile { .. }))
            .count();
        assert_eq!(temp_deletions, 2, "{:?}", plan.actions);

        // A stale candidate may become active while waiting for consent.
        std::fs::File::options()
            .write(true)
            .open(home.path().join(".tmpOther"))
            .unwrap()
            .set_modified(now)
            .unwrap();
        let results = apply_fixes(&DoctorFixPlan {
            actions: plan
                .actions
                .iter()
                .filter(|a| matches!(a, DoctorFixAction::DeleteStaleTempFile { .. }))
                .cloned()
                .collect(),
        });
        assert!(results.iter().all(|(_, o)| *o == DoctorFixOutcome::Applied));
        assert!(!home.path().join(".tmpAbC123").exists());
        for name in [
            ".tmpOther",
            ".tmpLive",
            ".tmpRecent",
            ".tmpFuture",
            ".tmpDir",
        ] {
            assert!(home.path().join(name).exists(), "must preserve {name}");
        }
        assert!(home.path().join("keep.txt").exists());
    }

    #[test]
    fn missing_user_directories_are_planned_and_scaffolding_is_idempotent() {
        let (home, config) = ScratchHome::new();
        let workspace = tempfile::tempdir().expect("workspace");
        let registry = codewhale_plugins::PluginRegistry::default();
        let plan = plan_fixes(&config, workspace.path(), &registry);
        assert!(
            plan.actions
                .iter()
                .any(|a| matches!(a, DoctorFixAction::ScaffoldDirectory { .. })),
            "scratch home plans scaffolding: {:?}",
            plan.actions
        );
        let results = apply_fixes(&plan);
        assert!(
            results.iter().all(|(_, o)| *o == DoctorFixOutcome::Applied),
            "{results:?}"
        );
        assert!(home.path().join("skills").exists());
        assert!(home.path().join("tools").exists());
        assert!(home.path().join("plugins").exists());
        let replan = plan_fixes(&config, workspace.path(), &registry);
        assert!(
            !replan
                .actions
                .iter()
                .any(|a| matches!(a, DoctorFixAction::ScaffoldDirectory { .. })),
            "scaffolding converges"
        );
    }

    #[cfg(unix)]
    #[test]
    fn loose_secret_file_permissions_are_planned_and_tightened() {
        use std::os::unix::fs::PermissionsExt;

        let (home, config) = ScratchHome::new();
        let secrets_dir = home.path().join("secrets");
        std::fs::create_dir_all(&secrets_dir).expect("secrets dir");
        let secrets_file = secrets_dir.join("secrets.json");
        std::fs::write(&secrets_file, b"{}").expect("secrets file");
        std::fs::set_permissions(&secrets_file, std::fs::Permissions::from_mode(0o644))
            .expect("loosen");

        let workspace = tempfile::tempdir().expect("workspace");
        let registry = codewhale_plugins::PluginRegistry::default();
        let plan = plan_fixes(&config, workspace.path(), &registry);
        let tighten = plan
            .actions
            .iter()
            .find(|a| matches!(a, DoctorFixAction::TightenSecretPermissions { .. }))
            .expect("loose secret file is planned");
        let results = apply_fixes(&DoctorFixPlan {
            actions: vec![tighten.clone()],
        });
        assert_eq!(results[0].1, DoctorFixOutcome::Applied);
        let mode = std::fs::metadata(&secrets_file)
            .expect("metadata")
            .permissions()
            .mode();
        assert_eq!(mode & 0o777, 0o600);
    }

    #[test]
    fn fix_flags_parse_and_conflict_with_json_modes() {
        use clap::Parser;

        let cli = crate::Cli::try_parse_from(["codewhale", "doctor", "--fix", "--yes"])
            .expect("--fix --yes parses");
        let Some(crate::Commands::Doctor(args)) = cli.command else {
            panic!("expected doctor command");
        };
        assert!(args.fix);
        assert!(args.yes);

        crate::Cli::try_parse_from(["codewhale", "doctor", "--fix", "--json"])
            .expect_err("--fix conflicts with --json");
        crate::Cli::try_parse_from(["codewhale", "doctor", "--fix", "--context-json"])
            .expect_err("--fix conflicts with --context-json");
        crate::Cli::try_parse_from(["codewhale", "doctor", "--yes"])
            .expect_err("--yes requires --fix");
    }

    #[test]
    fn broken_mcp_entry_is_disabled_through_the_atomic_save_path() {
        let (home, config) = ScratchHome::new();
        let workspace = tempfile::tempdir().expect("workspace");
        let mcp_path = home.path().join("mcp.json");
        std::fs::write(
            &mcp_path,
            serde_json::json!({
                "mcpServers": {
                    "broken": { "command": "", "args": [] },
                    "healthy": { "command": "node", "args": ["server.js"] }
                }
            })
            .to_string(),
        )
        .expect("mcp config");

        let mut config = config;
        config.mcp_config_path = Some(mcp_path.display().to_string());

        let registry = codewhale_plugins::PluginRegistry::default();
        let plan = plan_fixes(&config, workspace.path(), &registry);
        let disable = plan
            .actions
            .iter()
            .find_map(|a| match a {
                DoctorFixAction::DisableMcpServer {
                    config_path,
                    server,
                } if server == "broken" => Some(config_path.clone()),
                _ => None,
            })
            .expect("broken entry is planned");
        assert_eq!(disable, mcp_path);

        let results = apply_fixes(&DoctorFixPlan {
            actions: plan
                .actions
                .iter()
                .filter(|a| matches!(a, DoctorFixAction::DisableMcpServer { .. }))
                .cloned()
                .collect(),
        });
        assert!(
            results.iter().all(|(_, o)| *o == DoctorFixOutcome::Applied),
            "{results:?}"
        );
        let raw: serde_json::Value =
            serde_json::from_str(&std::fs::read_to_string(&mcp_path).expect("reread"))
                .expect("json");
        assert!(
            raw.get("servers").is_none(),
            "preserve the original mcpServers spelling"
        );
        assert_eq!(raw["mcpServers"]["broken"]["enabled"], false);
        assert_eq!(raw["mcpServers"]["broken"]["disabled"], true);
        assert_eq!(
            raw["mcpServers"]["healthy"],
            serde_json::json!({"command":"node", "args":["server.js"]}),
            "healthy entry remains unchanged"
        );
        assert!(mcp::load_config(&mcp_path).unwrap().servers["healthy"].enabled);
    }
}