Skip to main content

jev_repl/
install.rs

1//! Where the jev MCP server and the jev skill go, for each agent that can host them.
2//!
3//! Every agent keeps its own file in its own shape, but the job is always the same: put one entry
4//! in a config file without disturbing what is already there, and drop one `SKILL.md` in a
5//! directory. This module is the pure half — paths and merged text, no filesystem — so the table
6//! can be read in a test, and `jev install` stays a thin wrapper over it.
7//!
8//! ```
9//! # use jev_repl::install::{self, Kind, Scope, ServerEntry};
10//! let client = install::find("codex").unwrap();
11//! let server = ServerEntry::new("jev", "/opt/jev");
12//! let merged = install::merge(client, Kind::Mcp, "", &server).unwrap();
13//! assert!(merged.contains("[mcp_servers.jev]"));
14//! ```
15
16use serde_json::{Map, Value, json};
17
18use crate::skill::{SKILL_FILE, SKILL_MD, SKILL_NAME};
19
20/// How a client writes down an MCP server.
21#[derive(Debug, Clone, Copy, PartialEq, Eq)]
22pub enum Format {
23    /// `mcpServers` in a JSON file, the shape Claude Code reads.
24    McpJson,
25    /// A `[mcp_servers.<name>]` table in Codex's `config.toml`.
26    CodexToml,
27    /// `mcp` in a JSON file, with the command as a list.
28    OpencodeJson,
29}
30
31/// What is being installed.
32#[derive(Debug, Clone, Copy, PartialEq, Eq)]
33pub enum Kind {
34    Mcp,
35    Skill,
36}
37
38/// Installed for this user, or into the repository in front of you.
39#[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
63/// One agent: what to call it, where its files live, and what shape they are in.
64pub struct ClientSpec {
65    pub id: &'static str,
66    /// The name a person would recognise.
67    pub title: &'static str,
68    pub format: Format,
69    /// The config file the MCP entry goes in, under the home directory.
70    pub mcp_user: &'static [&'static str],
71    /// The same, under the project root.
72    pub mcp_project: &'static [&'static str],
73    /// The directory skills live in, which the skill's own folder goes inside.
74    pub skill_user: &'static [&'static str],
75    pub skill_project: &'static [&'static str],
76    /// Paths under the home directory that mean this agent is installed.
77    pub markers: &'static [&'static [&'static str]],
78    /// Anything a person needs to know after the file is written.
79    pub note: Option<&'static str>,
80}
81
82/// The four agents, and where each keeps its things.
83///
84/// Claude Code and Codex read the config formats their own docs describe. OpenCode keeps MCP
85/// servers under `mcp` with the command as a list. pi has no MCP client of its own — the entry is
86/// written in the shape its MCP extensions read, which is Claude's.
87pub 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
137/// The client with this id, if it is one we know.
138pub fn find(id: &str) -> Option<&'static ClientSpec> {
139    CLIENTS.iter().find(|c| c.id == id)
140}
141
142/// Every client id, for the help text and for `--client all`.
143pub fn ids() -> Vec<&'static str> {
144    CLIENTS.iter().map(|c| c.id).collect()
145}
146
147/// The server as a client writes it down.
148#[derive(Debug, Clone)]
149pub struct ServerEntry {
150    /// The key it is filed under; also what an agent prefixes its tools with.
151    pub name: String,
152    /// The program to run.
153    pub command: String,
154    pub args: Vec<String>,
155    /// Environment for the server process, over what it inherits, in the order it was given.
156    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
170/// The file one kind of install writes for one client at one scope.
171pub 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
186/// The MCP entry, in the shape this client reads.
187pub 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
212/// The key an MCP server is filed under in this client's config.
213fn section(format: Format) -> &'static str {
214    match format {
215        Format::OpencodeJson => "mcp",
216        _ => "mcpServers",
217    }
218}
219
220/// The config file with the entry in it, keeping everything else exactly as it was.
221///
222/// An empty or missing file becomes a fresh one; a file that is not JSON is left alone and
223/// reported, because a hand-edited config is not something to guess at.
224fn 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
258/// A TOML basic string: the two escapes a path or a key can actually need.
259fn toml_string(text: &str) -> String {
260    format!("\"{}\"", text.replace('\\', "\\\\").replace('"', "\\\""))
261}
262
263/// A bare key where TOML allows one, quoted where it does not.
264fn 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
276/// The `[mcp_servers.<name>]` table Codex reads.
277pub 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
301/// The same, for a file of TOML.
302///
303/// There is no TOML parser here on purpose: a config file is someone's, and a round trip through a
304/// parser would reflow their comments and reorder their tables. So the table is found as text and
305/// replaced as text — from its header to the next table that is not one of its own subtables.
306fn 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
344/// What this file should contain once jev is installed, given what it contains now.
345///
346/// `existing` is the current text, or `""` when there is no file yet.
347pub 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
362/// Whether a `SKILL.md` already there is a copy of ours, possibly an older one.
363///
364/// Installing over our own file is an upgrade; installing over a file someone wrote or edited is
365/// not, so it takes `--force`. The frontmatter name is the only mark a skill file carries.
366pub 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
382/// One line describing a file, for `--dry-run` and for the summary.
383pub 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}