Skip to main content

jev_repl/
installer.rs

1//! `jev install` — put the MCP server and the skill where an agent will find them.
2//!
3//! The decisions all live in [`crate::install`]; this is the part that reads the command line,
4//! finds the home directory, and writes the files it is told to.
5
6use std::collections::BTreeSet;
7use std::io::Write;
8use std::path::{Path, PathBuf};
9
10use crate::install::{self, ClientSpec, Kind, Scope, ServerEntry};
11
12/// 0 when it worked, 1 when a file did not, 2 when the command line did not parse.
13const OK: u8 = 0;
14const FAILED: u8 = 1;
15const BAD_USAGE: u8 = 2;
16
17pub fn help() -> String {
18    format!(
19        "jev install — register jev with a coding agent\n\n\
20         \x20 jev install                     the MCP server and the skill, for every agent found\n\
21         \x20 jev install mcp                 just the MCP server\n\
22         \x20 jev install skill               just the skill\n\
23         \x20 jev install --list              the agents, and where each one's files go\n\n\
24         Options\n\
25         \x20 --client <id[,id]>     {}, or all\n\
26         \x20                        (default: every agent installed for this user)\n\
27         \x20 --scope user|project   this user (default) or the repository in front of you\n\
28         \x20 --name <name>          file the server under this name (default jev)\n\
29         \x20 --command <path>       the program the agent runs (default: this binary)\n\
30         \x20 --env NAME=VALUE       an environment variable for the server; repeatable\n\
31         \x20 --root <dir>           the project root for --scope project (default: this directory)\n\
32         \x20 --home <dir>           the home directory to install under (default: yours)\n\
33         \x20 --dry-run              say what would be written, write nothing\n\
34         \x20 --force                replace a SKILL.md that is not ours\n\n\
35         Nothing else in a config file is touched: the entry is merged in, and a file that\n\
36         cannot be parsed is reported rather than rewritten.\n",
37        install::ids().join(", ")
38    )
39}
40
41/// What `jev install` was asked to do.
42struct Options {
43    kinds: Vec<Kind>,
44    clients: Option<Vec<&'static ClientSpec>>,
45    scope: Scope,
46    server: ServerEntry,
47    root: PathBuf,
48    home: PathBuf,
49    dry_run: bool,
50    force: bool,
51    list: bool,
52}
53
54/// The program an agent should run to start the server: this binary, as an absolute path.
55fn self_command() -> String {
56    std::env::current_exe()
57        .ok()
58        .and_then(|path| path.canonicalize().ok().or(Some(path)))
59        .map_or_else(|| "jev".to_owned(), |path| path.display().to_string())
60}
61
62fn home_dir() -> PathBuf {
63    std::env::var_os("HOME")
64        .or_else(|| std::env::var_os("USERPROFILE"))
65        .map_or_else(|| PathBuf::from("."), PathBuf::from)
66}
67
68fn parse_options(args: &[String]) -> Result<Options, String> {
69    let mut options = Options {
70        kinds: Vec::new(),
71        clients: None,
72        scope: Scope::User,
73        server: ServerEntry::new("jev", &self_command()),
74        root: std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")),
75        home: home_dir(),
76        dry_run: false,
77        force: false,
78        list: false,
79    };
80
81    let mut i = 0;
82    while i < args.len() {
83        let arg = &args[i];
84        let (name, inline) = match arg.strip_prefix("--").and_then(|_| arg.split_once('=')) {
85            Some((name, value)) => (name.to_owned(), Some(value.to_owned())),
86            None => (arg.clone(), None),
87        };
88        let mut value = || -> Result<String, String> {
89            match &inline {
90                Some(v) => Ok(v.clone()),
91                None => {
92                    i += 1;
93                    args.get(i)
94                        .cloned()
95                        .ok_or_else(|| format!("{name} needs a value."))
96                }
97            }
98        };
99        match name.as_str() {
100            "mcp" => options.kinds.push(Kind::Mcp),
101            "skill" => options.kinds.push(Kind::Skill),
102            "all" => options.kinds.extend([Kind::Mcp, Kind::Skill]),
103            "--list" | "--help" | "-h" => options.list = true,
104            "--client" => {
105                let mut chosen = options.clients.take().unwrap_or_default();
106                for id in value()?
107                    .split(',')
108                    .map(str::trim)
109                    .filter(|id| !id.is_empty())
110                {
111                    if id == "all" {
112                        chosen.extend(install::CLIENTS.iter());
113                        continue;
114                    }
115                    let client = install::find(id)
116                        .ok_or_else(|| format!("unknown agent {id:?}; --list has them."))?;
117                    chosen.push(client);
118                }
119                chosen.dedup_by(|a, b| a.id == b.id);
120                options.clients = Some(chosen);
121            }
122            "--scope" => {
123                options.scope = match value()?.as_str() {
124                    "user" => Scope::User,
125                    "project" => Scope::Project,
126                    _ => return Err("--scope takes user or project.".to_owned()),
127                }
128            }
129            "--name" => options.server.name = value()?,
130            "--command" => {
131                // A command given by hand replaces the whole invocation, not just the program.
132                options.server.command = value()?;
133                options.server.args = vec!["mcp".to_owned()];
134            }
135            "--env" => {
136                let pair = value()?;
137                let Some((name, text)) = pair.split_once('=') else {
138                    return Err("--env takes NAME=VALUE.".to_owned());
139                };
140                if name.is_empty() {
141                    return Err("--env takes NAME=VALUE.".to_owned());
142                }
143                options.server.env.push((name.to_owned(), text.to_owned()));
144            }
145            "--root" => options.root = PathBuf::from(value()?),
146            "--home" => options.home = PathBuf::from(value()?),
147            "--dry-run" => options.dry_run = true,
148            "--force" => options.force = true,
149            other => return Err(format!("unknown option {other:?}; jev install --help.")),
150        }
151        i += 1;
152    }
153
154    if options.kinds.is_empty() {
155        options.kinds = vec![Kind::Mcp, Kind::Skill];
156    }
157    options.kinds.dedup();
158    Ok(options)
159}
160
161/// The agents this user has, judged by whether their directories exist.
162pub fn detect(home: &Path) -> Vec<&'static ClientSpec> {
163    install::CLIENTS
164        .iter()
165        .filter(|client| {
166            client.markers.iter().any(|marker| {
167                let mut path = home.to_path_buf();
168                path.extend(marker.iter());
169                path.exists()
170            })
171        })
172        .collect()
173}
174
175/// Where a file lands on this machine.
176fn path_of(options: &Options, client: &ClientSpec, kind: Kind) -> PathBuf {
177    let mut path = match options.scope {
178        Scope::User => options.home.clone(),
179        Scope::Project => options.root.clone(),
180    };
181    path.extend(install::file(client, kind, options.scope));
182    path
183}
184
185/// The agents and their paths, for `--list`.
186fn list(options: &Options, out: &mut dyn FnMut(&str)) {
187    let found: Vec<&str> = detect(&options.home).iter().map(|c| c.id).collect();
188    for client in install::CLIENTS {
189        let installed = if found.contains(&client.id) {
190            " — installed"
191        } else {
192            ""
193        };
194        out(&format!("{} ({}){installed}\n", client.title, client.id));
195        for scope in [Scope::User, Scope::Project] {
196            for kind in [Kind::Mcp, Kind::Skill] {
197                let base = if scope == Scope::User { "~" } else { "." };
198                let path = install::file(client, kind, scope).join("/");
199                out(&format!(
200                    "  {:<5} {:<7} {base}/{path}\n",
201                    kind.as_str(),
202                    scope.as_str()
203                ));
204            }
205        }
206        if let Some(note) = client.note {
207            out(&format!("  note: {note}\n"));
208        }
209        out("\n");
210    }
211}
212
213/// Install, or say what installing would do.
214pub fn run(args: &[String]) -> u8 {
215    // Written rather than printed: `jev install --list | head` closes the pipe, and a summary
216    // line is not worth a panic.
217    let mut out = |text: &str| {
218        let mut stdout = std::io::stdout().lock();
219        let _ = stdout.write_all(text.as_bytes());
220        let _ = stdout.flush();
221    };
222    let options = match parse_options(args) {
223        Ok(options) => options,
224        Err(e) => {
225            eprintln!("jev install: {e}");
226            return BAD_USAGE;
227        }
228    };
229    if options.list {
230        out(&help());
231        out("\n");
232        list(&options, &mut out);
233        return OK;
234    }
235
236    let clients = match &options.clients {
237        Some(chosen) => chosen.clone(),
238        None => {
239            let found = detect(&options.home);
240            if found.is_empty() {
241                eprintln!(
242                    "jev install: no agent found under this home directory.\n\
243                     Pass --client <{}>, or --client all; jev install --list has them.",
244                    install::ids().join("|")
245                );
246                return FAILED;
247            }
248            found
249        }
250    };
251
252    let mut notes: BTreeSet<&'static str> = BTreeSet::new();
253    let mut failed = false;
254    let mut wrote = 0usize;
255    for client in clients {
256        for kind in &options.kinds {
257            let kind = *kind;
258            let path = path_of(&options, client, kind);
259            let shown = path.display().to_string();
260            let where_ = install::describe(client, kind, options.scope, &shown);
261            let existing = if path.exists() {
262                match std::fs::read_to_string(&path) {
263                    Ok(text) => text,
264                    Err(e) => {
265                        eprintln!("jev install: could not read {shown}: {e}");
266                        failed = true;
267                        continue;
268                    }
269                }
270            } else {
271                String::new()
272            };
273            if kind == Kind::Skill && !options.force && !install::looks_like_ours(&existing) {
274                eprintln!(
275                    "jev install: {shown} was not written by jev; pass --force to replace it."
276                );
277                failed = true;
278                continue;
279            }
280
281            let merged = match install::merge(client, kind, &existing, &options.server) {
282                Ok(text) => text,
283                Err(e) => {
284                    eprintln!("jev install: {shown}: {e}");
285                    failed = true;
286                    continue;
287                }
288            };
289            if merged == existing {
290                out(&format!("{where_} — already there\n"));
291                continue;
292            }
293            let verb = if existing.is_empty() {
294                "created"
295            } else {
296                "updated"
297            };
298            if options.dry_run {
299                out(&format!("{where_} — would be {verb}\n"));
300                continue;
301            }
302            if let Some(parent) = path.parent()
303                && let Err(e) = std::fs::create_dir_all(parent)
304            {
305                eprintln!("jev install: could not make {}: {e}", parent.display());
306                failed = true;
307                continue;
308            }
309            if let Err(e) = std::fs::write(&path, merged) {
310                eprintln!("jev install: could not write {shown}: {e}");
311                failed = true;
312                continue;
313            }
314            out(&format!("{where_} — {verb}\n"));
315            wrote += 1;
316            if let Some(note) = client.note {
317                notes.insert(note);
318            }
319        }
320    }
321
322    for note in &notes {
323        out(&format!("\nnote: {note}\n"));
324    }
325    if wrote > 0 && options.kinds.contains(&Kind::Mcp) {
326        out(
327            "\nThe server is started by the agent, so it inherits that agent's environment:\n\
328             set TYPESAFE_API_KEY there, or pass --env TYPESAFE_API_KEY=… to write it into the \
329             config.\nRestart the agent to pick up the new server.\n",
330        );
331    }
332    if failed { FAILED } else { OK }
333}