use std::path::{Path, PathBuf};
use std::process::Stdio;
use std::time::Duration;
use crate::cli_provider::CliSpec;
use crate::config::{CliPreset, Provider};
use crate::transcript::Source;
pub const MCP_SERVER_NAME: &str = "recall-echo";
const MCP_ADD_TIMEOUT: Duration = Duration::from_secs(30);
const OUTPUT_EXCERPT: usize = 200;
const ALREADY_PHRASES: [&str; 3] = ["already exists", "already configured", "already registered"];
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
pub enum AgentCli {
ClaudeCode,
Codex,
Grok,
Gemini,
}
impl AgentCli {
pub const ALL: [AgentCli; 4] = [
AgentCli::ClaudeCode,
AgentCli::Codex,
AgentCli::Grok,
AgentCli::Gemini,
];
#[must_use]
pub fn label(self) -> &'static str {
match self {
AgentCli::ClaudeCode => "claude-code",
AgentCli::Codex => "codex",
AgentCli::Grok => "grok",
AgentCli::Gemini => "gemini",
}
}
#[must_use]
pub fn provider(self) -> Provider {
match self {
AgentCli::ClaudeCode => Provider::ClaudeCode,
AgentCli::Codex => Provider::Codex,
AgentCli::Grok => Provider::Grok,
AgentCli::Gemini => Provider::Gemini,
}
}
#[must_use]
fn preset(self) -> CliPreset {
match self {
AgentCli::ClaudeCode => CliPreset::ClaudeCode,
AgentCli::Codex => CliPreset::Codex,
AgentCli::Grok => CliPreset::Grok,
AgentCli::Gemini => CliPreset::Gemini,
}
}
#[must_use]
pub fn capture_source(self) -> Option<Source> {
match self {
AgentCli::ClaudeCode => Some(Source::ClaudeCode),
AgentCli::Codex => Some(Source::Codex),
AgentCli::Grok => Some(Source::Grok),
AgentCli::Gemini => None,
}
}
#[must_use]
pub fn command(self) -> String {
CliSpec::preset(self.preset()).resolve_command()
}
#[must_use]
pub fn binary_path(self) -> Option<PathBuf> {
resolve_binary(&self.command())
}
#[must_use]
pub fn is_installed(self) -> bool {
self.binary_path().is_some()
}
#[must_use]
fn session_markers(self) -> &'static [&'static str] {
match self {
AgentCli::ClaudeCode => &["CLAUDECODE", "CLAUDE_CODE_ENTRYPOINT"],
AgentCli::Codex => &["CODEX_SANDBOX", "CODEX_SANDBOX_NETWORK_DISABLED"],
AgentCli::Grok => &["GROK_SESSION_ID"],
AgentCli::Gemini => &[],
}
}
#[must_use]
pub fn mcp_add_argv(self, exe: &str, entity_root: &Path) -> Vec<String> {
let server = vec![
exe.to_string(),
"mcp".to_string(),
"--entity-root".to_string(),
entity_root.display().to_string(),
];
let mut argv = vec![self.command(), "mcp".into(), "add".into()];
match self {
AgentCli::ClaudeCode | AgentCli::Grok => {
argv.extend([
MCP_SERVER_NAME.into(),
"-s".into(),
"user".into(),
"--".into(),
]);
}
AgentCli::Gemini => {
argv.extend(["-s".into(), "user".into(), MCP_SERVER_NAME.into()]);
}
AgentCli::Codex => argv.extend([MCP_SERVER_NAME.into(), "--".into()]),
}
argv.extend(server);
argv
}
}
impl std::fmt::Display for AgentCli {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(self.label())
}
}
#[must_use]
pub fn installed() -> Vec<AgentCli> {
AgentCli::ALL
.into_iter()
.filter(|cli| cli.is_installed())
.collect()
}
#[must_use]
pub fn current() -> Option<AgentCli> {
AgentCli::ALL.into_iter().find(|cli| {
cli.session_markers()
.iter()
.any(|key| std::env::var_os(key).is_some_and(|value| !value.is_empty()))
})
}
#[must_use]
pub fn capturing() -> Vec<Source> {
crate::transcript::detect_installed()
.iter()
.map(|adapter| adapter.source())
.collect()
}
#[must_use]
pub fn resolve_binary(command: &str) -> Option<PathBuf> {
let command = command.trim();
if command.is_empty() {
return None;
}
if command.contains(std::path::MAIN_SEPARATOR) {
let path = PathBuf::from(command);
return is_executable(&path).then_some(path);
}
std::env::split_paths(&std::env::var_os("PATH")?)
.map(|dir| dir.join(command))
.find(|candidate| is_executable(candidate))
}
#[cfg(unix)]
fn is_executable(path: &Path) -> bool {
use std::os::unix::fs::PermissionsExt;
std::fs::metadata(path)
.is_ok_and(|meta| meta.is_file() && meta.permissions().mode() & 0o111 != 0)
}
#[cfg(not(unix))]
fn is_executable(path: &Path) -> bool {
path.is_file()
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum McpStatus {
Registered,
AlreadyRegistered,
Failed(String),
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct McpReport {
pub cli: AgentCli,
pub status: McpStatus,
pub command: String,
}
pub async fn register_mcp(cli: AgentCli, exe: &str, entity_root: &Path) -> McpReport {
let argv = cli.mcp_add_argv(exe, entity_root);
let command = shell_line(&argv);
let Some((binary, args)) = argv.split_first() else {
return McpReport {
cli,
status: McpStatus::Failed("empty command".into()),
command,
};
};
let mut process = tokio::process::Command::new(binary);
process
.args(args)
.stdin(Stdio::null())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.kill_on_drop(true);
let status = match tokio::time::timeout(MCP_ADD_TIMEOUT, process.output()).await {
Err(_) => McpStatus::Failed(format!(
"{binary} did not finish within {}s",
MCP_ADD_TIMEOUT.as_secs()
)),
Ok(Err(e)) => McpStatus::Failed(format!("could not run {binary}: {e}")),
Ok(Ok(output)) => {
let mut text = String::from_utf8_lossy(&output.stdout).to_string();
text.push_str(&String::from_utf8_lossy(&output.stderr));
classify(output.status.success(), &text)
}
};
McpReport {
cli,
status,
command,
}
}
#[must_use]
pub fn classify(success: bool, output: &str) -> McpStatus {
let lower = output.to_lowercase();
let already = ALREADY_PHRASES.iter().any(|phrase| lower.contains(phrase));
match (success, already) {
(_, true) => McpStatus::AlreadyRegistered,
(true, false) => McpStatus::Registered,
(false, false) => McpStatus::Failed(first_meaningful_line(output)),
}
}
fn first_meaningful_line(output: &str) -> String {
let line = output
.lines()
.map(str::trim)
.find(|line| !line.is_empty())
.unwrap_or("no output");
truncate(strip_ansi(line).trim(), OUTPUT_EXCERPT)
}
fn strip_ansi(text: &str) -> String {
let mut out = String::with_capacity(text.len());
let mut chars = text.chars();
while let Some(c) = chars.next() {
if c != '\u{1b}' {
out.push(c);
continue;
}
if chars.next() != Some('[') {
continue;
}
for c in chars.by_ref() {
if c.is_ascii_alphabetic() {
break;
}
}
}
out
}
fn truncate(text: &str, max: usize) -> String {
if text.len() <= max {
return text.to_string();
}
let mut end = max;
while end > 0 && !text.is_char_boundary(end) {
end -= 1;
}
format!("{}…", &text[..end])
}
#[must_use]
pub fn shell_line(argv: &[String]) -> String {
argv.iter()
.map(|arg| {
if arg.chars().any(char::is_whitespace) {
format!("\"{arg}\"")
} else {
arg.clone()
}
})
.collect::<Vec<_>>()
.join(" ")
}
#[cfg(test)]
mod tests {
use super::*;
fn argv_of(cli: AgentCli) -> Vec<String> {
cli.mcp_add_argv("/usr/local/bin/recall-echo", Path::new("/home/d/entity"))
}
#[test]
fn claude_registration_puts_the_name_first_and_uses_a_separator() {
assert_eq!(
argv_of(AgentCli::ClaudeCode),
vec![
"claude",
"mcp",
"add",
"recall-echo",
"-s",
"user",
"--",
"/usr/local/bin/recall-echo",
"mcp",
"--entity-root",
"/home/d/entity",
]
);
}
#[test]
fn gemini_registration_takes_the_name_after_its_flags_and_no_separator() {
let argv = argv_of(AgentCli::Gemini);
assert_eq!(
argv,
vec![
"gemini",
"mcp",
"add",
"-s",
"user",
"recall-echo",
"/usr/local/bin/recall-echo",
"mcp",
"--entity-root",
"/home/d/entity",
]
);
assert!(!argv.iter().any(|arg| arg == "--"));
}
#[test]
fn grok_registration_matches_claudes_shape() {
let argv = argv_of(AgentCli::Grok);
assert_eq!(argv[0], "grok");
assert_eq!(argv[3..7], ["recall-echo", "-s", "user", "--"]);
}
#[test]
fn codex_registration_has_no_scope_flag() {
let argv = argv_of(AgentCli::Codex);
assert_eq!(
argv,
vec![
"codex",
"mcp",
"add",
"recall-echo",
"--",
"/usr/local/bin/recall-echo",
"mcp",
"--entity-root",
"/home/d/entity",
]
);
assert!(!argv.iter().any(|arg| arg == "-s"));
}
#[test]
fn every_client_is_given_the_same_server_command() {
for cli in AgentCli::ALL {
let argv = argv_of(cli);
let tail = &argv[argv.len() - 4..];
assert_eq!(
tail,
[
"/usr/local/bin/recall-echo",
"mcp",
"--entity-root",
"/home/d/entity"
],
"{cli}"
);
assert!(argv.contains(&MCP_SERVER_NAME.to_string()), "{cli}");
}
}
#[test]
fn claude_already_exists_is_not_a_failure() {
let status = classify(
false,
"MCP server recall-echo already exists in user config",
);
assert_eq!(status, McpStatus::AlreadyRegistered);
}
#[test]
fn gemini_already_configured_is_recognised_despite_success() {
let status = classify(
true,
"MCP server \"recall-echo\" is already configured within user settings.\n\
MCP server \"recall-echo\" updated in user settings.",
);
assert_eq!(status, McpStatus::AlreadyRegistered);
}
#[test]
fn a_silent_rewrite_reads_as_registered() {
let status = classify(true, "Added stdio MCP server 'recall-echo' to user config");
assert_eq!(status, McpStatus::Registered);
}
#[test]
fn a_real_failure_carries_the_clients_first_line() {
let status = classify(
false,
"\n\u{1b}[31mError: config is read-only\u{1b}[0m\ndetails",
);
assert_eq!(
status,
McpStatus::Failed("Error: config is read-only".into())
);
}
#[test]
fn a_failure_with_no_output_still_says_something() {
assert_eq!(
classify(false, " \n\n"),
McpStatus::Failed("no output".into())
);
}
#[test]
fn presets_and_sources_line_up_with_the_providers() {
assert_eq!(AgentCli::Grok.provider(), Provider::Grok);
assert_eq!(AgentCli::Codex.capture_source(), Some(Source::Codex));
assert_eq!(
AgentCli::Gemini.capture_source(),
None,
"gemini has no transcript adapter yet"
);
for cli in AgentCli::ALL {
assert_eq!(cli.provider().default_cli_preset(), Some(cli.preset()));
}
}
#[test]
fn the_command_is_the_presets_command() {
assert_eq!(AgentCli::ClaudeCode.command(), "claude");
assert_eq!(AgentCli::Gemini.command(), "gemini");
assert_eq!(AgentCli::Grok.command(), "grok");
assert_eq!(AgentCli::Codex.command(), "codex");
}
#[test]
fn an_explicit_path_is_resolved_without_consulting_path() {
let dir = tempfile::tempdir().unwrap();
let script = dir.path().join("mycli");
std::fs::write(&script, "#!/bin/sh\n").unwrap();
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(&script, std::fs::Permissions::from_mode(0o755)).unwrap();
}
assert_eq!(
resolve_binary(&script.display().to_string()),
Some(script.clone())
);
assert_eq!(
resolve_binary(&dir.path().join("absent").display().to_string()),
None
);
}
#[cfg(unix)]
#[test]
fn a_non_executable_file_is_not_a_binary() {
let dir = tempfile::tempdir().unwrap();
let file = dir.path().join("notes.txt");
std::fs::write(&file, "hello").unwrap();
assert_eq!(resolve_binary(&file.display().to_string()), None);
}
#[test]
fn an_empty_command_resolves_to_nothing() {
assert_eq!(resolve_binary(" "), None);
}
#[test]
fn a_shell_line_quotes_only_what_needs_it() {
let argv = vec!["claude".into(), "mcp".into(), "/a path/bin".into()];
assert_eq!(shell_line(&argv), "claude mcp \"/a path/bin\"");
}
}