Mega Mind v0.5.3

setup.rs

Rust · 1,250 lines · 56.5 KB
//! `mind setup`: one-time (re-runnable) configuration. Asks for the user's name, review policy,
//! AI providers, which agent tools to connect, and which projects to register; writes config.toml;
//! installs the skill, always-on instructions and hooks for each chosen tool, merging into
//! existing settings instead of overwriting them.

use crate::store::{self, home_dir, personalize, slugify, Mind, Config, Project};
use crate::ui;
use anyhow::{bail, Context, Result};
use serde_json::{json, Value};
use std::collections::BTreeMap;
use std::fs;
use std::io::{BufRead, IsTerminal, Write};
use std::path::{Path, PathBuf};

const SKILL_TEMPLATE: &str = include_str!("../skill/SKILL.md");
const RULE_TEMPLATE: &str = include_str!("../skill/RULE.md");
const BLOCK_START: &str = "<!-- mega-mind:start (managed by `mind setup`) -->";
const BLOCK_END: &str = "<!-- mega-mind:end -->";
/// The skill's folder/name and the start of what setup writes into it.
const SKILL_ID: &str = "mega-mind";
const SKILL_HEAD: &str = "---\nname: mega-mind\ndescription: Mega Mind (`mind` CLI)";
const RULE_HEAD: &str = "# Mega Mind: shared memory for all of";
/// The command, and the name it is also installed under.
const COMMAND: &str = "mind";
const ALIASES: [&str; 1] = ["mega-mind"];
/// Machine-specific additions appended to the installed skill, kept in the mind's directory.
const LOCAL_ADDON: &str = "skill-local.md";

#[derive(clap::Args, Default, Debug)]
pub struct SetupArgs {
    /// Accept defaults (and the flags below) without asking; for scripts and agents
    #[arg(long, short)]
    yes: bool,
    /// The person the agents work for, as they should refer to them
    #[arg(long)]
    user: Option<String>,
    /// Who reviews runbook proposals; "none" turns review off
    #[arg(long)]
    reviewer: Option<String>,
    /// Embeddings provider: local, azure, openai, none
    #[arg(long)]
    embeddings: Option<String>,
    #[arg(long)]
    embeddings_url: Option<String>,
    /// Azure: the embedding deployment name; openai: the model id
    #[arg(long)]
    embeddings_model: Option<String>,
    /// Auth for embeddings and the librarian: azure, env:VAR, command:<cmd>, none
    #[arg(long)]
    auth: Option<String>,
    /// Librarian provider: lmstudio, azure, openai, none
    #[arg(long)]
    librarian: Option<String>,
    #[arg(long)]
    librarian_url: Option<String>,
    /// Model/deployment for reviews
    #[arg(long)]
    librarian_model: Option<String>,
    /// Model/deployment for --deep search (fast)
    #[arg(long)]
    quick_model: Option<String>,
    /// Comma-separated tools to connect: claude, codex, grok, copilot (default: ask for each detected)
    #[arg(long)]
    tools: Option<String>,
    /// Folder to scan for git repos to register as projects
    #[arg(long)]
    scan: Option<PathBuf>,
}

struct Asker {
    yes: bool,
}

impl Asker {
    fn ask(&self, q: &str, default: &str) -> Result<String> {
        if self.yes {
            return Ok(default.to_string());
        }
        print!("{q}{}: ", if default.is_empty() { String::new() } else { format!(" [{default}]") });
        std::io::stdout().flush()?;
        let mut line = String::new();
        std::io::stdin().lock().read_line(&mut line)?;
        let a = line.trim();
        Ok(if a.is_empty() { default.to_string() } else { a.to_string() })
    }

    /// Like `ask`, but an empty answer is refused (asked again; an error with --yes).
    fn ask_required(&self, q: &str, default: &str) -> Result<String> {
        loop {
            let a = self.ask(q, default)?;
            if !a.trim().is_empty() {
                return Ok(a.trim().to_string());
            }
            if self.yes {
                bail!("{q}: a value is required (pass it as a flag)");
            }
            println!("  {}", ui::yellow("this one is required"));
        }
    }

    fn confirm(&self, q: &str, default: bool) -> Result<bool> {
        let a = self.ask(&format!("{q} ({})", if default { "Y/n" } else { "y/N" }), "")?;
        Ok(match a.to_lowercase().as_str() {
            "" => default,
            s => s.starts_with('y'),
        })
    }

    fn choose(&self, q: &str, options: &[&str], default: &str) -> Result<String> {
        loop {
            let a = self.ask(&format!("{q} ({})", options.join(" / ")), default)?.to_lowercase();
            if options.contains(&a.as_str()) {
                return Ok(a);
            }
            if self.yes {
                bail!("`{a}` isn't one of {}", options.join(", "));
            }
            println!("  please answer one of: {}", options.join(", "));
        }
    }
}

/// Whether the file at `path` is this program (any 0.5.0+ build, under any name): it carries the
/// skill template, so another tool that happens to be called `mind` never passes. Read, not run.
pub(crate) fn is_ours(path: &Path) -> bool {
    let Ok(bytes) = fs::read(path) else { return false };
    let head = SKILL_HEAD.as_bytes();
    bytes.windows(head.len()).any(|w| w == head)
}

/// The `mind` beside this binary when it is this program (run as `mind` or `mega-mind`), else this
/// binary itself.
pub(crate) fn command_path() -> PathBuf {
    let me = std::env::current_exe().unwrap_or_else(|_| PathBuf::from(COMMAND));
    let mind = me.with_file_name(format!("{COMMAND}{}", std::env::consts::EXE_SUFFIX));
    if mind != me && mind.is_file() && is_ours(&mind) { mind } else { me }
}

/// The command agents should type: `mind` once it is installed on PATH and is this program, else
/// the name this binary was run as (`mega-mind`).
pub(crate) fn command_word() -> String {
    let on_path = which_path(COMMAND).is_some_and(|p| is_ours(&p));
    if on_path {
        return COMMAND.to_string();
    }
    std::env::current_exe()
        .ok()
        .and_then(|p| p.file_stem().map(|s| s.to_string_lossy().to_string()))
        .filter(|s| s == "mega-mind")
        .unwrap_or_else(|| COMMAND.to_string())
}

/// The hook a command runs, if the program it starts is this one under any of its names (`mind`,
/// `mind`, `mega-mind`): `…/mind hook stop` → "stop". Also behind env assignments (`MIND_AGENT=x
/// mind hook stop`, `env X=1 …`) and a shell (`bash -lc "mind hook stop"`, `cmd /c mind.exe hook
/// stop`). Another program's arguments never count (`logger mind hook stop` isn't ours).
#[cfg(test)]
pub(crate) fn our_hook(cmd: &str) -> Option<String> {
    our_hook_program(cmd).map(|(_, sub)| sub)
}

/// Where a hook command's program word is: a word of the command line itself, or a word inside a
/// shell's quoted command string (`bash -lc "mind hook stop"`).
#[derive(Debug, Clone, PartialEq)]
enum Loc {
    Top(std::ops::Range<usize>),
    /// The quoted word `word` (one quoted run, `"…"` or `'…'`) holding a command line of its own.
    Nested { word: std::ops::Range<usize>, quote: char, shell: Shell, inner: Box<Loc> },
}

