1use std::collections::BTreeSet;
7use std::io::Write;
8use std::path::{Path, PathBuf};
9
10use crate::install::{self, ClientSpec, Kind, Scope, ServerEntry};
11
12const 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
41struct 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
54fn 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 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
161pub 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
175fn 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
185fn 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
213pub fn run(args: &[String]) -> u8 {
215 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 ¬es {
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}