use crate::clientfile;
use crate::clients::{self, Client, Stanza};
use crate::home;
use anyhow::{Context, Result};
use std::path::{Path, PathBuf};
const SHARED_SKILLS: &str = ".agents/skills";
pub const SKILL_NAME: &str = "semlith";
const RULES_BEGIN: &str = "<!-- >>> semlith >>> -->";
const RULES_END: &str = "<!-- <<< semlith <<< -->";
pub(crate) const RULES_MARKERS: (&str, &str) = (RULES_BEGIN, RULES_END);
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize)]
#[serde(rename_all = "lowercase")]
pub enum State {
Present,
Absent,
Stale,
Paste,
}
pub fn canonical_skill_dir() -> Result<PathBuf> {
Ok(home::home_or_error()?.join("skills").join(SKILL_NAME))
}
pub fn skill_dirs() -> Vec<PathBuf> {
let mut out = Vec::new();
if let Ok(home) = home::user_home() {
out.push(home.join(SHARED_SKILLS));
}
for client in clients::clients() {
for stanza in client.skill_dirs() {
if let Some(path) = clientfile::resolve(stanza) {
out.push(path);
}
}
}
out.sort();
out.dedup();
out
}
pub fn install_skill() -> Result<Vec<PathBuf>> {
let canonical = canonical_skill_dir()?;
home::secure_dir(&canonical).with_context(|| format!("creating {}", canonical.display()))?;
let file = canonical.join("SKILL.md");
if std::fs::read_to_string(&file).ok().as_deref() != Some(clients::SKILL) {
std::fs::write(&file, clients::SKILL)
.with_context(|| format!("writing {}", file.display()))?;
}
let mut linked = Vec::new();
let shared = home::user_home().ok().map(|h| h.join(SHARED_SKILLS));
for dir in skill_dirs() {
let at = dir.join(SKILL_NAME);
if link_state(&at, &canonical) == State::Present {
continue;
}
if Some(&dir) != shared.as_ref() && !clientfile::client_present(&dir) {
continue;
}
if std::fs::create_dir_all(&dir).is_err() {
continue;
}
if at.is_symlink() {
let _ = std::fs::remove_file(&at);
}
if link(&canonical, &at).is_ok() {
linked.push(at);
}
}
Ok(linked)
}
pub fn remove_skill() -> Result<Vec<PathBuf>> {
let canonical = canonical_skill_dir()?;
let mut declined = Vec::new();
for dir in skill_dirs() {
let at = dir.join(SKILL_NAME);
if at == canonical {
continue;
}
if at.is_symlink() {
match std::fs::read_link(&at) {
Ok(target) if target == canonical => {
let _ = std::fs::remove_file(&at);
}
_ if at.exists() => declined.push(at),
_ => {
let _ = std::fs::remove_file(&at);
}
}
continue;
}
if !at.is_dir() {
continue;
}
if ours_alone(&at) {
let _ = std::fs::remove_dir_all(&at);
} else {
declined.push(at);
}
}
let _ = std::fs::remove_dir_all(&canonical);
Ok(declined)
}
pub(crate) fn ours_alone(at: &Path) -> bool {
let Ok(entries) = std::fs::read_dir(at) else {
return false;
};
let mut names: Vec<String> = entries
.flatten()
.map(|e| e.file_name().to_string_lossy().into_owned())
.collect();
names.sort();
if names != ["SKILL.md"] {
return false;
}
std::fs::read_to_string(at.join("SKILL.md")).ok().as_deref() == Some(clients::SKILL)
}
pub fn skill_state() -> Vec<(PathBuf, State)> {
let canonical = match canonical_skill_dir() {
Ok(dir) => dir,
Err(_) => return Vec::new(),
};
skill_dirs()
.into_iter()
.map(|dir| {
let at = dir.join(SKILL_NAME);
(at.clone(), link_state(&at, &canonical))
})
.collect()
}
fn link_state(at: &Path, canonical: &Path) -> State {
if at.is_symlink() {
return match std::fs::read_link(at) {
Ok(target) if target == canonical => State::Present,
_ => State::Stale,
};
}
if !at.exists() {
return State::Absent;
}
match std::fs::read_to_string(at.join("SKILL.md")) {
Ok(text) if text == clients::SKILL => State::Present,
_ => State::Stale,
}
}
fn link(canonical: &Path, at: &Path) -> Result<()> {
#[cfg(unix)]
{
std::os::unix::fs::symlink(canonical, at)
.with_context(|| format!("linking {} to {}", at.display(), canonical.display()))?;
Ok(())
}
#[cfg(not(unix))]
{
if at.exists() && !ours_alone(at) {
anyhow::bail!("{} already exists and is not semlith's skill", at.display());
}
std::fs::create_dir_all(at)?;
std::fs::copy(canonical.join("SKILL.md"), at.join("SKILL.md"))
.with_context(|| format!("copying the skill to {}", at.display()))?;
Ok(())
}
}
fn explorer_path() -> Option<PathBuf> {
crate::home::user_home()
.ok()
.map(|h| h.join(".claude/agents/semlith-explorer.md"))
}
pub fn explorer_installed() -> bool {
explorer_path()
.and_then(|p| std::fs::read_to_string(p).ok())
.is_some_and(|text| text == clients::EXPLORER)
}
pub fn install_explorer() -> Result<Option<PathBuf>> {
let Some(path) = explorer_path() else {
return Ok(None);
};
let claude = path.parent().and_then(Path::parent);
if !claude.is_some_and(Path::exists) || explorer_installed() {
return Ok(None);
}
if let Some(dir) = path.parent() {
std::fs::create_dir_all(dir).with_context(|| format!("creating {}", dir.display()))?;
}
if path.exists() {
clientfile::back_up(&path)?;
}
std::fs::write(&path, clients::EXPLORER)
.with_context(|| format!("writing {}", path.display()))?;
Ok(Some(path))
}
pub fn remove_explorer() -> Result<Option<PathBuf>> {
let Some(path) = explorer_path() else {
return Ok(None);
};
match std::fs::read_to_string(&path) {
Ok(text) if text.contains("name: semlith-explorer") => {
std::fs::remove_file(&path).with_context(|| format!("removing {}", path.display()))?;
Ok(Some(path))
}
_ => Ok(None),
}
}
pub fn set_always_load() -> Result<Option<(PathBuf, bool)>> {
let Some(path) = crate::home::user_home()
.ok()
.map(|h| h.join(".claude.json"))
else {
return Ok(None);
};
let Ok(text) = std::fs::read_to_string(&path) else {
return Ok(None);
};
let mut config: serde_json::Value =
serde_json::from_str(&text).context("~/.claude.json is not valid JSON; left as it was")?;
let Some(entry) = config
.get_mut("mcpServers")
.and_then(|s| s.get_mut("semlith"))
.and_then(|e| e.as_object_mut())
else {
return Ok(None);
};
if entry.get("alwaysLoad").and_then(|v| v.as_bool()) == Some(true) {
return Ok(Some((path, false)));
}
entry.insert("alwaysLoad".into(), serde_json::Value::Bool(true));
clientfile::back_up(&path)?;
let mut next = serde_json::to_string_pretty(&config)?;
if text.ends_with('\n') {
next.push('\n');
}
clientfile::rewrite(&path, &next)?;
Ok(Some((path, true)))
}
pub fn hook_clients() -> Vec<(&'static Client, &'static Stanza, PathBuf)> {
clients::clients()
.iter()
.filter_map(|client| {
let stanza = client.hook_stanza()?;
let path = clientfile::resolve(stanza)?;
Some((client, stanza, path))
})
.collect()
}
const HOOK_EVENTS: [&str; 2] = ["PreToolUse", "PostToolUse"];
pub fn install_hooks(mode: crate::hook::Mode) -> Result<Vec<PathBuf>> {
let mut written = Vec::new();
for (_, stanza, path) in hook_clients() {
if !clientfile::client_present(&path) {
continue;
}
let wanted = entry(stanza, mode)?;
let existing = std::fs::read_to_string(&path).ok();
let next = with_entry(existing.as_deref().unwrap_or(""), &wanted)?;
if existing.as_deref() == Some(next.as_str()) {
continue;
}
write_json(&path, &next, existing.is_some())?;
written.push(path);
}
Ok(written)
}
pub fn remove_hooks() -> Result<Vec<PathBuf>> {
let mut changed = Vec::new();
for (_, _, path) in hook_clients() {
let Ok(text) = std::fs::read_to_string(&path) else {
continue;
};
let next = without_entry(&text)?;
if next == text {
continue;
}
write_json(&path, &next, true)?;
changed.push(path);
}
Ok(changed)
}
pub fn hook_state(mode: crate::hook::Mode) -> Vec<(String, PathBuf, State)> {
hook_clients()
.into_iter()
.map(|(client, stanza, path)| {
let state = match (entry(stanza, mode), std::fs::read_to_string(&path)) {
(Ok(wanted), Ok(text)) => match holds_entry(&text, &wanted) {
Some(true) => State::Present,
_ if names_our_hook(&text) => State::Stale,
_ => State::Absent,
},
_ => State::Absent,
};
(client.name.clone(), path, state)
})
.collect()
}
fn entry(stanza: &Stanza, mode: crate::hook::Mode) -> Result<serde_json::Value> {
let mut block: serde_json::Value = serde_json::from_str(&stanza.text)
.context("the documented hook stanza is not valid JSON")?;
if mode != crate::hook::Mode::Soft {
if let Some(command) = block
.pointer("/hooks/PreToolUse/0/hooks/0/command")
.and_then(|v| v.as_str().map(str::to_string))
{
block["hooks"]["PreToolUse"][0]["hooks"][0]["command"] =
serde_json::Value::String(format!("{command} --mode {}", mode.as_str()));
}
}
Ok(block)
}
pub fn installed_mode(text: &str) -> Option<crate::hook::Mode> {
let value: serde_json::Value = serde_json::from_str(text).ok()?;
let list = value.pointer("/hooks/PreToolUse")?.as_array()?;
let ours = list.iter().find(|item| is_ours(item))?;
let command = ours["hooks"][0]["command"].as_str()?;
Some(if command.contains("--mode hard") {
crate::hook::Mode::Hard
} else if command.contains("--mode gate") || command.contains("--strict") {
crate::hook::Mode::Gate
} else {
crate::hook::Mode::Soft
})
}
fn names_our_hook(text: &str) -> bool {
serde_json::from_str::<serde_json::Value>(text)
.ok()
.map(|v| {
HOOK_EVENTS.iter().any(|event| {
v.pointer(&format!("/hooks/{event}"))
.and_then(|list| list.as_array())
.is_some_and(|list| list.iter().any(is_ours))
})
})
.unwrap_or(false)
}
fn with_entry(text: &str, wanted: &serde_json::Value) -> Result<String> {
let mut base: serde_json::Value = if text.trim().is_empty() {
serde_json::Value::Object(Default::default())
} else {
serde_json::from_str(text).context("the file is not valid JSON")?
};
for event in HOOK_EVENTS {
let Some(ours) = wanted["hooks"][event].get(0).cloned() else {
continue;
};
let list = base
.as_object_mut()
.context("the file is not a JSON object")?
.entry("hooks")
.or_insert_with(|| serde_json::json!({}))
.as_object_mut()
.context("`hooks` is not an object")?
.entry(event)
.or_insert_with(|| serde_json::Value::Array(Vec::new()))
.as_array_mut()
.with_context(|| format!("`hooks.{event}` is not an array"))?;
list.retain(|item| !is_ours(item));
list.push(ours);
}
Ok(serialize(&base))
}
fn holds_entry(text: &str, wanted: &serde_json::Value) -> Option<bool> {
let base: serde_json::Value = serde_json::from_str(text).ok()?;
base.as_object()?;
Some(HOOK_EVENTS.iter().all(|event| {
let ours: Vec<&serde_json::Value> = base
.pointer(&format!("/hooks/{event}"))
.and_then(|v| v.as_array())
.map(|list| list.iter().filter(|item| is_ours(item)).collect())
.unwrap_or_default();
match wanted["hooks"][event].get(0) {
Some(want) => ours.len() == 1 && ours[0] == want,
None => ours.is_empty(),
}
}))
}
fn without_entry(text: &str) -> Result<String> {
let mut base: serde_json::Value =
serde_json::from_str(text).context("the file is not valid JSON")?;
for event in HOOK_EVENTS {
if let Some(list) = base
.pointer_mut(&format!("/hooks/{event}"))
.and_then(|v| v.as_array_mut())
{
list.retain(|item| !is_ours(item));
let empty = list.is_empty();
if empty && let Some(hooks) = base.pointer_mut("/hooks").and_then(|v| v.as_object_mut())
{
hooks.remove(event);
}
}
}
if base
.pointer("/hooks")
.and_then(|v| v.as_object())
.is_some_and(|h| h.is_empty())
{
base.as_object_mut().map(|o| o.remove("hooks"));
}
Ok(serialize(&base))
}
fn is_ours(item: &serde_json::Value) -> bool {
item["hooks"]
.as_array()
.map(|inner| {
inner.iter().any(|h| {
h["command"]
.as_str()
.map(|c| c.contains("semlith") && c.contains(" hook"))
.unwrap_or(false)
})
})
.unwrap_or(false)
}
pub fn rules_state() -> Vec<(String, Option<PathBuf>, State)> {
clients::clients()
.iter()
.filter_map(|client| {
let stanza = client.rules_file()?;
let path = clientfile::resolve(stanza)?;
let state = match std::fs::read_to_string(&path) {
Ok(text) if between(&text) == Some(block()) => State::Present,
Ok(text) if text.contains(RULES_BEGIN) => State::Stale,
_ => State::Absent,
};
Some((client.name.clone(), Some(path), state))
})
.collect()
}
fn block() -> String {
clients::RULES.trim_end().to_string()
}
fn between(text: &str) -> Option<String> {
let start = text.find(RULES_BEGIN)? + RULES_BEGIN.len();
let end = text[start..].find(RULES_END)? + start;
Some(text[start..end].trim().to_string())
}
pub fn install_rules() -> Result<Vec<PathBuf>> {
let mut written = Vec::new();
for (_, path, state) in rules_state() {
let Some(path) = path else { continue };
if state == State::Present || !clientfile::client_present(&path) {
continue;
}
let existing = std::fs::read_to_string(&path).ok();
let next = with_block(existing.as_deref().unwrap_or(""));
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)
.with_context(|| format!("creating {}", parent.display()))?;
}
if existing.is_some() {
clientfile::back_up(&path)?;
}
clientfile::rewrite(&path, &next)?;
written.push(path);
}
Ok(written)
}
fn with_block(text: &str) -> String {
let marked = format!("{RULES_BEGIN}\n{}\n{RULES_END}\n", block());
let Some(start) = text.find(RULES_BEGIN) else {
let separator = if text.trim().is_empty() { "" } else { "\n" };
return format!("{}{separator}{marked}", text.trim_end());
};
let Some(end) = text[start..]
.find(RULES_END)
.map(|i| start + i + RULES_END.len())
else {
return format!("{}\n{marked}", text.trim_end());
};
format!("{}{marked}{}", &text[..start], text[end..].trim_start())
}
fn serialize(value: &serde_json::Value) -> String {
serde_json::to_string_pretty(value).unwrap_or_default() + "\n"
}
fn write_json(path: &Path, next: &str, existed: bool) -> Result<()> {
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)
.with_context(|| format!("creating {}", parent.display()))?;
}
if existed {
clientfile::back_up(path)?;
}
clientfile::rewrite(path, next)
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
fn ours() -> serde_json::Value {
json!({
"hooks": { "PreToolUse": [{
"matcher": "Read|Grep",
"hooks": [{ "type": "command", "command": "/opt/semlith hook" }]
}]}
})
}
#[test]
fn writing_the_hook_keeps_every_other_hook_in_the_file() {
let theirs = json!({
"hooks": { "PreToolUse": [{
"matcher": "Bash",
"hooks": [{ "type": "command", "command": "/usr/local/bin/audit" }]
}]},
"model": "something they chose"
})
.to_string();
let next = with_entry(&theirs, &ours()).unwrap();
assert!(next.contains("/usr/local/bin/audit"), "{next}");
assert!(next.contains("/opt/semlith hook"), "{next}");
assert!(next.contains("something they chose"), "{next}");
}
#[test]
fn writing_the_hook_twice_leaves_one_entry() {
let once = with_entry("{}", &ours()).unwrap();
let twice = with_entry(&once, &ours()).unwrap();
assert_eq!(once, twice, "a second write changed the file");
assert_eq!(twice.matches("/opt/semlith hook").count(), 1, "{twice}");
}
#[test]
fn removing_the_hook_restores_what_was_there_before() {
let theirs = serialize(&json!({
"hooks": { "PreToolUse": [{
"matcher": "Bash",
"hooks": [{ "type": "command", "command": "/usr/local/bin/audit" }]
}]}
}));
let with = with_entry(&theirs, &ours()).unwrap();
assert_eq!(
without_entry(&with).unwrap(),
theirs,
"removing semlith's hook did not restore the file"
);
}
#[test]
fn removing_the_only_hook_leaves_no_empty_array_behind() {
let with = with_entry("{}", &ours()).unwrap();
let without = without_entry(&with).unwrap();
assert!(!without.contains("PreToolUse"), "{without}");
assert!(!without.contains("hooks"), "{without}");
}
#[test]
fn removal_declines_a_directory_semlith_did_not_write() {
let dir = tempfile::tempdir().unwrap();
let ours = dir.path().join("ours");
std::fs::create_dir_all(&ours).unwrap();
std::fs::write(ours.join("SKILL.md"), clients::SKILL).unwrap();
assert!(ours_alone(&ours), "semlith's own copy was not recognised");
let theirs = dir.path().join("theirs");
std::fs::create_dir_all(&theirs).unwrap();
std::fs::write(theirs.join("SKILL.md"), "# my own skill\n").unwrap();
assert!(!ours_alone(&theirs), "somebody else's skill read as ours");
let shared = dir.path().join("shared");
std::fs::create_dir_all(&shared).unwrap();
std::fs::write(shared.join("SKILL.md"), clients::SKILL).unwrap();
std::fs::write(shared.join("notes.md"), "mine\n").unwrap();
assert!(
!ours_alone(&shared),
"a directory somebody added to is not semlith's to delete"
);
}
#[test]
fn a_file_that_merely_mentions_semlith_holds_no_hook() {
let theirs = json!({
"permissions": { "allow": ["Bash(semlith:*)"] },
"statusLine": { "command": "my-hook-script" },
"hooks": { "PostToolUse": [{ "matcher": "Edit", "hooks": [] }] }
})
.to_string();
assert!(!names_our_hook(&theirs), "{theirs}");
let with = with_entry(&theirs, &ours()).unwrap();
assert!(names_our_hook(&with), "{with}");
assert!(with.contains("PostToolUse"), "{with}");
}
#[test]
fn the_rule_block_is_written_once_and_replaced_in_place() {
let theirs = "# My rules\n\nAlways use tabs.\n";
let once = with_block(theirs);
assert!(once.contains("Always use tabs."), "{once}");
assert_eq!(once.matches(RULES_BEGIN).count(), 1, "{once}");
let twice = with_block(&once);
assert_eq!(twice, once, "a second write changed the file");
assert_eq!(twice.matches(RULES_BEGIN).count(), 1, "{twice}");
assert!(twice.contains("Always use tabs."), "{twice}");
}
#[test]
fn the_rule_block_keeps_what_follows_it() {
let text = format!("# Mine\n\n{RULES_BEGIN}\nold text\n{RULES_END}\n\nAnd mine again.\n");
let next = with_block(&text);
assert!(next.contains("And mine again."), "{next}");
assert!(!next.contains("old text"), "{next}");
assert!(next.starts_with("# Mine"), "{next}");
}
}