use std::sync::OnceLock;
const CLIENTS_DOC: &str = include_str!("../docs/clients.md");
const SECTION: &str = "### Setting it up in your client";
const HTTP_SECTION: &str = "### Connecting over HTTP";
const KEY_PLACEHOLDER: &str = "${SEMLITH_AGENT_KEY}";
const BIN_PLACEHOLDER: &str = "${SEMLITH_BIN}";
const RULES_PLACEHOLDER: &str = "${SEMLITH_RULES}";
pub const RULES: &str = include_str!("../docs/skill/RULES.md");
pub const SKILL: &str = include_str!("../docs/skill/SKILL.md");
#[derive(Debug, Clone, serde::Serialize)]
pub struct Stanza {
pub format: String,
pub text: String,
pub register: bool,
pub unregister: bool,
pub path: Option<String>,
pub os: Option<String>,
pub hook: bool,
pub skills: bool,
pub rules: bool,
pub scope: Scope,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, serde::Serialize)]
#[serde(rename_all = "lowercase")]
pub enum Scope {
#[default]
Global,
Project,
}
fn info(line: &str) -> Info {
let mut format = String::new();
let (mut register, mut unregister) = (false, false);
let (mut hook, mut skills, mut rules) = (false, false, false);
let (mut path, mut os, mut scope) = (None, None, Scope::Global);
for word in split_attributes(line) {
match word.split_once('=') {
Some(("path", value)) => path = Some(unquote(value).to_string()),
Some(("os", value)) => os = Some(unquote(value).to_string()),
Some(("scope", "project")) => scope = Scope::Project,
Some(("scope", _)) => scope = Scope::Global,
_ if word == "register" => register = true,
_ if word == "unregister" => unregister = true,
_ if word == "hook" => hook = true,
_ if word == "skills" => skills = true,
_ if word == "rules" => rules = true,
_ if format.is_empty() => format = word.to_string(),
_ => {}
}
}
Info {
format,
register,
unregister,
hook,
skills,
rules,
path,
os,
scope,
}
}
struct Info {
format: String,
register: bool,
unregister: bool,
hook: bool,
skills: bool,
rules: bool,
path: Option<String>,
os: Option<String>,
scope: Scope,
}
fn split_attributes(line: &str) -> Vec<String> {
let mut out = Vec::new();
let mut word = String::new();
let mut quoted = false;
for c in line.chars() {
match c {
'"' => {
quoted = !quoted;
word.push(c);
}
c if c.is_whitespace() && !quoted => {
if !word.is_empty() {
out.push(std::mem::take(&mut word));
}
}
c => word.push(c),
}
}
if !word.is_empty() {
out.push(word);
}
out
}
fn unquote(value: &str) -> &str {
value
.strip_prefix('"')
.and_then(|rest| rest.strip_suffix('"'))
.unwrap_or(value)
}
#[derive(Debug, Clone, serde::Serialize)]
pub struct Client {
pub name: String,
pub note: String,
pub group: String,
pub stanzas: Vec<Stanza>,
}
impl Client {
pub fn register_command(&self) -> Option<String> {
self.stanzas
.iter()
.find(|stanza| stanza.register)
.map(|stanza| stanza.text.split_whitespace().collect::<Vec<_>>().join(" "))
}
pub fn unregister_commands(&self) -> Vec<String> {
self.stanzas
.iter()
.filter(|stanza| stanza.unregister)
.map(|stanza| stanza.text.split_whitespace().collect::<Vec<_>>().join(" "))
.collect()
}
pub fn registers_globally(&self) -> bool {
self.stanzas
.iter()
.any(|stanza| stanza.register && stanza.scope == Scope::Global)
}
pub fn config_files(&self) -> impl Iterator<Item = &Stanza> {
self.stanzas
.iter()
.filter(|stanza| stanza.path.is_some() && !stanza.hook && !stanza.rules)
}
pub fn hook_stanza(&self) -> Option<&Stanza> {
self.stanzas
.iter()
.find(|stanza| stanza.hook && stanza.path.is_some())
}
pub fn skill_dirs(&self) -> impl Iterator<Item = &Stanza> {
self.stanzas
.iter()
.filter(|stanza| stanza.skills && stanza.path.is_some())
}
pub fn rules_file(&self) -> Option<&Stanza> {
self.stanzas
.iter()
.find(|stanza| stanza.rules && stanza.path.is_some())
}
pub fn needs_a_file_written(&self) -> bool {
!self.registers_globally() && self.config_files().next().is_some()
}
}
pub const UNREGISTERABLE: [&str; 3] = ["Crush", "Zed", "Roo Code"];
pub fn http_stanzas(key: &str) -> Vec<Stanza> {
static PARSED: OnceLock<Vec<Stanza>> = OnceLock::new();
PARSED
.get_or_init(|| parse_http(CLIENTS_DOC))
.iter()
.map(|stanza| Stanza {
text: stanza.text.replace(KEY_PLACEHOLDER, key),
..stanza.clone()
})
.collect()
}
fn parse_http(readme: &str) -> Vec<Stanza> {
let Some(start) = readme.find(HTTP_SECTION) else {
return Vec::new();
};
let rest = &readme[start + HTTP_SECTION.len()..];
let section = match rest.find("\n## ") {
Some(end) => &rest[..end],
None => rest,
};
let mut out = Vec::new();
let mut lines = section.lines();
while let Some(line) = lines.next() {
let Some(format) = line.strip_prefix("```") else {
continue;
};
let mut text = String::new();
for body in lines.by_ref() {
if body.starts_with("```") {
break;
}
text.push_str(body);
text.push('\n');
}
out.push(stanza(info(format), &text));
}
out
}
pub fn binary_path() -> &'static str {
static RESOLVED: OnceLock<String> = OnceLock::new();
RESOLVED.get_or_init(resolve_binary_path)
}
fn resolve_binary_path() -> String {
let Ok(running) = std::env::current_exe() else {
return "semlith".to_string();
};
let resolved = std::fs::canonicalize(&running).unwrap_or(running);
let text = resolved.to_string_lossy().into_owned();
if !cfg!(windows) {
return text;
}
match text.strip_prefix(r"\\?\UNC\") {
Some(rest) => format!(r"\\{rest}"),
None => text.strip_prefix(r"\\?\").unwrap_or(&text).to_string(),
}
.replace('\\', "/")
}
fn stanza(info: Info, text: &str) -> Stanza {
Stanza {
format: info.format,
text: text
.replace(BIN_PLACEHOLDER, binary_path())
.replace(RULES_PLACEHOLDER, RULES.trim_end()),
register: info.register,
unregister: info.unregister,
hook: info.hook,
skills: info.skills,
rules: info.rules,
path: info.path,
os: info.os,
scope: info.scope,
}
}
pub fn clients() -> &'static [Client] {
static PARSED: OnceLock<Vec<Client>> = OnceLock::new();
PARSED.get_or_init(|| parse(CLIENTS_DOC))
}
fn parse(readme: &str) -> Vec<Client> {
let Some(start) = readme.find(SECTION) else {
return Vec::new();
};
let rest = &readme[start + SECTION.len()..];
let end = ["\n## ", "\n### "]
.iter()
.filter_map(|marker| rest.find(marker))
.min();
let section = match end {
Some(end) => &rest[..end],
None => rest,
};
let mut out: Vec<Client> = Vec::new();
let mut group = String::from("terminal");
let mut lines = section.lines().peekable();
while let Some(line) = lines.next() {
if let Some(heading) = line.strip_prefix("#### ") {
group = match heading.trim() {
"Editors" => "editor",
"Desktop apps" => "desktop",
_ => "terminal",
}
.to_string();
continue;
}
let Some(after) = line.strip_prefix("**") else {
continue;
};
let Some((name, tail)) = after.split_once("**") else {
continue;
};
let mut note = tail.trim_start_matches([' ', '—', '-']).trim().to_string();
for next in lines.by_ref() {
if next.trim().is_empty() {
break;
}
if next.starts_with("```") || next.starts_with("**") {
break;
}
note.push(' ');
note.push_str(next.trim());
}
let mut stanzas = Vec::new();
while let Some(peeked) = lines.peek() {
if peeked.starts_with("**") || peeked.starts_with("#### ") {
break;
}
let line = lines.next().expect("just peeked");
let Some(format) = line.strip_prefix("```") else {
continue;
};
let mut text = String::new();
for body in lines.by_ref() {
if body.starts_with("```") {
break;
}
text.push_str(body);
text.push('\n');
}
stanzas.push(stanza(info(format), &text));
}
if !stanzas.is_empty() {
out.push(Client {
name: name.trim().to_string(),
note: squeeze(¬e),
group: group.clone(),
stanzas,
});
}
}
out
}
fn squeeze(note: &str) -> String {
note.replace("**", "")
.replace('`', "")
.split_whitespace()
.collect::<Vec<_>>()
.join(" ")
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn every_documented_client_is_parsed_with_at_least_one_stanza() {
let parsed = clients();
assert!(
parsed.len() >= 27,
"only {} clients parsed out of the CLIENTS_DOC",
parsed.len()
);
for client in parsed {
assert!(!client.name.is_empty());
assert!(!client.stanzas.is_empty(), "{} has no stanza", client.name);
for stanza in &client.stanzas {
assert!(
!stanza.text.trim().is_empty(),
"{} has an empty stanza",
client.name
);
}
}
}
#[test]
fn no_parsed_stanza_carries_a_store_path() {
for client in clients() {
for stanza in &client.stanzas {
assert!(
!stanza.text.contains("--store"),
"{} still passes --store:\n{}",
client.name,
stanza.text
);
}
}
}
#[test]
fn every_client_carries_a_group_read_from_the_readme() {
let parsed = clients();
let mut counts = std::collections::BTreeMap::new();
for client in parsed {
assert!(
["terminal", "editor", "desktop"].contains(&client.group.as_str()),
"{} is in group {:?}",
client.name,
client.group
);
*counts.entry(client.group.as_str()).or_insert(0usize) += 1;
}
assert_eq!(
counts.values().sum::<usize>(),
parsed.len(),
"the groups do not cover every client"
);
assert_eq!(counts.len(), 3, "a group is empty: {counts:?}");
}
#[test]
fn the_http_stanzas_carry_the_key_they_are_given() {
let stanzas = http_stanzas("sml_abc123");
assert!(!stanzas.is_empty(), "no HTTP stanza was parsed");
for stanza in &stanzas {
assert!(
stanza.text.contains("127.0.0.1:7365/mcp"),
"an HTTP stanza does not name the endpoint:\n{}",
stanza.text
);
assert!(
stanza.text.contains("Bearer sml_abc123"),
"an HTTP stanza does not carry the key it was given:\n{}",
stanza.text
);
assert!(
!stanza.text.contains(KEY_PLACEHOLDER),
"an HTTP stanza still carries the placeholder:\n{}",
stanza.text
);
}
}
#[test]
fn sixteen_clients_carry_a_registration_command() {
let with: Vec<&str> = clients()
.iter()
.filter(|client| client.register_command().is_some())
.map(|client| client.name.as_str())
.collect();
assert_eq!(with.len(), 16, "registration commands found: {with:?}");
}
#[test]
fn every_client_is_reachable_or_is_one_of_the_three_that_are_not() {
let mut unreachable = Vec::new();
let mut by_file = Vec::new();
for client in clients() {
if client.registers_globally() {
continue;
}
if client.needs_a_file_written() {
by_file.push(client.name.as_str());
continue;
}
unreachable.push(client.name.as_str());
}
assert_eq!(
unreachable,
UNREGISTERABLE.to_vec(),
"the set semlith cannot register has changed; by file: {by_file:?}"
);
}
#[test]
fn the_project_scoped_registrations_are_the_two_that_are_known_to_be() {
let project: Vec<&str> = clients()
.iter()
.filter(|client| {
client
.stanzas
.iter()
.any(|stanza| stanza.register && stanza.scope == Scope::Project)
})
.map(|client| client.name.as_str())
.collect();
assert_eq!(project, vec!["OpenCode", "Kilo Code"]);
for name in &project {
let client = clients()
.iter()
.find(|client| &client.name == name)
.expect("just found");
assert!(
!client.registers_globally(),
"{name} is counted as global as well as project-scoped"
);
assert!(
client.needs_a_file_written(),
"{name} has no global route in at all, which would make it unreachable"
);
}
}
#[test]
fn every_config_path_semlith_writes_is_user_level() {
for client in clients() {
for stanza in client.config_files() {
let path = stanza.path.as_deref().expect("filtered on Some");
assert!(
path.starts_with('~') || path.starts_with('%') || path.starts_with('/'),
"{}'s config path {path} is not user-level",
client.name
);
}
}
}
#[test]
fn no_registration_command_carries_a_key_or_an_endpoint() {
for client in clients() {
let Some(command) = client.register_command() else {
continue;
};
for forbidden in [KEY_PLACEHOLDER, "Bearer", "127.0.0.1:7365", "sml_"] {
assert!(
!command.contains(forbidden),
"{}'s registration command carries {forbidden}:\n{command}",
client.name
);
}
assert!(
command.contains("semlith"),
"{}'s registration command does not name semlith:\n{command}",
client.name
);
}
}
#[test]
fn claude_code_is_the_first_client_and_has_both_of_its_forms() {
let first = &clients()[0];
assert_eq!(first.name, "Claude Code");
assert!(
first
.stanzas
.iter()
.all(|s| ["json", "sh", "text"].contains(&s.format.as_str())),
"an unexpected fence language on Claude Code: {:?}",
first.stanzas.iter().map(|s| &s.format).collect::<Vec<_>>()
);
assert_eq!(
first.stanzas.iter().filter(|s| s.register).count(),
1,
"a client may document exactly one registration command"
);
assert!(
!first.unregister_commands().is_empty(),
"the entry being replaced is often not at the scope the new one goes to"
);
assert!(
first.stanzas[0].text.contains("http://127.0.0.1:7365/mcp"),
"the first stanza is not the endpoint:\n{}",
first.stanzas[0].text
);
assert_eq!(
first.register_command().as_deref(),
Some(
format!(
"claude mcp add --scope user semlith -- \"{}\" mcp",
binary_path()
)
.as_str()
)
);
assert!(first.registers_globally());
}
#[test]
fn every_registration_hands_the_client_the_resolved_path_whole() {
for client in clients() {
let Some(command) = client.register_command() else {
continue;
};
let (_, args) = crate::setup::argv(&command).unwrap_or_else(|| {
panic!("{}'s registration does not split: {command}", client.name)
});
assert!(
args.iter().any(|word| word.contains(binary_path())),
"{}'s registration does not hand over {} in one piece:\n{command}",
client.name,
binary_path()
);
}
}
#[test]
fn the_resolved_binary_path_is_absolute_and_not_verbatim() {
let path = binary_path();
assert!(
path.starts_with('/') || path.contains(":/"),
"the resolved binary path is not absolute: {path}"
);
assert!(
!path.contains('\\'),
"the resolved binary path is in the verbatim or backslash \
spelling, which no stanza can embed unescaped: {path}"
);
}
}