use crate::clients::{Client, Stanza};
use crate::home;
use anyhow::{Context, Result, bail};
use std::path::{Path, PathBuf};
pub(crate) const BACKUP: &str = ".semlith-backup";
#[derive(Debug, Clone, serde::Serialize)]
pub struct Plan {
pub client: String,
pub path: PathBuf,
#[serde(flatten)]
pub action: Action,
}
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
#[serde(tag = "action", rename_all = "kebab-case")]
pub enum Action {
Create,
Merge,
AlreadyDone,
Refuse { reason: String },
}
impl std::fmt::Display for Plan {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
let what = match &self.action {
Action::Create => "create".to_string(),
Action::Merge => "merge into".to_string(),
Action::AlreadyDone => "already registered in".to_string(),
Action::Refuse { reason } => format!("skip ({reason})"),
};
write!(f, "{}: {what} {}", self.client, self.path.display())
}
}
pub fn plan(clients: &[&Client]) -> Vec<Plan> {
let mut out = Vec::new();
for client in clients {
for stanza in client.config_files() {
let Some(path) = resolve(stanza) else {
continue;
};
if !client_present(&path) {
continue;
}
let action = decide(stanza, &path);
out.push(Plan {
client: client.name.clone(),
path,
action,
});
}
}
out
}
pub fn apply(plans: &[Plan], stanzas: &[(&str, &Stanza)]) -> Result<Vec<PathBuf>> {
let mut written = Vec::new();
for plan in plans {
let Some(stanza) = stanzas
.iter()
.find(|(client, stanza)| {
*client == plan.client && resolve(stanza).as_deref() == Some(&plan.path)
})
.map(|(_, stanza)| *stanza)
else {
continue;
};
match &plan.action {
Action::AlreadyDone => {}
Action::Refuse { .. } => {}
Action::Create | Action::Merge => {
write_one(stanza, &plan.path)?;
written.push(plan.path.clone());
}
}
}
Ok(written)
}
pub fn registered_clients() -> Vec<String> {
crate::clients::clients()
.iter()
.filter(|client| {
client.config_files().any(|stanza| {
resolve(stanza)
.and_then(|path| std::fs::read_to_string(path).ok())
.is_some_and(|text| names_semlith(&text))
})
})
.map(|client| client.name.clone())
.collect()
}
fn names_semlith(text: &str) -> bool {
text.contains("\"semlith\"")
|| text.contains("semlith:")
|| text.contains("[mcp_servers.semlith")
}
pub fn resolve(stanza: &Stanza) -> Option<PathBuf> {
let raw = stanza.path.as_deref()?;
if !stanza.applies_here() {
return None;
}
if let Some(rest) = raw.strip_prefix("~/") {
if let Some(moved) = stanza
.root
.as_deref()
.and_then(std::env::var_os)
.filter(|v| !v.is_empty())
&& let Some((_, below)) = rest.split_once('/')
{
return Some(PathBuf::from(moved).join(below));
}
return home::user_home().ok().map(|home| home.join(rest));
}
for var in ["APPDATA", "LOCALAPPDATA"] {
if let Some(rest) = raw.strip_prefix(&format!("%{var}%\\")) {
if !cfg!(windows) {
return None;
}
return std::env::var_os(var).map(|base| PathBuf::from(base).join(rest));
}
}
Some(PathBuf::from(raw))
}
pub fn client_home(path: &Path) -> Option<PathBuf> {
let home = home::user_home().ok()?;
let rest = path.strip_prefix(&home).ok()?;
let parts: Vec<_> = rest.components().collect();
let depth = match parts.first()?.as_os_str().to_str()? {
".config" | "Documents" => 2,
"AppData" if parts.get(2).and_then(|p| p.as_os_str().to_str()) == Some("Packages") => 4,
"Library" | "AppData" => 3,
_ => 1,
};
if parts.len() <= depth {
return None;
}
Some(parts[..depth].iter().fold(home, |at, part| at.join(part)))
}
pub fn client_present(path: &Path) -> bool {
match client_home(path) {
Some(dir) => dir.is_dir(),
None => path.parent().is_some_and(Path::is_dir),
}
}
fn decide(stanza: &Stanza, path: &Path) -> Action {
let exists = path.exists();
if !exists {
return Action::Create;
}
let Ok(text) = std::fs::read_to_string(path) else {
return Action::Refuse {
reason: "the file cannot be read".into(),
};
};
match stanza.format.as_str() {
"json" => match merged_json(stanza, &text) {
Ok(next) => {
if next == text {
Action::AlreadyDone
} else {
Action::Merge
}
}
Err(e) => Action::Refuse {
reason: e.to_string(),
},
},
"toml" if names_semlith(&text) => Action::AlreadyDone,
"toml" => Action::Merge,
"yaml" if names_semlith(&text) => Action::AlreadyDone,
"yaml" => Action::Refuse {
reason: "semlith does not rewrite an existing YAML configuration; \
paste the block printed below"
.into(),
},
other => Action::Refuse {
reason: format!("semlith does not write {other} configuration files"),
},
}
}
fn write_one(stanza: &Stanza, path: &Path) -> Result<()> {
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)
.with_context(|| format!("creating {}", parent.display()))?;
}
let existing = std::fs::read_to_string(path).ok();
let next = match (stanza.format.as_str(), &existing) {
("json", Some(text)) => merged_json(stanza, text)?,
("json", None) => {
let value: serde_json::Value = serde_json::from_str(&stanza.text)
.with_context(|| "the documented stanza is not valid JSON".to_string())?;
serialize(&value)
}
("yaml", None) => stanza.text.clone(),
("toml", None) => format!("{}\n", stanza.text.trim_end()),
("toml", Some(text)) => {
let separator = match text.as_str() {
"" => "",
t if t.ends_with("\n\n") => "",
t if t.ends_with('\n') => "\n",
_ => "\n\n",
};
format!("{text}{separator}{}\n", stanza.text.trim_end())
}
(format, _) => bail!("semlith does not write {format} configuration files"),
};
if existing.as_deref() == Some(next.as_str()) {
return Ok(());
}
if existing.is_some() {
back_up(path)?;
}
rewrite(path, &next)
}
pub fn unregister_files(client: &Client) -> Result<Vec<PathBuf>> {
let mut changed = Vec::new();
for stanza in client.config_files() {
let Some(path) = resolve(stanza) else {
continue;
};
let Ok(text) = std::fs::read_to_string(&path) else {
continue;
};
if !names_semlith(&text) {
continue;
}
let next = match stanza.format.as_str() {
"json" => {
let mut base: serde_json::Value =
serde_json::from_str(&text).with_context(|| {
format!("{} is not valid JSON; nothing was changed", path.display())
})?;
let overlay: serde_json::Value = serde_json::from_str(&stanza.text)
.context("the documented stanza is not valid JSON")?;
remove_entry(&mut base, &overlay);
serialize(&base)
}
"toml" => without_toml_table(&text),
other => bail!(
"semlith does not rewrite {other} configuration; remove its entry from {} by hand",
path.display()
),
};
if next == text {
continue;
}
back_up(&path)?;
rewrite(&path, &next)?;
changed.push(path);
}
Ok(changed)
}
fn remove_entry(base: &mut serde_json::Value, overlay: &serde_json::Value) {
let (serde_json::Value::Object(base), serde_json::Value::Object(overlay)) = (base, overlay)
else {
return;
};
for (key, value) in overlay {
if key == "semlith" {
base.remove(key);
} else if let Some(inner) = base.get_mut(key) {
remove_entry(inner, value);
}
}
}
fn without_toml_table(text: &str) -> String {
let mut out = String::new();
let mut skipping = false;
for line in text.split_inclusive('\n') {
let trimmed = line.trim_start();
if trimmed.starts_with('[') {
skipping = trimmed.starts_with("[mcp_servers.semlith");
}
if !skipping {
out.push_str(line);
}
}
out
}
pub fn back_up(path: &Path) -> Result<()> {
let mut backup = path.as_os_str().to_os_string();
backup.push(BACKUP);
let backup = PathBuf::from(backup);
if backup.exists() {
return Ok(());
}
std::fs::copy(path, &backup).with_context(|| format!("backing up to {}", backup.display()))?;
Ok(())
}
pub(crate) fn rewrite(path: &Path, text: &str) -> Result<()> {
let target = match std::fs::symlink_metadata(path) {
Ok(meta) if meta.file_type().is_symlink() => {
std::fs::canonicalize(path).with_context(|| {
format!(
"{} is a link to a file that does not exist; nothing was written",
path.display()
)
})?
}
_ => path.to_path_buf(),
};
let mut name = target.file_name().unwrap_or_default().to_os_string();
name.push(".semlith-tmp");
let temp = target.with_file_name(name);
#[cfg(unix)]
let mode = {
use std::os::unix::fs::PermissionsExt;
std::fs::metadata(&target).map_or(0o600, |m| m.permissions().mode() & 0o7777)
};
let written = (|| -> std::io::Result<()> {
use std::io::Write;
let mut options = std::fs::OpenOptions::new();
options.write(true).create(true).truncate(true);
#[cfg(unix)]
{
use std::os::unix::fs::{OpenOptionsExt, PermissionsExt};
options.mode(0o600);
let mut file = options.open(&temp)?;
file.set_permissions(std::fs::Permissions::from_mode(mode))?;
file.write_all(text.as_bytes())
}
#[cfg(not(unix))]
options.open(&temp)?.write_all(text.as_bytes())
})();
let replaced = written
.with_context(|| format!("writing {}", temp.display()))
.and_then(|()| {
std::fs::rename(&temp, &target)
.with_context(|| format!("replacing {}", target.display()))
});
if replaced.is_err() {
let _ = std::fs::remove_file(&temp);
}
replaced
}
fn merged_json(stanza: &Stanza, existing: &str) -> Result<String> {
let mut base: serde_json::Value = if existing.trim().is_empty() {
serde_json::Value::Object(Default::default())
} else {
serde_json::from_str(existing).context("the file is not valid JSON")?
};
let overlay: serde_json::Value =
serde_json::from_str(&stanza.text).context("the documented stanza is not valid JSON")?;
merge(&mut base, &overlay);
Ok(serialize(&base))
}
fn merge(base: &mut serde_json::Value, overlay: &serde_json::Value) {
match (base, overlay) {
(serde_json::Value::Object(base), serde_json::Value::Object(overlay)) => {
for (key, value) in overlay {
match base.get_mut(key) {
Some(existing) => merge(existing, value),
None => {
base.insert(key.clone(), value.clone());
}
}
}
}
(base, overlay) => *base = overlay.clone(),
}
}
fn serialize(value: &serde_json::Value) -> String {
let mut out = Vec::new();
let indent = b" ";
let formatter = serde_json::ser::PrettyFormatter::with_indent(indent);
let mut ser = serde_json::Serializer::with_formatter(&mut out, formatter);
serde::Serialize::serialize(value, &mut ser).expect("a parsed value re-serializes");
let mut text = String::from_utf8(out).expect("serde_json emits UTF-8");
text.push('\n');
text
}
#[cfg(test)]
mod tests {
use super::*;
use crate::clients::Scope;
#[test]
fn a_toml_unregister_removes_only_semlith() {
let text = "model = \"x\"\n\n[mcp_servers.other]\ncommand = \"o\"\n\n\
[mcp_servers.semlith]\ncommand = \"semlith\"\nargs = [\"mcp\"]\n\n\
[mcp_servers.semlith.env]\nA = \"1\"\n\n[profile]\nname = \"p\"\n";
let out = without_toml_table(text);
assert!(!out.contains("semlith"), "{out}");
assert!(out.contains("[mcp_servers.other]") && out.contains("[profile]"));
assert!(out.starts_with("model = \"x\"\n"));
}
#[test]
fn a_json_unregister_removes_only_the_documented_key() {
let mut base: serde_json::Value = serde_json::from_str(
r#"{"mcpServers": {"semlith": {"command": "semlith"}, "other": {"command": "o"}}, "theme": "dark"}"#,
)
.unwrap();
let overlay = serde_json::json!({ "mcpServers": { "semlith": { "command": "semlith" } } });
remove_entry(&mut base, &overlay);
assert_eq!(
base,
serde_json::json!({ "mcpServers": { "other": { "command": "o" } }, "theme": "dark" })
);
}
#[test]
fn a_toml_file_gains_the_table_once_and_keeps_every_other_byte() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("config.toml");
let toml = stanza(
"toml",
"[mcp_servers.semlith]\ncommand = \"/b/semlith\"\nargs = [\"mcp\"]",
"~/.codex/config.toml",
);
let theirs = "model = \"x\"\n\n[mcp_servers.other]\ncommand = \"o\"\n";
std::fs::write(&path, theirs).unwrap();
assert_eq!(decide(&toml, &path), Action::Merge);
write_one(&toml, &path).unwrap();
let once = std::fs::read_to_string(&path).unwrap();
assert!(once.starts_with(theirs), "{once}");
assert!(
once.ends_with("[mcp_servers.semlith]\ncommand = \"/b/semlith\"\nargs = [\"mcp\"]\n"),
"{once}"
);
assert_eq!(decide(&toml, &path), Action::AlreadyDone);
std::fs::remove_file(&path).unwrap();
write_one(&toml, &path).unwrap();
assert!(
std::fs::read_to_string(&path)
.unwrap()
.starts_with("[mcp_servers.semlith]")
);
}
#[cfg(windows)]
#[test]
fn register_all_writes_each_clients_windows_file() {
let dir = tempfile::tempdir().unwrap();
let (home, appdata, local) = (
dir.path().join("home"),
dir.path().join("home").join("AppData").join("Roaming"),
dir.path().join("home").join("AppData").join("Local"),
);
for made in [
appdata.join("Zed"),
appdata.join("Claude"),
home.join(".cursor"),
home.join(".cline").join("data").join("settings"),
home.join(".config").join("opencode"),
] {
std::fs::create_dir_all(made).unwrap();
}
std::fs::create_dir_all(&local).unwrap();
let vars = [
("HOME", home.as_os_str()),
("USERPROFILE", home.as_os_str()),
("APPDATA", appdata.as_os_str()),
("LOCALAPPDATA", local.as_os_str()),
];
let written = crate::home::with_env_vars(&vars, || {
let file_clients: Vec<&Client> = crate::clients::clients()
.iter()
.filter(|c| {
["Zed", "Claude Desktop", "Cursor", "Cline", "OpenCode"]
.contains(&c.name.as_str())
})
.collect();
let plans = plan(&file_clients);
let stanzas: Vec<(&str, &Stanza)> = file_clients
.iter()
.flat_map(|c| c.config_files().map(move |s| (c.name.as_str(), s)))
.collect();
apply(&plans, &stanzas).unwrap()
});
for expected in [
appdata.join("Zed").join("settings.json"),
appdata.join("Claude").join("claude_desktop_config.json"),
home.join(".cursor").join("mcp.json"),
home.join(".cline")
.join("data")
.join("settings")
.join("cline_mcp_settings.json"),
home.join(".config").join("opencode").join("opencode.json"),
] {
assert!(
written.contains(&expected),
"{} not written: {written:?}",
expected.display()
);
}
assert!(!local.join("Packages").exists());
}
#[test]
fn an_os_list_applies_on_each_system_it_names() {
let mut both = stanza("json", "{}", "~/.config/zed/settings.json");
both.os = Some("macos,linux".into());
assert_eq!(both.applies_here(), !cfg!(windows));
both.os = Some("windows".into());
assert_eq!(both.applies_here(), cfg!(windows));
}
fn stanza(format: &str, text: &str, path: &str) -> Stanza {
Stanza {
format: format.to_string(),
text: text.to_string(),
register: false,
unregister: false,
hook: false,
skills: false,
rules: false,
path: Some(path.to_string()),
os: None,
root: None,
scope: Scope::Global,
}
}
const ENTRY: &str = r#"{"mcpServers":{"semlith":{"command":"semlith","args":["mcp"]}}}"#;
#[test]
fn a_merge_keeps_every_key_it_did_not_come_to_change() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("mcp.json");
std::fs::write(
&path,
r#"{"theme":"dark","mcpServers":{"other":{"command":"other"}}}"#,
)
.unwrap();
let s = stanza("json", ENTRY, "unused");
write_one(&s, &path).unwrap();
let after: serde_json::Value =
serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
assert_eq!(after["theme"], "dark", "an unrelated key was lost");
assert_eq!(
after["mcpServers"]["other"]["command"], "other",
"another server was lost"
);
assert_eq!(after["mcpServers"]["semlith"]["command"], "semlith");
assert_eq!(after["mcpServers"]["semlith"]["args"][0], "mcp");
}
#[test]
fn a_second_write_is_byte_identical_and_leaves_one_backup() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("mcp.json");
std::fs::write(&path, r#"{"mcpServers":{"other":{"command":"other"}}}"#).unwrap();
let s = stanza("json", ENTRY, "unused");
write_one(&s, &path).unwrap();
let first = std::fs::read_to_string(&path).unwrap();
let backup = std::fs::read_to_string(format!("{}{BACKUP}", path.display())).unwrap();
write_one(&s, &path).unwrap();
let second = std::fs::read_to_string(&path).unwrap();
assert_eq!(first, second, "a second write changed the file");
assert_eq!(
backup,
std::fs::read_to_string(format!("{}{BACKUP}", path.display())).unwrap(),
"the second write overwrote the backup with the already-registered file"
);
assert_eq!(decide(&s, &path), Action::AlreadyDone);
}
#[test]
fn a_file_already_holding_the_entry_is_not_rewritten() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("mcp.json");
let s = stanza("json", ENTRY, "unused");
write_one(&s, &path).unwrap();
assert_eq!(decide(&s, &path), Action::AlreadyDone);
assert!(
!PathBuf::from(format!("{}{BACKUP}", path.display())).exists(),
"a created file was backed up, which there was nothing to back up"
);
}
#[test]
fn a_malformed_file_is_refused_and_left_exactly_as_it_was() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("mcp.json");
let before = "{ this is not json";
std::fs::write(&path, before).unwrap();
let s = stanza("json", ENTRY, "unused");
match decide(&s, &path) {
Action::Refuse { reason } => assert!(
reason.contains("not valid JSON"),
"the refusal does not say why: {reason}"
),
other => panic!("a malformed file was not refused: {other:?}"),
}
assert!(write_one(&s, &path).is_err());
assert_eq!(
std::fs::read_to_string(&path).unwrap(),
before,
"a file that could not be parsed was written anyway"
);
}
#[test]
fn an_existing_yaml_configuration_is_refused_rather_than_rewritten() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("config.yaml");
let before = "extensions:\n other:\n enabled: true\n";
std::fs::write(&path, before).unwrap();
let s = stanza(
"yaml",
"extensions:\n semlith:\n cmd: semlith\n",
"unused",
);
assert!(matches!(decide(&s, &path), Action::Refuse { .. }));
assert!(write_one(&s, &path).is_err());
assert_eq!(std::fs::read_to_string(&path).unwrap(), before);
}
#[test]
fn a_yaml_file_that_is_not_there_yet_is_written_whole() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("nested").join("semlith.yaml");
let body = "name: semlith\ncommand: semlith\nargs:\n - mcp\n";
let s = stanza("yaml", body, "unused");
assert_eq!(decide(&s, &path), Action::Create);
write_one(&s, &path).unwrap();
assert_eq!(std::fs::read_to_string(&path).unwrap(), body);
}
#[test]
fn a_path_for_another_platform_resolves_to_nothing() {
let mut s = stanza("json", ENTRY, "%APPDATA%\\Claude\\config.json");
s.os = Some("windows".into());
if !cfg!(windows) {
assert_eq!(resolve(&s), None);
}
let mut s = stanza("json", ENTRY, "~/Library/Application Support/x.json");
s.os = Some("macos".into());
if !cfg!(target_os = "macos") {
assert_eq!(resolve(&s), None);
}
}
#[cfg(unix)]
#[test]
fn a_rewrite_keeps_the_mode_and_the_link() {
use std::os::unix::fs::PermissionsExt;
let dir = tempfile::tempdir().unwrap();
let mode = |p: &Path| std::fs::metadata(p).unwrap().permissions().mode() & 0o777;
let narrowed = dir.path().join("settings.json");
std::fs::write(&narrowed, "{}").unwrap();
std::fs::set_permissions(&narrowed, std::fs::Permissions::from_mode(0o600)).unwrap();
rewrite(&narrowed, "{\"a\":1}\n").unwrap();
assert_eq!(mode(&narrowed), 0o600);
assert_eq!(std::fs::read_to_string(&narrowed).unwrap(), "{\"a\":1}\n");
let wide = dir.path().join("wide.json");
std::fs::write(&wide, "{}").unwrap();
std::fs::set_permissions(&wide, std::fs::Permissions::from_mode(0o664)).unwrap();
rewrite(&wide, "{}\n").unwrap();
assert_eq!(
mode(&wide),
0o664,
"the mode is kept exactly, not through the umask"
);
let real = dir.path().join("dotfiles").join("mcp.json");
std::fs::create_dir_all(real.parent().unwrap()).unwrap();
std::fs::write(&real, "{}").unwrap();
std::fs::set_permissions(&real, std::fs::Permissions::from_mode(0o640)).unwrap();
let link = dir.path().join("mcp.json");
std::os::unix::fs::symlink(&real, &link).unwrap();
rewrite(&link, "{\"b\":2}\n").unwrap();
assert!(
std::fs::symlink_metadata(&link)
.unwrap()
.file_type()
.is_symlink()
);
assert_eq!(std::fs::read_to_string(&real).unwrap(), "{\"b\":2}\n");
assert_eq!(mode(&real), 0o640);
let fresh = dir.path().join("new.json");
rewrite(&fresh, "{}\n").unwrap();
assert_eq!(mode(&fresh), 0o600);
let left: Vec<_> = std::fs::read_dir(dir.path())
.unwrap()
.chain(std::fs::read_dir(real.parent().unwrap()).unwrap())
.map(|e| e.unwrap().file_name().to_string_lossy().into_owned())
.filter(|n| n.ends_with(".semlith-tmp"))
.collect();
assert!(left.is_empty(), "temporary files left behind: {left:?}");
}
}