use crate::home;
use anyhow::Result;
use std::path::{Path, PathBuf};
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
pub enum Repair {
Chmod { path: PathBuf, to: u32 },
}
impl Repair {
pub fn manual(&self) -> String {
match self {
Repair::Chmod { path, to } => format!("chmod {to:o} {}", shell_quote(path)),
}
}
}
#[derive(Debug, Clone, serde::Serialize)]
pub struct Finding {
pub id: &'static str,
pub ok: bool,
pub check: String,
pub applicable: bool,
pub manual: Option<String>,
pub repair: Option<Repair>,
}
#[derive(Debug, Clone, serde::Serialize)]
pub struct Applied {
pub path: PathBuf,
pub was: String,
pub now: String,
pub rechecked_ok: bool,
}
impl std::fmt::Display for Applied {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}: {}, was {}", self.path.display(), self.now, self.was)
}
}
const OWNER_ONLY: u32 = 0o077;
const MODES: bool = cfg!(unix);
pub fn privacy_findings(stores: &[(String, PathBuf)]) -> Vec<Finding> {
let mut out = Vec::new();
let allow_private = std::env::var_os(crate::add::ALLOW_PRIVATE_ENV).is_some();
out.push(Finding {
id: "private addresses",
ok: !allow_private,
check: if allow_private {
format!(
"{} is set, so private addresses are allowed",
crate::add::ALLOW_PRIVATE_ENV
)
} else {
"on".to_string()
},
manual: allow_private.then(|| {
format!(
"unset {} and restart semlith start",
crate::add::ALLOW_PRIVATE_ENV
)
}),
repair: None,
applicable: true,
});
let cache = crate::model_cache_dir().unwrap_or_default();
let cache_ok = crate::embed::check_cache_dir(&cache);
out.push(Finding {
id: "model cache",
ok: cache_ok.is_ok(),
check: match &cache_ok {
Ok(()) => format!("{} is yours alone", cache.display()),
Err(e) => e.to_string(),
},
manual: match (&cache_ok, owned_by_us(&cache)) {
(Err(_), true) => Some(
Repair::Chmod {
path: cache.clone(),
to: 0o700,
}
.manual(),
),
_ => None,
},
repair: match (&cache_ok, owned_by_us(&cache)) {
(Err(_), true) => Some(Repair::Chmod {
path: cache.clone(),
to: 0o700,
}),
_ => None,
},
applicable: true,
});
let rerank_cached = crate::rerank::cached(&cache);
let rerank_off = !crate::rerank::enabled();
out.push(Finding {
id: "rescoring model",
ok: true,
check: match (rerank_cached, rerank_off) {
(true, true) => format!(
"{} is in {} and off; {}=on rescores, at about 124 ms a search",
crate::rerank::RERANK_NAME,
cache.display(),
crate::rerank::RERANK_ENV
),
(true, false) => format!(
"{} is on, pinned at {}",
crate::rerank::RERANK_NAME,
&crate::rerank::RERANK_REVISION[..12]
),
(false, true) => format!(
"{} is not in {} and the stage is off, which is the default",
crate::rerank::RERANK_NAME,
cache.display()
),
(false, false) => format!(
"{}=on but {} is not in {}; searches rank by fusion alone until semlith setup fetches it",
crate::rerank::RERANK_ENV,
crate::rerank::RERANK_NAME,
cache.display()
),
},
manual: (!rerank_cached && !rerank_off).then(|| "semlith setup".to_string()),
repair: None,
applicable: true,
});
let home_dir = home::home_or_error();
let mut loose: Vec<(String, PathBuf, u32)> = Vec::new();
if let Ok(dir) = &home_dir
&& let Some(mode) = home::loose_mode(dir)
{
loose.push((dir.display().to_string(), dir.clone(), mode));
}
for (name, dir) in stores {
if let Some(mode) = home::loose_mode(dir) {
loose.push((name.clone(), dir.clone(), mode));
}
}
out.push(Finding {
id: "directory modes",
ok: home_dir.is_ok() && loose.is_empty(),
check: match (&home_dir, loose.is_empty()) {
(Err(e), _) => e.to_string(),
(Ok(dir), true) if !MODES => {
format!("{} — this platform has no file mode to read", dir.display())
}
(Ok(dir), true) => format!("{} and every open store are 0700", dir.display()),
(Ok(_), false) => loose
.iter()
.map(|(name, _, mode)| format!("{name} is {mode:o}"))
.collect::<Vec<_>>()
.join(", "),
},
manual: (!loose.is_empty()).then(|| {
loose
.iter()
.map(|(_, dir, _)| {
Repair::Chmod {
path: dir.clone(),
to: 0o700,
}
.manual()
})
.collect::<Vec<_>>()
.join(" && ")
}),
repair: loose.first().map(|(_, dir, _)| Repair::Chmod {
path: dir.clone(),
to: 0o700,
}),
applicable: MODES,
});
let key_path = home::agent_key_path().unwrap_or_default();
let key_mode = mode_of(&key_path);
let key_ok = key_mode.is_none_or(|mode| mode & OWNER_ONLY == 0);
out.push(Finding {
id: "agent key",
ok: key_ok,
check: match key_mode {
Some(mode) if mode & OWNER_ONLY == 0 => {
format!("{} is {mode:o}, owner only", key_path.display())
}
Some(mode) => format!(
"{} is {mode:o}, readable by more than you",
key_path.display()
),
None if !MODES => format!(
"{} — this platform has no file mode to read",
key_path.display()
),
None => format!("{} has no mode to read", key_path.display()),
},
manual: (!key_ok).then(|| {
Repair::Chmod {
path: key_path.clone(),
to: 0o600,
}
.manual()
}),
repair: (!key_ok).then(|| Repair::Chmod {
path: key_path.clone(),
to: 0o600,
}),
applicable: MODES,
});
out.push(on_path_finding());
out
}
fn on_path_finding() -> Finding {
let found = semliths_on_path();
let spelled = |semlith: &OnPath| format!("{} ({})", semlith.path.display(), semlith.version);
Finding {
id: "semlith on PATH",
ok: true,
check: match found.as_slice() {
[] => "no semlith on PATH".to_string(),
[only] => spelled(only),
[first, shadowed @ ..] => format!(
"{} is what a bare semlith reaches; also {}",
spelled(first),
shadowed.iter().map(spelled).collect::<Vec<_>>().join(", ")
),
},
manual: (found.len() > 1).then(|| {
found[1..]
.iter()
.map(|semlith| format!("rm {}", shell_quote(&semlith.path)))
.collect::<Vec<_>>()
.join(" && ")
}),
repair: None,
applicable: true,
}
}
pub fn apply(repair: &Repair, stores: &[(String, PathBuf)]) -> Result<Applied> {
let Repair::Chmod { path, to } = repair;
let was = mode_of(path).map_or_else(|| "no mode".to_string(), |mode| format!("{mode:o}"));
if let Some(current) = mode_of(path)
&& to & !current != 0
{
anyhow::bail!(
"{} is {current:o} and the repair would set {to:o}, which grants access rather than removing it. Refused.",
path.display()
);
}
if !owned_by_us(path) {
anyhow::bail!(
"{} belongs to another account. semlith repairs only what it owns.",
path.display()
);
}
set_mode(path, *to)?;
crate::daemon::changes::bump(crate::daemon::changes::Domain::Privacy);
let now = mode_of(path).map_or_else(|| "no mode".to_string(), |mode| format!("{mode:o}"));
let rechecked_ok = privacy_findings(stores)
.iter()
.find(|finding| finding.repair.as_ref().is_some_and(|next| next == repair))
.is_none_or(|finding| finding.ok);
Ok(Applied {
path: path.clone(),
was,
now,
rechecked_ok,
})
}
pub fn apply_all(stores: &[(String, PathBuf)]) -> Vec<Result<Applied>> {
let mut out = Vec::new();
while let Some(repair) = privacy_findings(stores)
.into_iter()
.filter(|finding| !finding.ok)
.find_map(|finding| finding.repair)
{
let applied = apply(&repair, stores);
let failed = applied.is_err();
out.push(applied);
if failed {
break;
}
if out.len() > 64 {
break;
}
}
out
}
fn mode_of(path: &Path) -> Option<u32> {
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
std::fs::metadata(path)
.map(|m| m.permissions().mode() & 0o777)
.ok()
}
#[cfg(not(unix))]
{
let _ = path;
None
}
}
fn set_mode(path: &Path, mode: u32) -> Result<()> {
#[cfg(unix)]
{
use anyhow::Context;
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(path, std::fs::Permissions::from_mode(mode))
.with_context(|| format!("setting {} to {mode:o}", path.display()))?;
Ok(())
}
#[cfg(not(unix))]
{
let _ = (path, mode);
anyhow::bail!("this platform has no file mode to set")
}
}
fn owned_by_us(path: &Path) -> bool {
#[cfg(unix)]
{
use std::os::unix::fs::MetadataExt;
let me = unsafe { libc::getuid() };
std::fs::metadata(path).is_ok_and(|m| m.uid() == me)
}
#[cfg(not(unix))]
{
let _ = path;
true
}
}
fn shell_quote(path: &Path) -> String {
let text = path.display().to_string();
if text
.chars()
.all(|c| c.is_ascii_alphanumeric() || "._-/~".contains(c))
{
return text;
}
format!("'{}'", text.replace('\'', r"'\''"))
}
#[cfg(test)]
mod tests {
use super::*;
#[cfg(unix)]
#[test]
fn a_repair_that_would_widen_access_is_refused() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("key");
std::fs::write(&path, "sml_test").unwrap();
set_mode(&path, 0o600).unwrap();
let widen = Repair::Chmod {
path: path.clone(),
to: 0o644,
};
let refused = apply(&widen, &[]).unwrap_err().to_string();
assert!(
refused.contains("grants access"),
"a widening repair was not refused: {refused}"
);
assert_eq!(mode_of(&path), Some(0o600), "the file was changed anyway");
}
#[cfg(unix)]
#[test]
fn a_repair_is_idempotent_and_reports_both_states() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("key");
std::fs::write(&path, "sml_test").unwrap();
set_mode(&path, 0o644).unwrap();
let narrow = Repair::Chmod {
path: path.clone(),
to: 0o600,
};
let first = apply(&narrow, &[]).unwrap();
assert_eq!(first.was, "644");
assert_eq!(first.now, "600");
assert_eq!(mode_of(&path), Some(0o600));
let second = apply(&narrow, &[]).unwrap();
assert_eq!(second.was, "600", "the second run saw a different state");
assert_eq!(second.now, "600", "the second run changed something");
}
#[test]
fn every_repair_has_the_manual_command_that_matches_it() {
for finding in privacy_findings(&[]) {
match (&finding.repair, &finding.manual) {
(Some(repair), Some(manual)) => assert!(
manual.contains(&repair.manual()),
"{}'s manual step does not contain its repair: {manual}",
finding.id
),
(Some(_), None) => {
panic!("{} offers a repair with no manual step", finding.id)
}
_ => {}
}
}
}
#[test]
fn private_addresses_has_no_repair() {
let finding = privacy_findings(&[])
.into_iter()
.find(|f| f.id == "private addresses")
.expect("the rule is reported");
assert!(
finding.repair.is_none(),
"a rule a daemon cannot repair was given a button"
);
}
#[test]
fn the_mode_rules_are_not_applicable_where_there_is_no_mode() {
for finding in privacy_findings(&[]) {
let is_a_mode_rule = matches!(finding.id, "directory modes" | "agent key");
assert_eq!(
finding.applicable,
!is_a_mode_rule || MODES,
"{} reports applicable = {} on a platform where MODES is {MODES}",
finding.id,
finding.applicable
);
if !finding.applicable {
assert!(
finding.repair.is_none() && finding.manual.is_none(),
"{} offers a repair for something it did not measure",
finding.id
);
assert!(
finding.check.contains("no file mode"),
"{} does not say why it was not measured: {}",
finding.id,
finding.check
);
}
}
}
#[test]
fn a_manual_command_quotes_a_path_that_needs_it() {
let repair = Repair::Chmod {
path: PathBuf::from("/tmp/two words/.semlith"),
to: 0o700,
};
assert_eq!(repair.manual(), "chmod 700 '/tmp/two words/.semlith'");
}
}
#[derive(Debug, Clone, serde::Serialize)]
pub struct ClientReport {
pub name: String,
pub command: Option<String>,
pub present: bool,
pub registered: bool,
pub scope: Option<&'static str>,
pub files: Vec<FileReport>,
pub repair: Option<String>,
pub note: Option<String>,
pub disabled_in: Vec<String>,
pub disabled_here: bool,
pub explain: Option<String>,
pub fault: bool,
pub skill: crate::agentfiles::State,
pub hook: crate::agentfiles::State,
pub rules: crate::agentfiles::State,
pub in_use: bool,
}
#[derive(Debug, Clone, serde::Serialize)]
pub struct FileReport {
pub path: PathBuf,
pub exists: bool,
pub parses: bool,
pub names_semlith: bool,
}
fn skill_state_for(client: &crate::clients::Client) -> crate::agentfiles::State {
use crate::agentfiles::State;
let dirs: Vec<PathBuf> = client
.skill_dirs()
.filter_map(crate::clientfile::resolve)
.collect();
if dirs.is_empty() {
return State::Paste;
}
let states = crate::agentfiles::skill_state();
for wanted in &dirs {
match states
.iter()
.find(|(at, _)| at.parent() == Some(wanted.as_path()))
{
Some((_, State::Present)) => {}
Some((_, state)) => return *state,
None => return State::Absent,
}
}
State::Present
}
fn hook_state_for(client: &crate::clients::Client) -> crate::agentfiles::State {
use crate::agentfiles::State;
if client.hook_stanza().is_none() {
return State::Paste;
}
let found = |strict: bool| {
crate::agentfiles::hook_state(strict)
.into_iter()
.find(|(name, _, _)| name == &client.name)
.map(|(_, _, state)| state)
.unwrap_or(State::Absent)
};
match (found(false), found(true)) {
(State::Present, _) | (_, State::Present) => State::Present,
(State::Stale, _) | (_, State::Stale) => State::Stale,
_ => State::Absent,
}
}
fn rules_state_for(client: &crate::clients::Client) -> crate::agentfiles::State {
crate::agentfiles::rules_state()
.into_iter()
.find(|(name, _, _)| name == &client.name)
.map(|(_, _, state)| state)
.unwrap_or(crate::agentfiles::State::Paste)
}
#[derive(Debug, Clone, serde::Serialize)]
pub struct Rewritten {
pub client: String,
pub path: PathBuf,
pub was: String,
pub now: String,
}
pub fn repair_bare_commands() -> Vec<Rewritten> {
if running_from_a_build_directory() {
return Vec::new();
}
bare_entries()
.into_iter()
.filter(|(client, stanza, path, ..)| {
crate::clientfile::apply(
&[crate::clientfile::Plan {
client: client.name.clone(),
path: path.clone(),
action: crate::clientfile::Action::Merge,
}],
&[(client.name.as_str(), stanza)],
)
.is_ok()
})
.map(|(client, _, path, was, now)| Rewritten {
client: client.name.clone(),
path,
was,
now,
})
.collect()
}
fn running_from_a_build_directory() -> bool {
let Ok(exe) = std::env::current_exe() else {
return false;
};
let Some(profile) = exe.parent() else {
return false;
};
let named = profile
.file_name()
.and_then(|n| n.to_str())
.is_some_and(|n| n == "debug" || n == "release");
named
&& profile
.parent()
.is_some_and(|target| target.file_name().and_then(|n| n.to_str()) == Some("target"))
&& profile
.parent()
.and_then(Path::parent)
.is_some_and(|root| root.join("Cargo.toml").is_file())
}
#[allow(clippy::type_complexity)]
fn bare_entries() -> Vec<(
&'static crate::clients::Client,
&'static crate::clients::Stanza,
PathBuf,
String,
String,
)> {
let mut out = Vec::new();
for client in crate::clients::clients() {
for stanza in client.config_files() {
if stanza.format != "json" {
continue;
}
let Some(path) = crate::clientfile::resolve(stanza) else {
continue;
};
let Ok(text) = std::fs::read_to_string(&path) else {
continue;
};
let (Ok(existing), Ok(documented)) = (
serde_json::from_str::<serde_json::Value>(&text),
serde_json::from_str::<serde_json::Value>(&stanza.text),
) else {
continue;
};
let Some(site) = command_site(&documented) else {
continue;
};
let (Some(was), Some(now)) =
(command_at(&existing, &site), command_at(&documented, &site))
else {
continue;
};
if was == now || was.contains('/') || was.contains('\\') {
continue;
}
out.push((client, stanza, path, was, now));
}
}
out
}
fn command_site(value: &serde_json::Value) -> Option<Vec<String>> {
let object = value.as_object()?;
if object.get("command").and_then(|c| c.as_str()).is_some() {
return Some(Vec::new());
}
object.iter().find_map(|(key, child)| {
let mut rest = command_site(child)?;
rest.insert(0, key.clone());
Some(rest)
})
}
fn command_at(value: &serde_json::Value, site: &[String]) -> Option<String> {
let mut at = value;
for key in site {
at = at.get(key)?;
}
at.get("command")?.as_str().map(str::to_string)
}
pub fn clients_report() -> Vec<ClientReport> {
let rewritten = repair_bare_commands();
report(&rewritten)
}
pub fn clients_report_read_only() -> Vec<ClientReport> {
report(&[])
}
fn report(rewritten: &[Rewritten]) -> Vec<ClientReport> {
let still_bare = bare_entries();
let project_scoped = claude_project_entries();
let switched_off = claude_disabled_entries();
let here = std::env::current_dir().ok();
crate::clients::clients()
.iter()
.map(|client| {
let command = client
.register_command()
.and_then(|c| c.split_whitespace().next().map(str::to_string));
let present = command.as_deref().is_some_and(on_path);
let files: Vec<FileReport> = client
.config_files()
.filter_map(|stanza| crate::clientfile::resolve(stanza).map(|p| (stanza, p)))
.map(|(stanza, path)| {
let text = std::fs::read_to_string(&path).ok();
FileReport {
exists: text.is_some(),
parses: match (&text, stanza.format.as_str()) {
(None, _) => true,
(Some(text), "json") => {
serde_json::from_str::<serde_json::Value>(text).is_ok()
}
_ => true,
},
names_semlith: text.as_deref().is_some_and(|t| t.contains("semlith")),
path,
}
})
.collect();
let in_a_file = files.iter().any(|f| f.names_semlith);
let only_project = client.name == "Claude Code" && !project_scoped.is_empty();
let registered = in_a_file || (client.name == "Claude Code" && claude_user_entry());
let note = if crate::clients::UNREGISTERABLE.contains(&client.name.as_str()) {
Some(client.note.clone())
} else {
None
};
let in_use = present || files.iter().any(|f| f.exists);
let switched_off_here: Vec<(String, &'static str)> = if client.name == "Claude Code" {
switched_off.clone()
} else {
Vec::new()
};
let disabled_by = here.as_deref().and_then(|cwd| {
switched_off_here
.iter()
.find(|(dir, _)| same_place(dir, cwd))
.map(|(_, key)| *key)
});
let disabled_in: Vec<String> = switched_off_here
.iter()
.map(|(dir, _)| dir.clone())
.collect();
let disabled_here = disabled_by.is_some();
let repaired = rewritten.iter().find(|r| r.client == client.name);
let bare = still_bare.iter().find(|(c, ..)| c.name == client.name);
let fault =
note.is_none() && in_use && (!registered || disabled_here || bare.is_some());
let repair = if note.is_some() || !in_use {
None
} else if disabled_here {
Some(
"claude mcp reset-project-choices # or turn semlith back on from inside the client with /mcp"
.to_string(),
)
} else if let Some((_, _, path, ..)) = bare {
Some(format!(
"semlith doctor # rewrites the semlith entry in {}",
path.display()
))
} else if only_project && !registered {
Some(format!(
"semlith setup # removes {} project-scope registration(s) and registers at user scope",
project_scoped.len()
))
} else if !registered && client.needs_a_file_written() {
Some(format!(
"semlith setup --register-all # {} is registered by writing its config file, which plain semlith setup does not do",
client.name
))
} else if !registered && present {
Some("semlith setup".to_string())
} else {
None
};
ClientReport {
skill: skill_state_for(client),
hook: hook_state_for(client),
rules: rules_state_for(client),
name: client.name.clone(),
command,
present,
registered,
fault,
scope: match (registered, only_project) {
(true, _) => Some("user"),
(false, true) => Some("project"),
(false, false) => None,
},
files,
repair,
note,
in_use,
explain: if let Some(key) = disabled_by {
Some(format!(
"registered at user scope and switched off for this directory: {} has \
projects[\"{}\"].{key} containing \"semlith\". \
Every other directory sees the server; this one sees nothing, \
and nothing says so.",
claude_config_path()
.map(|p| p.display().to_string())
.unwrap_or_else(|| "~/.claude.json".to_string()),
here.as_deref().unwrap_or(Path::new(".")).display(),
))
} else if let Some(r) = repaired {
Some(format!(
"repaired {}: the semlith entry named {}, which launches only from a \
PATH that carries it, and now names {}. The file it replaced is \
beside it as {}{}.",
r.path.display(),
r.was,
r.now,
r.path.display(),
crate::clientfile::BACKUP,
))
} else if let Some((_, _, path, was, now)) = bare {
Some(format!(
"{} names the semlith entry as {was}, which launches only from a PATH \
that carries it — and the process that reads this file is very often \
not a login shell. It has not been rewritten to {now}.",
path.display(),
))
} else {
None
},
disabled_in,
disabled_here,
}
})
.collect()
}
fn claude_project_entries() -> Vec<String> {
let Some(config) = claude_config() else {
return Vec::new();
};
config
.get("projects")
.and_then(|p| p.as_object())
.map(|projects| {
projects
.iter()
.filter(|(_, value)| {
value
.get("mcpServers")
.and_then(|s| s.get("semlith"))
.is_some()
})
.map(|(path, _)| path.clone())
.collect()
})
.unwrap_or_default()
}
const DISABLED_KEYS: [&str; 2] = ["disabledMcpjsonServers", "disabledMcpServers"];
fn claude_disabled_entries() -> Vec<(String, &'static str)> {
let Some(config) = claude_config() else {
return Vec::new();
};
let Some(projects) = config.get("projects").and_then(|p| p.as_object()) else {
return Vec::new();
};
projects
.iter()
.filter_map(|(path, value)| {
let holding = DISABLED_KEYS.iter().find(|key| {
value
.get(**key)
.and_then(|d| d.as_array())
.is_some_and(|off| off.iter().any(|name| name.as_str() == Some("semlith")))
})?;
Some((path.clone(), *holding))
})
.collect()
}
fn same_place(written: &str, actual: &Path) -> bool {
same_file(Path::new(written), actual)
}
fn claude_config_path() -> Option<PathBuf> {
Some(home::user_home().ok()?.join(".claude.json"))
}
fn claude_user_entry() -> bool {
claude_config().is_some_and(|config| {
config
.get("mcpServers")
.and_then(|s| s.get("semlith"))
.is_some()
})
}
fn claude_config() -> Option<serde_json::Value> {
serde_json::from_str(&std::fs::read_to_string(claude_config_path()?).ok()?).ok()
}
fn on_path(program: &str) -> bool {
if program.contains('/') || program.contains('\\') {
return Path::new(program).exists();
}
path_dirs()
.iter()
.any(|dir| candidate_in(dir, program).is_some())
}
fn every_on_path(program: &str) -> Vec<PathBuf> {
let mut out: Vec<PathBuf> = Vec::new();
for dir in path_dirs() {
if let Some(found) = candidate_in(&dir, program)
&& !out.iter().any(|seen| same_file(seen, &found))
{
out.push(found);
}
}
out
}
fn path_dirs() -> Vec<PathBuf> {
let paths = std::env::var_os("PATH").unwrap_or_default();
std::env::split_paths(&paths).collect()
}
fn candidate_in(dir: &Path, program: &str) -> Option<PathBuf> {
let bare = dir.join(program);
if bare.is_file() {
return Some(bare);
}
let exts = std::env::var_os("PATHEXT")?;
std::env::split_paths(&exts)
.map(|ext| dir.join(format!("{program}{}", ext.display())))
.find(|candidate| candidate.is_file())
}
fn same_file(a: &Path, b: &Path) -> bool {
match (std::fs::canonicalize(a), std::fs::canonicalize(b)) {
(Ok(a), Ok(b)) => a == b,
_ => a == b,
}
}
#[derive(Debug, Clone, serde::Serialize)]
pub struct OnPath {
pub path: PathBuf,
pub version: String,
}
pub fn semliths_on_path() -> Vec<OnPath> {
let running = std::env::current_exe().ok();
every_on_path("semlith")
.into_iter()
.map(|path| OnPath {
version: match &running {
Some(running) if same_file(running, &path) => {
format!("semlith {}", env!("CARGO_PKG_VERSION"))
}
_ => version_of(&path),
},
path,
})
.collect()
}
fn version_of(path: &Path) -> String {
let Ok(out) = std::process::Command::new(path).arg("--version").output() else {
return "did not run".to_string();
};
let stdout = String::from_utf8_lossy(&out.stdout).trim().to_string();
let said = if stdout.is_empty() {
String::from_utf8_lossy(&out.stderr).trim().to_string()
} else {
stdout
};
said.lines().next().unwrap_or("said nothing").to_string()
}
#[derive(Debug, Clone, serde::Serialize)]
pub struct Proof {
pub command: String,
pub launched: bool,
pub tools: Option<usize>,
pub client: Option<ClientVerdict>,
pub failed: Option<String>,
}
fn claude_registered_command() -> Option<String> {
claude_config()?
.get("mcpServers")?
.get("semlith")?
.get("command")?
.as_str()
.map(str::to_string)
}
#[derive(Debug, Clone, serde::Serialize)]
pub struct ClientVerdict {
pub name: &'static str,
pub says: String,
pub connected: bool,
}
pub fn proof() -> Proof {
let command =
claude_registered_command().unwrap_or_else(|| crate::clients::binary_path().to_string());
let (launched, tools) = launches(&command);
let client = claude_verdict();
let failed = if !launched {
Some(format!(
"{command} is what a client would run, and it does not launch from a plain PATH. Show it with: env -i PATH=/usr/bin:/bin:/usr/local/bin HOME=\"$HOME\" sh -c '{command} mcp'. Repair it with: semlith setup"
))
} else if tools.is_none_or(|n| n == 0) {
Some(format!(
"the server launched and listed no tools. Show it with: \
printf '%s\\n' '{{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"tools/list\",\"params\":{{}}}}' \
| {command} mcp"
))
} else {
client.as_ref().filter(|v| !v.connected).map(|v| {
format!(
"{} launched semlith and did not connect to it: {}. \
Show it with: claude mcp get semlith",
v.name, v.says,
)
})
};
Proof {
command,
launched,
tools,
client,
failed,
}
}
fn launches(command: &str) -> (bool, Option<usize>) {
use std::io::Write;
let mut child = match std::process::Command::new(command)
.arg("mcp")
.env_clear()
.env("PATH", "/usr/bin:/bin:/usr/local/bin")
.env("HOME", home::user_home().unwrap_or_default())
.env("SEMLITH_AIRGAP", "1")
.stdin(std::process::Stdio::piped())
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::null())
.spawn()
{
Ok(child) => child,
Err(_) => return (false, None),
};
if let Some(stdin) = child.stdin.as_mut() {
let _ = writeln!(
stdin,
r#"{{"jsonrpc":"2.0","id":1,"method":"initialize","params":{{"protocolVersion":"2024-11-05","capabilities":{{}},"clientInfo":{{"name":"semlith-doctor","version":"1"}}}}}}"#
);
let _ = writeln!(
stdin,
r#"{{"jsonrpc":"2.0","id":2,"method":"tools/list","params":{{}}}}"#
);
}
drop(child.stdin.take());
let Ok(done) = child.wait_with_output() else {
return (false, None);
};
let mut launched = false;
let mut tools = None;
for line in String::from_utf8_lossy(&done.stdout).lines() {
let Ok(value) = serde_json::from_str::<serde_json::Value>(line) else {
continue;
};
match value.get("id").and_then(serde_json::Value::as_u64) {
Some(1) => launched = value.get("result").is_some(),
Some(2) => {
tools = value["result"]["tools"].as_array().map(Vec::len);
}
_ => {}
}
}
(launched, tools)
}
fn claude_verdict() -> Option<ClientVerdict> {
if !on_path("claude") {
return None;
}
let neutral = std::env::temp_dir();
let out = std::process::Command::new("claude")
.args(["mcp", "get", "semlith"])
.current_dir(neutral)
.output()
.ok()?;
let said = String::from_utf8_lossy(&out.stdout);
let status = said
.lines()
.find(|line| line.trim_start().starts_with("Status:"))
.map(|line| line.trim().to_string())
.unwrap_or_else(|| {
said.lines()
.next()
.unwrap_or("no answer")
.trim()
.to_string()
});
Some(ClientVerdict {
name: "Claude Code",
connected: status.contains("Connected"),
says: status,
})
}