use anyhow::{Context, Result};
use mecha_core::doctor::Remedy;
use mecha_core::onboarding::{self, Facts, Status, Step};
use std::io::{IsTerminal, Write};
#[derive(clap::Args, Debug)]
pub struct Args {
#[arg(long)]
pub json: bool,
#[arg(long)]
pub write: bool,
}
pub async fn execute(global: &crate::GlobalOpts, args: Args) -> Result<()> {
let cfg = mecha_core::config::Config::load_global()
.context("reading the global config — run `mecha config init` first")?;
let (name, pcfg) = cfg.provider(global.provider.as_deref())?;
let props = if pcfg.kind == "local" {
match pcfg.base_url.as_deref() {
Some(url) => mecha_core::provider::preflight::fetch(url).await,
None => None,
}
} else {
None
};
let home = mecha_core::work::mecha_home()?;
let facts = Facts {
has_mail_binary: onboarding::on_path("mecha-mail"),
has_docs_binary: onboarding::on_path("mecha-docs"),
has_graph_binary: onboarding::on_path("mecha-graph-mcp"),
mail_accounts: onboarding::count_accounts(&home.join("mail")),
docs_accounts: onboarding::count_accounts(&home.join("docs")),
slack_linked: slack_linked(&home),
provider_credential: pcfg.resolve_api_key().is_some(),
props,
scheduler_installed: scheduler_installed(),
trigger_count: trigger_count(&home),
};
let steps = onboarding::plan(&cfg, &name, &facts);
if args.json {
println!("{}", serde_json::to_string_pretty(&steps)?);
return Ok(());
}
if args.write {
return write_verified(&name, &facts);
}
render(&steps);
let outstanding: Vec<&Step> = steps.iter().filter(|s| s.status != Status::Done).collect();
if outstanding.is_empty() {
println!("\nNothing outstanding.");
return Ok(());
}
if !std::io::stdin().is_terminal() {
println!("\n{} step(s) outstanding.", outstanding.len());
std::process::exit(1);
}
offer(&outstanding)
}
fn render(steps: &[Step]) {
for s in steps {
let (mark, label) = match s.status {
Status::Done => ("✓", "ok"),
Status::Missing => ("·", "not set up"),
Status::Wrong => ("!", "disagrees"),
Status::Unknown => ("?", "unknown"),
};
println!("\n{mark} {} [{label}]", s.title);
for line in s.detail.lines() {
println!(" {line}");
}
if let Some(r) = &s.remedy {
println!(" → {}", shell_words(&r.argv));
}
}
}
fn offer(steps: &[&Step]) -> Result<()> {
let stdin = std::io::stdin();
let mut offered: Vec<&[String]> = Vec::new();
for s in steps {
let Some(remedy) = &s.remedy else { continue };
if offered.contains(&remedy.argv.as_slice()) {
continue;
}
offered.push(&remedy.argv);
println!("\n{}", remedy.description);
print!("run `{}`? [y/N] ", shell_words(&remedy.argv));
std::io::stdout().flush()?;
let mut line = String::new();
let said_yes = match std::io::BufRead::read_line(&mut stdin.lock(), &mut line) {
Ok(0) => {
println!();
false
}
Ok(_) => line.trim().eq_ignore_ascii_case("y"),
Err(_) => false,
};
if !said_yes {
println!("skipped");
continue;
}
run(remedy)?;
}
Ok(())
}
fn run(remedy: &Remedy) -> Result<()> {
let (program, rest) = remedy
.argv
.split_first()
.context("a remedy with an empty argv")?;
let status = std::process::Command::new(program).args(rest).status();
match status {
Ok(s) if s.success() => println!("done"),
Ok(s) => println!("that exited {} — nothing else was changed", s),
Err(e) => println!("could not run it: {e}"),
}
Ok(())
}
fn write_verified(provider: &str, facts: &Facts) -> Result<()> {
let Some(props) = &facts.props else {
anyhow::bail!("nothing answered, so there is nothing to write down. Start the server.");
};
let settings = onboarding::verified_settings(props);
println!("Read back from the server, for [providers.{provider}]:\n");
for (k, v) in &settings {
println!(" {k} = {v}");
}
println!(
"\nThese are what the server reports, not what it was asked for — which is the point: \
`context_window` is the *per-slot* figure, so `-c` divided by `-np`."
);
if std::io::stdin().is_terminal() {
print!("\nwrite them into the config? [y/N] ");
std::io::stdout().flush()?;
let mut line = String::new();
std::io::BufRead::read_line(&mut std::io::stdin().lock(), &mut line)?;
if !line.trim().eq_ignore_ascii_case("y") {
println!("not written");
return Ok(());
}
} else {
println!("\n(not a terminal, so nothing was written — copy the lines above)");
return Ok(());
}
apply(provider, &settings)
}
fn apply(provider: &str, settings: &[(&'static str, String)]) -> Result<()> {
let path = mecha_core::config::Config::global_path()
.context("no global config path — is $HOME set?")?;
let text = std::fs::read_to_string(&path).with_context(|| format!("reading {path:?}"))?;
let header = format!("[providers.{provider}]");
let Some(start) = text.lines().position(|l| l.trim() == header) else {
anyhow::bail!("no {header} table in {}", path.display());
};
let mut lines: Vec<String> = text.lines().map(str::to_string).collect();
let end = lines
.iter()
.enumerate()
.skip(start + 1)
.find(|(_, l)| l.trim_start().starts_with('['))
.map(|(i, _)| i)
.unwrap_or(lines.len());
for (key, value) in settings {
let assignment = format!("{key} = {value}");
match lines[start + 1..end]
.iter()
.position(|l| l.split('=').next().map(str::trim) == Some(*key))
{
Some(rel) => lines[start + 1 + rel] = assignment,
None => lines.insert(end, assignment),
}
}
let backup = path.with_extension("toml.bak");
std::fs::copy(&path, &backup).ok();
std::fs::write(&path, lines.join("\n") + "\n")?;
println!(
"written to {} (previous copy at {})",
path.display(),
backup.display()
);
Ok(())
}
fn slack_linked(home: &std::path::Path) -> Option<bool> {
let dir = home.join("slack");
match std::fs::read_dir(&dir) {
Ok(entries) => Some(entries.flatten().any(|e| {
e.path()
.extension()
.is_some_and(|x| x == "json" || x == "toml")
})),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Some(false),
Err(_) => None,
}
}
fn trigger_count(home: &std::path::Path) -> usize {
std::fs::read_dir(home.join("triggers"))
.map(|e| {
e.flatten()
.filter(|f| f.path().extension().is_some_and(|x| x == "toml"))
.count()
})
.unwrap_or(0)
}
fn scheduler_installed() -> bool {
let unit = std::env::var_os("HOME")
.map(|h| std::path::PathBuf::from(h).join(".config/systemd/user/mecha-triggers.service"))
.is_some_and(|p| p.is_file());
let running = std::process::Command::new("pgrep")
.args(["-f", "mecha trigger daemon"])
.output()
.map(|o| o.status.success())
.unwrap_or(false);
let crontab = std::process::Command::new("crontab")
.arg("-l")
.output()
.map(|o| String::from_utf8_lossy(&o.stdout).contains("trigger tick"))
.unwrap_or(false);
unit || running || crontab
}
fn shell_words(argv: &[String]) -> String {
argv.iter()
.map(|a| {
if a.contains(' ') {
format!("{a:?}")
} else {
a.clone()
}
})
.collect::<Vec<_>>()
.join(" ")
}