use serde_json::{Map, Value, json};
use crate::skill::{SKILL_FILE, SKILL_MD, SKILL_NAME};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Format {
McpJson,
CodexToml,
OpencodeJson,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Kind {
Mcp,
Skill,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Scope {
User,
Project,
}
impl Kind {
pub fn as_str(self) -> &'static str {
match self {
Kind::Mcp => "mcp",
Kind::Skill => "skill",
}
}
}
impl Scope {
pub fn as_str(self) -> &'static str {
match self {
Scope::User => "user",
Scope::Project => "project",
}
}
}
pub struct ClientSpec {
pub id: &'static str,
pub title: &'static str,
pub format: Format,
pub mcp_user: &'static [&'static str],
pub mcp_project: &'static [&'static str],
pub skill_user: &'static [&'static str],
pub skill_project: &'static [&'static str],
pub markers: &'static [&'static [&'static str]],
pub note: Option<&'static str>,
}
pub const CLIENTS: &[ClientSpec] = &[
ClientSpec {
id: "claude-code",
title: "Claude Code",
format: Format::McpJson,
mcp_user: &[".claude.json"],
mcp_project: &[".mcp.json"],
skill_user: &[".claude", "skills"],
skill_project: &[".claude", "skills"],
markers: &[&[".claude"], &[".claude.json"]],
note: None,
},
ClientSpec {
id: "codex",
title: "Codex CLI",
format: Format::CodexToml,
mcp_user: &[".codex", "config.toml"],
mcp_project: &[".codex", "config.toml"],
skill_user: &[".codex", "skills"],
skill_project: &[".codex", "skills"],
markers: &[&[".codex"]],
note: None,
},
ClientSpec {
id: "opencode",
title: "OpenCode",
format: Format::OpencodeJson,
mcp_user: &[".config", "opencode", "opencode.json"],
mcp_project: &["opencode.json"],
skill_user: &[".config", "opencode", "skills"],
skill_project: &[".opencode", "skills"],
markers: &[&[".config", "opencode"]],
note: None,
},
ClientSpec {
id: "pi",
title: "pi",
format: Format::McpJson,
mcp_user: &[".pi", "agent", "mcp.json"],
mcp_project: &[".mcp.json"],
skill_user: &[".pi", "agent", "skills"],
skill_project: &[".pi", "skills"],
markers: &[&[".pi"]],
note: Some(
"pi has no MCP client built in: install an MCP extension (for example pi-mcp-adapter) \
to read this entry. The skill works as it is.",
),
},
];
pub fn find(id: &str) -> Option<&'static ClientSpec> {
CLIENTS.iter().find(|c| c.id == id)
}
pub fn ids() -> Vec<&'static str> {
CLIENTS.iter().map(|c| c.id).collect()
}
#[derive(Debug, Clone)]
pub struct ServerEntry {
pub name: String,
pub command: String,
pub args: Vec<String>,
pub env: Vec<(String, String)>,
}
impl ServerEntry {
pub fn new(name: &str, command: &str) -> Self {
Self {
name: name.to_owned(),
command: command.to_owned(),
args: vec!["mcp".to_owned()],
env: Vec::new(),
}
}
}
pub fn file(client: &ClientSpec, kind: Kind, scope: Scope) -> Vec<String> {
let base = match (kind, scope) {
(Kind::Mcp, Scope::User) => client.mcp_user,
(Kind::Mcp, Scope::Project) => client.mcp_project,
(Kind::Skill, Scope::User) => client.skill_user,
(Kind::Skill, Scope::Project) => client.skill_project,
};
let mut segments: Vec<String> = base.iter().map(|s| (*s).to_owned()).collect();
if kind == Kind::Skill {
segments.push(SKILL_NAME.to_owned());
segments.push(SKILL_FILE.to_owned());
}
segments
}
pub fn entry(format: Format, server: &ServerEntry) -> Value {
let mut env = Map::new();
for (name, value) in &server.env {
env.insert(name.clone(), Value::String(value.clone()));
}
if format == Format::OpencodeJson {
let mut command = vec![Value::String(server.command.clone())];
command.extend(server.args.iter().map(|a| Value::String(a.clone())));
let mut value = json!({ "type": "local", "command": command, "enabled": true });
if !env.is_empty() {
value["environment"] = Value::Object(env);
}
return value;
}
let mut value = json!({
"type": "stdio",
"command": server.command,
"args": server.args,
});
if !env.is_empty() {
value["env"] = Value::Object(env);
}
value
}
fn section(format: Format) -> &'static str {
match format {
Format::OpencodeJson => "mcp",
_ => "mcpServers",
}
}
fn merge_json(format: Format, existing: &str, server: &ServerEntry) -> Result<String, String> {
let mut root = if existing.trim().is_empty() {
let mut fresh = Map::new();
if format == Format::OpencodeJson {
fresh.insert(
"$schema".to_owned(),
Value::String("https://opencode.ai/config.json".to_owned()),
);
}
fresh
} else {
match serde_json::from_str::<Value>(existing) {
Ok(Value::Object(map)) => map,
_ => {
return Err(
"the file is not a JSON object; fix or move it and run again.".to_owned(),
);
}
}
};
let key = section(format);
let mut servers = match root.remove(key) {
Some(Value::Object(map)) => map,
_ => Map::new(),
};
servers.insert(server.name.clone(), entry(format, server));
root.insert(key.to_owned(), Value::Object(servers));
Ok(format!(
"{}\n",
serde_json::to_string_pretty(&Value::Object(root)).map_err(|e| e.to_string())?
))
}
fn toml_string(text: &str) -> String {
format!("\"{}\"", text.replace('\\', "\\\\").replace('"', "\\\""))
}
fn toml_key(name: &str) -> String {
if !name.is_empty()
&& name
.chars()
.all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-')
{
name.to_owned()
} else {
toml_string(name)
}
}
pub fn toml_table(server: &ServerEntry) -> String {
let args = server
.args
.iter()
.map(|a| toml_string(a))
.collect::<Vec<_>>()
.join(", ");
let mut out = format!(
"[mcp_servers.{}]\ncommand = {}\nargs = [{args}]\n",
toml_key(&server.name),
toml_string(&server.command),
);
if !server.env.is_empty() {
let pairs = server
.env
.iter()
.map(|(name, value)| format!("{} = {}", toml_key(name), toml_string(value)))
.collect::<Vec<_>>()
.join(", ");
out.push_str(&format!("env = {{ {pairs} }}\n"));
}
out
}
fn merge_toml(existing: &str, server: &ServerEntry) -> Result<String, String> {
let table = toml_table(server);
let header = format!("[mcp_servers.{}]", toml_key(&server.name));
let lines: Vec<&str> = existing.split('\n').collect();
let Some(start) = lines.iter().position(|line| line.trim() == header) else {
let body = existing.trim_end();
return Ok(if body.is_empty() {
table
} else {
format!("{body}\n\n{table}")
});
};
let subtable = format!("[mcp_servers.{}.", toml_key(&server.name));
let end = lines
.iter()
.enumerate()
.skip(start + 1)
.find(|(_, line)| {
let line = line.trim();
line.starts_with('[') && !line.starts_with(&subtable)
})
.map_or(lines.len(), |(at, _)| at);
let before = lines[..start].join("\n").trim_end().to_owned();
let after = lines[end..].join("\n").trim_start().to_owned();
let head = if before.is_empty() {
String::new()
} else {
format!("{before}\n\n")
};
let tail = if after.is_empty() {
String::new()
} else {
format!("\n{after}")
};
Ok(format!("{head}{table}{tail}"))
}
pub fn merge(
client: &ClientSpec,
kind: Kind,
existing: &str,
server: &ServerEntry,
) -> Result<String, String> {
if kind == Kind::Skill {
return Ok(SKILL_MD.to_owned());
}
match client.format {
Format::CodexToml => merge_toml(existing, server),
format => merge_json(format, existing, server),
}
}
pub fn looks_like_ours(existing: &str) -> bool {
if existing.trim().is_empty() {
return true;
}
let Some(rest) = existing.strip_prefix("---") else {
return false;
};
let frontmatter = match rest.find("\n---") {
Some(end) => &rest[..end],
None => rest,
};
frontmatter
.lines()
.any(|line| line.trim_end() == format!("name: {SKILL_NAME}"))
}
pub fn describe(client: &ClientSpec, kind: Kind, scope: Scope, path: &str) -> String {
let what = match kind {
Kind::Mcp => "MCP server",
Kind::Skill => "skill",
};
format!("{} {what} ({}): {path}", client.title, scope.as_str())
}