1use std::path::{Path, PathBuf};
61use std::process::Stdio;
62use std::time::Duration;
63
64use crate::cli_provider::CliSpec;
65use crate::config::{CliPreset, Provider};
66use crate::transcript::Source;
67
68pub const MCP_SERVER_NAME: &str = "recall-echo";
70
71const MCP_ADD_TIMEOUT: Duration = Duration::from_secs(30);
75
76const OUTPUT_EXCERPT: usize = 200;
78
79const ALREADY_PHRASES: [&str; 3] = ["already exists", "already configured", "already registered"];
81
82#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
86pub enum AgentCli {
87 ClaudeCode,
88 Codex,
89 Grok,
90 Gemini,
91}
92
93impl AgentCli {
94 pub const ALL: [AgentCli; 4] = [
97 AgentCli::ClaudeCode,
98 AgentCli::Codex,
99 AgentCli::Grok,
100 AgentCli::Gemini,
101 ];
102
103 #[must_use]
105 pub fn label(self) -> &'static str {
106 match self {
107 AgentCli::ClaudeCode => "claude-code",
108 AgentCli::Codex => "codex",
109 AgentCli::Grok => "grok",
110 AgentCli::Gemini => "gemini",
111 }
112 }
113
114 #[must_use]
116 pub fn provider(self) -> Provider {
117 match self {
118 AgentCli::ClaudeCode => Provider::ClaudeCode,
119 AgentCli::Codex => Provider::Codex,
120 AgentCli::Grok => Provider::Grok,
121 AgentCli::Gemini => Provider::Gemini,
122 }
123 }
124
125 #[must_use]
127 fn preset(self) -> CliPreset {
128 match self {
129 AgentCli::ClaudeCode => CliPreset::ClaudeCode,
130 AgentCli::Codex => CliPreset::Codex,
131 AgentCli::Grok => CliPreset::Grok,
132 AgentCli::Gemini => CliPreset::Gemini,
133 }
134 }
135
136 #[must_use]
141 pub fn capture_source(self) -> Option<Source> {
142 match self {
143 AgentCli::ClaudeCode => Some(Source::ClaudeCode),
144 AgentCli::Codex => Some(Source::Codex),
145 AgentCli::Grok => Some(Source::Grok),
146 AgentCli::Gemini => None,
147 }
148 }
149
150 #[must_use]
153 pub fn command(self) -> String {
154 CliSpec::preset(self.preset()).resolve_command()
155 }
156
157 #[must_use]
159 pub fn binary_path(self) -> Option<PathBuf> {
160 resolve_binary(&self.command())
161 }
162
163 #[must_use]
165 pub fn is_installed(self) -> bool {
166 self.binary_path().is_some()
167 }
168
169 #[must_use]
175 fn session_markers(self) -> &'static [&'static str] {
176 match self {
177 AgentCli::ClaudeCode => &["CLAUDECODE", "CLAUDE_CODE_ENTRYPOINT"],
178 AgentCli::Codex => &["CODEX_SANDBOX", "CODEX_SANDBOX_NETWORK_DISABLED"],
179 AgentCli::Grok => &["GROK_SESSION_ID"],
180 AgentCli::Gemini => &[],
181 }
182 }
183
184 #[must_use]
187 pub fn mcp_add_argv(self, exe: &str, entity_root: &Path) -> Vec<String> {
188 let server = vec![
189 exe.to_string(),
190 "mcp".to_string(),
191 "--entity-root".to_string(),
192 entity_root.display().to_string(),
193 ];
194 let mut argv = vec![self.command(), "mcp".into(), "add".into()];
195 match self {
196 AgentCli::ClaudeCode | AgentCli::Grok => {
198 argv.extend([
199 MCP_SERVER_NAME.into(),
200 "-s".into(),
201 "user".into(),
202 "--".into(),
203 ]);
204 }
205 AgentCli::Gemini => {
207 argv.extend(["-s".into(), "user".into(), MCP_SERVER_NAME.into()]);
208 }
209 AgentCli::Codex => argv.extend([MCP_SERVER_NAME.into(), "--".into()]),
211 }
212 argv.extend(server);
213 argv
214 }
215}
216
217impl std::fmt::Display for AgentCli {
218 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
219 f.write_str(self.label())
220 }
221}
222
223#[must_use]
225pub fn installed() -> Vec<AgentCli> {
226 AgentCli::ALL
227 .into_iter()
228 .filter(|cli| cli.is_installed())
229 .collect()
230}
231
232#[must_use]
237pub fn current() -> Option<AgentCli> {
238 AgentCli::ALL.into_iter().find(|cli| {
239 cli.session_markers()
240 .iter()
241 .any(|key| std::env::var_os(key).is_some_and(|value| !value.is_empty()))
242 })
243}
244
245#[must_use]
248pub fn capturing() -> Vec<Source> {
249 crate::transcript::detect_installed()
250 .iter()
251 .map(|adapter| adapter.source())
252 .collect()
253}
254
255#[must_use]
260pub fn resolve_binary(command: &str) -> Option<PathBuf> {
261 let command = command.trim();
262 if command.is_empty() {
263 return None;
264 }
265 if command.contains(std::path::MAIN_SEPARATOR) {
266 let path = PathBuf::from(command);
267 return is_executable(&path).then_some(path);
268 }
269 std::env::split_paths(&std::env::var_os("PATH")?)
270 .map(|dir| dir.join(command))
271 .find(|candidate| is_executable(candidate))
272}
273
274#[cfg(unix)]
275fn is_executable(path: &Path) -> bool {
276 use std::os::unix::fs::PermissionsExt;
277 std::fs::metadata(path)
278 .is_ok_and(|meta| meta.is_file() && meta.permissions().mode() & 0o111 != 0)
279}
280
281#[cfg(not(unix))]
282fn is_executable(path: &Path) -> bool {
283 path.is_file()
284}
285
286#[derive(Debug, Clone, PartialEq, Eq)]
290pub enum McpStatus {
291 Registered,
293 AlreadyRegistered,
295 Failed(String),
297}
298
299#[derive(Debug, Clone, PartialEq, Eq)]
302pub struct McpReport {
303 pub cli: AgentCli,
304 pub status: McpStatus,
305 pub command: String,
306}
307
308pub async fn register_mcp(cli: AgentCli, exe: &str, entity_root: &Path) -> McpReport {
313 let argv = cli.mcp_add_argv(exe, entity_root);
314 let command = shell_line(&argv);
315 let Some((binary, args)) = argv.split_first() else {
316 return McpReport {
317 cli,
318 status: McpStatus::Failed("empty command".into()),
319 command,
320 };
321 };
322
323 let mut process = tokio::process::Command::new(binary);
324 process
325 .args(args)
326 .stdin(Stdio::null())
327 .stdout(Stdio::piped())
328 .stderr(Stdio::piped())
329 .kill_on_drop(true);
330
331 let status = match tokio::time::timeout(MCP_ADD_TIMEOUT, process.output()).await {
332 Err(_) => McpStatus::Failed(format!(
333 "{binary} did not finish within {}s",
334 MCP_ADD_TIMEOUT.as_secs()
335 )),
336 Ok(Err(e)) => McpStatus::Failed(format!("could not run {binary}: {e}")),
337 Ok(Ok(output)) => {
338 let mut text = String::from_utf8_lossy(&output.stdout).to_string();
339 text.push_str(&String::from_utf8_lossy(&output.stderr));
340 classify(output.status.success(), &text)
341 }
342 };
343
344 McpReport {
345 cli,
346 status,
347 command,
348 }
349}
350
351#[must_use]
356pub fn classify(success: bool, output: &str) -> McpStatus {
357 let lower = output.to_lowercase();
358 let already = ALREADY_PHRASES.iter().any(|phrase| lower.contains(phrase));
359 match (success, already) {
360 (_, true) => McpStatus::AlreadyRegistered,
361 (true, false) => McpStatus::Registered,
362 (false, false) => McpStatus::Failed(first_meaningful_line(output)),
363 }
364}
365
366fn first_meaningful_line(output: &str) -> String {
368 let line = output
369 .lines()
370 .map(str::trim)
371 .find(|line| !line.is_empty())
372 .unwrap_or("no output");
373 truncate(strip_ansi(line).trim(), OUTPUT_EXCERPT)
374}
375
376fn strip_ansi(text: &str) -> String {
379 let mut out = String::with_capacity(text.len());
380 let mut chars = text.chars();
381 while let Some(c) = chars.next() {
382 if c != '\u{1b}' {
383 out.push(c);
384 continue;
385 }
386 if chars.next() != Some('[') {
387 continue;
388 }
389 for c in chars.by_ref() {
390 if c.is_ascii_alphabetic() {
391 break;
392 }
393 }
394 }
395 out
396}
397
398fn truncate(text: &str, max: usize) -> String {
399 if text.len() <= max {
400 return text.to_string();
401 }
402 let mut end = max;
403 while end > 0 && !text.is_char_boundary(end) {
404 end -= 1;
405 }
406 format!("{}…", &text[..end])
407}
408
409#[must_use]
411pub fn shell_line(argv: &[String]) -> String {
412 argv.iter()
413 .map(|arg| {
414 if arg.chars().any(char::is_whitespace) {
415 format!("\"{arg}\"")
416 } else {
417 arg.clone()
418 }
419 })
420 .collect::<Vec<_>>()
421 .join(" ")
422}
423
424#[cfg(test)]
425mod tests {
426 use super::*;
427
428 fn argv_of(cli: AgentCli) -> Vec<String> {
429 cli.mcp_add_argv("/usr/local/bin/recall-echo", Path::new("/home/d/entity"))
430 }
431
432 #[test]
435 fn claude_registration_puts_the_name_first_and_uses_a_separator() {
436 assert_eq!(
437 argv_of(AgentCli::ClaudeCode),
438 vec![
439 "claude",
440 "mcp",
441 "add",
442 "recall-echo",
443 "-s",
444 "user",
445 "--",
446 "/usr/local/bin/recall-echo",
447 "mcp",
448 "--entity-root",
449 "/home/d/entity",
450 ]
451 );
452 }
453
454 #[test]
457 fn gemini_registration_takes_the_name_after_its_flags_and_no_separator() {
458 let argv = argv_of(AgentCli::Gemini);
459 assert_eq!(
460 argv,
461 vec![
462 "gemini",
463 "mcp",
464 "add",
465 "-s",
466 "user",
467 "recall-echo",
468 "/usr/local/bin/recall-echo",
469 "mcp",
470 "--entity-root",
471 "/home/d/entity",
472 ]
473 );
474 assert!(!argv.iter().any(|arg| arg == "--"));
475 }
476
477 #[test]
478 fn grok_registration_matches_claudes_shape() {
479 let argv = argv_of(AgentCli::Grok);
480 assert_eq!(argv[0], "grok");
481 assert_eq!(argv[3..7], ["recall-echo", "-s", "user", "--"]);
482 }
483
484 #[test]
486 fn codex_registration_has_no_scope_flag() {
487 let argv = argv_of(AgentCli::Codex);
488 assert_eq!(
489 argv,
490 vec![
491 "codex",
492 "mcp",
493 "add",
494 "recall-echo",
495 "--",
496 "/usr/local/bin/recall-echo",
497 "mcp",
498 "--entity-root",
499 "/home/d/entity",
500 ]
501 );
502 assert!(!argv.iter().any(|arg| arg == "-s"));
503 }
504
505 #[test]
507 fn every_client_is_given_the_same_server_command() {
508 for cli in AgentCli::ALL {
509 let argv = argv_of(cli);
510 let tail = &argv[argv.len() - 4..];
511 assert_eq!(
512 tail,
513 [
514 "/usr/local/bin/recall-echo",
515 "mcp",
516 "--entity-root",
517 "/home/d/entity"
518 ],
519 "{cli}"
520 );
521 assert!(argv.contains(&MCP_SERVER_NAME.to_string()), "{cli}");
522 }
523 }
524
525 #[test]
528 fn claude_already_exists_is_not_a_failure() {
529 let status = classify(
530 false,
531 "MCP server recall-echo already exists in user config",
532 );
533 assert_eq!(status, McpStatus::AlreadyRegistered);
534 }
535
536 #[test]
538 fn gemini_already_configured_is_recognised_despite_success() {
539 let status = classify(
540 true,
541 "MCP server \"recall-echo\" is already configured within user settings.\n\
542 MCP server \"recall-echo\" updated in user settings.",
543 );
544 assert_eq!(status, McpStatus::AlreadyRegistered);
545 }
546
547 #[test]
548 fn a_silent_rewrite_reads_as_registered() {
549 let status = classify(true, "Added stdio MCP server 'recall-echo' to user config");
550 assert_eq!(status, McpStatus::Registered);
551 }
552
553 #[test]
554 fn a_real_failure_carries_the_clients_first_line() {
555 let status = classify(
556 false,
557 "\n\u{1b}[31mError: config is read-only\u{1b}[0m\ndetails",
558 );
559 assert_eq!(
560 status,
561 McpStatus::Failed("Error: config is read-only".into())
562 );
563 }
564
565 #[test]
566 fn a_failure_with_no_output_still_says_something() {
567 assert_eq!(
568 classify(false, " \n\n"),
569 McpStatus::Failed("no output".into())
570 );
571 }
572
573 #[test]
574 fn presets_and_sources_line_up_with_the_providers() {
575 assert_eq!(AgentCli::Grok.provider(), Provider::Grok);
576 assert_eq!(AgentCli::Codex.capture_source(), Some(Source::Codex));
577 assert_eq!(
578 AgentCli::Gemini.capture_source(),
579 None,
580 "gemini has no transcript adapter yet"
581 );
582 for cli in AgentCli::ALL {
583 assert_eq!(cli.provider().default_cli_preset(), Some(cli.preset()));
584 }
585 }
586
587 #[test]
590 fn the_command_is_the_presets_command() {
591 assert_eq!(AgentCli::ClaudeCode.command(), "claude");
592 assert_eq!(AgentCli::Gemini.command(), "gemini");
593 assert_eq!(AgentCli::Grok.command(), "grok");
594 assert_eq!(AgentCli::Codex.command(), "codex");
595 }
596
597 #[test]
598 fn an_explicit_path_is_resolved_without_consulting_path() {
599 let dir = tempfile::tempdir().unwrap();
600 let script = dir.path().join("mycli");
601 std::fs::write(&script, "#!/bin/sh\n").unwrap();
602 #[cfg(unix)]
603 {
604 use std::os::unix::fs::PermissionsExt;
605 std::fs::set_permissions(&script, std::fs::Permissions::from_mode(0o755)).unwrap();
606 }
607 assert_eq!(
608 resolve_binary(&script.display().to_string()),
609 Some(script.clone())
610 );
611 assert_eq!(
612 resolve_binary(&dir.path().join("absent").display().to_string()),
613 None
614 );
615 }
616
617 #[cfg(unix)]
618 #[test]
619 fn a_non_executable_file_is_not_a_binary() {
620 let dir = tempfile::tempdir().unwrap();
621 let file = dir.path().join("notes.txt");
622 std::fs::write(&file, "hello").unwrap();
623 assert_eq!(resolve_binary(&file.display().to_string()), None);
624 }
625
626 #[test]
627 fn an_empty_command_resolves_to_nothing() {
628 assert_eq!(resolve_binary(" "), None);
629 }
630
631 #[test]
632 fn a_shell_line_quotes_only_what_needs_it() {
633 let argv = vec!["claude".into(), "mcp".into(), "/a path/bin".into()];
634 assert_eq!(shell_line(&argv), "claude mcp \"/a path/bin\"");
635 }
636}