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())
}