/// The kind of shell reading a nested command string: each quotes a path differently.
#[derive(Debug, Clone, Copy, PartialEq)]
enum Shell {
    Posix,
    /// powershell or pwsh
    Pwsh,
    Cmd,
}

/// As `our_hook`, also saying where the program word is, so setup can swap just that word and keep
/// the rest of the user's command (env, shell wrapper, `&& …`).
fn our_hook_program(cmd: &str) -> Option<(Loc, String)> {
    let spans = shell_word_spans(cmd);
    let words: Vec<&str> = spans.iter().map(|(w, _)| w.as_str()).collect();
    let stem = |w: &str| Path::new(w).file_stem().map(|s| s.to_string_lossy().to_lowercase()).unwrap_or_default();
    // A shell's command string, as a command line of its own. Only a word that is one quoted run can
    // be edited in place, so only that kind is followed.
    let nested = |k: usize, shell: Shell| -> Option<(Loc, String)> {
        let r = spans[k].1.clone();
        let raw = &cmd[r.clone()];
        let q = raw.chars().next()?;
        let one_run = (q == '"' || q == '\'') && raw.len() >= 2 && raw.ends_with(q) && !raw[1..raw.len() - 1].contains(q);
        if !one_run {
            return None;
        }
        let (inner, sub) = our_hook_program(&raw[1..raw.len() - 1])?;
        Some((Loc::Nested { word: r, quote: q, shell, inner: Box::new(inner) }, sub))
    };
    let mut i = 0;
    // Env assignments and `env` before the program.
    while i < words.len() && (words[i].contains('=') && !words[i].starts_with('-') || stem(words[i]) == "env") {
        i += 1;
    }
    // A shell running a command string: skip it and its flags (`-lc`, `/c`, `-Command`).
    const SHELLS: [&str; 7] = ["sh", "bash", "zsh", "dash", "cmd", "powershell", "pwsh"];
    if i < words.len() && SHELLS.contains(&stem(words[i]).as_str()) {
        // Its command string is the last word (`-ExecutionPolicy Bypass -Command "mind hook stop"`).
        let shell = match stem(words[i]).as_str() {
            "powershell" | "pwsh" => Shell::Pwsh,
            "cmd" => Shell::Cmd,
            _ => Shell::Posix,
        };
        let last = words.len() - 1;
        if last > i && words[last].contains(char::is_whitespace) {
            return nested(last, shell);
        }
        i += 1;
        while i < words.len() && (words[i].starts_with('-') || (words[i].len() == 2 && words[i].starts_with('/'))) {
            i += 1;
        }
    }
    let prog = *words.get(i)?;
    let ours = stem(prog) == COMMAND || ALIASES.contains(&stem(prog).as_str());
    let sub = (ours && words.get(i + 1) == Some(&"hook")).then(|| words.get(i + 2).map(|s| s.to_string())).flatten()?;
    Some((Loc::Top(spans[i].1.clone()), sub))
}

/// The command with its program swapped for `exe`, as setup would write it.
#[cfg(test)]
pub(crate) fn rewrite_hook(cmd: &str, exe: &str) -> Option<String> {
    our_hook_program(cmd).and_then(|(loc, _)| replace_program(cmd, &loc, exe))
}

/// A path as one shell word: bare when it is only safe characters, else quoted. Unix: single quotes,
/// which keep `$`, backticks and `\` literal, with a `'` written as `'\''`. Windows: double quotes
/// (paths can't hold `"`).
pub(crate) fn shell_quote(path: &str) -> String {
    if is_plain(path) {
        path.to_string()
    } else if cfg!(windows) {
        format!("\"{path}\"")
    } else {
        format!("'{}'", path.replace('\'', r"'\''"))
    }
}

/// Only characters no shell treats specially.
fn is_plain(s: &str) -> bool {
    !s.is_empty() && s.chars().all(|c| c.is_ascii_alphanumeric() || "/._-:+@%,".contains(c))
}

/// `cmd` with the program word at `loc` replaced by `exe`, quoted for where it sits. None when it
/// can't be written safely inside the user's quoting (a path with `$` or quotes inside a shell
/// string, or two shells deep); the caller then leaves the command as it is.
fn replace_program(cmd: &str, loc: &Loc, exe: &str) -> Option<String> {
    /// `outer`: the quote around the string this command sits in, and the shell that reads it.
    fn put(cmd: &str, loc: &Loc, exe: &str, outer: Option<(char, Shell)>) -> Option<String> {
        // Characters the outer shell would act on inside its own quotes, or that end them. A
        // Windows path's `\\` is fine for PowerShell (but not for a POSIX shell).
        let risky = |e: &str| e.contains(['\'', '"', '$', '`', '\\', '!', '%']);
        let risky_ps = |e: &str| e.contains(['\'', '"', '$', '`', '%']);
        match loc {
            Loc::Top(r) => {
                let word = match outer {
                    None => shell_quote(exe),
                    _ if is_plain(exe) => exe.to_string(),
                    // PowerShell runs a quoted path with the call operator: `& '/a b/mind' hook stop`.
                    Some((q, Shell::Pwsh)) if !risky_ps(exe) => if q == '"' { format!("& '{exe}'") } else { format!("& \"{exe}\"") },
                    _ if risky(exe) || cfg!(windows) => return None,
                    // Inside "…": single quotes reach the inner shell as quotes.
                    Some(('"', Shell::Posix)) => format!("'{exe}'"),
                    // Inside '…': nothing is expanded, so double quotes reach the inner shell.
                    Some(('\'', Shell::Posix)) => format!("\"{exe}\""),
                    _ => return None,
                };
                Some(format!("{}{word}{}", cmd.get(..r.start)?, cmd.get(r.end..)?))
            }
            Loc::Nested { word, quote, shell, inner } => {
                if outer.is_some() && !is_plain(exe) {
                    return None;
                }
                let (a, b) = (word.start + 1, word.end - 1);
                let body = put(cmd.get(a..b)?, inner, exe, Some((*quote, *shell)))?;
                Some(format!("{}{body}{}", cmd.get(..a)?, cmd.get(b..)?))
            }
        }
    }
    put(cmd, loc, exe, None)
}

/// Split a command line into words the way a shell does: quotes group words
/// ("C:/Program Files/mind.exe", 'a b') and are removed. Each word comes with its byte range in `cmd`.
fn shell_word_spans(cmd: &str) -> Vec<(String, std::ops::Range<usize>)> {
    let mut words = vec![];
    let mut cur = String::new();
    let mut quote: Option<char> = None;
    let mut start: Option<usize> = None;
    for (at, c) in cmd.char_indices() {
        match quote {
            Some(q) if c == q => quote = None,
            Some(_) => cur.push(c),
            None if c.is_whitespace() => {
                if let Some(s) = start.take() {
                    words.push((std::mem::take(&mut cur), s..at));
                }
            }
            None => {
                start.get_or_insert(at);
                if c == '"' || c == '\'' {
                    quote = Some(c);
                } else {
                    cur.push(c);
                }
            }
        }
    }
    if let Some(s) = start {
        words.push((cur, s..cmd.len()));
    }
    words
}

