Skip to main content

recall_echo/
agent_cli.rs

1// This Source Code Form is subject to the terms of the Mozilla Public
2// License, v. 2.0. If a copy of the MPL was not distributed with this
3// file, You can obtain one at https://mozilla.org/MPL/2.0/.
4
5//! Which agent CLIs this machine has, and how recall-echo wires itself into
6//! them.
7//!
8//! Three questions are asked about the same four CLIs, and before this module
9//! each was answered somewhere else, or not at all:
10//!
11//! - *which one extracts knowledge* — [`crate::config::Provider`], chosen by a
12//!   six-item menu that only ever auto-detected Claude Code;
13//! - *whose sessions get captured* — [`crate::transcript::detect_installed`],
14//!   which answers for the three CLIs that have transcript adapters;
15//! - *who can query memory over MCP* — nobody, because registering the server
16//!   was a command in the README.
17//!
18//! [`AgentCli`] is the one place that knows a CLI end to end: its binary, its
19//! provider preset, its transcript source if it has one, and the exact
20//! `mcp add` incantation it wants. `init` asks here and stops guessing.
21//!
22//! # Detection
23//!
24//! A CLI counts as installed when its **binary resolves** — on `PATH`, or at
25//! the absolute path an override names. Recorded transcripts are a different
26//! question (a machine can hold Codex sessions long after Codex was
27//! uninstalled) and the capture layer already answers it; both extraction and
28//! MCP registration need a binary to run, so that is what is tested here.
29//!
30//! The binary name comes from [`CliSpec`], so `CLAUDE_BIN`, `GEMINI_BIN`,
31//! `GROK_BIN` and `CODEX_BIN` steer detection exactly as they steer the calls
32//! made later.
33//!
34//! # `mcp add` is four different commands
35//!
36//! Every one of these CLIs registers stdio MCP servers, and no two agree on how.
37//! Each argv below was verified against the installed binary (`claude` 2.x,
38//! `gemini` 0.27, `grok`, `codex` 0.146) by running it against a throwaway
39//! `HOME` and reading back the config it wrote:
40//!
41//! ```text
42//! claude mcp add recall-echo -s user -- <exe> mcp --entity-root <root>
43//! gemini mcp add -s user recall-echo  <exe> mcp --entity-root <root>
44//! grok   mcp add recall-echo -s user -- <exe> mcp --entity-root <root>
45//! codex  mcp add recall-echo          -- <exe> mcp --entity-root <root>
46//! ```
47//!
48//! Gemini takes the server name *after* its flags and rejects a `--` separator;
49//! Codex has no scope flag at all and always writes `~/.codex/config.toml`.
50//!
51//! # Idempotency
52//!
53//! Every client stores servers in a map keyed by name, so re-registering the
54//! same name with the same argv is a no-op by construction. What differs is
55//! what they say about it: Claude refuses with a non-zero exit and "already
56//! exists", Gemini says "already configured" and rewrites, Grok and Codex
57//! rewrite silently. [`classify`] folds those four dialects into one
58//! [`McpStatus`] so the user reads a result rather than four vendors' opinions.
59
60use 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
68/// Name recall-echo registers its MCP server under, in every client.
69pub const MCP_SERVER_NAME: &str = "recall-echo";
70
71/// Wall-clock limit for one `mcp add`. It is a local config write; anything
72/// slower is a CLI waiting on something it should not be waiting on, and `init`
73/// must not hang on it.
74const MCP_ADD_TIMEOUT: Duration = Duration::from_secs(30);
75
76/// Bytes of a failing client's output carried into the report.
77const OUTPUT_EXCERPT: usize = 200;
78
79/// Phrases a client uses to say the server was already there.
80const ALREADY_PHRASES: [&str; 3] = ["already exists", "already configured", "already registered"];
81
82// ── The CLIs ─────────────────────────────────────────────────────────────
83
84/// An agent CLI recall-echo can extract with, capture from, or serve over MCP.
85#[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    /// Every known CLI, in preference order: the first installed one is the
95    /// fallback default when nothing better identifies itself.
96    pub const ALL: [AgentCli; 4] = [
97        AgentCli::ClaudeCode,
98        AgentCli::Codex,
99        AgentCli::Grok,
100        AgentCli::Gemini,
101    ];
102
103    /// The name used in config, prompts and reports.
104    #[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    /// The extraction provider that spawns this CLI.
115    #[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    /// The CLI preset holding this CLI's binary name and flags.
126    #[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    /// The transcript adapter that reads this CLI's sessions, if one exists.
137    ///
138    /// Gemini has none yet: it can extract and it can query memory over MCP,
139    /// but its own sessions are not captured.
140    #[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    /// The binary to look for and to run, honouring the preset's `*_BIN`
151    /// environment override.
152    #[must_use]
153    pub fn command(self) -> String {
154        CliSpec::preset(self.preset()).resolve_command()
155    }
156
157    /// Where this CLI's binary lives, if it is on this machine.
158    #[must_use]
159    pub fn binary_path(self) -> Option<PathBuf> {
160        resolve_binary(&self.command())
161    }
162
163    /// True when this CLI's binary resolves.
164    #[must_use]
165    pub fn is_installed(self) -> bool {
166        self.binary_path().is_some()
167    }
168
169    /// Environment variables this CLI sets in the processes it spawns.
170    ///
171    /// Only markers observed in a real child environment are listed, because a
172    /// wrong guess here silently changes a menu default. Gemini exports no such
173    /// marker, so a session under Gemini is simply not identified.
174    #[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    /// The full `mcp add` argv registering recall-echo's MCP server with this
185    /// client. See the module docs for why all four differ.
186    #[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            // Name, then flags, then `--`, then the server command.
197            AgentCli::ClaudeCode | AgentCli::Grok => {
198                argv.extend([
199                    MCP_SERVER_NAME.into(),
200                    "-s".into(),
201                    "user".into(),
202                    "--".into(),
203                ]);
204            }
205            // Flags first: the name is a positional and `--` is not accepted.
206            AgentCli::Gemini => {
207                argv.extend(["-s".into(), "user".into(), MCP_SERVER_NAME.into()]);
208            }
209            // No scope flag; registration is always global.
210            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/// Every agent CLI whose binary resolves on this machine.
224#[must_use]
225pub fn installed() -> Vec<AgentCli> {
226    AgentCli::ALL
227        .into_iter()
228        .filter(|cli| cli.is_installed())
229        .collect()
230}
231
232/// The CLI this process is running under, when it says so.
233///
234/// Used only to pick a menu default, so a false negative costs a keystroke and
235/// there is no cost at all to the CLIs that stay anonymous.
236#[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/// Which CLIs have actually recorded sessions here — the capture layer's own
246/// answer, mapped back onto [`AgentCli`].
247#[must_use]
248pub fn capturing() -> Vec<Source> {
249    crate::transcript::detect_installed()
250        .iter()
251        .map(|adapter| adapter.source())
252        .collect()
253}
254
255// ── Binary resolution ────────────────────────────────────────────────────
256
257/// Find `command` the way a shell would: as a path if it looks like one,
258/// otherwise by walking `PATH`.
259#[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// ── MCP registration ─────────────────────────────────────────────────────
287
288/// What happened when recall-echo's MCP server was offered to one client.
289#[derive(Debug, Clone, PartialEq, Eq)]
290pub enum McpStatus {
291    /// Newly written to that client's config.
292    Registered,
293    /// The client already had a server under this name.
294    AlreadyRegistered,
295    /// The client refused, or could not be run. Carries its complaint.
296    Failed(String),
297}
298
299/// One client's outcome, with the command that produced it — so a failure can
300/// be handed to the user verbatim instead of described.
301#[derive(Debug, Clone, PartialEq, Eq)]
302pub struct McpReport {
303    pub cli: AgentCli,
304    pub status: McpStatus,
305    pub command: String,
306}
307
308/// Register recall-echo's MCP server with one client.
309///
310/// Never fails the caller: a client that is missing, broken or unrecognisable
311/// yields [`McpStatus::Failed`] carrying the command to run by hand.
312pub 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/// Read one client's answer to `mcp add`.
352///
353/// The exit code alone is not enough: Claude reports an existing registration
354/// as a failure, and Gemini reports it as a success it then overwrites.
355#[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
366/// The first line with something in it, trimmed to an excerpt.
367fn 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
376/// Drop CSI escape sequences, which several of these CLIs colour their errors
377/// with even when stdout is not a terminal.
378fn 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/// An argv as a line a user can paste back into a shell.
410#[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    /// Verified against `claude mcp add --help` and a real registration:
433    /// name first, `-s user` for a global scope, `--` before the server.
434    #[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    /// Gemini's name is a yargs positional: it follows the flags, and a `--`
455    /// separator is not accepted.
456    #[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    /// Codex has no scope flag — registration is always `~/.codex/config.toml`.
485    #[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    /// Whatever the shape, every client is told the same three things.
506    #[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    /// Claude reports an existing server as a *failure*; treating the exit code
526    /// as the answer would report a working setup as broken on every re-run.
527    #[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    /// Gemini says it both ways in one breath, and exits zero.
537    #[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    /// The binary name is the preset's, so `CLAUDE_BIN` and friends steer
588    /// detection and the later calls identically.
589    #[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}