use anyhow::{Context, Result, anyhow};
use regex::Regex;
use std::env;
use std::fs;
use std::path::PathBuf;
const DEFAULT_API_HOST: &str = "anyrouter.top";
#[derive(Debug)]
pub enum ClaudeCliError {
NotFound,
ReadError(std::io::Error),
WriteError(std::io::Error),
PathResolutionError(String),
}
impl std::fmt::Display for ClaudeCliError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
ClaudeCliError::NotFound => {
write!(f, "Claude CLI not found in PATH. Host patching skipped.")
}
ClaudeCliError::ReadError(e) => {
write!(f, "Failed to read Claude CLI file: {}", e)
}
ClaudeCliError::WriteError(e) => {
write!(f, "Failed to write Claude CLI file: {}", e)
}
ClaudeCliError::PathResolutionError(msg) => {
write!(f, "Failed to resolve Claude CLI path: {}", msg)
}
}
}
}
impl std::error::Error for ClaudeCliError {}
pub fn find_claude_cli() -> Result<PathBuf> {
let claude_exe = if cfg!(windows) {
["claude.cmd", "claude.exe", "claude.bat", "claude.ps1"]
.iter()
.find_map(|name| which_in_path(name))
} else {
which_in_path("claude")
};
claude_exe.ok_or_else(|| anyhow!(ClaudeCliError::NotFound))
}
fn which_in_path(name: &str) -> Option<PathBuf> {
if let Ok(current_exe) = env::current_exe() {
if current_exe.file_name()?.to_str()? == name {
return Some(current_exe);
}
}
let path_var = env::var("PATH").ok()?;
let path_sep = if cfg!(windows) { ";" } else { ":" };
for path_dir in path_var.split(path_sep) {
let full_path = PathBuf::from(path_dir).join(name);
if full_path.exists() {
return Some(full_path);
}
}
None
}
pub fn resolve_cli_path(claude_exe: &PathBuf) -> Result<PathBuf> {
let content = fs::read_to_string(claude_exe)
.with_context(|| format!("Failed to read claude executable at {:?}", claude_exe))?;
let cli_js_path = extract_cli_js_path(&content, claude_exe);
if let Some(path) = cli_js_path {
return Ok(path);
}
let common_paths = find_common_cli_paths(claude_exe);
for path in common_paths {
if path.exists() {
return Ok(path);
}
}
Ok(claude_exe.clone())
}
fn extract_cli_js_path(content: &str, wrapper_path: &PathBuf) -> Option<PathBuf> {
let lines: Vec<&str> = content.lines().collect();
for line in &lines {
if line.contains("node_modules/@anthropic-ai/claude-code/cli.js") {
if let Some(path) = extract_path_from_line(line, wrapper_path) {
return Some(path);
}
}
}
None
}
fn extract_path_from_line(line: &str, wrapper_path: &PathBuf) -> Option<PathBuf> {
if line.contains("$basedir") {
if let Ok(re) = Regex::new(r#"\$basedir/(node_modules/@anthropic-ai/claude-code/cli\.js)"#)
{
if let Some(caps) = re.captures(line) {
let relative_path = caps.get(1)?.as_str();
let wrapper_dir = wrapper_path.parent()?;
return Some(wrapper_dir.join(relative_path));
}
}
}
if line.contains("dirname") || line.contains("$0") {
let wrapper_dir = wrapper_path.parent()?;
return Some(
wrapper_dir
.join("node_modules/@anthropic-ai/claude-code/cli.js")
.clone(),
);
}
if line.contains("cli.js") {
if let Ok(re) =
Regex::new(r#"["']([^"']*node_modules/@anthropic-ai/claude-code/cli\.js)["']"#)
{
if let Some(caps) = re.captures(line) {
let path_str = caps.get(1)?.as_str();
let expanded = expand_env_vars(path_str);
return Some(PathBuf::from(expanded));
}
}
}
if line.contains("%~dp0") {
let wrapper_dir = wrapper_path.parent()?;
let relative = line
.replace("%~dp0", "")
.replace("\\", "/")
.trim()
.trim_matches('"')
.to_string();
return Some(wrapper_dir.join(relative));
}
None
}
fn expand_env_vars(s: &str) -> String {
let mut result = s.to_string();
if let Ok(re) = Regex::new(r"%([^%]+)%") {
while let Some(caps) = re.captures(&result) {
let var_name = &caps[1];
if let Ok(value) = env::var(var_name) {
result = result.replace(&caps[0], &value);
} else {
break;
}
}
}
if let Ok(re) = Regex::new(r"\$\{?([A-Za-z_][A-Za-z0-9_]*)\}?") {
while let Some(caps) = re.captures(&result) {
let var_name = &caps[1];
if let Ok(value) = env::var(var_name) {
result = result.replace(&caps[0], &value);
} else {
break;
}
}
}
result
}
fn find_common_cli_paths(wrapper_path: &PathBuf) -> Vec<PathBuf> {
let mut paths = Vec::new();
if let Some(wrapper_dir) = wrapper_path.parent() {
paths.push(
wrapper_dir
.join("node_modules/@anthropic-ai/claude-code/cli.js")
.clone(),
);
if let Some(parent_dir) = wrapper_dir.parent() {
paths.push(
parent_dir
.join("node_modules/@anthropic-ai/claude-code/cli.js")
.clone(),
);
}
}
if let Ok(npm_prefix) = env::var("npm_prefix") {
paths.push(
PathBuf::from(npm_prefix)
.join("node_modules/@anthropic-ai/claude-code/cli.js")
.clone(),
);
}
let common_global_paths = vec![
"/usr/local/lib/node_modules/@anthropic-ai/claude-code/cli.js",
"/usr/lib/node_modules/@anthropic-ai/claude-code/cli.js",
"/opt/homebrew/lib/node_modules/@anthropic-ai/claude-code/cli.js",
];
for path in common_global_paths {
paths.push(PathBuf::from(path));
}
if cfg!(windows) {
if let Ok(appdata) = env::var("APPDATA") {
paths.push(
PathBuf::from(appdata)
.join("npm/node_modules/@anthropic-ai/claude-code/cli.js")
.clone(),
);
}
if let Ok(localappdata) = env::var("LOCALAPPDATA") {
paths.push(
PathBuf::from(localappdata)
.join("npm/node_modules/@anthropic-ai/claude-code/cli.js")
.clone(),
);
}
}
paths
}
pub fn patch_claude_cli_host(dry_run: bool) -> Result<bool> {
let host = env::var("API_HOST").unwrap_or_else(|_| DEFAULT_API_HOST.to_string());
let claude_exe = find_claude_cli()?;
let cli_path = resolve_cli_path(&claude_exe)?;
let content = fs::read_to_string(&cli_path)
.with_context(|| format!("Failed to read CLI file at {:?}", cli_path))?;
let original_host = "api.anthropic.com";
if !content.contains(original_host) {
return Ok(false);
}
if dry_run {
return Ok(true);
}
let new_content = content.replace(original_host, &host);
fs::write(&cli_path, new_content)
.with_context(|| format!("Failed to write CLI file at {:?}", cli_path))?;
Ok(true)
}
pub fn patch_claude_cli_with_host(host: &str, dry_run: bool) -> Result<bool> {
let claude_exe = match find_claude_cli() {
Ok(path) => path,
Err(_) => {
return Err(anyhow!(ClaudeCliError::NotFound));
}
};
let cli_path = match resolve_cli_path(&claude_exe) {
Ok(path) => path,
Err(_) => {
return Err(anyhow!(
"Could not resolve Claude CLI path at {:?}. Host patching skipped.",
claude_exe
));
}
};
let content = match fs::read_to_string(&cli_path) {
Ok(content) => content,
Err(_) => {
return Err(anyhow!(
"Could not read Claude CLI file at {:?}. Host patching skipped.",
cli_path
));
}
};
let original_host = "api.anthropic.com";
let needs_patching =
content.contains(original_host) || content.contains(&format!("\"{}", host));
if !needs_patching {
return Ok(false);
}
let new_content = if content.contains(original_host) {
content.replace(original_host, host)
} else {
return Ok(false);
};
if dry_run {
return Ok(true);
}
match fs::write(&cli_path, new_content) {
Ok(_) => Ok(true),
Err(_) => Err(anyhow!(
"Could not write to Claude CLI file at {:?}. Host patching skipped.",
cli_path
)),
}
}
pub fn check_cli_needs_patching() -> Result<(PathBuf, bool)> {
let claude_exe = find_claude_cli()?;
let cli_path = resolve_cli_path(&claude_exe)?;
let content = fs::read_to_string(&cli_path)
.with_context(|| format!("Failed to read CLI file at {:?}", cli_path))?;
let needs_patching = content.contains("api.anthropic.com");
Ok((cli_path, needs_patching))
}
pub fn get_current_cli_host() -> Result<Option<String>> {
let claude_exe = find_claude_cli()?;
let cli_path = resolve_cli_path(&claude_exe)?;
let content = fs::read_to_string(&cli_path)
.with_context(|| format!("Failed to read CLI file at {:?}", cli_path))?;
if let Ok(re) = Regex::new(r#"https?://([^"'\s]+)"#) {
for cap in re.captures_iter(&content) {
let host = &cap[1];
if host != "api.anthropic.com" && host.contains(".") {
return Ok(Some(host.to_string()));
}
}
}
if content.contains("api.anthropic.com") {
return Ok(None);
}
Ok(None)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_claude_cli_error_display() {
let error = ClaudeCliError::NotFound;
assert!(error.to_string().contains("not found"));
assert!(error.to_string().contains("skipped"));
let error = ClaudeCliError::PathResolutionError("test error".to_string());
assert!(error.to_string().contains("test error"));
}
#[test]
fn test_extract_path_from_line_basedir() {
let line = r#"node "$basedir/node_modules/@anthropic-ai/claude-code/cli.js" "$@""#;
let wrapper_path = PathBuf::from("/usr/local/bin/claude");
let result = extract_path_from_line(line, &wrapper_path);
assert!(result.is_some());
let path = result.unwrap();
assert!(
path.to_str()
.unwrap()
.contains("node_modules/@anthropic-ai/claude-code/cli.js")
);
}
#[test]
fn test_extract_path_from_line_absolute() {
let line = r#"node "/some/path/node_modules/@anthropic-ai/claude-code/cli.js" "$@""#;
let wrapper_path = PathBuf::from("/usr/local/bin/claude");
let result = extract_path_from_line(line, &wrapper_path);
assert!(result.is_some());
let path = result.unwrap();
assert_eq!(
path,
PathBuf::from("/some/path/node_modules/@anthropic-ai/claude-code/cli.js")
);
}
#[test]
fn test_which_in_path_with_nonexistent_command() {
let result = which_in_path("nonexistent_command_12345");
assert!(result.is_none());
}
#[test]
fn test_find_common_cli_paths() {
let wrapper_path = PathBuf::from("/usr/local/bin/claude");
let paths = find_common_cli_paths(&wrapper_path);
assert!(!paths.is_empty());
for path in &paths {
assert!(
path.to_str()
.unwrap()
.contains("node_modules/@anthropic-ai/claude-code/cli.js")
);
}
}
}