#![cfg(unix)]
mod common;
use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
use std::process::{Command, Output};
const BEGIN: &str = "# >>> semlith >>>";
struct Machine {
_dir: tempfile::TempDir,
home: PathBuf,
store_home: PathBuf,
cache: PathBuf,
}
impl Machine {
fn new() -> Self {
let dir = tempfile::tempdir().expect("a temp directory");
let home = dir.path().join("home");
let cache = dir.path().join("cache");
std::fs::create_dir_all(&home).unwrap();
std::fs::create_dir_all(&cache).unwrap();
std::fs::write(cache.join("seeded"), b"weights would be here").unwrap();
std::fs::write(home.join(".zshrc"), "# a shell rc somebody already owns\n").unwrap();
Self {
store_home: home.join(".semlith"),
home,
cache,
_dir: dir,
}
}
fn setup_without_shell(&self, args: &[&str]) -> Output {
let mut command = Command::new(env!("CARGO_BIN_EXE_semlith"));
command
.arg("setup")
.arg("--no-service")
.args(args)
.env("HOME", &self.home)
.env_remove("SHELL")
.env("SEMLITH_HOME", &self.store_home)
.env("SEMLITH_MODEL_CACHE", &self.cache)
.env("PATH", "/usr/bin:/bin")
.stdin(std::process::Stdio::null());
command.output().expect("running semlith setup")
}
fn setup(&self, args: &[&str]) -> Output {
Command::new(env!("CARGO_BIN_EXE_semlith"))
.arg("setup")
.arg("--no-service")
.args(args)
.env("HOME", &self.home)
.env("SHELL", "/bin/zsh")
.env("SEMLITH_HOME", &self.store_home)
.env("SEMLITH_MODEL_CACHE", &self.cache)
.env("PATH", "/usr/bin:/bin")
.stdin(std::process::Stdio::null())
.output()
.expect("running semlith setup")
}
fn doctor(&self, args: &[&str]) -> Output {
Command::new(env!("CARGO_BIN_EXE_semlith"))
.arg("doctor")
.args(args)
.env("HOME", &self.home)
.env("SHELL", "/bin/zsh")
.env("SEMLITH_HOME", &self.store_home)
.env("SEMLITH_MODEL_CACHE", &self.cache)
.env("PATH", "/usr/bin:/bin")
.stdin(std::process::Stdio::null())
.output()
.expect("running semlith doctor")
}
fn said(out: &Output) -> String {
format!(
"{}{}",
String::from_utf8_lossy(&out.stdout),
String::from_utf8_lossy(&out.stderr)
)
}
fn rc(&self) -> String {
std::fs::read_to_string(self.home.join(".zshrc")).expect("the rc file")
}
}
fn snapshot(root: &Path) -> BTreeMap<PathBuf, Vec<u8>> {
let mut out = BTreeMap::new();
let mut stack = vec![root.to_path_buf()];
while let Some(dir) = stack.pop() {
let Ok(entries) = std::fs::read_dir(&dir) else {
continue;
};
for entry in entries.flatten() {
let path = entry.path();
if path.is_dir() {
stack.push(path);
} else if let Ok(bytes) = std::fs::read(&path) {
out.insert(path, bytes);
}
}
}
out
}
#[test]
fn a_second_run_changes_nothing() {
let machine = Machine::new();
let first = machine.setup(&["--yes", "--airgap"]);
assert!(
first.status.success(),
"the first `semlith setup --yes --airgap` exited {:?}:\n{}",
first.status.code(),
String::from_utf8_lossy(&first.stderr)
);
assert_eq!(
machine.rc().matches(BEGIN).count(),
1,
"the first run should leave exactly one semlith block in the rc file, got:\n{}",
machine.rc()
);
let before = snapshot(&machine.home);
let second = machine.setup(&["--yes", "--airgap"]);
assert!(
second.status.success(),
"the second run exited {:?}:\n{}",
second.status.code(),
String::from_utf8_lossy(&second.stderr)
);
assert_eq!(
machine.rc().matches(BEGIN).count(),
1,
"the second run added a second semlith block:\n{}",
machine.rc()
);
assert_eq!(
snapshot(&machine.home),
before,
"the second run changed a file; setup is meant to be a no-op once it has run"
);
let said = Machine::said(&second);
assert!(
said.contains("Nothing changed"),
"the second run should say it changed nothing, it said:\n{said}"
);
}
#[test]
fn the_users_own_rc_file_survives() {
let machine = Machine::new();
let original = machine.rc();
let run = machine.setup(&["--yes", "--airgap"]);
assert!(run.status.success());
let after = machine.rc();
assert!(
after.starts_with(&original),
"setup rewrote what was already in the rc file.\nbefore:\n{original}\nafter:\n{after}"
);
assert!(
after.contains(".semlith/bin"),
"the block should put the bin directory on PATH:\n{after}"
);
}
#[test]
fn yes_completes_with_stdin_closed_and_writes_no_client_file() {
let machine = Machine::new();
let run = machine.setup(&["--yes", "--airgap"]);
assert!(
run.status.success(),
"`--yes` with stdin closed exited {:?}",
run.status.code()
);
let said = Machine::said(&run);
assert!(
said.contains("agents"),
"`--yes` should report the agents step, it said:\n{said}"
);
for name in [".cursor", ".codeium", ".continue", ".lmstudio", ".warp"] {
assert!(
!machine.home.join(name).exists(),
"`--yes` wrote {name}, which it may not do without --register-all"
);
}
}
#[test]
fn airgap_with_an_empty_cache_skips_the_model_and_exits_zero() {
let machine = Machine::new();
std::fs::remove_file(machine.cache.join("seeded")).unwrap();
let run = machine.setup(&["--yes", "--airgap"]);
assert!(
run.status.success(),
"--airgap with an empty cache should still exit zero, it exited {:?}",
run.status.code()
);
let said = Machine::said(&run);
assert!(
said.contains("--airgap") && said.contains(machine.cache.to_str().unwrap()),
"the skip note should name --airgap and the cache to pre-seed:\n{said}"
);
}
#[test]
fn an_unset_shell_still_gets_a_path_block() {
let machine = Machine::new();
let run = machine.setup_without_shell(&["--yes", "--airgap"]);
assert!(
run.status.success(),
"setup with no SHELL exited {:?}:\n{}",
run.status.code(),
Machine::said(&run)
);
let profile = machine.home.join(".profile");
let written = std::fs::read_to_string(&profile)
.unwrap_or_else(|e| panic!("setup wrote no {}: {e}", profile.display()));
assert!(
written.contains(BEGIN) && written.contains(".semlith/bin"),
"the fallback rc file has no semlith block:\n{written}"
);
let said = Machine::said(&run);
assert!(
!said.contains("no HOME"),
"setup blamed a missing HOME when only SHELL was unset:\n{said}"
);
}
#[test]
fn a_hostile_store_home_is_quoted_into_the_rc_file_or_refused() {
let awkward = r#"se"m'l $(touch pwned) `touch also-pwned` ${HOME} \ith"#;
let dir = tempfile::tempdir().expect("a temp directory");
let home = dir.path().join("home");
let cache = dir.path().join("cache");
std::fs::create_dir_all(&home).unwrap();
std::fs::create_dir_all(&cache).unwrap();
std::fs::write(cache.join("seeded"), b"weights would be here").unwrap();
std::fs::write(home.join(".zshrc"), "# a shell rc somebody already owns\n").unwrap();
let store_home = dir.path().join(awkward);
std::fs::create_dir_all(store_home.join("bin")).unwrap();
let out = Command::new(env!("CARGO_BIN_EXE_semlith"))
.arg("setup")
.arg("--yes")
.arg("--airgap")
.arg("--no-service")
.env("HOME", &home)
.env("SHELL", "/bin/zsh")
.env("SEMLITH_HOME", &store_home)
.env("SEMLITH_MODEL_CACHE", &cache)
.env("PATH", "/usr/bin:/bin")
.stdin(std::process::Stdio::null())
.output()
.expect("running semlith setup");
assert!(
out.status.success(),
"setup failed on an awkward home:\n{}{}",
String::from_utf8_lossy(&out.stdout),
String::from_utf8_lossy(&out.stderr)
);
let rc = home.join(".zshrc");
let text = std::fs::read_to_string(&rc).expect("the rc file");
assert!(text.contains(BEGIN), "no block was written:\n{text}");
let checked = Command::new("sh")
.arg("-n")
.arg(&rc)
.output()
.expect("running sh -n");
assert!(
checked.status.success(),
"the rc file is not valid shell:\n{}\n---\n{}",
String::from_utf8_lossy(&checked.stderr),
text
);
assert!(!dir.path().join("pwned").exists(), "a substitution ran");
assert!(!dir.path().join("also-pwned").exists(), "a backtick ran");
let sourced = Command::new("sh")
.arg("-c")
.arg(format!(
". {} >/dev/null 2>&1; printf %s \"$PATH\"",
shell_word(&rc)
))
.env("PATH", "/usr/bin:/bin")
.env("HOME", &home)
.output()
.expect("sourcing the rc file");
let path = String::from_utf8_lossy(&sourced.stdout).into_owned();
let want = store_home.join("bin");
assert!(
path.split(':').any(|entry| Path::new(entry) == want),
"PATH does not carry {}:\n{path}",
want.display()
);
assert!(
!dir.path().join("pwned").exists(),
"a substitution ran on source"
);
let newline_home = dir.path().join("two\nlines");
std::fs::create_dir_all(newline_home.join("bin")).unwrap();
std::fs::write(&rc, "# a shell rc somebody already owns\n").unwrap();
let refused = Command::new(env!("CARGO_BIN_EXE_semlith"))
.arg("setup")
.arg("--yes")
.arg("--airgap")
.arg("--no-service")
.env("HOME", &home)
.env("SHELL", "/bin/zsh")
.env("SEMLITH_HOME", &newline_home)
.env("SEMLITH_MODEL_CACHE", &cache)
.env("PATH", "/usr/bin:/bin")
.stdin(std::process::Stdio::null())
.output()
.expect("running semlith setup");
let said = format!(
"{}{}",
String::from_utf8_lossy(&refused.stdout),
String::from_utf8_lossy(&refused.stderr)
);
assert!(
said.contains("newline") && said.contains("SEMLITH_HOME"),
"a newline in the home should be refused naming the variable:\n{said}"
);
assert_eq!(
std::fs::read_to_string(&rc).unwrap(),
"# a shell rc somebody already owns\n",
"the rc file was edited despite the refusal"
);
}
fn shell_word(path: &Path) -> String {
format!("'{}'", path.display().to_string().replace('\'', r"'\''"))
}
#[test]
fn the_key_never_reaches_a_command_line_or_a_config_file() {
let m = Machine::new();
let out = m.setup(&["--yes", "--airgap"]);
assert!(out.status.success(), "{}", Machine::said(&out));
let rc = std::fs::read_to_string(m.home.join(".zshrc")).expect("the rc file");
assert!(
!rc.contains("SEMLITH_AGENT_KEY"),
"the rc block still exports the key:\n{rc}"
);
assert!(
!rc.contains("sml_"),
"the rc file carries a literal key:\n{rc}"
);
let checked = Command::new("sh")
.arg("-n")
.arg(m.home.join(".zshrc"))
.output();
assert!(
checked.expect("running sh -n").status.success(),
"the rc file is not valid shell:\n{rc}"
);
}
#[test]
fn a_rotation_never_loosens_a_config_file() {
use std::os::unix::fs::PermissionsExt;
let m = Machine::new();
let config = m
.home
.join(".codeium")
.join("windsurf")
.join("mcp_config.json");
std::fs::create_dir_all(config.parent().unwrap()).unwrap();
let old = format!("sml_{}", "a".repeat(64));
let fresh = format!("sml_{}", "b".repeat(64));
std::fs::write(&config, format!(r#"{{"key":"{old}"}}"#)).unwrap();
std::fs::set_permissions(&config, std::fs::Permissions::from_mode(0o600)).unwrap();
let before = std::fs::metadata(&config).unwrap().permissions().mode() & 0o777;
assert_eq!(before, 0o600);
let was = std::env::var_os("HOME");
unsafe { std::env::set_var("HOME", &m.home) };
let changed = semlith::setup::recarry_key(&old, &fresh);
match was {
Some(v) => unsafe { std::env::set_var("HOME", v) },
None => unsafe { std::env::remove_var("HOME") },
}
assert_eq!(changed, vec![config.clone()], "{changed:?}");
let after = std::fs::metadata(&config).unwrap().permissions().mode() & 0o777;
assert_eq!(
after, 0o600,
"the rotation loosened the file from {before:o} to {after:o}"
);
assert!(std::fs::read_to_string(&config).unwrap().contains(&fresh));
}
fn writable_clients() -> Vec<(String, String)> {
let mut out = Vec::new();
for client in semlith::clients::clients() {
if !client.needs_a_file_written() {
continue;
}
for stanza in client.config_files() {
let Some(path) = stanza.path.as_deref() else {
continue;
};
let applies = match stanza.os.as_deref() {
Some("windows") => cfg!(windows),
Some("macos") => cfg!(target_os = "macos"),
Some("linux") => cfg!(target_os = "linux"),
_ => true,
};
if applies && let Some(rest) = path.strip_prefix("~/") {
out.push((client.name.clone(), rest.to_string()));
}
}
}
out
}
#[test]
fn register_all_writes_every_file_only_client_and_is_idempotent() {
let machine = Machine::new();
let expected = writable_clients();
let writable = semlith::clients::clients()
.iter()
.filter(|client| client.needs_a_file_written())
.count();
assert_eq!(writable, 10, "ten clients have no registration command");
assert!(
expected.len() >= 9,
"only {} writable client paths resolve on this platform: {expected:?}",
expected.len()
);
let cursor = machine.home.join(".cursor/mcp.json");
std::fs::create_dir_all(cursor.parent().unwrap()).unwrap();
std::fs::write(
&cursor,
r#"{"mcpServers":{"other":{"command":"other","args":["serve"]}}}"#,
)
.unwrap();
let run = machine.setup(&["--yes", "--airgap", "--register-all"]);
assert!(
run.status.success(),
"--register-all exited {:?}:\n{}",
run.status.code(),
Machine::said(&run)
);
for (client, relative) in &expected {
let path = machine.home.join(relative);
assert!(
path.exists(),
"{client}'s file was not written at {}",
path.display()
);
let text = std::fs::read_to_string(&path).unwrap();
assert!(
text.contains("semlith"),
"{client}'s file has no semlith entry:\n{text}"
);
assert!(
!text.contains("sml_") && !text.contains("SEMLITH_AGENT_KEY"),
"{client}'s file carries a credential:\n{text}"
);
}
let merged = std::fs::read_to_string(&cursor).unwrap();
let parsed: serde_json::Value = serde_json::from_str(&merged).expect("the merge is valid JSON");
assert_eq!(
parsed["mcpServers"]["other"]["command"], "other",
"the merge dropped a server that was already there:\n{merged}"
);
let written = parsed["mcpServers"]["semlith"]["command"]
.as_str()
.expect("the merged entry has a command");
assert!(
std::path::Path::new(written).is_absolute(),
"the merge wrote `{written}`, which launches only from a PATH that carries it"
);
assert!(
written.ends_with("semlith") || written.ends_with("semlith.exe"),
"the merge wrote a command that is not semlith: {written}"
);
assert!(
machine
.home
.join(".cursor/mcp.json.semlith-backup")
.exists(),
"a file that already existed was written with no backup beside it"
);
let before: BTreeMap<String, String> = expected
.iter()
.map(|(_, relative)| {
let path = machine.home.join(relative);
(
relative.clone(),
std::fs::read_to_string(&path).unwrap_or_default(),
)
})
.collect();
let again = machine.setup(&["--yes", "--airgap", "--register-all"]);
assert!(again.status.success());
for (relative, text) in &before {
assert_eq!(
&std::fs::read_to_string(machine.home.join(relative)).unwrap_or_default(),
text,
"{relative} changed on a second --register-all"
);
}
}
#[test]
fn register_all_refuses_a_malformed_file_and_writes_the_others() {
let machine = Machine::new();
let cursor = machine.home.join(".cursor/mcp.json");
std::fs::create_dir_all(cursor.parent().unwrap()).unwrap();
let broken = "{ this was never json";
std::fs::write(&cursor, broken).unwrap();
let run = machine.setup(&["--yes", "--airgap", "--register-all"]);
assert!(
run.status.success(),
"one unreadable file failed the whole install"
);
assert_eq!(
std::fs::read_to_string(&cursor).unwrap(),
broken,
"a file that could not be parsed was written anyway"
);
let warp = machine.home.join(".warp/.mcp.json");
assert!(
warp.exists(),
"the other clients were not written after one was refused"
);
}
#[test]
fn a_default_install_writes_no_client_configuration() {
let machine = Machine::new();
let run = machine.setup(&["--yes", "--airgap"]);
assert!(run.status.success());
for (client, relative) in writable_clients() {
assert!(
!machine.home.join(&relative).exists(),
"a default install wrote {client}'s {relative}"
);
}
}
struct FakeClients {
dir: PathBuf,
log: PathBuf,
}
impl FakeClients {
fn new(root: &Path, programs: &[String], exit: i32) -> Self {
let dir = root.join("fake-bin");
let log = root.join("invocations.txt");
std::fs::create_dir_all(&dir).unwrap();
for program in programs {
common::write_executable(
&dir.join(program),
&format!(
"#!/bin/sh\nprintf '%s' \"{program}\" >> \"$SEMLITH_FAKE_LOG\"\n\
for a in \"$@\"; do printf ' %s' \"$a\" >> \"$SEMLITH_FAKE_LOG\"; done\n\
printf '\\n' >> \"$SEMLITH_FAKE_LOG\"\nexit {exit}\n"
),
);
}
Self { dir, log }
}
fn lines(&self) -> Vec<String> {
std::fs::read_to_string(&self.log)
.unwrap_or_default()
.lines()
.map(str::to_string)
.collect()
}
}
fn global_registrations() -> Vec<(String, String)> {
semlith::clients::clients()
.iter()
.filter(|client| client.registers_globally())
.filter_map(|client| {
client.register_command().and_then(|command| {
semlith::setup::argv(&command).map(|(program, args)| {
let line = std::iter::once(program)
.chain(args)
.collect::<Vec<_>>()
.join(" ");
let mine = semlith::clients::binary_path();
let child = std::fs::canonicalize(env!("CARGO_BIN_EXE_semlith"))
.map(|p| p.to_string_lossy().into_owned())
.unwrap_or_else(|_| env!("CARGO_BIN_EXE_semlith").to_string());
(client.name.clone(), line.replace(mine, &child))
})
})
})
.collect()
}
#[test]
fn setup_invokes_each_client_with_the_documented_command_line() {
let machine = Machine::new();
let expected = global_registrations();
assert_eq!(
expected.len(),
14,
"fourteen clients register by their own CLI: {:?}",
expected.iter().map(|(n, _)| n).collect::<Vec<_>>()
);
let programs: Vec<String> = expected
.iter()
.flat_map(|(_, command)| command.split_whitespace().next().map(str::to_string))
.collect::<std::collections::BTreeSet<_>>()
.into_iter()
.collect();
let fake = FakeClients::new(&machine.home, &programs, 0);
let mut command = Command::new(env!("CARGO_BIN_EXE_semlith"));
command
.arg("setup")
.args(["--yes", "--airgap", "--no-service"])
.env("HOME", &machine.home)
.env("SEMLITH_HOME", &machine.store_home)
.env("SEMLITH_MODEL_CACHE", &machine.cache)
.env("SEMLITH_FAKE_LOG", &fake.log)
.env(
"PATH",
format!(
"{}:{}",
fake.dir.display(),
std::env::var("PATH").unwrap_or_default()
),
);
let run = command.output().expect("setup runs");
assert!(
run.status.success(),
"setup exited {:?}:\n{}{}",
run.status.code(),
String::from_utf8_lossy(&run.stdout),
String::from_utf8_lossy(&run.stderr)
);
let invoked = fake.lines();
for (client, documented) in &expected {
assert!(
invoked.iter().any(|line| line == documented),
"{client} was not invoked as `{documented}`.\nWhat was invoked:\n{}",
invoked.join("\n")
);
assert_eq!(
invoked.iter().filter(|line| *line == documented).count(),
1,
"{client} was registered more than once"
);
}
for name in ["OpenCode", "Kilo Code"] {
let client = semlith::clients::clients()
.iter()
.find(|c| c.name == name)
.expect("documented");
let program = client
.register_command()
.and_then(|c| c.split_whitespace().next().map(str::to_string))
.expect("has a command");
assert!(
!invoked.iter().any(|line| line.starts_with(&program)),
"{name} registers only the current directory and was invoked anyway"
);
}
}
#[test]
fn a_client_that_refuses_is_reported_and_the_install_still_succeeds() {
let machine = Machine::new();
let fake = FakeClients::new(&machine.home, &["claude".to_string()], 1);
let mut command = Command::new(env!("CARGO_BIN_EXE_semlith"));
command
.arg("setup")
.args(["--yes", "--airgap", "--no-service"])
.env("HOME", &machine.home)
.env("SEMLITH_HOME", &machine.store_home)
.env("SEMLITH_MODEL_CACHE", &machine.cache)
.env("SEMLITH_FAKE_LOG", &fake.log)
.env("PATH", format!("{}:/usr/bin:/bin", fake.dir.display()));
let run = command.output().expect("setup runs");
assert!(
run.status.success(),
"a refusing client CLI failed the whole install"
);
let said = format!(
"{}{}",
String::from_utf8_lossy(&run.stdout),
String::from_utf8_lossy(&run.stderr)
);
assert!(
said.contains("would not register") || said.contains("Claude Code"),
"the refusal was not reported:\n{said}"
);
assert!(
said.contains("not on this machine"),
"the clients that are simply not installed were not named as absent:\n{said}"
);
}
#[test]
fn the_path_block_exports_no_credential() {
let machine = Machine::new();
let run = machine.setup(&["--yes", "--airgap"]);
assert!(run.status.success());
let rc = std::fs::read_to_string(machine.home.join(".zshrc")).unwrap();
assert!(rc.contains(BEGIN), "setup wrote no block at all:\n{rc}");
assert!(
rc.contains(".semlith/bin"),
"the block no longer puts the bin directory on PATH:\n{rc}"
);
assert!(
!rc.contains("SEMLITH_AGENT_KEY"),
"the block still exports the agent key:\n{rc}"
);
assert!(
!rc.contains("agent.key"),
"the block still reads the key file:\n{rc}"
);
}
#[test]
fn an_older_blocks_key_export_is_removed_by_a_later_setup() {
let machine = Machine::new();
let rc = machine.home.join(".zshrc");
let key = machine.store_home.join("agent.key");
std::fs::write(
&rc,
format!(
"# a shell rc somebody already owns\n\
{BEGIN}\n\
export PATH='{}':$PATH\n\
[ -r '{}' ] && export SEMLITH_AGENT_KEY=\"$(cat '{}')\"\n\
# <<< semlith <<<\n\
# something the user put after it\n",
machine.home.join(".semlith/bin").display(),
key.display(),
key.display(),
),
)
.unwrap();
let run = machine.setup(&["--yes", "--airgap"]);
assert!(run.status.success());
let after = std::fs::read_to_string(&rc).unwrap();
assert!(
!after.contains("SEMLITH_AGENT_KEY"),
"the stale export survived a setup run:\n{after}"
);
assert!(
after.contains("# a shell rc somebody already owns")
&& after.contains("# something the user put after it"),
"replacing the block ate what was around it:\n{after}"
);
assert!(after.contains(".semlith/bin"), "PATH was lost:\n{after}");
}
#[test]
fn setup_links_the_skill_into_every_documented_directory_and_is_idempotent() {
let machine = Machine::new();
let run = machine.setup(&["--yes", "--airgap"]);
assert!(run.status.success(), "{}", Machine::said(&run));
let canonical = machine.store_home.join("skills/semlith/SKILL.md");
assert!(
canonical.is_file(),
"no canonical skill at {}",
canonical.display()
);
for dir in [
".agents/skills",
".claude/skills",
".qwen/skills",
".kiro/skills",
] {
let at = machine.home.join(dir).join("semlith");
assert!(
at.exists(),
"the skill was not linked into {}",
at.display()
);
assert!(
at.join("SKILL.md").is_file(),
"the link at {} does not reach a skill",
at.display()
);
}
let again = machine.setup(&["--yes", "--airgap"]);
let said = Machine::said(&again);
assert!(
said.contains("skill") && said.contains("already done"),
"a second run did the skill again:\n{said}"
);
}
#[test]
fn doctor_names_a_skill_link_that_has_been_removed() {
let machine = Machine::new();
assert!(machine.setup(&["--yes", "--airgap"]).status.success());
let link = machine.home.join(".claude/skills/semlith");
std::fs::remove_file(&link).expect("removing the link");
let report = machine.doctor(&["--json"]);
let body: serde_json::Value =
serde_json::from_str(&String::from_utf8_lossy(&report.stdout)).expect("doctor JSON");
let claude = body["clients"]
.as_array()
.unwrap()
.iter()
.find(|c| c["name"] == "Claude Code")
.expect("Claude Code is reported");
assert_eq!(
claude["skill"], "absent",
"doctor did not notice the removed link: {claude}"
);
}
#[test]
fn setup_writes_the_hook_by_default_and_backs_the_file_up() {
let machine = Machine::new();
let settings = machine.home.join(".claude/settings.json");
std::fs::create_dir_all(settings.parent().unwrap()).unwrap();
let theirs = serde_json::json!({
"model": "something they chose",
"hooks": { "PreToolUse": [{
"matcher": "Bash",
"hooks": [{ "type": "command", "command": "/usr/local/bin/audit" }]
}]}
});
let before = serde_json::to_string_pretty(&theirs).unwrap() + "\n";
std::fs::write(&settings, &before).unwrap();
assert!(machine.setup(&["--yes", "--airgap"]).status.success());
let after = std::fs::read_to_string(&settings).unwrap();
assert!(after.contains("semlith"), "no hook was written:\n{after}");
assert!(
after.contains("/usr/local/bin/audit"),
"writing the hook ate another hook:\n{after}"
);
assert!(
after.contains("something they chose"),
"writing the hook ate the rest of the file:\n{after}"
);
let backup = machine.home.join(".claude/settings.json.semlith-backup");
assert_eq!(
std::fs::read_to_string(&backup).unwrap(),
before,
"the backup is not the file as it was"
);
}
#[test]
fn no_hooks_removes_the_entry_and_leaves_the_rest_of_the_file_alone() {
let machine = Machine::new();
let settings = machine.home.join(".claude/settings.json");
std::fs::create_dir_all(settings.parent().unwrap()).unwrap();
let theirs = serde_json::to_string_pretty(&serde_json::json!({
"hooks": { "PreToolUse": [{
"matcher": "Bash",
"hooks": [{ "type": "command", "command": "/usr/local/bin/audit" }]
}]}
}))
.unwrap()
+ "\n";
std::fs::write(&settings, &theirs).unwrap();
assert!(machine.setup(&["--yes", "--airgap"]).status.success());
assert!(
machine
.setup(&["--yes", "--airgap", "--no-hooks"])
.status
.success()
);
assert_eq!(
std::fs::read_to_string(&settings).unwrap(),
theirs,
"--no-hooks did not give the file back as it was"
);
}
#[test]
fn strict_writes_one_hook_rather_than_a_second_one() {
let machine = Machine::new();
assert!(machine.setup(&["--yes", "--airgap"]).status.success());
assert!(
machine
.setup(&["--yes", "--airgap", "--strict"])
.status
.success()
);
let after = std::fs::read_to_string(machine.home.join(".claude/settings.json")).unwrap();
let json: serde_json::Value = serde_json::from_str(&after).unwrap();
let ours = |event: &str| {
json["hooks"][event]
.as_array()
.map(|list| {
list.iter()
.filter(|e| {
e.to_string().contains("semlith") && e.to_string().contains(" hook")
})
.count()
})
.unwrap_or(0)
};
assert_eq!(
ours("PreToolUse"),
1,
"strict left two semlith hooks behind:\n{after}"
);
assert_eq!(ours("PostToolUse"), 1, "{after}");
assert!(after.contains("--mode gate"), "{after}");
assert!(after.contains("Bash|Read|Grep|Glob"), "{after}");
}
#[test]
fn the_rule_block_is_written_only_under_register_all() {
let machine = Machine::new();
let rules = machine.home.join(".config/opencode/AGENTS.md");
std::fs::create_dir_all(rules.parent().unwrap()).unwrap();
std::fs::write(&rules, "# Mine\n\nAlways use tabs.\n").unwrap();
assert!(machine.setup(&["--yes", "--airgap"]).status.success());
assert_eq!(
std::fs::read_to_string(&rules).unwrap(),
"# Mine\n\nAlways use tabs.\n",
"a plain setup wrote into a rules file"
);
assert!(
machine
.setup(&["--yes", "--airgap", "--register-all"])
.status
.success()
);
let after = std::fs::read_to_string(&rules).unwrap();
assert!(after.contains("Always use tabs."), "{after}");
assert!(
after.contains("semlith"),
"no rule block was written:\n{after}"
);
assert!(
machine
.home
.join(".config/opencode/AGENTS.md.semlith-backup")
.is_file(),
"the rules file was written without a backup"
);
}
#[test]
fn the_suite_leaves_the_developers_own_client_configuration_alone() {
let Some(real) = std::env::var_os("HOME").map(PathBuf::from) else {
return;
};
let watched = [
real.join(".claude/settings.json"),
real.join(".config/opencode/AGENTS.md"),
real.join(".codeium/windsurf/memories/global_rules.md"),
];
let before: Vec<Option<String>> = watched
.iter()
.map(|p| std::fs::read_to_string(p).ok())
.collect();
let machine = Machine::new();
assert!(
machine
.setup(&["--yes", "--airgap", "--register-all"])
.status
.success()
);
for (path, was) in watched.iter().zip(before) {
assert_eq!(
std::fs::read_to_string(path).ok(),
was,
"a setup run under a redirected HOME changed {}",
path.display()
);
}
assert!(
!real.join(".agents/skills/semlith").is_symlink()
|| std::fs::read_link(real.join(".agents/skills/semlith"))
.map(|t| !t.starts_with(&machine.store_home))
.unwrap_or(true),
"a fixture skill was linked into the developer's own skill directory"
);
}
#[test]
fn setup_upgrades_a_0_29_entry_with_always_load_and_both_hooks() {
let machine = Machine::new();
let config = machine.home.join(".claude.json");
std::fs::write(
&config,
serde_json::to_string_pretty(&serde_json::json!({
"numStartups": 7,
"mcpServers": { "semlith": { "type": "stdio", "command": "/old/semlith", "args": ["mcp"] } },
"projects": {}
}))
.unwrap(),
)
.unwrap();
let settings = machine.home.join(".claude/settings.json");
std::fs::create_dir_all(settings.parent().unwrap()).unwrap();
std::fs::write(
&settings,
r#"{"hooks":{"PreToolUse":[{"matcher":"Read|Grep","hooks":[{"type":"command","command":"/old/semlith hook"}]},{"matcher":"Write","hooks":[{"type":"command","command":"theirs"}]}]}}"#,
)
.unwrap();
assert!(machine.setup(&["--yes", "--airgap"]).status.success());
let after: serde_json::Value =
serde_json::from_str(&std::fs::read_to_string(&config).unwrap()).unwrap();
assert_eq!(
after["mcpServers"]["semlith"]["alwaysLoad"], true,
"{after}"
);
assert_eq!(
after["numStartups"], 7,
"a sibling key was changed: {after}"
);
let hooks = std::fs::read_to_string(&settings).unwrap();
assert!(hooks.contains("Bash|Read|Grep|Glob"), "{hooks}");
assert!(
hooks.contains("PostToolUse") && hooks.contains("mcp__.*semlith.*"),
"{hooks}"
);
assert!(
hooks.contains("theirs"),
"somebody else's hook was taken: {hooks}"
);
assert!(
!hooks.contains("Read|Grep\""),
"the 0.29.0 matcher is still there: {hooks}"
);
assert!(
machine
.setup(&["--yes", "--airgap", "--no-hooks"])
.status
.success()
);
let hooks = std::fs::read_to_string(&settings).unwrap();
assert!(
!hooks.contains("semlith"),
"--no-hooks left a semlith hook: {hooks}"
);
assert!(
hooks.contains("theirs"),
"--no-hooks took somebody else's hook: {hooks}"
);
}
#[test]
fn setup_writes_the_research_agent_and_no_agents_removes_it() {
let machine = Machine::new();
std::fs::create_dir_all(machine.home.join(".claude")).unwrap();
assert!(machine.setup(&["--yes", "--airgap"]).status.success());
let agent = machine.home.join(".claude/agents/semlith-explorer.md");
let text = std::fs::read_to_string(&agent).expect("the agent was written");
assert!(text.contains("name: semlith-explorer"), "{text}");
assert!(
machine
.setup(&["--yes", "--airgap", "--no-agents"])
.status
.success()
);
assert!(!agent.exists(), "--no-agents left the agent behind");
}