use anyhow::{Context, Result};
use std::io::Write;
use std::path::{Path, PathBuf};
use std::process::Command;
use crate::{chunk, clients, embed, home, model_cache_dir};
const BEGIN: &str = "# >>> semlith >>>";
const END: &str = "# <<< semlith <<<";
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize)]
#[serde(rename_all = "kebab-case")]
pub enum State {
Done,
AlreadyDone,
Skipped,
Failed,
}
#[derive(Debug, Clone, serde::Serialize)]
pub struct Step {
pub name: &'static str,
pub state: State,
pub detail: String,
}
#[derive(Debug, Clone, serde::Serialize)]
pub struct Status {
pub bin_dir: String,
pub binary_installed: bool,
pub on_path: bool,
pub rc_file: Option<String>,
pub rc_block_present: bool,
pub model_cache: String,
pub model_cached: bool,
pub claude_registered: Option<bool>,
pub version: &'static str,
pub install_sh: &'static str,
pub install_ps1: &'static str,
pub steps: Vec<Step>,
}
pub const INSTALL_SH: &str =
"curl -fsSL https://raw.githubusercontent.com/semlith/semlith/main/install.sh | sh";
pub const INSTALL_PS1: &str =
"irm https://raw.githubusercontent.com/semlith/semlith/main/install.ps1 | iex";
pub fn status() -> Status {
let bin = home::bin_dir();
let rc = rc_file();
let block = rc
.as_deref()
.and_then(|p| std::fs::read_to_string(p).ok())
.map(|text| text.contains(BEGIN))
.unwrap_or(false);
let cache = model_cache_dir();
let registered = claude_registered();
let installed = bin.join(exe_name()).exists();
let path_has_bin = on_path(&bin);
let model_cached = embed::is_cached(&cache);
let steps = vec![
Step {
name: "binary",
state: if installed {
State::AlreadyDone
} else {
State::Skipped
},
detail: bin.join(exe_name()).display().to_string(),
},
Step {
name: "path",
state: if path_has_bin {
State::AlreadyDone
} else {
State::Skipped
},
detail: match &rc {
Some(p) => p.display().to_string(),
None => "no shell rc file found".into(),
},
},
Step {
name: "model",
state: if model_cached {
State::AlreadyDone
} else {
State::Skipped
},
detail: cache.display().to_string(),
},
Step {
name: "agents",
state: match registered {
Some(true) => State::AlreadyDone,
Some(false) | None => State::Skipped,
},
detail: match registered {
Some(true) => "Claude Code has the semlith server".into(),
Some(false) => "Claude Code is installed but has no semlith server".into(),
None => "the claude CLI is not on PATH".into(),
},
},
];
Status {
bin_dir: bin.display().to_string(),
binary_installed: installed,
on_path: path_has_bin,
rc_file: rc.map(|p| p.display().to_string()),
rc_block_present: block,
model_cache: cache.display().to_string(),
model_cached,
claude_registered: registered,
version: env!("CARGO_PKG_VERSION"),
install_sh: INSTALL_SH,
install_ps1: INSTALL_PS1,
steps,
}
}
pub fn run(yes: bool, airgap: bool) -> Result<()> {
let _ = cliclack::intro(format!(" semlith {} setup ", env!("CARGO_PKG_VERSION")));
let report = [
announce(step_binary(yes)?),
announce(step_path(yes)?),
announce(step_model(yes, airgap)?),
announce(step_agents(yes)?),
announce(step_verify()?),
];
if report.iter().all(|s| s.state != State::Done) {
let _ = cliclack::log::success("Everything was already set up. Nothing changed.");
}
let _ = cliclack::outro(format!(
"Next: `semlith index .` to index this directory, then `semlith start` \
for the portal on http://127.0.0.1:7365\n Open a new shell first if \
{} was only just added to PATH.",
home::bin_dir().display()
));
Ok(())
}
fn announce(step: Step) -> Step {
let line = format!("{}: {}", step.name, step.detail);
let _ = match step.state {
State::Done => cliclack::log::success(line),
State::AlreadyDone => cliclack::log::info(format!("{line} (already done)")),
State::Skipped => cliclack::log::info(format!("{line} (skipped)")),
State::Failed => cliclack::log::warning(line),
};
step
}
fn exe_name() -> &'static str {
if cfg!(windows) {
"semlith.exe"
} else {
"semlith"
}
}
fn on_path(bin: &Path) -> bool {
std::env::var_os("PATH")
.map(|paths| std::env::split_paths(&paths).any(|p| p == bin))
.unwrap_or(false)
}
fn step_binary(yes: bool) -> Result<Step> {
let bin = home::bin_dir();
let target = bin.join(exe_name());
let running = std::env::current_exe().context("locating the running binary")?;
if running == target || target.exists() {
return Ok(Step {
name: "binary",
state: State::AlreadyDone,
detail: target.display().to_string(),
});
}
let copy = yes
|| cliclack::confirm(format!("Copy this binary into {}?", bin.display()))
.initial_value(true)
.interact()
.unwrap_or(false);
if !copy {
return Ok(Step {
name: "binary",
state: State::Skipped,
detail: format!("left at {}", running.display()),
});
}
std::fs::create_dir_all(&bin).with_context(|| format!("creating {}", bin.display()))?;
std::fs::copy(&running, &target)
.with_context(|| format!("copying {} to {}", running.display(), target.display()))?;
Ok(Step {
name: "binary",
state: State::Done,
detail: target.display().to_string(),
})
}
fn rc_file() -> Option<PathBuf> {
let base = PathBuf::from(std::env::var_os("HOME")?);
let shell = std::env::var("SHELL").unwrap_or_default();
let name = Path::new(&shell)
.file_name()
.map(|n| n.to_string_lossy().to_string())
.unwrap_or_default();
Some(match name.as_str() {
"zsh" => base.join(".zshrc"),
"fish" => base.join(".config").join("fish").join("config.fish"),
"bash" => {
let rc = base.join(".bashrc");
if rc.exists() {
rc
} else {
base.join(".bash_profile")
}
}
_ => base.join(".profile"),
})
}
fn step_path(yes: bool) -> Result<Step> {
let bin = home::bin_dir();
if on_path(&bin) {
return Ok(Step {
name: "path",
state: State::AlreadyDone,
detail: bin.display().to_string(),
});
}
if cfg!(windows) {
return step_path_windows(&bin, yes);
}
let Some(rc) = rc_file() else {
return Ok(Step {
name: "path",
state: State::Failed,
detail: format!(
"no HOME, so no rc file to edit — add {} to PATH by hand",
bin.display()
),
});
};
let existing = std::fs::read_to_string(&rc).unwrap_or_default();
if existing.contains(BEGIN) {
return Ok(Step {
name: "path",
state: State::AlreadyDone,
detail: format!("{} already has the semlith block", rc.display()),
});
}
let add = yes
|| cliclack::confirm(format!(
"Add {} to PATH in {}?",
bin.display(),
rc.display()
))
.initial_value(true)
.interact()
.unwrap_or(false);
if !add {
return Ok(Step {
name: "path",
state: State::Skipped,
detail: format!("add {} to PATH by hand", bin.display()),
});
}
if let Some(parent) = rc.parent() {
std::fs::create_dir_all(parent).ok();
}
let mut file = std::fs::OpenOptions::new()
.create(true)
.append(true)
.open(&rc)
.with_context(|| format!("opening {}", rc.display()))?;
let sep = if existing.is_empty() || existing.ends_with('\n') {
""
} else {
"\n"
};
write!(file, "{sep}\n{}\n{}\n{END}\n", BEGIN, path_line(&bin))
.with_context(|| format!("writing {}", rc.display()))?;
Ok(Step {
name: "path",
state: State::Done,
detail: rc.display().to_string(),
})
}
fn path_line(bin: &Path) -> String {
let is_fish = std::env::var("SHELL")
.map(|s| s.ends_with("fish"))
.unwrap_or(false);
if is_fish {
format!("set -gx PATH {} $PATH", bin.display())
} else {
format!("export PATH=\"{}:$PATH\"", bin.display())
}
}
fn step_path_windows(bin: &Path, yes: bool) -> Result<Step> {
let add = yes
|| cliclack::confirm(format!("Add {} to your user PATH?", bin.display()))
.initial_value(true)
.interact()
.unwrap_or(false);
if !add {
return Ok(Step {
name: "path",
state: State::Skipped,
detail: format!("add {} to PATH by hand", bin.display()),
});
}
let script = format!(
"$dir = '{}'; \
$cur = [Environment]::GetEnvironmentVariable('Path','User'); \
if ($cur -notlike \"*$dir*\") {{ \
[Environment]::SetEnvironmentVariable('Path', \"$dir;$cur\", 'User') }}",
bin.display()
);
let out = Command::new("powershell")
.args(["-ExecutionPolicy", "Bypass", "-NoProfile", "-Command"])
.arg(&script)
.output();
match out {
Ok(o) if o.status.success() => Ok(Step {
name: "path",
state: State::Done,
detail: "user PATH".into(),
}),
_ => Ok(Step {
name: "path",
state: State::Failed,
detail: format!(
"could not set the user PATH — add {} through System Properties > \
Environment Variables",
bin.display()
),
}),
}
}
fn step_model(yes: bool, airgap: bool) -> Result<Step> {
let cache = model_cache_dir();
if embed::is_cached(&cache) {
return Ok(Step {
name: "model",
state: State::AlreadyDone,
detail: cache.display().to_string(),
});
}
if airgap || embed::airgap() {
return Ok(Step {
name: "model",
state: State::Skipped,
detail: format!("--airgap; pre-seed {}", cache.display()),
});
}
let fetch = yes
|| cliclack::confirm("Download the default embedding model now (~52 MB)?")
.initial_value(true)
.interact()
.unwrap_or(false);
if !fetch {
return Ok(Step {
name: "model",
state: State::Skipped,
detail: "the first `semlith index` will fetch it".into(),
});
}
let _ = cliclack::log::step(format!("model: downloading into {}", cache.display()));
std::fs::create_dir_all(&cache).with_context(|| format!("creating {}", cache.display()))?;
match embed::Model::default().load(cache.clone(), chunk::MAX_CHARS / 2, false) {
Ok(_) => Ok(Step {
name: "model",
state: State::Done,
detail: cache.display().to_string(),
}),
Err(e) => Ok(Step {
name: "model",
state: State::Failed,
detail: format!("{e} — `semlith setup` again when the network is back"),
}),
}
}
fn step_agents(yes: bool) -> Result<Step> {
if yes {
return Ok(Step {
name: "agents",
state: State::Skipped,
detail: "--yes registers no agent; run `semlith setup` to pick one".into(),
});
}
let all = clients::clients();
if all.is_empty() {
return Ok(Step {
name: "agents",
state: State::Skipped,
detail: "no documented clients".into(),
});
}
let mut prompt = cliclack::multiselect::<usize>("Which agents do you use? (space to pick)");
for (i, client) in all.iter().enumerate() {
prompt = prompt.item(i, &client.name, "");
}
let picked = prompt.required(false).interact().unwrap_or_default();
if picked.is_empty() {
return Ok(Step {
name: "agents",
state: State::Skipped,
detail: "none picked".into(),
});
}
let mut wired = Vec::new();
for i in picked {
let client = &all[i];
if client.name.eq_ignore_ascii_case("Claude Code") && register_claude() {
wired.push(client.name.clone());
continue;
}
let body = client
.stanzas
.iter()
.map(|s| s.text.as_str())
.collect::<Vec<_>>()
.join("\n\n");
let _ = cliclack::note(format!("{} — {}", client.name, client.note), body);
}
Ok(Step {
name: "agents",
state: if wired.is_empty() {
State::Skipped
} else {
State::Done
},
detail: if wired.is_empty() {
"stanzas printed; paste them into each client's config".into()
} else {
wired.join(", ")
},
})
}
fn register_claude() -> bool {
Command::new("claude")
.args([
"mcp", "add", "--scope", "user", "semlith", "--", "semlith", "mcp",
])
.output()
.map(|o| o.status.success())
.unwrap_or(false)
}
fn claude_registered() -> Option<bool> {
let out = Command::new("claude").args(["mcp", "list"]).output().ok()?;
Some(String::from_utf8_lossy(&out.stdout).contains("semlith"))
}
fn step_verify() -> Result<Step> {
let target = home::bin_dir().join(exe_name());
let which = if target.exists() {
target
} else {
std::env::current_exe().context("locating the running binary")?
};
match Command::new(&which).arg("--version").output() {
Ok(o) if o.status.success() => {
let line = String::from_utf8_lossy(&o.stdout).trim().to_string();
Ok(Step {
name: "verify",
state: State::AlreadyDone,
detail: line,
})
}
_ => Ok(Step {
name: "verify",
state: State::Failed,
detail: format!("{} would not run", which.display()),
}),
}
}