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 rerank_cached: bool,
pub registered_clients: Vec<String>,
pub version: &'static str,
pub install_sh: &'static str,
pub install_ps1: &'static str,
pub steps: Vec<Step>,
}
pub const NO_SERVICE_ENV: &str = "SEMLITH_NO_SERVICE";
pub const INSTALL_SH: &str = "curl --proto '=https' --tlsv1.2 -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 = crate::clientfile::registered_clients();
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 rerank_cached = crate::rerank::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: format!(
"{} — rescoring model {}",
cache.display(),
if rerank_cached { "present" } else { "absent" }
),
},
Step {
name: "agents",
state: if registered.is_empty() {
State::Skipped
} else {
State::AlreadyDone
},
detail: if registered.is_empty() {
"no client on this machine has the semlith server".into()
} else {
format!("{} has the semlith server", registered.join(", "))
},
},
];
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,
rerank_cached,
registered_clients: registered,
version: env!("CARGO_PKG_VERSION"),
install_sh: INSTALL_SH,
install_ps1: INSTALL_PS1,
steps,
}
}
#[allow(clippy::too_many_arguments)]
pub fn run(
yes: bool,
airgap: bool,
register_all: bool,
service: bool,
hooks: bool,
mode: crate::hook::Mode,
agents: bool,
file_managers: Option<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_neural_engine(yes, airgap)?),
announce(step_agents(register_all, yes)?),
announce(step_leftovers()),
announce(step_always_load()?),
announce(step_skill()?),
announce(step_explorer(agents)?),
announce(step_file_managers(file_managers, yes)?),
announce(step_hooks(hooks, mode)?),
announce(step_rules(register_all)?),
announce(step_service(service)?),
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
}
pub(crate) 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) {
let wanted = path_line(&bin)?;
let current = block_body(&existing);
if current.as_deref() == Some(wanted.as_str()) {
return Ok(Step {
name: "path",
state: State::AlreadyDone,
detail: format!("{} already has the semlith block", rc.display()),
});
}
let dropped_key = current
.as_deref()
.is_some_and(|body| body.contains(KEY_ENV));
replace_block(&rc, &existing, &wanted)?;
return Ok(Step {
name: "path",
state: State::Done,
detail: if dropped_key {
format!(
"{} — replaced the block an older version wrote, which exported {KEY_ENV}. \
Nothing reads it now: a registered client launches `semlith mcp`, which reads \
the key from the file itself. Open a new shell to be rid of it.",
rc.display()
)
} else {
format!(
"{} — replaced the block an older version wrote",
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 block_body(text: &str) -> Option<String> {
let start = text.find(BEGIN)? + BEGIN.len();
let end = text[start..].find(END)? + start;
Some(text[start..end].trim_matches('\n').to_string())
}
fn replace_block(rc: &Path, existing: &str, line: &str) -> Result<()> {
let start = existing
.find(BEGIN)
.context("the block's opening fence went missing between reading and writing")?;
let end = existing[start..]
.find(END)
.map(|at| start + at + END.len())
.context("the semlith block has an opening fence and no closing one; fix it by hand")?;
let next = format!(
"{}{BEGIN}\n{line}\n{END}{}",
&existing[..start],
&existing[end..]
);
crate::clientfile::rewrite(rc, &next)
}
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);
Ok(if is_fish {
format!("set -gx PATH {} $PATH", fish_quote(&path))
} else {
format!("export PATH={}:$PATH", posix_quote(&path))
})
}
pub(crate) fn posix_quote(raw: &str) -> String {
format!("'{}'", raw.replace('\'', r"'\''"))
}
fn fish_quote(raw: &str) -> String {
format!("'{}'", raw.replace('\\', r"\\").replace('\'', r"\'"))
}
pub(crate) fn powershell_quote(raw: &str) -> String {
let mut out = String::with_capacity(raw.len() + 2);
out.push('\'');
for c in raw.chars() {
if matches!(c, '\'' | '\u{2018}' | '\u{2019}' | '\u{201A}' | '\u{201B}') {
out.push(c);
}
out.push(c);
}
out.push('\'');
out
}
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(_) => {
let _ = cliclack::log::step(format!(
"model: downloading the rescoring model ({}, ~38 MB)",
crate::rerank::RERANK_NAME
));
if let Err(e) = crate::rerank::load(&cache, false) {
let _ = cliclack::log::warning(format!(
"the rescoring model did not download: {e}. Searches rank by fusion \
alone until `semlith setup` is run again."
));
}
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_neural_engine(yes: bool, airgap: bool) -> Result<Step> {
let name = "neural engine";
if let Some(why) = crate::accel::unavailable_here("ane") {
return Ok(Step {
name,
state: State::Skipped,
detail: why,
});
}
if !crate::accel::enabled().ane {
return Ok(Step {
name,
state: State::Skipped,
detail: "switched off — `semlith accel on ane` turns it on".into(),
});
}
let cache = model_cache_dir()?;
let pack = crate::packs::coreml();
if let Some(dir) = crate::packs::installed(&cache, &pack) {
return Ok(Step {
name,
state: State::AlreadyDone,
detail: dir.display().to_string(),
});
}
if airgap || embed::airgap() {
return Ok(Step {
name,
state: State::Skipped,
detail: format!("--airgap; pre-seed {}", pack.dir(&cache).display()),
});
}
let size = crate::human_bytes(pack.bytes() as i64);
let fetch = yes
|| cliclack::confirm(format!(
"Download the Neural Engine models ({size})? Indexing on this Mac is several times faster with them."
))
.initial_value(true)
.interact()
.unwrap_or(false);
if !fetch {
return Ok(Step {
name,
state: State::Skipped,
detail: "`semlith accel on ane` fetches them later".into(),
});
}
let _ = cliclack::log::step(format!("neural engine: downloading {size}"));
let mut said = 0u8;
match crate::packs::fetch(&cache, &pack, &mut |percent| {
if percent >= said.saturating_add(25) {
said = percent;
let _ = cliclack::log::step(format!("neural engine: {percent} %"));
}
}) {
Ok(dir) => Ok(Step {
name,
state: State::Done,
detail: format!(
"{} — the first run compiles them for this Mac, a few minutes once",
dir.display()
),
}),
Err(e) => Ok(Step {
name,
state: State::Failed,
detail: format!("{e:#} — `semlith accel on ane` tries again"),
}),
}
}
fn step_agents(register_all: bool, yes: bool) -> Result<Step> {
let all = clients::clients();
if all.is_empty() {
return Ok(Step {
name: "agents",
state: State::Skipped,
detail: "no documented clients".into(),
});
}
let mut wired: Vec<&str> = Vec::new();
let mut replaced: Vec<String> = Vec::new();
let mut failed: Vec<(&str, String)> = Vec::new();
let mut absent = 0usize;
let mut by_file: Vec<&clients::Client> = Vec::new();
let mut nowhere: Vec<&str> = Vec::new();
for client in all {
match register(client) {
Registration::Registered { replaced: was } => {
wired.push(&client.name);
replaced.extend(was);
}
Registration::Absent
if client.config_files().any(|stanza| {
crate::clientfile::resolve(stanza)
.is_some_and(|path| crate::clientfile::client_present(&path))
}) =>
{
by_file.push(client)
}
Registration::Absent => absent += 1,
Registration::Failed { reason } => failed.push((&client.name, reason)),
Registration::ProjectScoped => by_file.push(client),
Registration::Unregisterable => nowhere.push(&client.name),
}
}
let extra: Vec<&clients::Client> = all
.iter()
.filter(|client| client.needs_a_file_written())
.filter(|client| !by_file.iter().any(|seen| seen.name == client.name))
.collect();
by_file.extend(extra);
let written = if register_all {
write_client_files(&by_file, yes)?
} else {
for client in &by_file {
print_stanza(client);
}
Vec::new()
};
for (name, reason) in &failed {
let client = all.iter().find(|c| &c.name == name);
let _ = cliclack::log::warning(format!("{name} would not register: {reason}"));
if let Some(client) = client {
print_stanza(client);
}
}
for name in &nowhere {
let _ = cliclack::log::info(format!(
"{name}: semlith cannot register this one — it documents no file outside a project. \
Paste the stanza below."
));
if let Some(client) = all.iter().find(|c| &c.name == name) {
print_stanza(client);
}
}
for was in &replaced {
let _ = cliclack::log::info(format!("replaced an existing entry: {was}"));
}
let mut detail = Vec::new();
if !wired.is_empty() {
detail.push(format!("registered {}", wired.join(", ")));
}
if !written.is_empty() {
detail.push(format!(
"wrote {}",
written
.iter()
.map(|p| p.display().to_string())
.collect::<Vec<_>>()
.join(", ")
));
}
if absent > 0 {
detail.push(format!("{absent} client CLIs are not on this machine"));
}
if !by_file.is_empty() && !register_all {
detail.push(format!(
"{} need `semlith setup --register-all` or the printed stanza",
by_file.len()
));
}
Ok(Step {
name: "agents",
state: if wired.is_empty() && written.is_empty() {
State::Skipped
} else {
State::Done
},
detail: if detail.is_empty() {
"no client CLI on this machine; stanzas printed".into()
} else {
detail.join("; ")
},
})
}
fn step_leftovers() -> Step {
let removed = crate::leftovers::clean();
for line in &removed {
let _ = match line.left {
None => cliclack::log::info(line.to_string()),
Some(_) => cliclack::log::warning(line.to_string()),
};
}
let done = removed.iter().filter(|r| r.left.is_none()).count();
Step {
name: "leftovers",
state: if done > 0 {
State::Done
} else {
State::AlreadyDone
},
detail: if removed.is_empty() {
"nothing left in unsupported clients".into()
} else {
format!(
"removed {done} semlith entr{} from clients semlith no longer supports; \
each file is backed up beside itself as .semlith-backup",
if done == 1 { "y" } else { "ies" }
)
},
}
}
fn write_client_files(clients: &[&clients::Client], yes: bool) -> Result<Vec<PathBuf>> {
let plans = crate::clientfile::plan(clients);
if plans.is_empty() {
return Ok(Vec::new());
}
let listing = plans
.iter()
.map(|plan| plan.to_string())
.collect::<Vec<_>>()
.join("\n");
let _ = cliclack::note("Files semlith would write", listing);
let go = yes
|| cliclack::confirm("Write these files? Each is backed up beside itself first.")
.initial_value(false)
.interact()
.unwrap_or(false);
if !go {
for client in clients {
print_stanza(client);
}
return Ok(Vec::new());
}
let stanzas: Vec<(&str, &clients::Stanza)> = clients
.iter()
.flat_map(|client| {
client
.config_files()
.map(move |stanza| (client.name.as_str(), stanza))
})
.collect();
let written = crate::clientfile::apply(&plans, &stanzas)?;
for plan in &plans {
if let crate::clientfile::Action::Refuse { reason } = &plan.action {
let _ = cliclack::log::warning(format!(
"{}: {} was left alone — {reason}",
plan.client,
plan.path.display()
));
if let Some(client) = clients.iter().find(|c| c.name == plan.client) {
print_stanza(client);
}
}
}
Ok(written)
}
fn print_stanza(client: &clients::Client) {
let mut body = client
.stanzas
.iter()
.filter(|stanza| !stanza.unregister)
.map(|stanza| stanza.text.as_str())
.collect::<Vec<_>>()
.join("\n\n");
body.push_str(&http_suffix());
let _ = cliclack::note(format!("{} — {}", client.name, client.note), body);
}
fn http_suffix() -> String {
let http = crate::clients::http_stanzas(crate::clients::KEY_PLACEHOLDER);
if http.is_empty() {
return String::new();
}
format!(
"\n\nOr over HTTP, against a running `semlith start` (export {KEY_ENV} from \
`semlith key show` first):\n\n{}",
http.iter()
.map(|s| s.text.as_str())
.collect::<Vec<_>>()
.join("\n")
)
}
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
#[serde(tag = "outcome", rename_all = "kebab-case")]
pub enum Registration {
Registered { replaced: Vec<String> },
Absent,
Failed { reason: String },
ProjectScoped,
Unregisterable,
}
pub fn register(client: &clients::Client) -> Registration {
if !client.registers_globally() {
return if client.config_files().next().is_some() {
Registration::ProjectScoped
} else {
Registration::Unregisterable
};
}
let Some(command) = client.register_command() else {
return Registration::Unregisterable;
};
let Some((program, args)) = argv(&command) else {
return Registration::Failed {
reason: format!("{command:?} is not a command semlith could read"),
};
};
let mut replaced = Vec::new();
for undo in client.unregister_commands() {
let Some((program, args)) = argv(&undo) else {
continue;
};
match Command::new(crate::doctor::program_path(&program))
.args(&args)
.output()
{
Ok(out) if out.status.success() => replaced.push(undo),
Ok(_) => {}
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
return Registration::Absent;
}
Err(_) => {}
}
}
match Command::new(crate::doctor::program_path(&program))
.args(&args)
.output()
{
Ok(out) if out.status.success() => Registration::Registered { replaced },
Ok(out) => Registration::Failed {
reason: first_line(&out.stderr, &out.stdout)
.unwrap_or_else(|| format!("{program} exited {}", out.status)),
},
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Registration::Absent,
Err(e) => Registration::Failed {
reason: e.to_string(),
},
}
}
pub fn argv(command: &str) -> Option<(String, Vec<String>)> {
let mut words: Vec<String> = Vec::new();
let mut word = String::new();
let mut started = false;
let mut quote: Option<char> = None;
let mut chars = command.chars();
while let Some(c) = chars.next() {
match (quote, c) {
(Some(q), c) if c == q => quote = None,
(Some(_), c) => word.push(c),
(None, '\'' | '"') => {
quote = Some(c);
started = true;
}
(None, '\\') => word.push(chars.next()?),
(None, c) if c.is_whitespace() => {
if started || !word.is_empty() {
words.push(std::mem::take(&mut word));
started = false;
}
}
(None, c) => word.push(c),
}
}
if quote.is_some() {
return None;
}
if started || !word.is_empty() {
words.push(word);
}
let mut words = words.into_iter();
let program = words.next()?;
Some((program, words.collect()))
}
fn first_line(stderr: &[u8], stdout: &[u8]) -> Option<String> {
[stderr, stdout]
.into_iter()
.flat_map(|bytes| {
String::from_utf8_lossy(bytes)
.lines()
.map(str::trim)
.filter(|line| !line.is_empty())
.map(str::to_string)
.collect::<Vec<_>>()
})
.next()
.map(|line| line.chars().take(200).collect())
}
pub const KEY_ENV: &str = "SEMLITH_AGENT_KEY";
fn step_skill() -> Result<Step> {
let linked = crate::agentfiles::install_skill()?;
if linked.is_empty() {
let held = crate::agentfiles::skill_state()
.into_iter()
.filter(|(_, state)| *state == crate::agentfiles::State::Present)
.count();
return Ok(Step {
name: "skill",
state: if held > 0 {
State::AlreadyDone
} else {
State::Skipped
},
detail: if held > 0 {
format!("linked in {held} skill directories")
} else {
"no client on this machine reads a user-level skill directory".into()
},
});
}
Ok(Step {
name: "skill",
state: State::Done,
detail: format!(
"linked into {}",
linked
.iter()
.map(|p| p.display().to_string())
.collect::<Vec<_>>()
.join(", ")
),
})
}
fn step_always_load() -> Result<Step> {
Ok(match crate::agentfiles::set_always_load()? {
Some((path, true)) => Step {
name: "always load",
state: State::Done,
detail: format!(
"the semlith tools load on the first turn ({})",
path.display()
),
},
Some((_, false)) => Step {
name: "always load",
state: State::AlreadyDone,
detail: "Claude Code loads the semlith tools on the first turn".into(),
},
None => Step {
name: "always load",
state: State::Skipped,
detail: "no user-scope Claude Code entry to upgrade".into(),
},
})
}
fn step_explorer(wanted: bool) -> Result<Step> {
if !wanted {
let removed = crate::agentfiles::remove_explorer()?;
return Ok(Step {
name: "research agent",
state: if removed.is_some() {
State::Done
} else {
State::AlreadyDone
},
detail: match removed {
Some(path) => format!("removed {}", path.display()),
None => "no semlith-explorer agent was installed".into(),
},
});
}
Ok(match crate::agentfiles::install_explorer()? {
Some(path) => Step {
name: "research agent",
state: State::Done,
detail: format!("semlith-explorer for Claude Code at {}", path.display()),
},
None if crate::agentfiles::explorer_installed() => Step {
name: "research agent",
state: State::AlreadyDone,
detail: "semlith-explorer is installed for Claude Code".into(),
},
None => Step {
name: "research agent",
state: State::Skipped,
detail: "Claude Code is not on this machine".into(),
},
})
}
fn step_file_managers(choice: Option<bool>, yes: bool) -> Result<Step> {
const NAME: &str = "file managers";
let env = crate::helpers::Env::current()?;
let before = crate::helpers::status(&env);
let all = !before.is_empty() && before.iter().all(|h| h.installed);
let any = before.iter().any(|h| h.installed);
let labels = |list: &[crate::helpers::Helper]| {
list.iter().map(|h| h.label).collect::<Vec<_>>().join(", ")
};
let wanted = match choice {
Some(wanted) => wanted,
None if all => {
return Ok(Step {
name: NAME,
state: State::AlreadyDone,
detail: format!("{} — `--no-file-managers` removes them", labels(&before)),
});
}
None if yes => false,
None => cliclack::confirm(format!(
"Add \"Index with semlith\" to your file manager ({})?",
labels(&before)
))
.initial_value(false)
.interact()
.unwrap_or(false),
};
if !wanted {
if choice != Some(false) {
return Ok(Step {
name: NAME,
state: State::Skipped,
detail: "opt-in: `semlith setup --file-managers` adds \"Index with semlith\""
.into(),
});
}
crate::helpers::remove(&env)?;
return Ok(Step {
name: NAME,
state: if any { State::Done } else { State::AlreadyDone },
detail: if any {
format!("removed {}", labels(&before))
} else {
"no file-manager helper was installed".into()
},
});
}
let after = crate::helpers::install(&env)?;
Ok(Step {
name: NAME,
state: if all { State::AlreadyDone } else { State::Done },
detail: format!(
"\"Index with semlith\" in {}",
after
.iter()
.map(|h| h.path.as_str())
.collect::<Vec<_>>()
.join(", ")
),
})
}
fn step_hooks(wanted: bool, mode: crate::hook::Mode) -> Result<Step> {
if !wanted {
let removed = crate::agentfiles::remove_hooks()?;
return Ok(Step {
name: "hook",
state: if removed.is_empty() {
State::AlreadyDone
} else {
State::Done
},
detail: if removed.is_empty() {
"no steering hook was installed".into()
} else {
format!(
"removed from {}",
removed
.iter()
.map(|p| p.display().to_string())
.collect::<Vec<_>>()
.join(", ")
)
},
});
}
let written = crate::agentfiles::install_hooks(mode)?;
if written.is_empty() {
return Ok(Step {
name: "hook",
state: State::AlreadyDone,
detail: "already installed, or no client here documents one".into(),
});
}
Ok(Step {
name: "hook",
state: State::Done,
detail: format!(
"{} in {}{}",
mode.as_str(),
written
.iter()
.map(|p| p.display().to_string())
.collect::<Vec<_>>()
.join(", "),
gate_hint(mode)
),
})
}
fn gate_hint(mode: crate::hook::Mode) -> &'static str {
if matches!(mode, crate::hook::Mode::Soft) {
"; `semlith setup --hook-mode gate` makes agents call semlith before grep on code \
(44 of 50 sessions against 0, two more correct answers, about 31 % more cost a session)"
} else {
""
}
}
fn step_rules(register_all: bool) -> Result<Step> {
if !register_all {
return Ok(Step {
name: "rules",
state: State::Skipped,
detail: "`semlith setup --register-all` writes them; `semlith doctor` prints them"
.into(),
});
}
let written = crate::agentfiles::install_rules()?;
Ok(Step {
name: "rules",
state: if written.is_empty() {
State::AlreadyDone
} else {
State::Done
},
detail: if written.is_empty() {
"already written, or no client here documents a rules file".into()
} else {
format!(
"wrote {}",
written
.iter()
.map(|p| p.display().to_string())
.collect::<Vec<_>>()
.join(", ")
)
},
})
}
fn step_service(wanted: bool) -> Result<Step> {
if !wanted {
return Ok(Step {
name: "service",
state: State::Skipped,
detail: "--no-service: start the daemon yourself with `semlith start`".to_string(),
});
}
let target = home::bin_dir()?.join(exe_name());
let binary = target.exists().then_some(target);
let installed = crate::service::status();
if installed.installed
&& names_the_binary(&installed, binary.as_deref())
&& crate::service::stale_definition().is_none()
{
return Ok(match crate::service::restart_if_stale() {
Ok(Some(previous)) => Step {
name: "service",
state: State::Done,
detail: format!(
"restarted the daemon, which was serving {previous}, onto {} ({})",
env!("CARGO_PKG_VERSION"),
installed.mechanism,
),
},
Ok(None) => Step {
name: "service",
state: State::AlreadyDone,
detail: format!(
"running from every login ({}) — remove with `semlith start --no-service`",
installed.mechanism,
),
},
Err(e) => Step {
name: "service",
state: State::Failed,
detail: format!("{e:#}"),
},
});
}
let installed = crate::service::install(binary.as_deref(), None);
let restarted = match &installed {
Ok(status) if status.installed && !status.started_now => crate::service::restart_if_stale(),
_ => Ok(None),
};
if let Err(e) = &restarted {
return Ok(Step {
name: "service",
state: State::Failed,
detail: format!("{e:#}"),
});
}
if let (Ok(status), Ok(Some(previous))) = (&installed, &restarted) {
return Ok(Step {
name: "service",
state: State::Done,
detail: format!(
"restarted the daemon, which was serving {previous}, onto {} ({})",
env!("CARGO_PKG_VERSION"),
status.mechanism,
),
});
}
match installed {
Ok(status) if status.installed => Ok(Step {
name: "service",
state: State::Done,
detail: if status.started_now {
format!(
"running now and from every login ({}) — remove with `semlith start --no-service`",
status.mechanism,
)
} else {
format!(
"a daemon is already listening, so this one starts at the next login ({}) — remove with `semlith start --no-service`",
status.mechanism,
)
},
}),
Ok(status) => Ok(Step {
name: "service",
state: State::Failed,
detail: format!(
"{} did not report the service as installed — start the daemon with `semlith start`",
status.mechanism,
),
}),
Err(e) => Ok(Step {
name: "service",
state: State::Failed,
detail: format!("{e} — start the daemon with `semlith start`"),
}),
}
}
fn names_the_binary(status: &crate::service::Status, binary: Option<&std::path::Path>) -> bool {
let Some(definition) = status.definition.as_deref() else {
return false;
};
let Ok(text) = std::fs::read_to_string(definition) else {
return false;
};
let wanted = match binary {
Some(path) => path.to_path_buf(),
None => match crate::service::exe() {
Ok(exe) => exe,
Err(_) => return false,
},
};
text.contains(&wanted.to_string_lossy().into_owned())
}
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;
}
if crate::clientfile::rewrite(&path, &text.replace(previous, fresh)).is_ok() {
done.push(path);
}
}
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",
".cursor/mcp.json",
".config/zed/settings.json",
".cline/data/settings/cline_mcp_settings.json",
"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 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",
"opencode.json",
"io.local.toml",
] {
out.push(here.join(rest));
}
}
out
}
#[cfg(test)]
mod rotation_tests {
use super::*;
#[cfg(windows)]
#[test]
fn a_client_cli_that_is_a_cmd_shim_is_registered() {
let dir = tempfile::tempdir().unwrap();
let log = dir.path().join("calls.txt");
std::fs::write(
dir.path().join("gemini.cmd"),
format!(
"@echo off\r\necho %*>> \"{}\"\r\nexit /b 0\r\n",
log.display()
),
)
.unwrap();
let system = std::env::var("SystemRoot").unwrap_or_else(|_| r"C:\Windows".into());
let path = std::ffi::OsString::from(format!("{};{system}\\System32", dir.path().display()));
let gemini = crate::clients::clients()
.iter()
.find(|c| c.name == "Gemini CLI")
.expect("documented");
let outcome = home::with_env_var("PATH", &path, || register(gemini));
assert!(
matches!(outcome, Registration::Registered { .. }),
"{outcome:?}"
);
let calls = std::fs::read_to_string(&log).unwrap();
assert!(
calls.contains("mcp add") && calls.contains("semlith"),
"{calls}"
);
}
#[test]
fn a_printed_stanza_carries_the_placeholder_and_no_key() {
let text = http_suffix();
assert!(text.contains("${SEMLITH_AGENT_KEY}"), "{text}");
assert!(!text.contains("sml_"), "{text}");
}
#[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 changed = home::with_env_var(home::HOME_VAR, home_dir.as_os_str(), || {
recarry_key(&previous, &fresh)
});
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)'");
}
}