/// Make sure `mind` and its `mega-mind` alias exist beside this binary. Never overwrites a file it didn't write; a copy is written beside
/// the target and renamed, so an interrupted copy never leaves a broken `mind`. Returns what it made,
/// and a warning when another program already uses the name.
fn ensure_commands() -> (Vec<String>, Option<String>) {
    let Ok(me) = std::env::current_exe() else { return (vec![], None) };
    let mut made = vec![];
    let mind = me.with_file_name(format!("{COMMAND}{}", std::env::consts::EXE_SUFFIX));
    if mind == me {
        // Running as `mind` already: only the alias may be missing.
    } else if mind.exists() && !is_ours(&mind) {
        return (
            made,
            Some(format!(
                "{} is another program, so it was left alone: hooks keep calling {}; rename one of them to use `{COMMAND}`",
                mind.display(),
                me.display()
            )),
        );
    } else if !mind.exists() {
        let tmp = mind.with_file_name(format!(".{COMMAND}.setup-tmp{}", std::env::consts::EXE_SUFFIX));
        if fs::copy(&me, &tmp).is_ok() && fs::rename(&tmp, &mind).is_ok() {
            made.push(mind.display().to_string());
        } else {
            let _ = fs::remove_file(&tmp);
        }
    }
    if !mind.is_file() {
        return (made, None);
    }
    #[cfg(unix)]
    {
        let alias = me.with_file_name("mega-mind");
        if !alias.exists() && !alias.is_symlink() && std::os::unix::fs::symlink(COMMAND, &alias).is_ok() {
            made.push(format!("alias {} → {COMMAND}", alias.display()));
        }
    }
    #[cfg(windows)]
    {
        let alias = me.with_file_name("mega-mind.cmd");
        if !alias.exists() && fs::write(&alias, "@\"%~dp0mind.exe\" %*\r\n").is_ok() {
            made.push(alias.display().to_string());
        }
    }
    (made, None)
}

fn exe_path() -> String {
    command_path().to_string_lossy().replace('\\', "/")
}

fn exe_command(sub: &str) -> String {
    format!("{} {sub}", shell_quote(&exe_path()))
}

/// The machine notes already in an installed skill (what was approved and installed last time).
fn installed_addon(h: &Path) -> Option<String> {
    for p in [format!(".claude/skills/{SKILL_ID}/SKILL.md"), format!(".codex/skills/{SKILL_ID}/SKILL.md"), format!(".grok/skills/{SKILL_ID}/SKILL.md")] {
        if let Ok(t) = fs::read_to_string(h.join(p)).map(|t| t.replace("\r\n", "\n"))
            && let Some((_, notes)) = t.split_once("\n## On this machine\n\n")
        {
            return Some(notes.trim().to_string());
        }
    }
    None
}

/// `skill-local.md` becomes part of every agent's instructions, and any agent can write that file.
/// So it goes in only if it is exactly what is already installed (a refresh), or a person approves
/// it at the keyboard. There is no approval record an agent could forge: `--yes` and non-terminal
/// runs never add new or changed text.
fn approved_addon(mind_home: &Path, a: &Asker, interactive: bool) -> Result<Option<String>> {
    let Ok(local) = store::read_plain(&mind_home.join(LOCAL_ADDON)) else { return Ok(None) };
    // Windows line endings are fine; a lone `\r` is not (it hides the start of a line in the prompt).
    let local = local.replace("\r\n", "\n").trim().to_string();
    if local.is_empty() {
        return Ok(None);
    }
    if let Some(what) = store::secret_kind(&local) {
        bail!("{LOCAL_ADDON} looks like it contains {what}; remove it before running setup");
    }
    // Terminal control codes could hide text while it is being reviewed.
    if local.chars().any(|c| (c.is_control() && c != '\n' && c != '\t') || store::is_hidden_char(c)) {
        bail!(
            "{LOCAL_ADDON} contains control or invisible characters (a carriage return, terminal escape codes, \
             zero-width or bidi marks), which could hide text while you review it; remove them before running setup"
        );
    }
    if installed_addon(&home_dir()).as_deref() == Some(local.as_str()) {
        return Ok(Some(local));
    }
    if a.yes || !interactive {
        println!(
            "  {} {LOCAL_ADDON} is new or changed, so it was left out of the skill. \
             Run `mind setup` (without --yes) in a terminal to review it.",
            ui::yellow("⚠")
        );
        return Ok(None);
    }
    println!("\n{} adds these machine notes to every agent's instructions:\n", ui::bold(LOCAL_ADDON));
    // Wrapped inside the rail: a line wider than the terminal would wrap on its own, break the rail
    // and, when copied, run into the next line. This is the screen the notes are approved from, so
    // the text is shown exactly: spaces kept, a long word broken rather than overflowing.
    let room = ui::width().saturating_sub(4).max(20);
    for l in local.lines() {
        let l = l.replace('\t', "    ");
        if l.trim().is_empty() {
            println!("  {}", ui::dim("│"));
        }
        for piece in wrap_exact(&l, room) {
            println!("  {} {piece}", ui::dim("│"));
        }
    }
    Ok(a.confirm("\nInclude them?", false)?.then_some(local))
}

/// `line` in pieces of at most `room` characters, broken after a space where there is one and
/// mid-word only when a word is longer than a whole piece. Nothing is squeezed: runs of spaces
/// inside a piece stay as they are; only the spaces at a break go. Continuation pieces are indented
/// under the line's text (under a bullet's text for `- `).
pub(crate) fn wrap_exact(line: &str, room: usize) -> Vec<String> {
    let mut chars: Vec<char> = line.chars().collect();
    if chars.iter().all(|c| c.is_whitespace()) {
        return vec![];
    }
    // An indent deeper than half the room is shown at half the room, so the text still fits.
    let deep = chars.iter().take_while(|c| **c == ' ').count();
    if deep > room / 2 {
        chars.drain(..deep - room / 2);
    }
    let indent: String = chars.iter().take_while(|c| **c == ' ').collect();
    let bullet = matches!(chars.get(indent.len()..indent.len() + 2), Some(['-' | '*', ' ']));
    let hang = " ".repeat((indent.len() + if bullet { 2 } else { 0 }).min(room / 2));
    let (mut out, mut start) = (vec![], 0);
    while start < chars.len() {
        let prefix = if out.is_empty() { "" } else { hang.as_str() };
        let width = room.saturating_sub(prefix.len()).max(1);
        if chars.len() - start <= width {
            out.push(format!("{prefix}{}", chars[start..].iter().collect::<String>()));
            break;
        }
        // Break after the last space in the piece (not inside the leading indent), else mid-word.
        let end = (start + 1..=start + width).rev().find(|&i| chars[i - 1] == ' ' && i > indent.len()).unwrap_or(start + width);
        out.push(format!("{prefix}{}", chars[start..end].iter().collect::<String>().trim_end_matches(' ')));
        start = end;
        while start < chars.len() && chars[start] == ' ' && !out.is_empty() {
            start += 1;
        }
    }
    out
}

