use anyhow::{Context, Result, bail};
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 bin_path = bin.as_deref().unwrap_or(Path::new(""));
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().unwrap_or_default();
let registered = claude_registered();
let installed = bin.is_ok() && bin_path.join(exe_name()).exists();
let path_has_bin = bin.is_ok() && on_path(bin_path);
let model_cached = embed::is_cached(&cache);
let steps = vec![
Step {
name: "binary",
state: if installed {
State::AlreadyDone
} else {
State::Skipped
},
detail: match &bin {
Ok(dir) => dir.join(exe_name()).display().to_string(),
Err(e) => e.to_string(),
},
},
Step {
name: "path",
state: if path_has_bin || block {
State::AlreadyDone
} else {
State::Skipped
},
detail: match (&rc, path_has_bin, block) {
(Some(p), false, true) => {
format!("{} — a new shell picks it up", p.display())
}
(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_path.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()
.map(|d| d.display().to_string())
.unwrap_or_else(|e| e.to_string())
));
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()),
});
}
home::secure_dir(&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 = home::user_home().ok()?;
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"),
})
}
pub fn run_path_step() -> Result<Step> {
step_path(true)
}
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()),
});
}
let line = path_line(&bin)?;
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, line)
.with_context(|| format!("writing {}", rc.display()))?;
Ok(Step {
name: "path",
state: State::Done,
detail: rc.display().to_string(),
})
}
fn path_line(bin: &Path) -> Result<String> {
let path = bin.display().to_string();
if path.contains('\n') || path.contains('\r') || path.contains('\0') {
bail!(
"{} contains a newline, so it cannot be written into a shell startup \
file. Point {} at a directory whose name is one line and run \
`semlith setup` again.",
path.escape_debug(),
home::HOME_ENV
);
}
let is_fish = std::env::var("SHELL")
.map(|s| s.ends_with("fish"))
.unwrap_or(false);
let key = home::agent_key_path()?.display().to_string();
if key.contains('\n') || key.contains('\r') || key.contains('\0') {
bail!(
"the agent key path contains a newline, so it cannot be written into a shell startup file"
);
}
Ok(if is_fish {
format!(
"set -gx PATH {} $PATH\n\
test -r {key_q}; and set -gx {KEY_ENV} (cat {key_q})",
fish_quote(&path),
key_q = fish_quote(&key),
)
} else {
format!(
"export PATH={}:$PATH\n\
[ -r {key_q} ] && export {KEY_ENV}=\"$(cat {key_q})\"",
posix_quote(&path),
key_q = posix_quote(&key),
)
})
}
fn posix_quote(raw: &str) -> String {
format!("'{}'", raw.replace('\'', r"'\''"))
}
fn fish_quote(raw: &str) -> String {
format!("'{}'", raw.replace('\\', r"\\").replace('\'', r"\'"))
}
fn powershell_quote(raw: &str) -> String {
format!("'{}'", raw.replace('\'', "''"))
}
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') }}",
powershell_quote(&bin.display().to_string())
);
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()));
home::secure_dir(&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 key = home::agent_key().unwrap_or_default();
let http = crate::clients::http_stanzas(&key);
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 mut body = client
.stanzas
.iter()
.map(|s| s.text.as_str())
.collect::<Vec<_>>()
.join("\n\n");
if !key.is_empty() {
body.push_str("\n\nOr over HTTP, against a running `semlith start`:\n\n");
body.push_str(
&http
.iter()
.map(|s| s.text.as_str())
.collect::<Vec<_>>()
.join("\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(", ")
},
})
}
pub fn register_claude() -> bool {
Command::new("claude")
.args([
"mcp", "add", "--scope", "user", "semlith", "--", "semlith", "mcp",
])
.output()
.map(|o| o.status.success())
.unwrap_or(false)
}
pub fn register_claude_http(url: &str) -> bool {
let _ = Command::new("claude")
.args(["mcp", "remove", "--scope", "user", "semlith"])
.output();
Command::new("claude")
.args([
"mcp",
"add",
"--scope",
"user",
"--transport",
"http",
"semlith",
url,
"--header",
&format!("Authorization: Bearer ${{{KEY_ENV}}}"),
])
.output()
.map(|o| o.status.success())
.unwrap_or(false)
}
pub const KEY_ENV: &str = "SEMLITH_AGENT_KEY";
pub fn claude_present() -> bool {
Command::new("claude")
.arg("--version")
.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()),
}),
}
}
pub fn recarry_key(previous: &str, fresh: &str) -> Vec<PathBuf> {
let mut done = Vec::new();
if previous == fresh || !home::is_agent_key(previous) || !home::is_agent_key(fresh) {
return done;
}
for path in client_configs() {
let Ok(text) = std::fs::read_to_string(&path) else {
continue;
};
if !text.contains(previous) {
continue;
}
let swapped = text.replace(previous, fresh);
let temp = path.with_extension(format!(
"{}semlith-tmp",
path.extension()
.map(|e| format!("{}.", e.to_string_lossy()))
.unwrap_or_default()
));
#[cfg(unix)]
let mode = {
use std::os::unix::fs::PermissionsExt;
std::fs::metadata(&path)
.map(|m| m.permissions().mode() & 0o777)
.unwrap_or(0o600)
};
let written = (|| -> std::io::Result<()> {
let mut options = std::fs::OpenOptions::new();
options.write(true).create(true).truncate(true);
#[cfg(unix)]
{
use std::os::unix::fs::OpenOptionsExt;
options.mode(mode);
}
let mut file = options.open(&temp)?;
file.write_all(swapped.as_bytes())
})();
if written.is_ok() && std::fs::rename(&temp, &path).is_ok() {
done.push(path);
} else {
let _ = std::fs::remove_file(&temp);
}
}
done
}
fn client_configs() -> Vec<PathBuf> {
let mut out = Vec::new();
if let Ok(home_dir) = home::user_home() {
for rest in [
".claude.json",
".codex/config.toml",
".config/opencode/opencode.json",
".copilot/mcp-config.json",
".gemini/settings.json",
".qwen/settings.json",
".config/amp/settings.json",
".factory/mcp.json",
".config/goose/config.yaml",
".aws/amazonq/mcp.json",
".openclaw/openclaw.json",
".codewhale/mcp.json",
".deepseek/mcp.json",
".warp/.mcp.json",
".cursor/mcp.json",
".codeium/windsurf/mcp_config.json",
".config/zed/settings.json",
".junie/mcp/mcp.json",
".kiro/settings/mcp.json",
".lmstudio/mcp.json",
".cache/lm-studio/mcp.json",
".config/kilo/kilo.jsonc",
".continue/config.yaml",
"Library/Application Support/Claude/claude_desktop_config.json",
"Library/Application Support/Code/User/mcp.json",
".config/Code/User/mcp.json",
] {
out.push(home_dir.join(rest));
}
if let Ok(entries) = std::fs::read_dir(home_dir.join(".continue/mcpServers")) {
out.extend(entries.flatten().map(|e| e.path()));
}
}
if let Some(appdata) = std::env::var_os("APPDATA").map(PathBuf::from) {
out.push(appdata.join("Claude/claude_desktop_config.json"));
out.push(appdata.join("Code/User/mcp.json"));
}
if let Ok(here) = std::env::current_dir() {
for rest in [
".mcp.json",
".vscode/mcp.json",
".cursor/mcp.json",
".roo/mcp.json",
".kilocode/mcp.json",
".kiro/settings/mcp.json",
".junie/mcp/mcp.json",
".factory/mcp.json",
".amazonq/mcp.json",
"opencode.json",
"crush.json",
"io.local.toml",
] {
out.push(here.join(rest));
}
}
out
}
#[cfg(test)]
mod rotation_tests {
use super::*;
#[test]
fn only_the_files_holding_the_old_key_are_rewritten() {
let dir = tempfile::tempdir().unwrap();
let home_dir = dir.path().join("home");
std::fs::create_dir_all(home_dir.join(".gemini")).unwrap();
std::fs::create_dir_all(home_dir.join(".cursor")).unwrap();
let previous = format!("sml_{}", "a".repeat(64));
let fresh = format!("sml_{}", "b".repeat(64));
let carries = home_dir.join(".gemini/settings.json");
std::fs::write(
&carries,
format!("{{\"mcpServers\":{{\"semlith\":{{\"headers\":{{\"Authorization\":\"Bearer {previous}\"}}}}}}}}"),
)
.unwrap();
let untouched = home_dir.join(".cursor/mcp.json");
std::fs::write(&untouched, "{\"mcpServers\":{\"other\":{}}}").unwrap();
let before = std::fs::read_to_string(&untouched).unwrap();
let was = std::env::var_os(home::HOME_VAR);
unsafe { std::env::set_var(home::HOME_VAR, &home_dir) };
let changed = recarry_key(&previous, &fresh);
match was {
Some(v) => unsafe { std::env::set_var(home::HOME_VAR, v) },
None => unsafe { std::env::remove_var(home::HOME_VAR) },
}
assert_eq!(changed, vec![carries.clone()], "{changed:?}");
let text = std::fs::read_to_string(&carries).unwrap();
assert!(
text.contains(&fresh),
"the new key is not in the file: {text}"
);
assert!(!text.contains(&previous), "the old key survived: {text}");
assert_eq!(std::fs::read_to_string(&untouched).unwrap(), before);
assert!(
!carries.with_extension("json.semlith-tmp").exists(),
"the temporary file was left behind"
);
}
#[test]
fn a_non_rotation_changes_nothing() {
let key = format!("sml_{}", "c".repeat(64));
assert!(recarry_key(&key, &key).is_empty());
assert!(recarry_key("sml_YOURKEY", &key).is_empty());
assert!(recarry_key(&key, "").is_empty());
}
}
#[cfg(test)]
mod quoting_tests {
use super::*;
#[test]
fn a_posix_word_is_exactly_the_path() {
assert_eq!(
posix_quote("/home/me/.semlith/bin"),
"'/home/me/.semlith/bin'"
);
assert_eq!(posix_quote("with space"), "'with space'");
assert_eq!(posix_quote("$(touch x)"), "'$(touch x)'");
assert_eq!(posix_quote("`touch x`"), "'`touch x`'");
assert_eq!(posix_quote("${HOME}"), "'${HOME}'");
assert_eq!(posix_quote(r#"a"b"#), r#"'a"b'"#);
assert_eq!(posix_quote(r"back\slash"), r"'back\slash'");
assert_eq!(posix_quote("it's"), r"'it'\''s'");
}
#[test]
fn a_fish_word_escapes_what_fish_reads() {
assert_eq!(fish_quote("/home/me/bin"), "'/home/me/bin'");
assert_eq!(fish_quote("it's"), r"'it\'s'");
assert_eq!(fish_quote(r"back\slash"), r"'back\\slash'");
assert_eq!(fish_quote(r"\'"), r"'\\\''");
}
#[test]
fn a_powershell_word_doubles_its_quotes() {
assert_eq!(powershell_quote(r"C:\Users\me\bin"), r"'C:\Users\me\bin'");
assert_eq!(powershell_quote("it's"), "'it''s'");
assert_eq!(powershell_quote("$(pwd)"), "'$(pwd)'");
}
}