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 -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,
}
}
pub fn run(
yes: bool,
airgap: bool,
register_all: bool,
service: bool,
hooks: bool,
strict: 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(register_all, yes)?),
announce(step_skill()?),
announce(step_hooks(hooks, strict)?),
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
}
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..]
);
let temp = rc.with_extension("semlith-tmp");
std::fs::write(&temp, &next).with_context(|| format!("writing {}", temp.display()))?;
std::fs::rename(&temp, rc).with_context(|| format!("replacing {}", rc.display()))?;
Ok(())
}
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))
})
}
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(_) => {
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_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 => 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 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");
if let Ok(key) = home::agent_key()
&& !key.is_empty()
{
let http = crate::clients::http_stanzas(&key);
if !http.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);
}
#[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(&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(&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_hooks(wanted: bool, strict: bool) -> 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(strict)?;
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 {}",
if strict { "strict" } else { "nudging" },
written
.iter()
.map(|p| p.display().to_string())
.collect::<Vec<_>>()
.join(", ")
),
})
}
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()) {
return Ok(Step {
name: "service",
state: State::AlreadyDone,
detail: format!(
"running from every login ({}) — remove with `semlith start --no-service`",
installed.mechanism,
),
});
}
match crate::service::install(binary.as_deref(), None) {
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;
}
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)'");
}
}