/// Asks for the folder to scan, again until it exists (or Enter skips). `~/home/<you>` is the usual
/// slip (`~` is already the home folder), so the folder that was probably meant is suggested.
fn ask_scan_folder(a: &Asker) -> Result<Option<PathBuf>> {
    loop {
        let d = a.ask("Folder that holds your git repos (Enter to skip)", "")?;
        if d.trim().is_empty() {
            return Ok(None);
        }
        let p = store::expand(d.trim());
        if p.is_dir() {
            return Ok(Some(p));
        }
        // Only for the slip of typing the home folder's own path after `~` (`~/home/<you>`, `~/Users/<you>`).
        let meant = d
            .trim()
            .strip_prefix("~/")
            .filter(|rest| rest.starts_with("home/") || rest.starts_with("Users/"))
            .map(|rest| PathBuf::from("/").join(rest))
            .filter(|m| cfg!(unix) && m.is_dir());
        match meant {
            Some(m) => println!(
                "  {} {} doesn't exist. `~` already means your home folder: did you mean {}? Type it again, or press Enter to skip.",
                ui::yellow("⚠"),
                p.display(),
                m.display()
            ),
            None if p.exists() => println!("  {} {} is a file, not a folder. Type a folder, or press Enter to skip.", ui::yellow("⚠"), p.display()),
            None => println!("  {} {} doesn't exist. Type it again, or press Enter to skip.", ui::yellow("⚠"), p.display()),
        }
        if a.yes {
            return Ok(None);
        }
    }
}

fn render_skill(cfg: &Config, addon: Option<&str>) -> String {
    let mut s = personalize(SKILL_TEMPLATE, &cfg.identity);
    if let Some(local) = addon {
        s.push_str("\n## On this machine\n\n");
        s.push_str(local);
        s.push('\n');
    }
    s
}

/// A file setup may (over)write: missing, or one it wrote before (it starts with `prefix`).
fn ours_or_absent(path: &Path, prefix: &str) -> bool {
    match fs::read_to_string(path) {
        // CRLF: a Windows clone checks the skill template out with CRLF line endings.
        Ok(t) => t.replace("\r\n", "\n").starts_with(prefix),
        Err(_) => !path.exists(),
    }
}

fn step(n: usize, title: &str) {
    if ui::rich() {
        // A five-cell progress strip, then the step's heading.
        let strip: String = (1..=5).map(|i| if i <= n { ui::rail("▰", (i - 1) as f32 / 4.0) } else { ui::muted("▱") }).collect();
        println!("\n {strip}  {} {}", ui::muted(&format!("{n}/5")), ui::heading(title));
    } else {
        println!("\n{} {}", ui::dim(&format!("{n}/5")), ui::heading(title));
    }
}

/// Files `mind setup` writes for a tool (shown before anything is changed).
fn planned_files(tool: &str, h: &Path) -> Vec<PathBuf> {
    match tool {
        "claude" => vec![h.join(format!(".claude/skills/{SKILL_ID}/SKILL.md")), h.join(".claude/settings.json")],
        "codex" => vec![h.join(format!(".codex/skills/{SKILL_ID}/SKILL.md")), h.join(".codex/AGENTS.md"), h.join(".codex/hooks.json")],
        "grok" => vec![h.join(format!(".grok/rules/{SKILL_ID}.md"))],
        _ => vec![],
    }
}

/// An Azure-token URL whose host isn't one of Azure's own domains (a company API gateway): ask
/// whether that host may receive the sign-in token, and remember it in `token_hosts`.
fn gateway_check(a: &Asker, url: &str, auth: &str, hosts: &mut Vec<String>) -> Result<()> {
    if !auth.trim().starts_with("azure") && auth.trim() != "az" && auth.trim() != "managed-identity" {
        return Ok(());
    }
    let Some((_, host)) = crate::ai::url_host(url) else { return Ok(()) };
    if crate::ai::AZURE_HOST_SUFFIXES.iter().any(|s| host.ends_with(s)) || crate::ai::is_loopback(&host) || hosts.contains(&host) {
        return Ok(());
    }
    if a.confirm(&format!("  {host} isn't an Azure domain. Is it your company's gateway, allowed to receive your Azure sign-in token?"), false)? {
        hosts.push(host);
    } else {
        println!("  {} Mega Mind will refuse to send the token there; use the Azure endpoint instead.", ui::yellow("⚠"));
    }
    Ok(())
}

fn write_file(path: &Path, content: &str, changed: &mut Vec<String>) -> Result<()> {
    if fs::read_to_string(path).ok().as_deref() == Some(content) {
        return Ok(());
    }
    if let Some(dir) = path.parent() {
        fs::create_dir_all(dir)?;
    }
    // Keep the user's original once, before the first time setup touches it.
    let name = path.file_name().unwrap_or_default().to_string_lossy().to_string();
    let backup = path.with_file_name(format!("{name}.before-{SKILL_ID}"));
    let backed_up = backup.exists();
    if path.exists() && !backed_up {
        fs::copy(path, &backup).with_context(|| format!("backing up {}", path.display()))?;
    }
    // A symlink (a dotfiles repo) is written through, so the link and its target stay as they are.
    let target = if path.is_symlink() { fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf()) } else { path.to_path_buf() };
    // Write beside it, then swap, so a crash never leaves a half-written settings file; the new
    // file keeps the old one's permissions (settings.json may hold keys and be 0600).
    // Per process: two setups at once must never write or remove each other's temp file.
    let tmp = target.with_file_name(format!(
        ".{}.{SKILL_ID}-tmp-{}",
        target.file_name().unwrap_or_default().to_string_lossy(),
        std::process::id()
    ));
    let perms = fs::metadata(&target).ok().map(|m| m.permissions());
    let _ = fs::remove_file(&tmp);
    let written = write_new(&tmp, content, perms.as_ref()).and_then(|()| match &perms {
        Some(p) => fs::set_permissions(&tmp, p.clone()),
        None => Ok(()),
    });
    if let Err(e) = written {
        let _ = fs::remove_file(&tmp);
        return Err(e).with_context(|| format!("writing {}", tmp.display()));
    }
    fs::rename(&tmp, &target).with_context(|| format!("replacing {}", target.display()))?;
    changed.push(path.display().to_string());
    Ok(())
}

/// Create `path` (never following an existing file or link there) and write `content`. On Unix it is
/// created with the permissions it will end up with, so a 0600 file's contents are never readable
/// by others, even for a moment.
fn write_new(path: &Path, content: &str, perms: Option<&fs::Permissions>) -> std::io::Result<()> {
    use std::io::Write;
    let mut opts = fs::OpenOptions::new();
    opts.write(true).create_new(true);
    #[cfg(unix)]
    {
        use std::os::unix::fs::{OpenOptionsExt, PermissionsExt};
        opts.mode(perms.map_or(0o666, |p| p.mode() & 0o777));
    }
    #[cfg(not(unix))]
    let _ = perms;
    let mut f = opts.open(path)?;
    f.write_all(content.as_bytes())?;
    f.sync_all()
}

/// A file's text, or "" if it doesn't exist. A file that exists but can't be read as UTF-8 (UTF-16
/// from a Windows editor, permissions) is an error: treating it as empty would overwrite the user's text.
fn read_existing(path: &Path) -> Result<String> {
    match fs::read_to_string(path) {
        Ok(t) => Ok(t),
        Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(String::new()),
        Err(e) => bail!("can't read {} ({e}); fix or move it, then run setup again (nothing was changed)", path.display()),
    }
}

