1use serde_json::{Map, Value, json};
17
18use crate::skill::{SKILL_FILE, SKILL_MD, SKILL_NAME};
19
20#[derive(Debug, Clone, Copy, PartialEq, Eq)]
22pub enum Format {
23 McpJson,
25 CodexToml,
27 OpencodeJson,
29}
30
31#[derive(Debug, Clone, Copy, PartialEq, Eq)]
33pub enum Kind {
34 Mcp,
35 Skill,
36}
37
38#[derive(Debug, Clone, Copy, PartialEq, Eq)]
40pub enum Scope {
41 User,
42 Project,
43}
44
45impl Kind {
46 pub fn as_str(self) -> &'static str {
47 match self {
48 Kind::Mcp => "mcp",
49 Kind::Skill => "skill",
50 }
51 }
52}
53
54impl Scope {
55 pub fn as_str(self) -> &'static str {
56 match self {
57 Scope::User => "user",
58 Scope::Project => "project",
59 }
60 }
61}
62
63pub struct ClientSpec {
65 pub id: &'static str,
66 pub title: &'static str,
68 pub format: Format,
69 pub mcp_user: &'static [&'static str],
71 pub mcp_project: &'static [&'static str],
73 pub skill_user: &'static [&'static str],
75 pub skill_project: &'static [&'static str],
76 pub markers: &'static [&'static [&'static str]],
78 pub note: Option<&'static str>,
80}
81
82pub const CLIENTS: &[ClientSpec] = &[
88 ClientSpec {
89 id: "claude-code",
90 title: "Claude Code",
91 format: Format::McpJson,
92 mcp_user: &[".claude.json"],
93 mcp_project: &[".mcp.json"],
94 skill_user: &[".claude", "skills"],
95 skill_project: &[".claude", "skills"],
96 markers: &[&[".claude"], &[".claude.json"]],
97 note: None,
98 },
99 ClientSpec {
100 id: "codex",
101 title: "Codex CLI",
102 format: Format::CodexToml,
103 mcp_user: &[".codex", "config.toml"],
104 mcp_project: &[".codex", "config.toml"],
105 skill_user: &[".codex", "skills"],
106 skill_project: &[".codex", "skills"],
107 markers: &[&[".codex"]],
108 note: None,
109 },
110 ClientSpec {
111 id: "opencode",
112 title: "OpenCode",
113 format: Format::OpencodeJson,
114 mcp_user: &[".config", "opencode", "opencode.json"],
115 mcp_project: &["opencode.json"],
116 skill_user: &[".config", "opencode", "skills"],
117 skill_project: &[".opencode", "skills"],
118 markers: &[&[".config", "opencode"]],
119 note: None,
120 },
121 ClientSpec {
122 id: "pi",
123 title: "pi",
124 format: Format::McpJson,
125 mcp_user: &[".pi", "agent", "mcp.json"],
126 mcp_project: &[".mcp.json"],
127 skill_user: &[".pi", "agent", "skills"],
128 skill_project: &[".pi", "skills"],
129 markers: &[&[".pi"]],
130 note: Some(
131 "pi has no MCP client built in: install an MCP extension (for example pi-mcp-adapter) \
132 to read this entry. The skill works as it is.",
133 ),
134 },
135];
136
137pub fn find(id: &str) -> Option<&'static ClientSpec> {
139 CLIENTS.iter().find(|c| c.id == id)
140}
141
142pub fn ids() -> Vec<&'static str> {
144 CLIENTS.iter().map(|c| c.id).collect()
145}
146
147#[derive(Debug, Clone)]
149pub struct ServerEntry {
150 pub name: String,
152 pub command: String,
154 pub args: Vec<String>,
155 pub env: Vec<(String, String)>,
157}
158
159impl ServerEntry {
160 pub fn new(name: &str, command: &str) -> Self {
161 Self {
162 name: name.to_owned(),
163 command: command.to_owned(),
164 args: vec!["mcp".to_owned()],
165 env: Vec::new(),
166 }
167 }
168}
169
170pub fn file(client: &ClientSpec, kind: Kind, scope: Scope) -> Vec<String> {
172 let base = match (kind, scope) {
173 (Kind::Mcp, Scope::User) => client.mcp_user,
174 (Kind::Mcp, Scope::Project) => client.mcp_project,
175 (Kind::Skill, Scope::User) => client.skill_user,
176 (Kind::Skill, Scope::Project) => client.skill_project,
177 };
178 let mut segments: Vec<String> = base.iter().map(|s| (*s).to_owned()).collect();
179 if kind == Kind::Skill {
180 segments.push(SKILL_NAME.to_owned());
181 segments.push(SKILL_FILE.to_owned());
182 }
183 segments
184}
185
186pub fn entry(format: Format, server: &ServerEntry) -> Value {
188 let mut env = Map::new();
189 for (name, value) in &server.env {
190 env.insert(name.clone(), Value::String(value.clone()));
191 }
192 if format == Format::OpencodeJson {
193 let mut command = vec![Value::String(server.command.clone())];
194 command.extend(server.args.iter().map(|a| Value::String(a.clone())));
195 let mut value = json!({ "type": "local", "command": command, "enabled": true });
196 if !env.is_empty() {
197 value["environment"] = Value::Object(env);
198 }
199 return value;
200 }
201 let mut value = json!({
202 "type": "stdio",
203 "command": server.command,
204 "args": server.args,
205 });
206 if !env.is_empty() {
207 value["env"] = Value::Object(env);
208 }
209 value
210}
211
212fn section(format: Format) -> &'static str {
214 match format {
215 Format::OpencodeJson => "mcp",
216 _ => "mcpServers",
217 }
218}
219
220fn merge_json(format: Format, existing: &str, server: &ServerEntry) -> Result<String, String> {
225 let mut root = if existing.trim().is_empty() {
226 let mut fresh = Map::new();
227 if format == Format::OpencodeJson {
228 fresh.insert(
229 "$schema".to_owned(),
230 Value::String("https://opencode.ai/config.json".to_owned()),
231 );
232 }
233 fresh
234 } else {
235 match serde_json::from_str::<Value>(existing) {
236 Ok(Value::Object(map)) => map,
237 _ => {
238 return Err(
239 "the file is not a JSON object; fix or move it and run again.".to_owned(),
240 );
241 }
242 }
243 };
244
245 let key = section(format);
246 let mut servers = match root.remove(key) {
247 Some(Value::Object(map)) => map,
248 _ => Map::new(),
249 };
250 servers.insert(server.name.clone(), entry(format, server));
251 root.insert(key.to_owned(), Value::Object(servers));
252 Ok(format!(
253 "{}\n",
254 serde_json::to_string_pretty(&Value::Object(root)).map_err(|e| e.to_string())?
255 ))
256}
257
258fn toml_string(text: &str) -> String {
260 format!("\"{}\"", text.replace('\\', "\\\\").replace('"', "\\\""))
261}
262
263fn toml_key(name: &str) -> String {
265 if !name.is_empty()
266 && name
267 .chars()
268 .all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-')
269 {
270 name.to_owned()
271 } else {
272 toml_string(name)
273 }
274}
275
276pub fn toml_table(server: &ServerEntry) -> String {
278 let args = server
279 .args
280 .iter()
281 .map(|a| toml_string(a))
282 .collect::<Vec<_>>()
283 .join(", ");
284 let mut out = format!(
285 "[mcp_servers.{}]\ncommand = {}\nargs = [{args}]\n",
286 toml_key(&server.name),
287 toml_string(&server.command),
288 );
289 if !server.env.is_empty() {
290 let pairs = server
291 .env
292 .iter()
293 .map(|(name, value)| format!("{} = {}", toml_key(name), toml_string(value)))
294 .collect::<Vec<_>>()
295 .join(", ");
296 out.push_str(&format!("env = {{ {pairs} }}\n"));
297 }
298 out
299}
300
301fn merge_toml(existing: &str, server: &ServerEntry) -> Result<String, String> {
307 let table = toml_table(server);
308 let header = format!("[mcp_servers.{}]", toml_key(&server.name));
309 let lines: Vec<&str> = existing.split('\n').collect();
310 let Some(start) = lines.iter().position(|line| line.trim() == header) else {
311 let body = existing.trim_end();
312 return Ok(if body.is_empty() {
313 table
314 } else {
315 format!("{body}\n\n{table}")
316 });
317 };
318 let subtable = format!("[mcp_servers.{}.", toml_key(&server.name));
319 let end = lines
320 .iter()
321 .enumerate()
322 .skip(start + 1)
323 .find(|(_, line)| {
324 let line = line.trim();
325 line.starts_with('[') && !line.starts_with(&subtable)
326 })
327 .map_or(lines.len(), |(at, _)| at);
328
329 let before = lines[..start].join("\n").trim_end().to_owned();
330 let after = lines[end..].join("\n").trim_start().to_owned();
331 let head = if before.is_empty() {
332 String::new()
333 } else {
334 format!("{before}\n\n")
335 };
336 let tail = if after.is_empty() {
337 String::new()
338 } else {
339 format!("\n{after}")
340 };
341 Ok(format!("{head}{table}{tail}"))
342}
343
344pub fn merge(
348 client: &ClientSpec,
349 kind: Kind,
350 existing: &str,
351 server: &ServerEntry,
352) -> Result<String, String> {
353 if kind == Kind::Skill {
354 return Ok(SKILL_MD.to_owned());
355 }
356 match client.format {
357 Format::CodexToml => merge_toml(existing, server),
358 format => merge_json(format, existing, server),
359 }
360}
361
362pub fn looks_like_ours(existing: &str) -> bool {
367 if existing.trim().is_empty() {
368 return true;
369 }
370 let Some(rest) = existing.strip_prefix("---") else {
371 return false;
372 };
373 let frontmatter = match rest.find("\n---") {
374 Some(end) => &rest[..end],
375 None => rest,
376 };
377 frontmatter
378 .lines()
379 .any(|line| line.trim_end() == format!("name: {SKILL_NAME}"))
380}
381
382pub fn describe(client: &ClientSpec, kind: Kind, scope: Scope, path: &str) -> String {
384 let what = match kind {
385 Kind::Mcp => "MCP server",
386 Kind::Skill => "skill",
387 };
388 format!("{} {what} ({}): {path}", client.title, scope.as_str())
389}