use std::io::{BufRead, BufReader, Write};
use std::path::{Path, PathBuf};
use std::process::{Command, Stdio};
use std::sync::mpsc;
use std::time::Duration;
use anyhow::Result;
use serde_json::{Map, Value};
use crate::profile::{atomic_write, claude_dir, home_dir};
pub(crate) const PLUGIN_ID: &str = "clauth@clauth";
pub(crate) const MARKETPLACE_KEY: &str = "clauth";
pub(crate) fn on_path(binary: &str) -> Option<PathBuf> {
let exts: &[&str] = if cfg!(windows) {
&["", ".exe", ".cmd", ".bat"]
} else {
&[""]
};
let path = std::env::var_os("PATH")?;
std::env::split_paths(&path).find_map(|dir| {
exts.iter()
.map(|ext| dir.join(format!("{binary}{ext}")))
.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, Default, PartialEq, Eq)]
pub(crate) struct InstallRecord {
pub(crate) scope: Option<String>,
pub(crate) version: Option<String>,
pub(crate) git_commit_sha: Option<String>,
pub(crate) installed_at: Option<String>,
pub(crate) install_path: Option<String>,
pub(crate) project_path: Option<String>,
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub(crate) struct MarketplaceInfo {
pub(crate) repo: Option<String>,
pub(crate) install_location: Option<String>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum McpWiring {
GlobalConfig,
ProjectFile,
None,
}
pub(crate) fn installed_records() -> Vec<InstallRecord> {
let Some(root) = read_json(plugins_dir().map(|dir| dir.join("installed_plugins.json"))) else {
return Vec::new();
};
root.get("plugins")
.and_then(|plugins| plugins.get(PLUGIN_ID))
.and_then(Value::as_array)
.map(|records| records.iter().map(install_record_from).collect())
.unwrap_or_default()
}
pub(crate) fn marketplace_known() -> Option<MarketplaceInfo> {
let root = read_json(plugins_dir().map(|d| d.join("known_marketplaces.json")))?;
let entry = root.get(MARKETPLACE_KEY)?;
Some(MarketplaceInfo {
repo: entry
.get("source")
.and_then(|source| source.get("repo"))
.and_then(Value::as_str)
.map(str::to_string),
install_location: entry
.get("installLocation")
.and_then(Value::as_str)
.map(str::to_string),
})
}
pub(crate) fn manual_mcp_wiring() -> McpWiring {
if global_claude_json_path().is_some_and(|path| json_has_clauth_mcp(&path)) {
McpWiring::GlobalConfig
} else if json_has_clauth_mcp(Path::new(".mcp.json")) {
McpWiring::ProjectFile
} else {
McpWiring::None
}
}
pub(crate) fn global_entry_drifted() -> Option<bool> {
let entry = read_json(global_claude_json_path()).and_then(|root| {
root.get("mcpServers")
.and_then(|servers| servers.get("clauth"))
.cloned()
})?;
let canon = clauth_mcp_entry();
let same =
entry.get("command") == canon.get("command") && entry.get("args") == canon.get("args");
Some(!same)
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) enum McpProbe {
Ok,
Failed(String),
}
pub(crate) fn mcp_boots() -> McpProbe {
let mut child = match Command::new("clauth")
.arg("mcp")
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::null())
.spawn()
{
Ok(child) => child,
Err(e) => return McpProbe::Failed(format!("spawn failed: {e}")),
};
let (Some(mut stdin), Some(stdout)) = (child.stdin.take(), child.stdout.take()) else {
let _ = child.kill();
let _ = child.wait();
return McpProbe::Failed("no stdio pipes".to_string());
};
let req = serde_json::json!({
"jsonrpc": "2.0", "id": 1, "method": "initialize",
"params": {
"protocolVersion": "2024-11-05",
"capabilities": {},
"clientInfo": { "name": "clauth-probe", "version": env!("CARGO_PKG_VERSION") }
}
});
if writeln!(stdin, "{req}")
.and_then(|()| stdin.flush())
.is_err()
{
let _ = child.kill();
let _ = child.wait();
return McpProbe::Failed("write failed".to_string());
}
let (tx, rx) = mpsc::channel();
let reader = std::thread::spawn(move || {
let mut line = String::new();
let result = BufReader::new(stdout).read_line(&mut line).map(|_| line);
let _ = tx.send(result);
});
let verdict = match rx.recv_timeout(Duration::from_secs(3)) {
Ok(Ok(line)) => parse_initialize_reply(&line),
Ok(Err(e)) => McpProbe::Failed(format!("read failed: {e}")),
Err(_) => McpProbe::Failed("no reply within 3s".to_string()),
};
let _ = child.kill();
let _ = child.wait();
drop(stdin);
let _ = reader.join();
verdict
}
fn parse_initialize_reply(line: &str) -> McpProbe {
let Ok(value) = serde_json::from_str::<Value>(line.trim()) else {
return McpProbe::Failed("unparseable reply".to_string());
};
if value.get("error").is_some() {
return McpProbe::Failed("server returned an error".to_string());
}
if value.get("result").is_some() {
McpProbe::Ok
} else {
McpProbe::Failed("no result in reply".to_string())
}
}
pub(crate) fn cc_version() -> Option<String> {
let output = crate::runtime::claude_command()
.arg("--version")
.stdin(Stdio::null())
.stdout(Stdio::piped())
.stderr(Stdio::null())
.output()
.ok()?;
if !output.status.success() {
return None;
}
let text = String::from_utf8_lossy(&output.stdout);
let line = text.lines().next()?.trim();
(!line.is_empty()).then(|| line.to_string())
}
pub(crate) fn global_claude_json_path() -> Option<PathBuf> {
Some(home_dir().ok()?.join(".claude.json"))
}
pub(crate) fn wire_mcp_server() -> Result<()> {
let path = home_dir()?.join(".claude.json");
let mut root: Map<String, Value> = match std::fs::read(&path) {
Ok(bytes) => match serde_json::from_slice::<Value>(&bytes) {
Ok(Value::Object(map)) => map,
_ => Map::new(),
},
Err(_) => Map::new(),
};
let entry = clauth_mcp_entry();
match root
.entry("mcpServers")
.or_insert_with(|| Value::Object(Map::new()))
{
Value::Object(servers) => {
servers.insert("clauth".to_string(), entry);
}
other => *other = Value::Object(Map::from_iter([("clauth".to_string(), entry)])),
}
atomic_write(&path, serde_json::to_vec_pretty(&Value::Object(root))?)?;
Ok(())
}
fn clauth_mcp_entry() -> Value {
serde_json::json!({ "type": "stdio", "command": "clauth", "args": ["mcp"] })
}
fn plugins_dir() -> Option<PathBuf> {
Some(claude_dir().ok()?.join("plugins"))
}
fn read_json(path: Option<PathBuf>) -> Option<Value> {
let bytes = std::fs::read(path?).ok()?;
serde_json::from_slice(&bytes).ok()
}
fn json_has_clauth_mcp(path: &Path) -> bool {
read_json(Some(path.to_path_buf()))
.and_then(|root| {
root.get("mcpServers")
.and_then(|servers| servers.get("clauth"))
.cloned()
})
.is_some()
}
fn install_record_from(value: &Value) -> InstallRecord {
let field = |key: &str| value.get(key).and_then(Value::as_str).map(str::to_string);
InstallRecord {
scope: field("scope"),
version: field("version"),
git_commit_sha: field("gitCommitSha"),
installed_at: field("installedAt"),
install_path: field("installPath"),
project_path: field("projectPath"),
}
}
#[cfg(test)]
#[path = "../tests/inline/plugin_probe.rs"]
mod tests;