fn find_block(text: &str, start: &str, end: &str) -> Option<(usize, usize)> {
    match (text.find(start), text.find(end)) {
        (Some(a), Some(b)) if b > a => Some((a, b + end.len())),
        _ => None,
    }
}

/// Replace (or append) the block between our markers, leaving the rest of the file alone.
pub(crate) fn write_managed_block(path: &Path, block: &str, changed: &mut Vec<String>) -> Result<()> {
    let mut text = read_existing(path)?;
    let wrapped = format!("{BLOCK_START}\n{}\n{BLOCK_END}", block.trim());
    let mut placed = false;
    if let Some((a, b)) = find_block(&text, BLOCK_START, BLOCK_END) {
        text = format!("{}{wrapped}{}", &text[..a], &text[b..]);
        placed = true;
    }
    let new = match placed {
        true => text,
        false if text.trim().is_empty() => format!("{wrapped}\n"),
        false => format!("{}\n\n{wrapped}\n", text.trim_end()),
    };
    write_file(path, &new, changed)
}

/// Add our three hooks to a Claude-format hooks file (Claude Code settings.json, Codex hooks.json),
/// keeping every other setting. A hook already present (`mind hook <name>` at any path, also
/// behind env assignments or a shell) is updated in place rather than duplicated.
pub(crate) fn merge_hooks(path: &Path, changed: &mut Vec<String>) -> Result<()> {
    let text = read_existing(path)?;
    let mut root: Value = if text.trim().is_empty() {
        json!({})
    } else {
        serde_json::from_str(&text).with_context(|| format!("{} isn't valid JSON; fix it first", path.display()))?
    };
    let hooks = root
        .as_object_mut()
        .with_context(|| format!("{} isn't a JSON object; fix or move it, then run setup again", path.display()))?
        .entry("hooks")
        .or_insert_with(|| json!({}));
    for (event, sub, timeout) in [("SessionStart", "session-start", 15), ("UserPromptSubmit", "prompt", 5), ("Stop", "stop", 10)] {
        let want = exe_command(&format!("hook {sub}"));
        let groups = hooks
            .as_object_mut()
            .with_context(|| format!("`hooks` in {} isn't an object; fix or move it, then run setup again", path.display()))?
            .entry(event)
            .or_insert_with(|| json!([]));
        let groups = groups
            .as_array_mut()
            .with_context(|| format!("`hooks.{event}` in {} isn't a list; fix or move it, then run setup again", path.display()))?;
        let mut found = false;
        for g in groups.iter_mut() {
            // Groups without a `hooks` list (or that aren't objects) are someone else's: untouched.
            let Some(list) = g.get_mut("hooks").and_then(Value::as_array_mut) else { continue };
            for h in list.iter_mut() {
                let Some(cmd) = h.get("command").and_then(Value::as_str).map(str::to_string) else { continue };
                if cmd.trim() == want {
                    found = true;
                } else if let Some((loc, s)) = our_hook_program(&cmd)
                    && s == sub
                {
                    // Swap only the program's path: env assignments, a shell wrapper or `&& …` stay.
                    match replace_program(&cmd, &loc, &exe_path()) {
                        Some(new) => h["command"] = json!(new),
                        None => eprintln!(
                            "  {} left this hook in {} as it is (the new path can't be quoted safely inside it): {cmd}",
                            ui::yellow("⚠"),
                            path.display()
                        ),
                    }
                    found = true;
                }
            }
        }
        if !found {
            groups.push(json!({"hooks": [{"type": "command", "command": want, "timeout": timeout}]}));
        }
    }
    let out = serde_json::to_string_pretty(&root)? + "\n";
    write_file(path, &out, changed)
}

fn find_repos(dir: &Path, depth: usize, out: &mut Vec<PathBuf>) {
    // Never the mind itself (a mind under the scanned folder isn't a project).
    let mind = Mind::home();
    if dir.canonicalize().ok().zip(mind.canonicalize().ok()).is_some_and(|(d, b)| d.starts_with(&b)) {
        return;
    }
    if dir.join(".git").exists() {
        out.push(dir.to_path_buf());
        return;
    }
    if depth == 0 {
        return;
    }
    let Ok(rd) = fs::read_dir(dir) else { return };
    let mut subs: Vec<PathBuf> = rd
        .filter_map(|e| e.ok())
        .map(|e| e.path())
        .filter(|p| p.is_dir())
        .filter(|p| {
            let n = p.file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_default();
            !n.starts_with('.') && !["node_modules", "target", "Library", "build", "dist"].contains(&n.as_str())
        })
        .collect();
    subs.sort();
    for s in subs {
        find_repos(&s, depth - 1, out);
    }
}

pub fn run(args: SetupArgs) -> Result<()> {
    let interactive = std::io::stdin().is_terminal();
    if !interactive && !args.yes {
        bail!("not a terminal: pass --yes with flags (see `mind setup --help`)");
    }
    let a = Asker { yes: args.yes };
    println!("{} {}", ui::wordmark("setup"), ui::dim(env!("CARGO_PKG_VERSION")));
    if !args.yes {
        println!("{}", ui::dim("Five short steps. Press Enter to keep the value in [brackets]."));
    }

    let home = Mind::init()?;
    let mind = Mind::open()?;
    // Held-back AI settings (changed since this machine approved them): start from what is really
    // in config.toml, so saving never replaces them with the safe stand-ins. Only a person at a
    // terminal approves them; `--yes` keeps them held.
    let held = mind.config.ai_held.is_some();
    let mut cfg = if held {
        let mut raw: store::Config = toml::from_str(&fs::read_to_string(home.join("config.toml"))?)?;
        // As `Mind::open` reads it, so the approved fingerprint is the one it checks.
        raw.clamp_limits();
        println!(
            "  {} The AI settings in config.toml changed since you approved them on this machine: {}",
            ui::yellow("⚠"),
            if interactive && !args.yes { "review them in step 2 below; saving approves them." } else { "they stay held back (run setup in a terminal, without --yes, to approve them)." }
        );
        store::Config { identity: mind.config.identity.clone(), ..raw }
    } else {
        mind.config.clone()
    };
    let before = cfg.clone();
    println!("{}", ui::dim(&format!("Mind data: {}", home.display())));

    // --- who
    step(1, "You");
    let default_user = args
        .user
        .clone()
        .unwrap_or_else(|| if cfg.identity.user == "the user" { whoami() } else { cfg.identity.user.clone() });
    // Plain names only: they are written into every agent's instructions.
    let default_user = store::clean_name(&default_user).unwrap_or_else(|| "the user".into());
    if let Some(u) = &args.user {
        store::plain_name(u)?;
    }
    if let Some(r) = args.reviewer.as_deref().filter(|r| !r.eq_ignore_ascii_case("none")) {
        store::plain_name(r)?;
    }
    cfg.identity.user = loop {
        let u = a.ask_required("Your name, as agents should refer to you", &default_user)?;
        match store::plain_name(&u) {
            Ok(u) => break u,
            Err(e) if !a.yes => println!("  {} {e}", ui::yellow("⚠")),
            Err(e) => return Err(e),
        }
    };
    let reviewer_default = args.reviewer.clone().unwrap_or_else(|| {
        if cfg.identity.review_runbooks && cfg.identity.reviewer != "the reviewer" {
            cfg.identity.reviewer.clone()
        } else if cfg.identity.review_runbooks {
            cfg.identity.user.clone()
        } else {
            "none".into()
        }
    });
    println!(
        "{}",
        ui::dim("Runbooks are step-by-step procedures agents record (deploys, backups, server tasks). \
         A change to one waits until this person accepts it.")
    );
    let reviewer = a.ask(
        "Who reviews runbook changes? A name, or `none` for no review",
        &reviewer_default,
    )?;
    cfg.identity.review_runbooks = !reviewer.eq_ignore_ascii_case("none");
    cfg.identity.reviewer = if cfg.identity.review_runbooks { store::plain_name(&reviewer)? } else { "the reviewer".into() };

    // --- embeddings
    step(2, "Search");
    let mut emb_options = vec!["azure", "openai", "none"];
    if cfg!(feature = "local-embeddings") {
        emb_options.insert(0, "local");
    }
    println!(
        "{}",
        ui::dim(&format!("Search by meaning needs an embedding model. `local` runs one on this machine{}; `azure` uses an \
         Azure OpenAI embedding deployment; `openai` any OpenAI-compatible server; `none` = keyword search only.",
        if cfg!(feature = "local-embeddings") { "" } else { " (not in this build)" }))
    );
    let emb_default = args.embeddings.clone().unwrap_or_else(|| cfg.embeddings.provider.clone());
    cfg.embeddings.provider = a.choose("Embeddings provider", &emb_options, &emb_default)?;
    let mut shared_auth = args.auth.clone();
    if matches!(cfg.embeddings.provider.as_str(), "azure" | "openai") {
        let azure = cfg.embeddings.provider == "azure";
        let url_q = if azure { "Azure OpenAI endpoint (https://<resource>.openai.azure.com)" } else { "Base URL (incl. /v1)" };
        let url_default = args
            .embeddings_url
            .clone()
            .or_else(|| std::env::var("AZURE_OPENAI_ENDPOINT").ok().filter(|_| azure))
            .unwrap_or_else(|| cfg.embeddings.url.clone());
        cfg.embeddings.url = a.ask_required(url_q, &url_default)?;
        let model_q = if azure { "Embedding deployment name" } else { "Embedding model id" };
        cfg.embeddings.model = a.ask_required(model_q, &args.embeddings_model.clone().unwrap_or_else(|| cfg.embeddings.model.clone()))?;
        let auth_default = shared_auth.clone().unwrap_or_else(|| {
            if cfg.embeddings.auth != "none" { cfg.embeddings.auth.clone() } else if azure { "azure".into() } else { "none".into() }
        });
        cfg.embeddings.auth = a.ask(
            "Auth: `azure` (managed identity, else your `az login`), `env:VAR` (API key in VAR), `command:<cmd>`, `none`",
            &auth_default,
        )?;
        shared_auth = Some(cfg.embeddings.auth.clone());
        gateway_check(&a, &cfg.embeddings.url, &cfg.embeddings.auth, &mut cfg.embeddings.token_hosts)?;
    } else {
        cfg.embeddings.url.clear();
        cfg.embeddings.model.clear();
        cfg.embeddings.auth = "none".into();
    }

    // --- librarian
    step(3, "Librarian");
    println!(
        "{}",
        ui::dim("Optional. Reviews stale entries against code changes and powers `search --deep`. \
         `lmstudio` starts LM Studio as needed; `azure` uses an Azure OpenAI chat deployment; `none` turns it off.")
    );
    let lib_default = args.librarian.clone().unwrap_or_else(|| cfg.librarian.provider.clone());
    cfg.librarian.provider = a.choose("Librarian provider", &["lmstudio", "azure", "openai", "none"], &lib_default)?;
    match cfg.librarian.provider.as_str() {
        "lmstudio" => {
            cfg.librarian.url = a.ask("LM Studio URL", &args.librarian_url.clone().unwrap_or_else(|| cfg.librarian.url.clone()))?;
            cfg.librarian.auth = "none".into();
            cfg.librarian.model = a.ask("Model for reviews (strongest)", &args.librarian_model.clone().unwrap_or_else(|| cfg.librarian.model.clone()))?;
            cfg.librarian.quick_model = a.ask("Model for --deep search (fast)", &args.quick_model.clone().unwrap_or_else(|| cfg.librarian.quick_model.clone()))?;
        }
        "azure" | "openai" => {
            let azure = cfg.librarian.provider == "azure";
            // Keep earlier answers for this provider; otherwise start from the embeddings settings.
            let kept = before.librarian.provider == cfg.librarian.provider;
            let url_default = if kept {
                cfg.librarian.url.clone()
            } else if cfg.embeddings.provider == cfg.librarian.provider {
                cfg.embeddings.url.clone()
            } else if azure {
                std::env::var("AZURE_OPENAI_ENDPOINT").unwrap_or_default()
            } else {
                String::new()
            };
            let url_q = if azure { "Azure OpenAI endpoint (https://<resource>.openai.azure.com)" } else { "Base URL (incl. /v1)" };
            cfg.librarian.url = a.ask_required(url_q, &args.librarian_url.clone().unwrap_or(url_default))?;
            // An --auth flag wins; then this librarian's earlier answer; then the embeddings' auth.
            let auth_default = args.auth.clone().unwrap_or_else(|| {
                if kept && cfg.librarian.auth != "none" {
                    cfg.librarian.auth.clone()
                } else if let Some(shared) = shared_auth.clone() {
                    shared
                } else if azure {
                    "azure".into()
                } else {
                    "none".into()
                }
            });
            cfg.librarian.auth = a.ask("Auth", &auth_default)?;
            if cfg.embeddings.url != cfg.librarian.url || !cfg.librarian.auth.starts_with("azure") {
                gateway_check(&a, &cfg.librarian.url, &cfg.librarian.auth, &mut cfg.librarian.token_hosts)?;
            } else {
                cfg.librarian.token_hosts = cfg.embeddings.token_hosts.clone();
            }
            let model_default = if kept { cfg.librarian.model.clone() } else { String::new() };
            cfg.librarian.model = a.ask_required(
                "Chat deployment/model for reviews (strongest)",
                &args.librarian_model.clone().unwrap_or(model_default),
            )?;
            let quick_default = args.quick_model.clone().unwrap_or_else(|| {
                if kept && !cfg.librarian.quick_model.is_empty() { cfg.librarian.quick_model.clone() } else { cfg.librarian.model.clone() }
            });
            cfg.librarian.quick_model = a.ask_required("Chat deployment/model for --deep search (fast)", &quick_default)?;
        }
        _ => {}
    }

    // Save config now, so a later step failing doesn't lose the answers.
    // Held settings are approved only after showing every field that can run a program or send
    // anything (including ones setup didn't ask about, like the LM Studio path), default No.
    let approve = !held || (interactive && !args.yes && {
        let (e, l) = (&cfg.embeddings, &cfg.librarian);
        println!("\n  {} These AI settings came from config.toml, not from you on this machine:", ui::yellow("⚠"));
        for (k, v) in [
            ("embeddings.provider", e.provider.clone()), ("embeddings.url", e.url.clone()), ("embeddings.auth", e.auth.clone()),
            ("embeddings.token_hosts", e.token_hosts.join(", ")), ("librarian.provider", l.provider.clone()),
            ("librarian.url", l.url.clone()), ("librarian.auth", l.auth.clone()),
            ("librarian.token_hosts", l.token_hosts.join(", ")), ("librarian.lms (program it runs)", l.lms.clone()),
            ("embeddings.model", e.model.clone()), ("librarian.model", l.model.clone()), ("librarian.quick_model", l.quick_model.clone()),
            ("librarian.concurrency", l.concurrency.to_string()), ("librarian.context_length", l.context_length.to_string()),
            ("privacy.mask (hide personal data sent out)", cfg.privacy.mask.to_string()),
            ("privacy.patterns", format!("{:?}", cfg.privacy.patterns)),
        ] {
            println!("    {k:<34} {}", store::strip_controls(&v));
        }
        a.confirm("Approve them on this machine (they can run that program and send your key and entries to those URLs)?", false)?
    });
    store::write_atomic(&home.join("config.toml"), toml::to_string_pretty(&cfg)?)?;
    if approve {
        store::approve_ai(&home, &cfg)?;
    } else if held {
        println!("  {} Not approved: they stay held back (keyword search, no librarian).", ui::yellow("⚠"));
    }
    let mind = Mind::open()?;
    println!("\n  {} saved {}", ui::green("✓"), home.join("config.toml").display());

    // Quick connection test for remote embeddings.
    let mut emb_ok = None;
    if matches!(cfg.embeddings.provider.as_str(), "azure" | "openai") {
        print!("  testing embeddings… ");
        std::io::stdout().flush()?;
        let mut ix = crate::index::Index::open(&mind)?;
        match ix.embed(&["connection test".to_string()]) {
            Ok(v) if !v.is_empty() && !v[0].is_empty() => {
                emb_ok = Some(true);
                println!("{} ({} dimensions)", ui::green("✓ works"), v[0].len())
            }
            Ok(_) => {
                emb_ok = Some(false);
                println!("{}", ui::red("✗ no vectors returned"))
            }
            Err(e) => {
                emb_ok = Some(false);
                println!(
                    "{} {e:#}\n    {}",
                    ui::red("✗ failed:"),
                    ui::dim("search falls back to keywords until this works; `mind doctor` explains more")
                )
            }
        }
    }

    // --- tools
    step(4, "Agent tools");
    let h = home_dir();
    let detected: Vec<(&str, bool)> = vec![
        ("claude", h.join(".claude").is_dir()),
        ("codex", h.join(".codex").is_dir()),
        ("grok", h.join(".grok").is_dir()),
        ("copilot", h.join(".copilot").is_dir() || h.join(".vscode").is_dir() || which("code")),
    ];
    let chosen: Vec<String> = match &args.tools {
        Some(list) => list.split(',').map(|s| s.trim().to_lowercase()).filter(|s| !s.is_empty() && s != "none").collect(),
        None => {
            println!("{}", ui::dim("Installs the skill, always-on instructions, and hooks where the tool supports them."));
            let mut v = vec![];
            for (tool, present) in &detected {
                if *present && a.confirm(&format!("  Found {tool}. Connect it?"), true)? {
                    v.push(tool.to_string());
                }
            }
            v
        }
    };
    let mut changed = vec![];
    let planned: Vec<PathBuf> = chosen.iter().flat_map(|t| planned_files(t, &h)).collect();
    let mut go = true;
    if !planned.is_empty() {
        println!("\n  Setup will update (each original is kept once as *.before-{SKILL_ID}):");
        for p in &planned {
            println!("    {}", p.display());
        }
        println!("  {}", ui::dim(&format!("Hooks run: {}", exe_command("hook <session-start|prompt|stop>"))));
        go = a.confirm("  Go ahead?", true)?;
    }
    let chosen: Vec<String> = if go { chosen } else { vec![] };
    // Before any hook is written, so hooks call `mind` even when this run is `mind setup`; only
    // with the user's go-ahead (or when no tool files are planned).
    if go {
        let (made, warning) = ensure_commands();
        for m in made {
            println!("  {} installed the `{COMMAND}` command: {m}", ui::green("✓"));
        }
        if let Some(w) = warning {
            println!("  {} {w}", ui::yellow("⚠"));
        }
    }
    let addon = if chosen.iter().any(|t| t == "claude" || t == "codex" || t == "grok") {
        approved_addon(&home, &a, interactive)?
    } else {
        None
    };
    let skill = render_skill(&cfg, addon.as_deref());
    let rule = personalize(RULE_TEMPLATE, &cfg.identity);
    // Tools whose new-name files were actually installed: only those get their old copies moved.
    let mut installed: Vec<String> = vec![];
    for tool in &chosen {
        match tool.as_str() {
            "claude" => {
                let dest = h.join(format!(".claude/skills/{SKILL_ID}/SKILL.md"));
                if !ours_or_absent(&dest, SKILL_HEAD) {
                    println!("  {} {} isn't ours; left as it is (rename it to let setup install the skill)", ui::yellow("⚠"), dest.display());
                    continue;
                }
                write_file(&dest, &skill, &mut changed)?;
                merge_hooks(&h.join(".claude/settings.json"), &mut changed)?;
                installed.push(tool.clone());
            }
            "codex" => {
                let p = h.join(format!(".codex/skills/{SKILL_ID}"));
                // A symlink to Claude's copy (set up by hand) keeps working, as long as it leads to a
                // real skill; a dangling one is replaced by our own copy.
                if p.is_symlink() && !p.join("SKILL.md").exists() {
                    let _ = fs::remove_file(&p);
                }
                if !p.is_symlink() && !ours_or_absent(&p.join("SKILL.md"), SKILL_HEAD) {
                    println!("  {} {} isn't ours; left as it is", ui::yellow("⚠"), p.join("SKILL.md").display());
                    continue;
                }
                if !p.is_symlink() {
                    write_file(&p.join("SKILL.md"), &skill, &mut changed)?;
                }
                write_managed_block(&h.join(".codex/AGENTS.md"), &rule, &mut changed)?;
                merge_hooks(&h.join(".codex/hooks.json"), &mut changed)?;
                installed.push(tool.clone());
            }
            "grok" => {
                let dest = h.join(format!(".grok/rules/{SKILL_ID}.md"));
                if !ours_or_absent(&dest, RULE_HEAD) {
                    println!("  {} {} isn't ours; left as it is", ui::yellow("⚠"), dest.display());
                    continue;
                }
                write_file(&dest, &rule, &mut changed)?;
                let gskill = h.join(format!(".grok/skills/{SKILL_ID}/SKILL.md"));
                if !h.join(format!(".claude/skills/{SKILL_ID}")).exists() {
                    if ours_or_absent(&gskill, SKILL_HEAD) {
                        write_file(&gskill, &skill, &mut changed)?;
                    } else {
                        println!("  {} {} isn't ours; left as it is", ui::yellow("⚠"), gskill.display());
                    }
                }
                installed.push(tool.clone());
            }
            "copilot" => {
                println!(
                    "\nCopilot reads `AGENTS.md` and `.github/copilot-instructions.md` in each repo. Add this block to \
                     the repos you use with Copilot (or your Copilot user instructions):\n\n{rule}"
                );
            }
            "other" => println!("\nPaste this into your agent tool's always-on instructions:\n\n{rule}"),
            other => println!("(unknown tool `{other}` skipped; use claude, codex, grok, copilot or other)"),
        }
    }
    for c in &changed {
        println!("  {} updated {c}", ui::green("✓"));
    }
    if !planned.is_empty() && changed.is_empty() && go {
        println!("  {} already up to date", ui::green("✓"));
    }
    if chosen.iter().any(|t| t == "codex") {
        println!("  {}", ui::dim("Codex asks you to trust new hooks the first time it starts; approve them once."));
    }
    // Grok reads Claude Code's hooks; with no Claude Code there are none to trust, and the rule does it.
    if chosen.iter().any(|t| t == "grok") && chosen.iter().any(|t| t == "claude") {
        println!("  {}", ui::dim("Grok only runs hooks in trusted folders: run `/hooks-trust` once in each project."));
    }

    // --- projects
    step(5, "Projects");
    // Projects that came through a pull: shown, approved only by a person at the keyboard.
    let held = store::held_projects(&home);
    if !held.is_empty() {
        println!("  {} These projects are in projects.toml but weren't added on this machine:", ui::yellow("⚠"));
        for (n, p) in &held {
            println!("    {n:<20} {}", store::strip_controls(&p.path));
        }
        if interactive && !args.yes {
            // One at a time: a project someone else added mustn't ride along with your own.
            for (n, p) in &held {
                let q = format!("Use `{n}` ({}) here? Agents will be pointed at files in it", store::strip_controls(&p.path));
                if a.confirm(&q, false)? {
                    let mut one = BTreeMap::new();
                    one.insert(n.clone(), p.clone());
                    store::approve_projects(&home, &one)?;
                }
            }
        } else {
            println!("  {}", ui::dim("Left unused. Add the ones you want with `mind project add <name> <folder>`."));
        }
    }
    let scan = match args.scan.clone() {
        Some(p) => Some(p),
        None if args.yes => None,
        None => {
            println!(
                "{}",
                ui::dim("Setup can find your git repos in a folder (up to 3 levels down) and register each one as a \
                 project, so agents know where its code lives. Type the folder's path: `~/code`, or a full path \
                 such as `/home/<you>/code`. `~` means your home folder.")
            );
            ask_scan_folder(&a)?
        }
    };
    // Absolute, so the registered paths are (`--scan .` would store `./repo`).
    let scan = scan.map(|d| store::absolute_dir(&d).unwrap_or(d));
    if let Some(dir) = scan.filter(|d| {
        // A --scan folder that doesn't exist is said so, not reported as "no repos found".
        d.is_dir() || {
            println!("  {} {} isn't a folder (or doesn't exist), so nothing was scanned.", ui::yellow("⚠"), d.display());
            false
        }
    }) {
        let mut repos = vec![];
        find_repos(&dir, 3, &mut repos);
        let known: Vec<PathBuf> = mind.projects.values().map(|p| store::expand(&p.path)).collect();
        let found = repos.len();
        let new: Vec<PathBuf> = repos.into_iter().filter(|r| !known.contains(r)).collect();
        if new.is_empty() && found > 0 {
            println!("All {found} git repos under {} are already registered.", dir.display());
        } else if new.is_empty() {
            println!("No git repos found under {}.", dir.display());
        } else {
            println!("Found {} new repos:", new.len());
            for r in &new {
                println!("  {}", r.display());
            }
            if a.confirm("Register them all as projects?", true)? {
                let mut projects = mind.projects.clone();
                for r in new {
                    let mut name = slugify(&r.file_name().unwrap_or_default().to_string_lossy());
                    while projects.contains_key(&name) {
                        name.push_str("-2");
                    }
                    projects.insert(name, Project { path: r.to_string_lossy().to_string(), ..Default::default() });
                }
                save_projects(&mind, &projects)?;
                println!("Registered. Add descriptions and stacks with `mind project add <name> <path> --description … --stack …`.");
            }
        }
    }

    if let Ok(_lock) = mind.lock() {
        let _ = mind.commit(&[&home.join("config.toml")], "setup: configuration", "setup");
    }

    // --- summary
    let mind = Mind::open()?;
    let emb = match cfg.embeddings.provider.as_str() {
        "none" => "keyword search only".to_string(),
        "local" => "local model".to_string(),
        p => format!("{p} · {}", cfg.embeddings.model),
    };
    let lib = match cfg.librarian.provider.as_str() {
        "none" => "off".to_string(),
        p => format!("{p} · {}", cfg.librarian.model),
    };
    let emb_mark = match emb_ok {
        Some(true) => format!(" {}", ui::green("✓")),
        Some(false) => format!(" {}", ui::red("✗")),
        None => String::new(),
    };
    println!("\n{}", ui::rule());
    println!("{}", ui::bold("Mega Mind is set up"));
    println!("  {}  {}", ui::dim("you        "), cfg.identity.user);
    println!("  {}  {emb}{emb_mark}", ui::dim("search     "));
    println!("  {}  {lib}", ui::dim("librarian  "));
    println!("  {}  {}", ui::dim("tools      "), if chosen.is_empty() { "none".to_string() } else { chosen.join(", ") });
    println!("  {}  {}", ui::dim("projects   "), mind.projects.len());
    println!("\n  Next: {}  {}", ui::bold("mind doctor"), ui::dim("checks everything end to end"));
    println!(
        "  {}",
        ui::dim("For nightly upkeep, schedule `mind maintain` (systemd/cron, launchd, or Task Scheduler; see https://megamind.coinopolis.io/source/f/nightly.sh.html).")
    );
    Ok(())
}

/// Add `projects` to `projects.toml` (re-read under the lock, so a concurrent `project add` isn't lost).
pub fn save_projects(mind: &Mind, projects: &BTreeMap<String, Project>) -> Result<()> {
    let _lock = mind.lock()?;
    store::approve_projects(&mind.home, projects)?;
    let mut all = mind.projects_now()?;
    for (k, v) in projects {
        all.entry(k.clone()).or_insert_with(|| v.clone());
    }
    let projects = &all;
    #[derive(serde::Serialize)]
    struct F<'a> {
        projects: &'a BTreeMap<String, Project>,
    }
    let file = mind.home.join("projects.toml");
    store::write_atomic(
        &file,
        format!(
            "# Projects the mind knows. Refs are written as `<project>:<path inside it>`.\n\n{}",
            toml::to_string_pretty(&F { projects })?
        ),
    )?;
    mind.commit(&[&file], "projects updated", "setup")
}

fn whoami() -> String {
    std::env::var("USER").or_else(|_| std::env::var("USERNAME")).unwrap_or_else(|_| "the user".into())
}

fn which(cmd: &str) -> bool {
    which_path(cmd).is_some()
}

/// The first `cmd` (or `cmd.exe` / `cmd.cmd`) on PATH.
pub(crate) fn which_path(cmd: &str) -> Option<PathBuf> {
    let paths = std::env::var_os("PATH")?;
    std::env::split_paths(&paths)
        .flat_map(|d| ["", ".exe", ".cmd"].map(|ext| d.join(format!("{cmd}{ext}"))))
        .find(|p| p.is_file())
}