Files
EasyCLIProxyAPI/src-tauri/src/agents/discovery.rs
T
lzt404 dfba9b019f feat: improve request pricing, model diagnostics, and client discovery
- Align response model handling with CPA-Manager-Plus and flag routing mismatches
- Add per-request costs and CSV export; fix mixed-context cost aggregation
- Display API-reported access types and move providers next to timestamps
- Use white table backgrounds and simplify cost and status displays
- Expand client discovery paths, validate executables, and prioritize installed clients
- Add model, pricing, client discovery, and UI regression tests
2026-10-04 17:31:52 +08:00

3758 lines
133 KiB
Rust

use super::*;
const AGENT_VERSION_PROBE_TIMEOUT: Duration = Duration::from_secs(5);
const AGENT_VERSION_PROBE_POLL_INTERVAL: Duration = Duration::from_millis(20);
#[cfg(not(test))]
pub(crate) fn agent_configuration_environment(name: &str) -> Option<PathBuf> {
env::var_os(name)
.map(PathBuf::from)
.filter(|path| !path.as_os_str().is_empty())
}
#[cfg(test)]
pub(crate) fn agent_configuration_environment(_: &str) -> Option<PathBuf> {
None
}
pub(crate) fn agent_config_paths(client: AgentClient, home: &Path) -> Vec<PathBuf> {
match client {
AgentClient::ClaudeCode => {
let directory = home.join(".claude");
let settings = directory.join("settings.json");
let legacy = directory.join("claude.json");
let settings_state_exists = agent_state_path(std::slice::from_ref(&settings))
.map(|path| path.exists())
.unwrap_or(false);
let legacy_state_exists = agent_state_path(std::slice::from_ref(&legacy))
.map(|path| path.exists())
.unwrap_or(false);
vec![if settings_state_exists {
settings
} else if legacy_state_exists || (!settings.exists() && legacy.exists()) {
legacy
} else {
settings
}]
}
AgentClient::ClaudeDesktop => claude_desktop_config_paths(home),
AgentClient::Codex => vec![codex_configuration_directory(home).join("config.toml")],
AgentClient::OpenCode => vec![opencode_config_path(home)],
AgentClient::OpenClaw => vec![home.join(".openclaw/openclaw.json")],
AgentClient::Hermes => vec![hermes_agent_config_path(home)],
AgentClient::DeepSeekHarness => {
let directory = deepseek_harness_home(home);
vec![
directory.join(DEEPSEEK_HARNESS_SETTINGS_FILE),
directory.join(DEEPSEEK_HARNESS_CREDENTIALS_FILE),
]
}
AgentClient::WorkBuddy => vec![workbuddy_home(home).join("models.json")],
AgentClient::AntigravityCli => antigravity_config_paths(client, home),
AgentClient::ZCode => vec![home.join(".zcode/v2").join(ZCODE_PROVIDER_CONFIG_FILE)],
AgentClient::KimiCode => vec![kimi_code_home(home).join(KIMI_CODE_CONFIG_FILE)],
AgentClient::GrokBuild => vec![grok_build_home(home).join(GROK_BUILD_CONFIG_FILE)],
}
}
pub(crate) fn opencode_config_path(home: &Path) -> PathBuf {
#[cfg(test)]
let (custom_config, xdg_config_home): (Option<PathBuf>, Option<PathBuf>) = (None, None);
#[cfg(not(test))]
let custom_config = env::var_os("OPENCODE_CONFIG").map(PathBuf::from);
#[cfg(not(test))]
let xdg_config_home = env::var_os("XDG_CONFIG_HOME").map(PathBuf::from);
opencode_config_path_from_environment(
home,
custom_config.as_deref(),
xdg_config_home.as_deref(),
)
}
pub(crate) fn opencode_config_path_from_environment(
home: &Path,
custom_config: Option<&Path>,
xdg_config_home: Option<&Path>,
) -> PathBuf {
if let Some(path) = custom_config.filter(|path| !path.as_os_str().is_empty()) {
return path.to_path_buf();
}
let config_directory = xdg_config_home
.filter(|path| path.is_absolute())
.map(Path::to_path_buf)
.unwrap_or_else(|| home.join(".config"))
.join("opencode");
let json = config_directory.join("opencode.json");
let jsonc = config_directory.join("opencode.jsonc");
let candidates = [json.clone(), jsonc];
candidates
.iter()
.find(|path| {
agent_state_path(std::slice::from_ref(*path))
.map(|state| state.exists())
.unwrap_or(false)
})
.or_else(|| candidates.iter().find(|path| path.is_file()))
.cloned()
.unwrap_or(json)
}
pub(crate) fn kimi_code_home(home: &Path) -> PathBuf {
agent_configuration_environment("KIMI_CODE_HOME").unwrap_or_else(|| home.join(".kimi-code"))
}
pub(crate) fn grok_build_home(home: &Path) -> PathBuf {
agent_configuration_environment("GROK_HOME").unwrap_or_else(|| home.join(".grok"))
}
pub(crate) fn deepseek_harness_home(home: &Path) -> PathBuf {
agent_configuration_environment("DSH_HOME").unwrap_or_else(|| home.join(".dsh"))
}
pub(crate) fn read_deepseek_harness_profile_version(home: &Path) -> Option<String> {
let directory = deepseek_harness_home(home);
[
directory.join("profiles/node_modules/@deepseek-ai/dsh/package.json"),
directory.join("node_modules/@deepseek-ai/dsh/package.json"),
]
.into_iter()
.find_map(|path| read_package_json_version(&path))
}
pub(crate) fn read_package_json_version(path: &Path) -> Option<String> {
let content = fs::read_to_string(path).ok()?;
let package = serde_json::from_str::<serde_json::Value>(&content).ok()?;
package
.get("version")
.and_then(serde_json::Value::as_str)
.map(str::trim)
.filter(|version| !version.is_empty())
.map(str::to_string)
}
pub(crate) fn pi_agent_directory(home: &Path) -> PathBuf {
agent_configuration_environment("PI_CODING_AGENT_DIR").unwrap_or_else(|| home.join(".pi/agent"))
}
pub(crate) fn pi_provider_config_path(home: &Path) -> PathBuf {
pi_agent_directory(home).join(PI_AGENT_CONFIG_FILE)
}
pub(crate) fn pi_provider_settings_path(home: &Path) -> PathBuf {
pi_agent_directory(home).join(PI_AGENT_SETTINGS_FILE)
}
pub(crate) fn pi_provider_package_json_path(home: &Path) -> PathBuf {
pi_agent_directory(home)
.join("npm/node_modules/@router-for-me/pi-cliproxyapi-provider/package.json")
}
pub(crate) fn read_pi_provider_version(home: &Path) -> Result<Option<String>, String> {
let path = pi_provider_package_json_path(home);
if !path.is_file() {
return Ok(None);
}
let content = fs::read_to_string(&path).map_err(|error| {
format!(
"Failed to read Pi provider package.json {}: {error}",
path_to_string(&path)
)
})?;
let package = serde_json::from_str::<serde_json::Value>(&content).map_err(|error| {
format!(
"Failed to parse Pi provider package.json {}: {error}",
path_to_string(&path)
)
})?;
Ok(package
.get("version")
.and_then(serde_json::Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
.map(str::to_string))
}
pub(crate) fn parse_pi_provider_latest_version(
payload: &serde_json::Value,
) -> Result<String, String> {
payload
.get("version")
.and_then(serde_json::Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
.map(str::to_string)
.ok_or_else(|| "Invalid Pi provider version returned by npm registry".to_string())
}
pub(crate) async fn fetch_pi_provider_latest_version(proxy_url: &str) -> Result<String, String> {
let client = build_http_client_with_proxy(
reqwest::Client::builder()
.connect_timeout(Duration::from_secs(5))
.timeout(Duration::from_secs(12)),
proxy_url,
"Failed to create Pi provider update check client",
)?;
let response = client
.get(PI_CLIPROXYAPI_NPM_LATEST_URL)
.header(reqwest::header::ACCEPT, "application/json")
.header(reqwest::header::USER_AGENT, APP_USER_AGENT)
.send()
.await
.map_err(|error| format!("Failed to query latest Pi provider version: {error}"))?;
let status = response.status();
if !status.is_success() {
return Err(format!(
"Failed to query latest Pi provider version: HTTP {}",
status.as_u16()
));
}
let payload = response
.json::<serde_json::Value>()
.await
.map_err(|error| format!("Failed to parse latest Pi provider version: {error}"))?;
parse_pi_provider_latest_version(&payload)
}
pub(crate) fn pi_provider_update_available(installed: &str, latest: &str) -> Result<bool, String> {
let parse = |value: &str| {
semver::Version::parse(value.trim().trim_start_matches('v'))
.map_err(|error| format!("Failed to parse Pi provider version {value}: {error}"))
};
Ok(parse(latest)? > parse(installed)?)
}
pub(crate) fn pi_package_source_matches(value: &str) -> bool {
let value = value.trim();
value == PI_CLIPROXYAPI_PACKAGE || value.starts_with(&format!("{PI_CLIPROXYAPI_PACKAGE}@"))
}
pub(crate) fn read_pi_settings(home: &Path) -> Result<Option<serde_json::Value>, String> {
let path = pi_provider_settings_path(home);
if !path.is_file() {
return Ok(None);
}
let content = fs::read_to_string(&path).map_err(|error| {
format!(
"Failed to read Pi settings.json {}: {error}",
path_to_string(&path)
)
})?;
serde_json::from_str::<serde_json::Value>(&content)
.map(Some)
.map_err(|error| {
format!(
"Failed to parse Pi settings.json {}: {error}",
path_to_string(&path)
)
})
}
pub(crate) fn pi_settings_contains_provider(settings: &serde_json::Value) -> bool {
settings
.get("packages")
.and_then(serde_json::Value::as_array)
.is_some_and(|packages| {
packages.iter().any(|package| {
package.as_str().is_some_and(pi_package_source_matches)
|| package
.get("source")
.and_then(serde_json::Value::as_str)
.is_some_and(pi_package_source_matches)
})
})
}
pub(crate) fn pi_provider_package_installed(home: &Path) -> Result<bool, String> {
Ok(read_pi_settings(home)?
.as_ref()
.is_some_and(pi_settings_contains_provider))
}
pub(crate) fn build_pi_provider_config(
existing: Option<&str>,
base_url: &str,
api_key: &str,
) -> Result<String, String> {
let mut root = match existing.map(str::trim).filter(|value| !value.is_empty()) {
Some(value) => serde_json::from_str::<serde_json::Value>(value)
.map_err(|error| format!("Failed to parse Pi CLIProxyAPI configuration: {error}"))?,
None => serde_json::json!({}),
};
let object = root
.as_object_mut()
.ok_or_else(|| "Pi CLIProxyAPI configuration root must be a JSON object".to_string())?;
object.insert("baseUrl".to_string(), serde_json::json!(base_url));
object.insert("apiKey".to_string(), serde_json::json!(api_key));
let mut rendered = serde_json::to_string_pretty(&root)
.map_err(|error| format!("Failed to generate Pi CLIProxyAPI configuration: {error}"))?;
rendered.push('\n');
Ok(rendered)
}
pub(crate) fn build_pi_provider_settings(
existing: &str,
default_model: &str,
) -> Result<String, String> {
let default_model = default_model.trim();
if default_model.is_empty() {
return Err("Pi default model cannot be empty".to_string());
}
let mut root = serde_json::from_str::<serde_json::Value>(existing)
.map_err(|error| format!("Failed to parse Pi settings.json: {error}"))?;
let object = root
.as_object_mut()
.ok_or_else(|| "Pi settings.json root must be a JSON object".to_string())?;
let packages = object
.entry("packages")
.or_insert_with(|| serde_json::json!([]))
.as_array_mut()
.ok_or_else(|| "Pi settings.json packages must be an array".to_string())?;
if !packages.iter().any(|package| {
package.as_str().is_some_and(pi_package_source_matches)
|| package
.get("source")
.and_then(serde_json::Value::as_str)
.is_some_and(pi_package_source_matches)
}) {
packages.push(serde_json::json!(PI_CLIPROXYAPI_PACKAGE));
}
object.insert(
"defaultProvider".to_string(),
serde_json::json!(PI_CLIPROXYAPI_PROVIDER_ID),
);
object.insert("defaultModel".to_string(), serde_json::json!(default_model));
let mut rendered = serde_json::to_string_pretty(&root)
.map_err(|error| format!("Failed to generate Pi settings.json: {error}"))?;
rendered.push('\n');
Ok(rendered)
}
pub(crate) fn inspect_pi_provider_status(
home: &Path,
port: u16,
api_key: &str,
) -> AgentConfigStatus {
let config_path = pi_provider_config_path(home);
let settings_path = pi_provider_settings_path(home);
let executable = find_pi_executable(home);
let mut errors = Vec::new();
let (plugin_installed, default_provider_matches, current_model) = match read_pi_settings(home) {
Ok(Some(settings)) => {
let plugin_installed = pi_settings_contains_provider(&settings);
let default_provider_matches = settings
.get("defaultProvider")
.and_then(serde_json::Value::as_str)
.is_some_and(|value| value.trim() == PI_CLIPROXYAPI_PROVIDER_ID);
let current_model = settings
.get("defaultModel")
.and_then(serde_json::Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
.map(str::to_string);
(plugin_installed, default_provider_matches, current_model)
}
Ok(None) => (false, false, None),
Err(error) => {
errors.push(error);
(false, false, None)
}
};
let mut credentials_match = false;
if config_path.is_file() {
match fs::read_to_string(&config_path)
.map_err(|error| format!("Failed to read Pi CLIProxyAPI configuration: {error}"))
.and_then(|content| {
let root = serde_json::from_str::<serde_json::Value>(&content)
.map_err(|error| format!("Failed to parse Pi CLIProxyAPI configuration: {error}"))?;
let object = root
.as_object()
.ok_or_else(|| "Pi CLIProxyAPI configuration root must be a JSON object".to_string())?;
let expected_base_url = managed_core_loopback_origin(port);
credentials_match = object
.get("baseUrl")
.and_then(serde_json::Value::as_str)
.is_some_and(|value| value.trim() == expected_base_url)
&& object.get("apiKey").and_then(serde_json::Value::as_str) == Some(api_key);
Ok(())
}) {
Ok(()) => {}
Err(error) => errors.push(error),
}
}
let config_exists = config_path.is_file() || settings_path.is_file();
let config_valid = errors.is_empty();
let configured = plugin_installed
&& credentials_match
&& default_provider_matches
&& current_model.is_some()
&& config_valid;
let mut warnings = Vec::new();
if executable.is_some() && !plugin_installed && config_valid {
warnings.push("Pi CLIProxyAPI provider plugin is not installed".to_string());
} else if plugin_installed
&& (!credentials_match || !default_provider_matches || current_model.is_none())
&& config_valid
&& config_exists
{
warnings.push(
"Pi plugin configuration is incomplete. Use “Apply Configuration” to rewrite the credentials, default provider, and default model".to_string(),
);
}
if executable.is_none() && config_exists {
warnings.push(
"Only a Pi configuration file was detected. You can still edit the configuration and connect it to CPA, but launch functionality is unavailable".to_string(),
);
}
let modification_state = if configured {
"applied"
} else {
"unconfigured"
};
let error = errors.into_iter().next();
let cli_version = executable
.as_deref()
.and_then(|path| read_agent_version(path, home));
let plugin_version = read_pi_provider_version(home).ok().flatten();
AgentConfigStatus {
id: PI_AGENT_ID.to_string(),
name: PI_AGENT_NAME.to_string(),
supported_platform: true,
installed: executable.is_some(),
plugin_installed,
launch_targets: executable
.as_ref()
.map(|path| {
vec![AgentLaunchTarget {
id: "cli".to_string(),
label: PI_AGENT_NAME.to_string(),
detail: path_to_string(path),
}]
})
.unwrap_or_default(),
version: cli_version.clone(),
cli_version,
app_version: None,
plugin_version,
config_paths: vec![path_to_string(&config_path), path_to_string(&settings_path)],
config_exists,
config_valid,
configured,
configuration_synchronized: configured,
connection_state: agent_connection_state(
configured,
config_valid,
Ok(config_path.is_file() || default_provider_matches),
)
.into(),
current_model: current_model.clone(),
oauth_configuration: false,
codex_native_oauth: false,
modification_enabled: configured,
modification_state: modification_state.to_string(),
backup_available: false,
applied_model: current_model,
claude_code_model_mappings: None,
claude_desktop_model_mappings: None,
warnings,
error,
}
}
pub(crate) fn install_pi_provider_inner(
home: &Path,
executable: &Path,
port: u16,
api_key: &str,
default_model: &str,
proxy_url: &str,
) -> Result<AgentConfigActionResult, String> {
if port == 0 {
return Err("Invalid kernel port".to_string());
}
if api_key.trim().is_empty() {
return Err("EasyCLIProxyAPI has no available API key".to_string());
}
let settings_path = pi_provider_settings_path(home);
let mut changed_files = Vec::new();
if !pi_provider_package_installed(home)? {
config_package_operation(home, "plugin-install", || {
install_pi_package(executable, home, proxy_url)
})?;
changed_files.push(path_to_string(&settings_path));
}
let mut result = repair_pi_provider_inner(home, port, api_key, default_model)?;
changed_files.extend(result.changed_files);
result.changed_files = changed_files;
Ok(result)
}
pub(crate) fn configure_pi_provider_without_cli_inner(
home: &Path,
port: u16,
api_key: &str,
default_model: &str,
) -> Result<AgentConfigActionResult, String> {
if port == 0 {
return Err("Invalid kernel port".to_string());
}
if api_key.trim().is_empty() {
return Err("EasyCLIProxyAPI has no available API key".to_string());
}
let settings_path = pi_provider_settings_path(home);
config_package_operation(home, "plugin-config", || {
let settings = if settings_path.is_file() {
fs::read_to_string(&settings_path).map_err(|error| {
format!(
"Failed to read Pi settings.json {}: {error}",
path_to_string(&settings_path)
)
})?
} else {
"{}".to_string()
};
let rendered = build_pi_provider_settings(&settings, default_model)?;
write_bytes_directly(&settings_path, rendered.as_bytes())
})?;
repair_pi_provider_inner(home, port, api_key, default_model)
}
pub(crate) fn repair_pi_provider_inner(
home: &Path,
port: u16,
api_key: &str,
default_model: &str,
) -> Result<AgentConfigActionResult, String> {
if port == 0 {
return Err("Core port is invalid".to_string());
}
if api_key.trim().is_empty() {
return Err("EasyCLIProxyAPI has no usable API key".to_string());
}
let config_path = pi_provider_config_path(home);
let settings_path = pi_provider_settings_path(home);
let _guard = AGENT_CONFIG_FILE_LOCK
.lock()
.map_err(|_| "Configuration file lock is poisoned")?;
let before = config_images(&config_paths(PI_AGENT_ID, home)?)?;
validate_config_images(&before)?;
if !pi_provider_package_installed(home)? {
return Err("Pi CLIProxyAPI provider is not installed".to_string());
}
let existing = if config_path.is_file() {
Some(fs::read_to_string(&config_path).map_err(|error| {
format!(
"Failed to read Pi CLIProxyAPI configuration {}: {error}",
path_to_string(&config_path)
)
})?)
} else {
None
};
let base_url = managed_core_loopback_origin(port);
let rendered = build_pi_provider_config(existing.as_deref(), &base_url, api_key)?;
let settings = fs::read_to_string(&settings_path).map_err(|error| {
format!(
"Failed to read Pi settings.json {}: {error}",
path_to_string(&settings_path)
)
})?;
let rendered_settings = build_pi_provider_settings(&settings, default_model)?;
config_updates(
PI_AGENT_ID,
home,
&before,
&[
AgentFileUpdate {
path: config_path,
after: rendered,
},
AgentFileUpdate {
path: settings_path,
after: rendered_settings,
},
],
"update",
Some(default_model.to_string()),
None,
)
}
pub(crate) fn update_pi_provider_inner(
home: &Path,
executable: &Path,
port: u16,
api_key: &str,
default_model: &str,
proxy_url: &str,
) -> Result<AgentConfigActionResult, String> {
if !pi_provider_package_installed(home)? {
return Err("Pi CLIProxyAPI provider plugin is not installed".to_string());
}
config_package_operation(home, "plugin-update", || {
update_pi_package(executable, home, proxy_url)
})?;
let mut result = repair_pi_provider_inner(home, port, api_key, default_model)?;
result.outcome = "updated".to_string();
Ok(result)
}
pub(crate) fn uninstall_pi_provider_inner(
home: &Path,
executable: &Path,
) -> Result<AgentConfigActionResult, String> {
if !pi_provider_package_installed(home)? {
return Ok(action_result(
"not-installed",
false,
None,
Vec::new(),
Vec::new(),
));
}
config_package_operation(home, "plugin-remove", || {
remove_pi_package(executable, home)
})?;
Ok(action_result(
"removed",
false,
None,
vec![path_to_string(&pi_provider_settings_path(home))],
Vec::new(),
))
}
pub(crate) fn agent_command_path(
home: &Path,
executable: &Path,
) -> Result<std::ffi::OsString, String> {
let mut directories = agent_executable_directories(home);
if let Some(executable_directory) = executable
.parent()
.filter(|path| !path.as_os_str().is_empty())
{
directories.retain(|directory| directory != executable_directory);
directories.insert(0, executable_directory.to_path_buf());
}
env::join_paths(directories).map_err(|error| format!("Failed to construct agent command PATH: {error}"))
}
pub(crate) fn configure_agent_command_environment(
command: &mut Command,
home: &Path,
executable: &Path,
) -> Result<(), String> {
command.env("PATH", agent_command_path(home, executable)?);
Ok(())
}
pub(crate) fn install_pi_package(
executable: &Path,
home: &Path,
proxy_url: &str,
) -> Result<(), String> {
#[cfg(target_os = "windows")]
let mut command = windows_command_for_executable(executable, false);
#[cfg(not(target_os = "windows"))]
let mut command = Command::new(executable);
command
.arg("install")
.arg(PI_CLIPROXYAPI_PACKAGE)
.current_dir(home)
.stdin(Stdio::null());
configure_background_command(&mut command);
configure_networked_command(&mut command, proxy_url);
configure_agent_command_environment(&mut command, home, executable)?;
let output = command
.output()
.map_err(|error| format!("Failed to run Pi plugin installation: {error}"))?;
if output.status.success() {
return Ok(());
}
let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string();
let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
let detail = if !stderr.is_empty() { stderr } else { stdout };
Err(format!(
"Pi CLIProxyAPI provider plugin installation failed{}",
if detail.is_empty() {
String::new()
} else {
format!(": {detail}")
}
))
}
pub(crate) fn update_pi_package(
executable: &Path,
home: &Path,
proxy_url: &str,
) -> Result<(), String> {
#[cfg(target_os = "windows")]
let mut command = windows_command_for_executable(executable, false);
#[cfg(not(target_os = "windows"))]
let mut command = Command::new(executable);
command
.arg("update")
.arg("--extension")
.arg(PI_CLIPROXYAPI_PACKAGE)
.current_dir(home)
.stdin(Stdio::null());
configure_background_command(&mut command);
configure_networked_command(&mut command, proxy_url);
configure_agent_command_environment(&mut command, home, executable)?;
let output = command
.output()
.map_err(|error| format!("Failed to run Pi plugin update: {error}"))?;
if output.status.success() {
return Ok(());
}
let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string();
let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
let detail = if !stderr.is_empty() { stderr } else { stdout };
Err(format!(
"Pi CLIProxyAPI provider plugin update failed{}",
if detail.is_empty() {
String::new()
} else {
format!(": {detail}")
}
))
}
pub(crate) fn remove_pi_package(executable: &Path, home: &Path) -> Result<(), String> {
#[cfg(target_os = "windows")]
let mut command = windows_command_for_executable(executable, false);
#[cfg(not(target_os = "windows"))]
let mut command = Command::new(executable);
command
.arg("remove")
.arg(PI_CLIPROXYAPI_PACKAGE)
.current_dir(home)
.stdin(Stdio::null());
configure_background_command(&mut command);
configure_agent_command_environment(&mut command, home, executable)?;
let output = command
.output()
.map_err(|error| format!("Failed to run Pi plugin uninstall: {error}"))?;
if output.status.success() {
return Ok(());
}
let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string();
let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
let detail = if !stderr.is_empty() { stderr } else { stdout };
Err(format!(
"Pi CLIProxyAPI provider plugin uninstall failed{}",
if detail.is_empty() {
String::new()
} else {
format!(": {detail}")
}
))
}
pub(crate) fn codex_configuration_directory(home: &Path) -> PathBuf {
agent_configuration_environment("CODEX_HOME").unwrap_or_else(|| home.join(".codex"))
}
pub(crate) fn current_codex_oauth_configuration(home: &Path) -> Result<bool, String> {
let path = codex_configuration_directory(home).join("config.toml");
if !path.is_file() {
return Ok(false);
}
let root: toml::Value = toml::from_str(
&fs::read_to_string(&path).map_err(|error| format!("Failed to read Codex configuration: {error}"))?,
)
.map_err(|error| format!("Failed to parse Codex configuration: {error}"))?;
Ok(root
.get("model_providers")
.and_then(toml::Value::as_table)
.and_then(|providers| providers.get(MANAGED_AGENT_PROVIDER_ID))
.and_then(toml::Value::as_table)
.and_then(|provider| provider.get("requires_openai_auth"))
.and_then(toml::Value::as_bool)
== Some(true))
}
pub(crate) fn codex_auth_file_has_oauth_tokens(path: &Path) -> bool {
let Ok(content) = fs::read_to_string(path) else {
return false;
};
let Ok(root) = serde_json::from_str::<serde_json::Value>(&content) else {
return false;
};
root.get("tokens")
.and_then(serde_json::Value::as_object)
.is_some_and(|tokens| {
tokens
.values()
.any(|value| value.as_str().is_some_and(|token| !token.trim().is_empty()))
})
}
pub(crate) fn codex_auth_file_has_api_key(path: &Path, api_key: &str) -> bool {
let Ok(content) = fs::read_to_string(path) else {
return false;
};
let Ok(root) = serde_json::from_str::<serde_json::Value>(&content) else {
return false;
};
let api_key_matches = root
.get("OPENAI_API_KEY")
.and_then(serde_json::Value::as_str)
.is_some_and(|value| value == api_key);
let mode_matches = root
.get("auth_mode")
.is_none_or(|value| value.as_str() == Some("apikey"));
api_key_matches && mode_matches
}
pub(crate) fn validate_codex_oauth_login(home: &Path) -> Result<(), String> {
if validate_codex_oauth_login_at(&codex_configuration_directory(home).join("auth.json")).is_ok()
{
return Ok(());
}
available_codex_oauth_auth(home).map(|_| ())
}
pub(crate) fn validate_codex_oauth_login_at(auth_path: &Path) -> Result<(), String> {
if codex_auth_file_has_oauth_tokens(auth_path) {
Ok(())
} else {
Err(CODEX_OAUTH_LOGIN_REQUIRED_ERROR.to_string())
}
}
pub(crate) fn clear_codex_config_files(home: &Path) -> Result<Vec<String>, String> {
let mut paths = config_paths("codex", home)?;
paths.push(codex_configuration_directory(home).join(CODEX_NATIVE_OAUTH_STATE_FILE));
let before = config_images(&paths)?;
let mut after = before.clone();
for (path, bytes) in &mut after {
if path.file_name().and_then(|v| v.to_str()).is_some_and(|v| {
v == "auth.json" || v == "config.toml" || v == CODEX_NATIVE_OAUTH_STATE_FILE
}) {
*bytes = None;
}
}
Ok(commit_config("codex", &paths, &before, &after, "clear", None)?.changed_files)
}
pub(crate) fn codex_model_catalog_path(home: &Path) -> PathBuf {
codex_configuration_directory(home).join(CODEX_MODEL_CATALOG_FILE)
}
pub(crate) fn expected_agent_record_paths(client: AgentClient, paths: &[PathBuf]) -> Vec<PathBuf> {
if client == AgentClient::Codex && paths.len() == 1 {
let mut expected = paths.to_vec();
expected.push(paths[0].with_file_name(CODEX_MODEL_CATALOG_FILE));
expected.push(paths[0].with_file_name("auth.json"));
expected
} else {
paths.to_vec()
}
}
pub(crate) fn claude_desktop_config_paths(_home: &Path) -> Vec<PathBuf> {
#[cfg(target_os = "macos")]
let (normal, threep) = {
let support = _home.join("Library/Application Support");
(support.join("Claude"), support.join("Claude-3p"))
};
#[cfg(target_os = "windows")]
{
let local = agent_configuration_environment("LOCALAPPDATA")
.unwrap_or_else(|| _home.join("AppData/Local"));
claude_desktop_config_paths_from_local_app_data(&local)
}
#[cfg(target_os = "linux")]
let (normal, threep) = {
let config_home = agent_configuration_environment("XDG_CONFIG_HOME")
.filter(|path| path.is_absolute())
.unwrap_or_else(|| _home.join(".config"));
(config_home.join("Claude"), config_home.join("Claude-3p"))
};
#[cfg(not(any(target_os = "macos", target_os = "windows", target_os = "linux")))]
return Vec::new();
#[cfg(any(target_os = "macos", target_os = "linux"))]
{
claude_desktop_config_paths_from_directories(normal, threep)
}
}
pub(crate) fn claude_desktop_config_paths_from_directories(
normal: PathBuf,
threep: PathBuf,
) -> Vec<PathBuf> {
let library = threep.join("configLibrary");
vec![
normal.join("claude_desktop_config.json"),
threep.join("claude_desktop_config.json"),
library.join(format!("{CLAUDE_DESKTOP_PROFILE_ID}.json")),
library.join("_meta.json"),
]
}
#[cfg(target_os = "windows")]
pub(crate) fn claude_desktop_config_paths_from_local_app_data(
local_app_data: &Path,
) -> Vec<PathBuf> {
let normal = find_windows_claude_data_directory(local_app_data, false)
.unwrap_or_else(|| local_app_data.join("Claude"));
let threep = find_windows_claude_data_directory(local_app_data, true)
.unwrap_or_else(|| local_app_data.join("Claude-3p"));
claude_desktop_config_paths_from_directories(normal, threep)
}
#[cfg(target_os = "windows")]
pub(crate) fn find_windows_claude_data_directory(
local_app_data: &Path,
threep: bool,
) -> Option<PathBuf> {
let exact_name = if threep { "Claude-3p" } else { "Claude" };
let exact = local_app_data.join(exact_name);
if exact.is_dir() {
return Some(exact);
}
let mut candidates = fs::read_dir(local_app_data)
.ok()?
.filter_map(Result::ok)
.map(|entry| entry.path())
.filter(|path| path.is_dir())
.filter(|path| {
let Some(name) = path.file_name().and_then(|value| value.to_str()) else {
return false;
};
let normalized = name.to_ascii_lowercase();
normalized.starts_with("claude") && normalized.contains("-3p") == threep
})
.collect::<Vec<_>>();
candidates.sort();
candidates.into_iter().next()
}
pub(crate) fn hermes_agent_config_path(home: &Path) -> PathBuf {
if let Some(directory) = agent_configuration_environment("HERMES_HOME") {
return directory.join("config.yaml");
}
#[cfg(target_os = "windows")]
{
agent_configuration_environment("LOCALAPPDATA")
.unwrap_or_else(|| home.join("AppData/Local"))
.join("hermes/config.yaml")
}
#[cfg(not(target_os = "windows"))]
home.join(".hermes/config.yaml")
}
pub(crate) fn inspect_agent_config(
client: AgentClient,
home: &Path,
port: u16,
api_key: &str,
) -> AgentConfigStatus {
let paths = agent_config_paths(client, home);
let config_exists = paths.iter().any(|path| path.is_file());
let result = config_paths(client.id(), home)
.and_then(|mut paths| {
if client == AgentClient::Codex && codex_native_oauth_enabled(home)? {
paths.retain(|path| {
path.file_name().and_then(|name| name.to_str())
!= Some(CODEX_MODEL_CATALOG_FILE)
});
}
config_images(&paths)
})
.and_then(|images| validate_config_images(&images))
.and_then(|_| inspect_agent_managed_config(client, &paths, port, api_key))
.and_then(|(configured, model, oauth_configuration)| {
if client == AgentClient::Codex && configured {
let model = model
.as_deref()
.ok_or_else(|| "Codex configuration is missing a default model".to_string())?;
validate_codex_catalog_file(&paths[0], model)?;
}
Ok((configured, model, oauth_configuration))
});
let (configured, current_model, oauth_configuration, config_valid, error) = match result {
Ok((configured, model, oauth_configuration)) => {
(configured, model, oauth_configuration, true, None)
}
Err(_) => (
false,
None,
false,
false,
Some(
"Failed to read or parse configuration. Check file permissions, restore from a manual backup, or repair it with the base configuration template"
.to_string(),
),
),
};
let executable = find_agent_executable(client, home);
let cli_version = if client == AgentClient::ZCode {
find_named_agent_executable(home, &["zcode"])
.filter(|path| executable.as_ref() != Some(path))
.as_deref()
.and_then(|path| read_agent_version(path, home))
} else if should_probe_primary_agent_executable_version(client) {
executable
.as_deref()
.and_then(|path| read_agent_version(path, home))
} else {
None
};
let codex_app_installation = (client == AgentClient::Codex)
.then(|| find_codex_app_installation(home))
.flatten();
let opencode_desktop_application = (client == AgentClient::OpenCode)
.then(|| find_opencode_desktop_application(home))
.flatten();
let harness_desktop_application = (client == AgentClient::DeepSeekHarness)
.then(|| find_deepseek_harness_desktop_application(home))
.flatten();
let app_version = match client {
AgentClient::ClaudeDesktop => read_claude_desktop_version(home),
AgentClient::Codex => codex_app_installation
.as_ref()
.and_then(|installation| read_codex_app_installation_version(installation, home)),
AgentClient::OpenCode => opencode_desktop_application
.as_deref()
.and_then(read_opencode_desktop_version),
AgentClient::DeepSeekHarness => harness_desktop_application
.as_deref()
.and_then(read_deepseek_harness_desktop_version),
AgentClient::ZCode => executable.as_deref().and_then(read_zcode_app_version),
AgentClient::WorkBuddy => executable.as_deref().and_then(read_workbuddy_app_version),
_ => None,
};
let version = cli_version.clone().or_else(|| app_version.clone()).or_else(|| {
(client == AgentClient::DeepSeekHarness)
.then(|| read_deepseek_harness_profile_version(home))
.flatten()
});
let app_installed = codex_app_installation.is_some()
|| opencode_desktop_application.is_some()
|| harness_desktop_application.is_some();
let installed = agent_installation_detected(
client,
version.as_deref(),
executable.is_some(),
app_installed,
);
let launch_targets = agent_launch_targets(
client,
executable.as_deref(),
app_version.as_deref(),
app_installed,
);
let mut warnings = Vec::new();
if !client.supported_platform() {
warnings.push("The current platform does not support Claude Desktop 3P configuration".to_string());
} else if !installed && config_exists {
warnings.push(
"Only a configuration file was detected. You can still edit the configuration and connect it to CPA, but launch functionality is unavailable".to_string(),
);
}
if let Some(message) = error.as_ref() {
warnings.push(message.clone());
}
let configuration_synchronized = agent_configuration_is_synchronized(client, home, configured);
AgentConfigStatus {
id: client.id().to_string(),
name: client.name().to_string(),
supported_platform: client.supported_platform(),
installed,
plugin_installed: true,
launch_targets,
version,
cli_version,
app_version,
plugin_version: None,
config_paths: paths.iter().map(|path| path_to_string(path)).collect(),
config_exists,
config_valid,
configured,
configuration_synchronized,
connection_state: agent_connection_state(
configured,
config_valid,
agent_has_connection_evidence(client, &paths),
)
.into(),
current_model: current_model.clone(),
oauth_configuration,
codex_native_oauth: client == AgentClient::Codex
&& codex_native_oauth_enabled(home).unwrap_or(false),
modification_enabled: configured,
modification_state: if !config_valid {
"invalid"
} else if configured {
"applied"
} else {
"unconfigured"
}
.into(),
backup_available: false,
applied_model: current_model,
claude_code_model_mappings: (client == AgentClient::ClaudeCode)
.then(|| inspect_claude_code_model_mappings(&paths[0]).ok().flatten())
.flatten(),
claude_desktop_model_mappings: (client == AgentClient::ClaudeDesktop)
.then(|| current_desktop_mappings(home))
.flatten(),
warnings,
error,
}
}
pub(crate) fn agent_installation_detected(
client: AgentClient,
version: Option<&str>,
executable_found: bool,
app_installed: bool,
) -> bool {
executable_found
|| app_installed
|| (client == AgentClient::ClaudeDesktop && version.is_some())
}
pub(crate) fn should_probe_primary_agent_executable_version(client: AgentClient) -> bool {
!matches!(client, AgentClient::ClaudeDesktop | AgentClient::ZCode | AgentClient::WorkBuddy)
}
pub(crate) fn agent_launch_targets(
client: AgentClient,
executable: Option<&Path>,
app_version: Option<&str>,
app_installed: bool,
) -> Vec<AgentLaunchTarget> {
let mut targets = Vec::new();
match client {
AgentClient::ClaudeDesktop => {
if executable.is_some() || app_version.is_some() {
targets.push(AgentLaunchTarget {
id: "app".to_string(),
label: "Claude Desktop".to_string(),
detail: executable
.map(path_to_string)
.unwrap_or_else(|| "Claude Desktop application".to_string()),
});
}
}
AgentClient::Codex => {
if app_installed {
targets.push(AgentLaunchTarget {
id: "app".to_string(),
label: "Codex App".to_string(),
detail: "Codex desktop application".to_string(),
});
}
if let Some(executable) = executable {
targets.push(AgentLaunchTarget {
id: "cli".to_string(),
label: "Codex CLI".to_string(),
detail: path_to_string(executable),
});
}
}
AgentClient::OpenCode => {
if let Some(executable) = executable {
targets.push(AgentLaunchTarget {
id: "cli".to_string(),
label: "OpenCode CLI".to_string(),
detail: path_to_string(executable),
});
}
if app_installed {
targets.push(AgentLaunchTarget {
id: "app".to_string(),
label: "OpenCode Desktop".to_string(),
detail: "OpenCode Desktop".to_string(),
});
}
}
AgentClient::ZCode | AgentClient::WorkBuddy => {
if let Some(executable) = executable {
targets.push(AgentLaunchTarget {
id: "app".to_string(),
label: client.name().to_string(),
detail: path_to_string(executable),
});
}
}
AgentClient::DeepSeekHarness => {
if let Some(executable) = executable {
targets.push(AgentLaunchTarget {
id: "cli".to_string(),
label: "DeepSeek Harness Web".to_string(),
detail: format!("{} web", path_to_string(executable)),
});
}
if app_installed {
targets.push(AgentLaunchTarget {
id: "app".to_string(),
label: "DeepSeek Harness Desktop".to_string(),
detail: "DeepSeek Harness Desktop".to_string(),
});
}
}
_ => {
if let Some(executable) = executable {
targets.push(AgentLaunchTarget {
id: "cli".to_string(),
label: client.name().to_string(),
detail: path_to_string(executable),
});
}
}
}
targets
}
pub(crate) fn agent_configuration_is_synchronized(
_client: AgentClient,
_home: &Path,
configured: bool,
) -> bool {
configured
}
#[cfg(test)]
pub(crate) fn inspect_agent_application(
client: AgentClient,
home: &Path,
) -> AgentModificationInspection {
match load_agent_applied_state(client, home) {
Ok(Some(state)) => {
let paths = agent_config_paths(client, home);
let backup_available = !state.backup_files.is_empty()
&& state
.backup_files
.iter()
.all(|file| !file.existed_before || file.backup_path.is_file());
if state.backup_files.is_empty() {
match agent_has_managed_marker(client, &paths) {
Ok(false) => {
return AgentModificationInspection {
enabled: false,
state: "unconfigured".to_string(),
backup_available: false,
warnings: Vec::new(),
};
}
Err(error) => {
return AgentModificationInspection {
enabled: false,
state: "invalid".to_string(),
backup_available: false,
warnings: vec![error],
};
}
Ok(true) => {}
}
}
let mut warnings = Vec::new();
if state.backup_files.is_empty() {
warnings.push("Legacy application state detected; only CPA-managed configuration fields will be removed when disabling".to_string());
} else if !backup_available {
warnings.push("The original configuration session backup is incomplete and cannot be safely restored yet".to_string());
}
AgentModificationInspection {
enabled: true,
state: "applied".to_string(),
backup_available,
warnings,
}
}
Ok(None) => AgentModificationInspection {
enabled: false,
state: "unconfigured".to_string(),
backup_available: false,
warnings: Vec::new(),
},
Err(error) => AgentModificationInspection {
enabled: false,
state: "invalid".to_string(),
backup_available: false,
warnings: vec![error],
},
}
}
pub(crate) fn inspect_agent_managed_config(
client: AgentClient,
paths: &[PathBuf],
port: u16,
api_key: &str,
) -> Result<(bool, Option<String>, bool), String> {
match client {
AgentClient::ClaudeCode => inspect_claude_agent_config(&paths[0], port, api_key)
.map(|(configured, model)| (configured, model, false)),
AgentClient::ClaudeDesktop if client.supported_platform() => {
inspect_claude_desktop_agent_config(paths, port, api_key)
.map(|(configured, model)| (configured, model, false))
}
AgentClient::ClaudeDesktop => Ok((false, None, false)),
AgentClient::Codex => inspect_codex_agent_config(&paths[0], port, api_key),
AgentClient::OpenCode => inspect_opencode_agent_config(&paths[0], port, api_key)
.map(|(configured, model)| (configured, model, false)),
AgentClient::OpenClaw => inspect_openclaw_agent_config(&paths[0], port, api_key)
.map(|(configured, model)| (configured, model, false)),
AgentClient::Hermes => inspect_hermes_agent_config(&paths[0], port, api_key)
.map(|(configured, model)| (configured, model, false)),
AgentClient::DeepSeekHarness => inspect_deepseek_harness_config(paths, port, api_key)
.map(|(configured, model)| (configured, model, false)),
AgentClient::ZCode => {
if paths.len() != 1 {
return Err("Invalid number of ZCode configuration paths".to_string());
}
inspect_zcode_agent_config(&paths[0], port, api_key)
.map(|(configured, model)| (configured, model, false))
}
AgentClient::AntigravityCli => inspect_antigravity_config(client, paths, port, api_key).map(|(configured, model)| (configured, model, false)),
AgentClient::WorkBuddy => inspect_workbuddy_agent_config(&paths[0], port, api_key)
.map(|(configured, model)| (configured, model, false)),
AgentClient::KimiCode => inspect_kimi_code_agent_config(&paths[0], port, api_key)
.map(|(configured, model)| (configured, model, false)),
AgentClient::GrokBuild => inspect_grok_build_agent_config(&paths[0], port, api_key)
.map(|(configured, model)| (configured, model, false)),
}
}
pub(crate) fn agent_connection_state(
configured: bool,
valid: bool,
managed: Result<bool, String>,
) -> &'static str {
if !valid || managed.is_err() {
"invalid"
} else if configured {
"configured"
} else if managed == Ok(true) {
"needs-update"
} else {
"not-configured"
}
}
pub(crate) fn agent_has_connection_evidence(
client: AgentClient,
paths: &[PathBuf],
) -> Result<bool, String> {
let active_marker = agent_has_managed_marker(client, paths)?;
if active_marker {
return Ok(true);
}
for path in paths {
let Some(bytes) = read_agent_bytes(path)? else {
continue;
};
let content = std::str::from_utf8(&bytes).map_err(|e| e.to_string())?;
let value: serde_json::Value = match path.extension().and_then(|v| v.to_str()) {
Some("toml") => serde_json::to_value(
toml::from_str::<toml::Value>(content).map_err(|e| e.to_string())?,
)
.map_err(|e| e.to_string())?,
Some("yaml" | "yml") => serde_yaml::from_str(content).map_err(|e| e.to_string())?,
_ => json5::from_str(content).map_err(|e| e.to_string())?,
};
let provider_present = client != AgentClient::Codex
&& [
"/provider/cpa-gui",
"/model_providers/cpa-gui",
"/providers/cpa-gui",
"/models/providers/cpa-gui",
]
.iter()
.any(|pointer| value.pointer(pointer).is_some());
let selected = [
"/model_provider",
"/model",
"/model/main",
"/model/provider",
"/default_model",
"/models/default",
"/agents/defaults/model/primary",
]
.iter()
.filter_map(|pointer| value.pointer(pointer).and_then(serde_json::Value::as_str))
.any(|model| {
model == MANAGED_AGENT_PROVIDER_ID
|| model.starts_with(&format!("{MANAGED_AGENT_PROVIDER_ID}/"))
});
let catalog = client == AgentClient::Codex
&& value
.get("model_catalog_json")
.and_then(serde_json::Value::as_str)
== Some(CODEX_MODEL_CATALOG_FILE);
let hermes_provider = client == AgentClient::Hermes
&& value
.get("custom_providers")
.and_then(serde_json::Value::as_array)
.is_some_and(|items| {
items.iter().any(|item| {
item.get("name").and_then(serde_json::Value::as_str)
== Some(MANAGED_AGENT_PROVIDER_ID)
})
});
if provider_present || selected || catalog || hermes_provider {
return Ok(true);
}
}
Ok(false)
}
pub(crate) fn agent_has_managed_marker(
client: AgentClient,
paths: &[PathBuf],
) -> Result<bool, String> {
match client {
AgentClient::ClaudeCode => {
if !paths[0].is_file() {
return Ok(false);
}
let root = read_agent_json_or_empty(&paths[0], "Claude Code configuration")?;
let env = root.get("env");
Ok(env
.and_then(|value| value.get("ANTHROPIC_BASE_URL"))
.and_then(serde_json::Value::as_str)
.is_some_and(is_managed_agent_base_url))
}
AgentClient::ClaudeDesktop => {
if paths.len() != 4 {
return Ok(false);
}
let meta = read_agent_json_or_empty(&paths[3], "Claude Desktop configuration index")?;
let profile = read_agent_json_or_empty(&paths[2], "Claude Desktop gateway configuration")?;
Ok(meta.get("appliedId").and_then(serde_json::Value::as_str)
== Some(CLAUDE_DESKTOP_PROFILE_ID)
|| meta
.get("entries")
.and_then(serde_json::Value::as_array)
.is_some_and(|entries| {
entries.iter().any(|entry| {
entry.get("id").and_then(serde_json::Value::as_str)
== Some(CLAUDE_DESKTOP_PROFILE_ID)
})
})
|| profile
.get("inferenceGatewayBaseUrl")
.and_then(serde_json::Value::as_str)
.is_some_and(is_managed_agent_base_url))
}
AgentClient::Codex => {
if !paths[0].is_file() {
return Ok(false);
}
let root: toml::Value = toml::from_str(
&fs::read_to_string(&paths[0])
.map_err(|error| format!("Failed to read Codex configuration: {error}"))?,
)
.map_err(|error| format!("Failed to parse Codex configuration: {error}"))?;
Ok(root.get("model_provider").and_then(toml::Value::as_str)
== Some(MANAGED_AGENT_PROVIDER_ID))
}
AgentClient::OpenCode => {
if !paths[0].is_file() {
return Ok(false);
}
let root = read_agent_json5_or_empty(&paths[0], "OpenCode configuration")?;
let prefix = format!("{MANAGED_AGENT_PROVIDER_ID}/");
let provider_exists = root
.get("provider")
.and_then(|value| value.get(MANAGED_AGENT_PROVIDER_ID))
.is_some();
let model_selected = root
.get("model")
.and_then(serde_json::Value::as_str)
.is_some_and(|model| model.starts_with(&prefix));
Ok(provider_exists && model_selected)
}
AgentClient::OpenClaw => {
if !paths[0].is_file() {
return Ok(false);
}
let root: serde_json::Value = json5::from_str(
&fs::read_to_string(&paths[0])
.map_err(|error| format!("Failed to read OpenClaw configuration: {error}"))?,
)
.map_err(|error| format!("Failed to parse OpenClaw configuration: {error}"))?;
let prefix = format!("{MANAGED_AGENT_PROVIDER_ID}/");
let provider_exists = root
.get("models")
.and_then(|value| value.get("providers"))
.and_then(|value| value.get(MANAGED_AGENT_PROVIDER_ID))
.is_some();
let model_selected = root
.get("agents")
.and_then(|value| value.get("defaults"))
.and_then(|value| value.get("model"))
.and_then(|value| value.get("primary"))
.and_then(serde_json::Value::as_str)
.is_some_and(|model| model.starts_with(&prefix));
Ok(provider_exists && model_selected)
}
AgentClient::Hermes => {
if !paths[0].is_file() {
return Ok(false);
}
let root: serde_yaml::Value = serde_yaml::from_str(
&fs::read_to_string(&paths[0])
.map_err(|error| format!("Failed to read Hermes configuration: {error}"))?,
)
.map_err(|error| format!("Failed to parse Hermes configuration: {error}"))?;
let provider_exists = root
.get("custom_providers")
.and_then(serde_yaml::Value::as_sequence)
.is_some_and(|providers| {
providers.iter().any(|provider| {
provider.get("name").and_then(serde_yaml::Value::as_str)
== Some(MANAGED_AGENT_PROVIDER_ID)
})
});
let model_selected = root
.get("model")
.and_then(|value| value.get("provider"))
.and_then(serde_yaml::Value::as_str)
== Some(MANAGED_AGENT_PROVIDER_ID);
Ok(provider_exists && model_selected)
}
AgentClient::DeepSeekHarness => deepseek_harness_has_managed_marker(paths),
AgentClient::WorkBuddy => workbuddy_has_managed_marker(&paths[0]),
AgentClient::AntigravityCli => antigravity_has_marker(client, paths),
AgentClient::ZCode => {
let Some(path) = paths.first() else { return Ok(false); };
if !path.is_file() { return Ok(false); }
let root = read_agent_json_or_empty(path, "ZCode provider configuration")?;
let provider_exists = root.pointer("/config/providerConfigRules/providerRules")
.and_then(serde_json::Value::as_array)
.is_some_and(|rules| rules.iter().any(|rule| {
rule.get("providerId").and_then(serde_json::Value::as_str)
== Some(MANAGED_AGENT_PROVIDER_ID)
}));
let model_selected = root.pointer("/config/defaultModelSelection/providerId")
.and_then(serde_json::Value::as_str)
== Some(MANAGED_AGENT_PROVIDER_ID);
Ok(provider_exists && model_selected)
}
AgentClient::KimiCode => inspect_managed_toml_model_marker(
&paths[0],
"Kimi Code configuration",
Some("providers"),
"models",
None,
"default_model",
),
AgentClient::GrokBuild => inspect_managed_toml_model_marker(
&paths[0],
"Grok Build configuration",
None,
"model",
Some("models"),
"default",
),
}
}
pub(crate) fn is_managed_agent_base_url(value: &str) -> bool {
let value = value.trim().to_ascii_lowercase();
if value.starts_with("http://127.0.0.1:")
|| value.starts_with("http://localhost:")
|| value.starts_with("https://127.0.0.1:")
|| value.starts_with("https://localhost:")
{
return true;
}
#[cfg(not(test))]
{
let Ok(settings) = read_installed_core_config_settings() else {
return false;
};
let host = core_connect_host(&settings.host);
let host = if host.contains(':') {
format!("[{host}]")
} else {
host
};
value.starts_with(&format!("http://{host}:"))
|| value.starts_with(&format!("https://{host}:"))
}
#[cfg(test)]
{
false
}
}
pub(crate) fn inspect_managed_toml_model_marker(
path: &Path,
label: &str,
provider_section: Option<&str>,
model_entry_section: &str,
default_section: Option<&str>,
default_key: &str,
) -> Result<bool, String> {
if !path.is_file() {
return Ok(false);
}
let root: toml::Value = toml::from_str(
&fs::read_to_string(path).map_err(|error| format!("Failed to read {label}: {error}"))?,
)
.map_err(|error| format!("Failed to parse {label}: {error}"))?;
let prefix = format!("{MANAGED_AGENT_PROVIDER_ID}/");
let selected = (if let Some(section) = default_section {
root.get(section)
.and_then(toml::Value::as_table)
.and_then(|section| section.get(default_key))
} else {
root.get(default_key)
})
.and_then(toml::Value::as_str)
.filter(|model| model.starts_with(&prefix));
let catalog_has_selected = selected.is_some_and(|model| {
root.get(model_entry_section)
.and_then(toml::Value::as_table)
.is_some_and(|catalog| catalog.contains_key(model))
});
let provider_exists = provider_section.is_none_or(|section| {
root.get(section)
.and_then(toml::Value::as_table)
.is_some_and(|providers| providers.contains_key(MANAGED_AGENT_PROVIDER_ID))
});
Ok(provider_exists && catalog_has_selected)
}
pub(crate) fn find_codex_app_installation(home: &Path) -> Option<DesktopAppTarget> {
#[cfg(target_os = "macos")]
{
[PathBuf::from("/Applications"), home.join("Applications")]
.into_iter()
.flat_map(|directory| {
[
"ChatGPT.app",
"Codex.app",
"OpenAI Codex.app",
"OpenAI.Codex.app",
]
.into_iter()
.map(move |name| directory.join(name))
})
.find(|path| path.is_dir())
.map(DesktopAppTarget::Application)
}
#[cfg(target_os = "windows")]
{
find_windows_codex_app_installation(home)
}
#[cfg(not(any(target_os = "macos", target_os = "windows")))]
{
let _ = home;
None
}
}
pub(crate) fn find_opencode_desktop_application(home: &Path) -> Option<PathBuf> {
#[cfg(target_os = "macos")]
{
[
PathBuf::from("/Applications/OpenCode.app"),
home.join("Applications/OpenCode.app"),
]
.into_iter()
.find(|path| path.is_dir())
}
#[cfg(target_os = "windows")]
{
let local = env::var_os("LOCALAPPDATA")
.map(PathBuf::from)
.filter(|path| !path.as_os_str().is_empty())
.unwrap_or_else(|| home.join("AppData/Local"));
let program_files_roots = ["ProgramFiles", "ProgramFiles(x86)"]
.into_iter()
.filter_map(env::var_os)
.map(PathBuf::from)
.filter(|path| !path.as_os_str().is_empty())
.collect::<Vec<_>>();
find_windows_opencode_desktop_application_in_roots(&local, &program_files_roots)
}
#[cfg(target_os = "linux")]
{
let mut executable_directories = agent_executable_directories(home);
for directory in ["/opt/OpenCode", "/opt/opencode-desktop"] {
let directory = PathBuf::from(directory);
if !executable_directories.contains(&directory) {
executable_directories.push(directory);
}
}
let data_home = env::var_os("XDG_DATA_HOME")
.map(PathBuf::from)
.filter(|path| path.is_absolute())
.unwrap_or_else(|| home.join(".local/share"));
let mut data_directories = vec![data_home];
let system_data_directories = env::var_os("XDG_DATA_DIRS")
.map(|value| env::split_paths(&value).collect::<Vec<_>>())
.filter(|directories| !directories.is_empty())
.unwrap_or_else(|| {
vec![
PathBuf::from("/usr/local/share"),
PathBuf::from("/usr/share"),
]
});
for directory in system_data_directories {
if directory.is_absolute() && !data_directories.contains(&directory) {
data_directories.push(directory);
}
}
let appimage_directories = [
home.join("Applications"),
home.join(".local/Applications"),
home.join("Downloads"),
home.join("Desktop"),
];
find_linux_opencode_desktop_application_in_roots(
home,
&executable_directories,
&data_directories,
&appimage_directories,
)
}
#[cfg(not(any(target_os = "macos", target_os = "windows", target_os = "linux")))]
{
let _ = home;
None
}
}
#[cfg(target_os = "windows")]
pub(crate) fn find_windows_opencode_desktop_application_in_roots(
local_app_data: &Path,
program_files_roots: &[PathBuf],
) -> Option<PathBuf> {
let mut candidates = vec![
local_app_data.join("Programs/@opencode-aidesktop/OpenCode.exe"),
local_app_data.join("Programs/OpenCode/OpenCode.exe"),
local_app_data.join("Programs/opencode/OpenCode.exe"),
local_app_data.join("OpenCode/OpenCode.exe"),
local_app_data.join("opencode/OpenCode.exe"),
];
for root in program_files_roots {
candidates.extend([
root.join("@opencode-aidesktop/OpenCode.exe"),
root.join("OpenCode/OpenCode.exe"),
root.join("opencode/OpenCode.exe"),
]);
}
candidates.into_iter().find(|path| path.is_file())
}
#[cfg(any(target_os = "linux", test))]
pub(crate) fn find_linux_opencode_desktop_application_in_roots(
home: &Path,
executable_directories: &[PathBuf],
data_directories: &[PathBuf],
appimage_directories: &[PathBuf],
) -> Option<PathBuf> {
const EXECUTABLE_NAMES: &[&str] = &[
"ai.opencode.desktop",
"ai.opencode.desktop.beta",
"ai.opencode.desktop.dev",
"opencode-desktop",
"@opencode-aidesktop",
"OpenCode",
"OpenCode Desktop",
];
for directory in executable_directories {
for name in EXECUTABLE_NAMES {
let candidate = directory.join(name);
if candidate.is_file() {
return Some(candidate);
}
}
}
find_linux_opencode_application_from_desktop_entries(
home,
executable_directories,
data_directories,
)
.or_else(|| {
appimage_directories.iter().find_map(|directory| {
let mut candidates = fs::read_dir(directory)
.ok()?
.flatten()
.map(|entry| entry.path())
.filter(|path| path.is_file() && is_opencode_desktop_appimage(path))
.collect::<Vec<_>>();
candidates.sort();
candidates.into_iter().next()
})
})
}
#[cfg(any(target_os = "linux", test))]
fn find_linux_opencode_application_from_desktop_entries(
home: &Path,
executable_directories: &[PathBuf],
data_directories: &[PathBuf],
) -> Option<PathBuf> {
for data_directory in data_directories {
let applications = data_directory.join("applications");
let mut entries = fs::read_dir(applications)
.ok()
.into_iter()
.flatten()
.flatten()
.map(|entry| entry.path())
.filter(|path| {
path.extension()
.and_then(|extension| extension.to_str())
.is_some_and(|extension| extension.eq_ignore_ascii_case("desktop"))
})
.collect::<Vec<_>>();
entries.sort();
for entry in entries {
let Ok(content) = fs::read_to_string(&entry) else {
continue;
};
if !is_opencode_desktop_entry(&entry, &content) {
continue;
}
let Some(command) = linux_desktop_entry_command(&content) else {
continue;
};
if let Some(application) =
resolve_linux_desktop_command(home, executable_directories, command.as_str())
{
return Some(application);
}
}
}
None
}
#[cfg(any(target_os = "linux", test))]
fn is_opencode_desktop_entry(path: &Path, content: &str) -> bool {
let file_name = path
.file_name()
.and_then(|name| name.to_str())
.unwrap_or_default()
.to_ascii_lowercase();
if file_name.contains("opencode") {
return true;
}
let mut in_desktop_entry = false;
content.lines().any(|line| {
let line = line.trim();
if line.starts_with('[') && line.ends_with(']') {
in_desktop_entry = line.eq_ignore_ascii_case("[Desktop Entry]");
return false;
}
if !in_desktop_entry {
return false;
}
let Some((key, value)) = line.split_once('=') else {
return false;
};
let value = value.trim().to_ascii_lowercase();
match key.trim() {
"Name" => {
value == "opencode"
|| value.starts_with("opencode beta")
|| value.starts_with("opencode dev")
}
"Icon" | "StartupWMClass" => {
value.starts_with("ai.opencode.desktop")
|| value == "@opencode-aidesktop"
|| value == "opencode"
}
_ => false,
}
})
}
#[cfg(any(target_os = "linux", test))]
fn linux_desktop_entry_command(content: &str) -> Option<String> {
let mut in_desktop_entry = false;
for line in content.lines() {
let line = line.trim();
if line.starts_with('[') && line.ends_with(']') {
in_desktop_entry = line.eq_ignore_ascii_case("[Desktop Entry]");
continue;
}
if !in_desktop_entry {
continue;
}
let Some((key, value)) = line.split_once('=') else {
continue;
};
if !key.trim().eq_ignore_ascii_case("Exec") {
continue;
}
let tokens = tokenize_linux_desktop_exec(value.trim());
let mut index = 0;
if tokens.first().is_some_and(|token| {
Path::new(token)
.file_name()
.and_then(|name| name.to_str())
.is_some_and(|name| name == "env")
}) {
index += 1;
while tokens.get(index).is_some_and(|token| token.contains('=')) {
index += 1;
}
}
let command = tokens.get(index)?.trim();
if command.is_empty()
|| command.starts_with('%')
|| ["flatpak", "snap", "gtk-launch", "sh", "bash"]
.iter()
.any(|wrapper| {
Path::new(command)
.file_name()
.and_then(|name| name.to_str())
.is_some_and(|name| name == *wrapper)
})
{
return None;
}
return Some(command.to_string());
}
None
}
#[cfg(any(target_os = "linux", test))]
fn tokenize_linux_desktop_exec(value: &str) -> Vec<String> {
let mut tokens = Vec::new();
let mut current = String::new();
let mut quote = None;
let mut escaped = false;
for character in value.chars() {
if escaped {
current.push(character);
escaped = false;
continue;
}
if character == '\\' {
escaped = true;
continue;
}
if let Some(delimiter) = quote {
if character == delimiter {
quote = None;
} else {
current.push(character);
}
continue;
}
if character == '"' || character == '\'' {
quote = Some(character);
} else if character.is_whitespace() {
if !current.is_empty() {
tokens.push(std::mem::take(&mut current));
}
} else {
current.push(character);
}
}
if escaped {
current.push('\\');
}
if !current.is_empty() {
tokens.push(current);
}
tokens
}
#[cfg(any(target_os = "linux", test))]
fn resolve_linux_desktop_command(
home: &Path,
executable_directories: &[PathBuf],
command: &str,
) -> Option<PathBuf> {
let candidate = if let Some(relative) = command.strip_prefix("~/") {
home.join(relative)
} else {
PathBuf::from(command)
};
if candidate.is_absolute() || command.contains('/') {
return candidate.is_file().then_some(candidate);
}
executable_directories
.iter()
.map(|directory| directory.join(&candidate))
.find(|path| path.is_file())
}
#[cfg(any(target_os = "linux", test))]
fn is_opencode_desktop_appimage(path: &Path) -> bool {
let Some(file_name) = path.file_name().and_then(|name| name.to_str()) else {
return false;
};
let file_name = file_name.to_ascii_lowercase();
file_name.ends_with(".appimage")
&& (file_name.starts_with("opencode-desktop") || file_name.starts_with("opencode_"))
}
pub(crate) fn read_opencode_desktop_version(application: &Path) -> Option<String> {
#[cfg(target_os = "windows")]
{
read_windows_executable_version(application)
}
#[cfg(target_os = "macos")]
{
read_macos_app_version(application)
}
#[cfg(target_os = "linux")]
{
read_linux_opencode_desktop_version(application)
}
#[cfg(not(any(target_os = "macos", target_os = "windows", target_os = "linux")))]
{
let _ = application;
None
}
}
#[cfg(target_os = "linux")]
fn read_linux_opencode_desktop_version(application: &Path) -> Option<String> {
let path_version = linux_opencode_desktop_version_from_path(application);
if application
.extension()
.and_then(|extension| extension.to_str())
.is_some_and(|extension| extension.eq_ignore_ascii_case("appimage"))
{
path_version
} else {
path_version.or_else(read_linux_opencode_package_version)
}
}
#[cfg(any(target_os = "linux", test))]
pub(crate) fn linux_opencode_desktop_version_from_path(application: &Path) -> Option<String> {
application
.components()
.rev()
.filter_map(|component| component.as_os_str().to_str())
.find_map(|component| {
let lower = component.to_ascii_lowercase();
if !lower.contains("opencode") {
return None;
}
let component = lower
.strip_suffix(".appimage")
.map(|_| &component[..component.len() - ".appimage".len()])
.unwrap_or(component);
component.split(['-', '_']).find_map(|token| {
let version = token.strip_prefix('v').unwrap_or(token);
(version.starts_with(|character: char| character.is_ascii_digit())
&& version.contains('.')
&& version.chars().all(|character| {
character.is_ascii_alphanumeric() || matches!(character, '.' | '+' | '~')
}))
.then(|| version.to_string())
})
})
}
#[cfg(target_os = "linux")]
fn read_linux_opencode_package_version() -> Option<String> {
const PACKAGE_NAMES: &[&str] = &[
"opencode",
"opencode-desktop",
"opencode-ai-desktop",
"opencode-aidesktop",
];
let mut dpkg = Command::new("dpkg-query");
dpkg.args(["--show", "--showformat=${Package}\\t${Version}\\n"])
.args(PACKAGE_NAMES);
if let Some(version) = linux_package_version_from_output(&mut dpkg, '\t') {
return Some(version);
}
let mut rpm = Command::new("rpm");
rpm.args(["--query", "--queryformat", "%{NAME}\\t%{VERSION}\\n"])
.args(PACKAGE_NAMES);
if let Some(version) = linux_package_version_from_output(&mut rpm, '\t') {
return Some(version);
}
let mut pacman = Command::new("pacman");
pacman.arg("--query").args(PACKAGE_NAMES);
linux_package_version_from_output(&mut pacman, ' ')
}
#[cfg(target_os = "linux")]
fn linux_package_version_from_output(command: &mut Command, separator: char) -> Option<String> {
configure_background_command(command);
let output = command_output_with_timeout(command, AGENT_VERSION_PROBE_TIMEOUT).ok()??;
String::from_utf8_lossy(&output.stdout)
.lines()
.find_map(|line| {
let (package, version) = line.trim().split_once(separator)?;
let package = package.trim();
let version = version.trim();
([
"opencode",
"opencode-desktop",
"opencode-ai-desktop",
"opencode-aidesktop",
]
.contains(&package))
.then(|| normalize_detected_agent_version(version))
.flatten()
})
}
pub(crate) fn read_claude_desktop_version(home: &Path) -> Option<String> {
#[cfg(target_os = "windows")]
{
read_windows_claude_desktop_store_version().or_else(|| {
find_claude_desktop_executable(home)
.and_then(|path| read_windows_executable_version(&path))
})
}
#[cfg(target_os = "macos")]
{
find_claude_desktop_executable(home).and_then(|path| {
let application = path.ancestors().find(|candidate| {
candidate.extension().and_then(|value| value.to_str()) == Some("app")
})?;
read_macos_app_version(application)
})
}
#[cfg(not(any(target_os = "windows", target_os = "macos")))]
{
let _ = home;
None
}
}
pub(crate) fn read_codex_app_installation_version(
installation: &DesktopAppTarget,
_home: &Path,
) -> Option<String> {
#[cfg(target_os = "windows")]
{
match installation {
DesktopAppTarget::WindowsAppId(app_id) => read_windows_codex_store_version(app_id),
DesktopAppTarget::Application(path) => read_windows_codex_desktop_version(path),
}
}
#[cfg(target_os = "macos")]
{
let DesktopAppTarget::Application(path) = installation;
read_macos_app_version(path)
}
#[cfg(not(any(target_os = "windows", target_os = "macos")))]
{
let _ = installation;
None
}
}
pub(crate) fn read_zcode_app_version(executable: &Path) -> Option<String> {
#[cfg(target_os = "windows")]
{
read_windows_executable_version(executable)
}
#[cfg(target_os = "macos")]
{
let application = executable
.ancestors()
.find(|path| path.extension().and_then(|value| value.to_str()) == Some("app"))?;
read_macos_app_version(application)
}
#[cfg(not(any(target_os = "windows", target_os = "macos")))]
{
let _ = executable;
None
}
}
pub(crate) fn find_zcode_desktop_executable(home: &Path) -> Option<PathBuf> {
#[cfg(target_os = "windows")]
{
let local = env::var_os("LOCALAPPDATA")
.map(PathBuf::from)
.filter(|path| !path.as_os_str().is_empty())
.unwrap_or_else(|| home.join("AppData/Local"));
let mut candidates = vec![
local.join("Programs/ZCode/ZCode.exe"),
local.join("ZCode/ZCode.exe"),
];
for variable in ["ProgramFiles", "ProgramFiles(x86)"] {
if let Some(root) = env::var_os(variable)
.map(PathBuf::from)
.filter(|path| !path.as_os_str().is_empty())
{
candidates.push(root.join("ZCode/ZCode.exe"));
}
}
candidates
.into_iter()
.find(|path| path.is_file())
.or_else(|| find_named_agent_executable(home, &["zcode"]))
.or_else(find_windows_registered_zcode_executable)
}
#[cfg(target_os = "macos")]
{
[
PathBuf::from("/Applications/ZCode.app/Contents/MacOS/ZCode"),
home.join("Applications/ZCode.app/Contents/MacOS/ZCode"),
]
.into_iter()
.find(|path| path.is_file())
.or_else(|| find_named_agent_executable(home, &["zcode"]))
}
#[cfg(target_os = "linux")]
{
let mut candidates = agent_executable_directories(home)
.into_iter()
.flat_map(|directory| [directory.join("zcode"), directory.join("ZCode")])
.collect::<Vec<_>>();
candidates.extend([
PathBuf::from("/opt/ZCode/zcode"),
PathBuf::from("/opt/ZCode/ZCode"),
PathBuf::from("/opt/zcode/zcode"),
]);
candidates.into_iter().find(|path| path.is_file())
}
#[cfg(not(any(target_os = "windows", target_os = "macos", target_os = "linux")))]
{
find_named_agent_executable(home, &["zcode"])
}
}
#[cfg(target_os = "macos")]
pub(crate) fn read_macos_app_version(application: &Path) -> Option<String> {
let info_plist = application.join("Contents/Info.plist");
for key in ["CFBundleShortVersionString", "CFBundleVersion"] {
let mut command = Command::new("/usr/bin/plutil");
command
.args(["-extract", key, "raw", "-o", "-"])
.arg(&info_plist);
let output =
command_output_with_timeout(&mut command, AGENT_VERSION_PROBE_TIMEOUT).ok()??;
if !output.status.success() {
continue;
}
if let Some(version) =
normalize_detected_agent_version(&String::from_utf8_lossy(&output.stdout))
{
return Some(version);
}
}
None
}
#[cfg(target_os = "windows")]
pub(crate) fn windows_system_root() -> PathBuf {
env::var_os("SystemRoot")
.map(PathBuf::from)
.filter(|path| !path.as_os_str().is_empty())
.unwrap_or_else(|| PathBuf::from(r"C:\Windows"))
}
#[cfg(target_os = "windows")]
pub(crate) fn windows_powershell_executable() -> PathBuf {
let executable = windows_system_root().join("System32/WindowsPowerShell/v1.0/powershell.exe");
if executable.is_file() {
executable
} else {
PathBuf::from("powershell.exe")
}
}
#[cfg(target_os = "windows")]
pub(crate) fn windows_explorer_executable() -> PathBuf {
let executable = windows_system_root().join("explorer.exe");
if executable.is_file() {
executable
} else {
PathBuf::from("explorer.exe")
}
}
#[cfg(target_os = "windows")]
pub(crate) fn windows_command_processor() -> PathBuf {
env::var_os("ComSpec")
.map(PathBuf::from)
.filter(|path| !path.as_os_str().is_empty())
.unwrap_or_else(|| windows_system_root().join("System32/cmd.exe"))
}
#[cfg(target_os = "windows")]
pub(crate) fn find_windows_codex_app_installation(home: &Path) -> Option<DesktopAppTarget> {
find_windows_codex_app_executable(home)
.map(DesktopAppTarget::Application)
.or_else(find_windows_registered_codex_app_installation)
.or_else(|| find_windows_codex_app_id_via_registry().map(DesktopAppTarget::WindowsAppId))
}
#[cfg(target_os = "windows")]
pub(crate) fn read_windows_codex_store_version(app_id: &str) -> Option<String> {
find_windows_codex_store_executable(app_id)
.and_then(|path| read_windows_codex_desktop_version(&path))
}
#[cfg(target_os = "windows")]
pub(crate) fn read_windows_codex_desktop_version(executable: &Path) -> Option<String> {
let resources = executable.parent()?.join("resources");
let owl_ini = resources.join("owl-app.ini");
let asar = resources.join("app.asar");
fs::read_to_string(&owl_ini)
.ok()
.and_then(|content| parse_codex_owl_app_version(&content))
.or_else(|| read_codex_asar_version(&asar))
.or_else(|| {
if owl_ini.exists() || asar.exists() || resources.join("owl-electron-app.json").exists()
{
None
} else {
read_windows_executable_version(executable)
}
})
}
#[cfg(target_os = "windows")]
pub(crate) fn parse_codex_owl_app_version(content: &str) -> Option<String> {
let mut in_owl_section = false;
for line in content
.trim_start_matches('\u{feff}')
.lines()
.map(str::trim)
{
if line.starts_with('[') && line.ends_with(']') {
in_owl_section = line.eq_ignore_ascii_case("[Owl]");
} else if in_owl_section {
if let Some((key, value)) = line.split_once('=') {
if key.trim().eq_ignore_ascii_case("AppVersion") {
return normalize_detected_agent_version(value);
}
}
}
}
None
}
#[cfg(target_os = "windows")]
pub(crate) fn read_codex_asar_version(path: &Path) -> Option<String> {
use std::io::{Read, Seek, SeekFrom};
let mut file = fs::File::open(path).ok()?;
let mut prefix = [0_u8; 16];
file.read_exact(&mut prefix).ok()?;
let word = |offset| u32::from_le_bytes(prefix[offset..offset + 4].try_into().unwrap()) as u64;
let header_size = word(4);
let json_size = word(12);
if word(0) != 4
|| !(8..=16 * 1024 * 1024).contains(&header_size)
|| word(8) != header_size - 4
|| json_size > header_size - 8
{
return None;
}
let mut header = vec![0; json_size as usize];
file.read_exact(&mut header).ok()?;
let header: serde_json::Value = serde_json::from_slice(&header).ok()?;
let entry = header.get("files")?.get("package.json")?;
if entry.get("unpacked").and_then(serde_json::Value::as_bool) == Some(true) {
return None;
}
let size = entry.get("size")?.as_u64()?;
if size > 1024 * 1024 {
return None;
}
let offset = entry.get("offset")?.as_str()?.parse::<u64>().ok()?;
let position = (8 + header_size).checked_add(offset)?;
if position.checked_add(size)? > file.metadata().ok()?.len() {
return None;
}
file.seek(SeekFrom::Start(position)).ok()?;
let mut package = vec![0; size as usize];
file.read_exact(&mut package).ok()?;
let package: serde_json::Value = serde_json::from_slice(&package).ok()?;
normalize_detected_agent_version(package.get("version")?.as_str()?)
}
#[cfg(target_os = "windows")]
pub(crate) fn windows_powershell_single_quoted_literal(value: &str) -> String {
format!("'{}'", value.replace('\'', "''"))
}
#[cfg(target_os = "windows")]
pub(crate) fn find_windows_codex_app_executable(home: &Path) -> Option<PathBuf> {
let local = env::var_os("LOCALAPPDATA")
.map(PathBuf::from)
.unwrap_or_else(|| home.join("AppData/Local"));
let mut candidates = vec![
local.join("Programs/OpenAI/ChatGPT/ChatGPT.exe"),
local.join("Programs/ChatGPT/ChatGPT.exe"),
local.join("OpenAI/ChatGPT/ChatGPT.exe"),
local.join("OpenAI/Codex/Codex.exe"),
local.join("Programs/OpenAI/Codex/Codex.exe"),
local.join("Programs/Codex/Codex.exe"),
local.join("Microsoft/WindowsApps/ChatGPT.exe"),
];
for variable in ["ProgramFiles", "ProgramFiles(x86)"] {
let Some(root) = env::var_os(variable)
.map(PathBuf::from)
.filter(|path| !path.as_os_str().is_empty())
else {
continue;
};
candidates.extend([
root.join("OpenAI/ChatGPT/ChatGPT.exe"),
root.join("ChatGPT/ChatGPT.exe"),
root.join("OpenAI/Codex/Codex.exe"),
root.join("Codex/Codex.exe"),
]);
}
candidates.into_iter().find(|path| path.is_file())
}
pub(crate) fn find_agent_executable(client: AgentClient, home: &Path) -> Option<PathBuf> {
if client == AgentClient::AntigravityCli { return find_antigravity_cli(home); }
if client == AgentClient::ClaudeDesktop {
return find_claude_desktop_executable(home);
}
if client == AgentClient::ZCode {
return find_zcode_desktop_executable(home);
}
if client == AgentClient::WorkBuddy {
return find_workbuddy_desktop_executable(home);
}
if client == AgentClient::KimiCode {
return find_kimi_code_executable(home);
}
if client == AgentClient::GrokBuild {
return find_grok_build_executable(home);
}
if client == AgentClient::OpenCode {
return find_opencode_executable(home);
}
find_named_agent_executable(home, client.executable_names())
}
pub(crate) fn find_opencode_executable(home: &Path) -> Option<PathBuf> {
find_named_agent_executable(home, &["opencode"])
.or_else(|| find_opencode_managed_executable(home))
.or_else(|| find_named_agent_executable(home, &["opencode-cli"]))
}
pub(crate) fn find_opencode_managed_executable(home: &Path) -> Option<PathBuf> {
#[cfg(target_os = "macos")]
{
[
home.join(".opencode/bin/opencode"),
PathBuf::from("/Applications/OpenCode.app/Contents/MacOS/opencode-cli"),
home.join("Applications/OpenCode.app/Contents/MacOS/opencode-cli"),
]
.into_iter()
.find(|path| agent_cli_executable(path))
}
#[cfg(not(target_os = "macos"))]
{
find_named_agent_executable_in_directories(&[home.join(".opencode/bin")], &["opencode"])
}
}
pub(crate) fn find_kimi_code_executable(home: &Path) -> Option<PathBuf> {
let directory = kimi_code_home(home).join("bin");
#[cfg(target_os = "windows")]
let managed = [directory.join("kimi.exe"), directory.join("kimi.cmd")];
#[cfg(not(target_os = "windows"))]
let managed = [directory.join("kimi")];
managed
.into_iter()
.find(|path| agent_cli_executable(path))
.or_else(|| find_named_agent_executable(home, &["kimi"]))
}
pub(crate) fn find_grok_build_executable(home: &Path) -> Option<PathBuf> {
let directory = grok_build_home(home).join("bin");
#[cfg(target_os = "windows")]
let managed = [directory.join("grok.exe"), directory.join("grok.cmd")];
#[cfg(not(target_os = "windows"))]
let managed = [directory.join("grok")];
managed
.into_iter()
.find(|path| agent_cli_executable(path))
.or_else(|| find_named_agent_executable(home, &["grok"]))
}
pub(crate) fn find_pi_executable(home: &Path) -> Option<PathBuf> {
find_named_agent_executable(home, &["pi"])
}
pub(crate) fn find_named_agent_executable(home: &Path, names: &[&str]) -> Option<PathBuf> {
find_named_agent_executable_in_directories(&agent_executable_directories(home), names)
}
pub(crate) fn agent_cli_executable(path: &Path) -> bool {
let Ok(metadata) = fs::metadata(path) else {
return false;
};
if !metadata.is_file() {
return false;
}
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
metadata.permissions().mode() & 0o111 != 0
}
#[cfg(target_os = "windows")]
{
path.extension()
.and_then(|extension| extension.to_str())
.is_some_and(|extension| {
["exe", "cmd", "bat", "com"]
.iter()
.any(|expected| extension.eq_ignore_ascii_case(expected))
})
}
#[cfg(not(any(unix, target_os = "windows")))]
true
}
pub(crate) fn find_named_agent_executable_in_directories(
directories: &[PathBuf],
names: &[&str],
) -> Option<PathBuf> {
for directory in directories {
for name in names {
#[cfg(target_os = "windows")]
let candidates = [
directory.join(format!("{name}.exe")),
directory.join(format!("{name}.cmd")),
directory.join(format!("{name}.bat")),
directory.join(format!("{name}.com")),
];
#[cfg(not(target_os = "windows"))]
let candidates = [directory.join(name)];
if let Some(candidate) = candidates
.into_iter()
.find(|path| agent_cli_executable(path))
{
return Some(candidate);
}
}
}
None
}
pub(crate) fn agent_executable_directories(home: &Path) -> Vec<PathBuf> {
#[cfg(target_os = "windows")]
let registered = windows_registered_path_directories();
#[cfg(not(target_os = "windows"))]
let registered = Vec::new();
agent_executable_directories_from_environment(home, |name| env::var_os(name), &registered)
}
pub(crate) fn agent_executable_directories_from_environment(
home: &Path,
environment: impl Fn(&str) -> Option<std::ffi::OsString>,
registered: &[PathBuf],
) -> Vec<PathBuf> {
let mut directories = Vec::new();
let mut push = |path: PathBuf| {
if !path.as_os_str().is_empty() && !directories.iter().any(|item| item == &path) {
directories.push(path);
}
};
if let Some(path) = environment("PATH") {
env::split_paths(&path).for_each(&mut push);
}
registered.iter().cloned().for_each(&mut push);
[
home.join(".local/bin"),
home.join(".npm-global/bin"),
home.join(".bun/bin"),
home.join(".cargo/bin"),
home.join(".volta/bin"),
home.join(".local/share/pnpm"),
home.join(".pyenv/shims"),
home.join("bin"),
]
.into_iter()
.for_each(&mut push);
for variable in [
"PNPM_HOME",
"BUN_INSTALL",
"NPM_CONFIG_PREFIX",
"npm_config_prefix",
"VOLTA_HOME",
"UV_TOOL_BIN_DIR",
"NVM_SYMLINK",
] {
if let Some(path) = environment(variable).filter(|path| !path.is_empty()) {
let path = PathBuf::from(path);
if matches!(variable, "NPM_CONFIG_PREFIX" | "npm_config_prefix") {
push(if cfg!(target_os = "windows") {
path
} else {
path.join("bin")
});
} else {
push(if matches!(variable, "BUN_INSTALL" | "VOLTA_HOME") {
path.join("bin")
} else {
path
});
}
}
}
for root in [
home.join(".nvm/versions/node"),
home.join(".local/state/fnm_multishells"),
] {
if let Ok(entries) = fs::read_dir(root) {
let mut entries = entries
.flatten()
.map(|entry| entry.path())
.filter(|path| path.is_dir())
.collect::<Vec<_>>();
entries.sort();
for entry in entries.into_iter().rev() {
push(entry.join("bin"));
}
}
}
#[cfg(unix)]
for path in ["/usr/local/bin", "/usr/bin", "/bin"] {
push(PathBuf::from(path));
}
#[cfg(target_os = "macos")]
push(PathBuf::from("/opt/homebrew/bin"));
#[cfg(target_os = "windows")]
{
let app_data = environment("APPDATA")
.filter(|path| !path.is_empty())
.map(PathBuf::from)
.unwrap_or_else(|| home.join("AppData/Roaming"));
let local = environment("LOCALAPPDATA")
.filter(|path| !path.is_empty())
.map(PathBuf::from)
.unwrap_or_else(|| home.join("AppData/Local"));
push(app_data.join("npm"));
push(home.join(".npm-global"));
push(local.join("Microsoft/WindowsApps"));
push(local.join("pnpm"));
push(local.join("Volta/bin"));
for root in [local.join("Programs/Python"), local.join("Python")] {
if let Ok(entries) = fs::read_dir(root) {
let mut entries = entries
.flatten()
.map(|entry| entry.path())
.filter(|path| path.is_dir())
.collect::<Vec<_>>();
entries.sort();
for entry in entries.into_iter().rev() {
push(entry.join("Scripts"));
}
}
}
}
directories
}
pub(crate) fn find_claude_desktop_executable(home: &Path) -> Option<PathBuf> {
#[cfg(target_os = "macos")]
{
[
PathBuf::from("/Applications/Claude.app/Contents/MacOS/Claude"),
home.join("Applications/Claude.app/Contents/MacOS/Claude"),
]
.into_iter()
.find(|path| path.is_file())
}
#[cfg(target_os = "windows")]
{
let local = env::var_os("LOCALAPPDATA")
.map(PathBuf::from)
.unwrap_or_else(|| home.join("AppData/Local"));
[
local.join("Programs/Claude/Claude.exe"),
local.join("Claude/Claude.exe"),
]
.into_iter()
.find(|path| path.is_file())
}
#[cfg(target_os = "linux")]
{
let mut candidates = agent_executable_directories(home)
.into_iter()
.map(|directory| directory.join("claude-desktop"))
.collect::<Vec<_>>();
candidates.extend([
PathBuf::from("/opt/Claude/claude-desktop"),
PathBuf::from("/opt/Claude/claude"),
PathBuf::from("/opt/claude-desktop/claude-desktop"),
]);
candidates.into_iter().find(|path| path.is_file())
}
#[cfg(not(any(target_os = "macos", target_os = "windows", target_os = "linux")))]
{
let _ = home;
None
}
}
#[cfg(target_os = "windows")]
pub(crate) fn windows_command_for_executable(executable: &Path, keep_shell_open: bool) -> Command {
use std::os::windows::process::CommandExt;
let is_batch_script = executable
.extension()
.and_then(|extension| extension.to_str())
.is_some_and(|extension| {
extension.eq_ignore_ascii_case("cmd") || extension.eq_ignore_ascii_case("bat")
});
if !is_batch_script {
return Command::new(executable);
}
let mut command = Command::new(windows_command_processor());
command.args(["/D", if keep_shell_open { "/K" } else { "/C" }, "call"]);
command.raw_arg(windows_batch_executable_argument(executable));
command
}
#[cfg(target_os = "windows")]
pub(crate) fn windows_batch_executable_argument(executable: &Path) -> String {
format!("\"{}\"", path_to_string(executable))
}
pub(crate) fn command_output_with_timeout(
command: &mut Command,
timeout: Duration,
) -> io::Result<Option<std::process::Output>> {
command
.stdin(Stdio::null())
.stdout(Stdio::piped())
.stderr(Stdio::piped());
let mut child = command.spawn()?;
let mut stdout = child
.stdout
.take()
.ok_or_else(|| io::Error::other("Unable to read agent detection standard output"))?;
let mut stderr = child
.stderr
.take()
.ok_or_else(|| io::Error::other("Unable to read agent detection error output"))?;
thread::scope(|scope| {
let stdout_reader = scope.spawn(move || {
let mut output = Vec::new();
stdout.read_to_end(&mut output).map(|_| output)
});
let stderr_reader = scope.spawn(move || {
let mut output = Vec::new();
stderr.read_to_end(&mut output).map(|_| output)
});
let deadline = Instant::now() + timeout;
let status = loop {
if let Some(status) = child.try_wait()? {
break Some(status);
}
if Instant::now() >= deadline {
terminate_agent_probe_process(&mut child);
break None;
}
thread::sleep(AGENT_VERSION_PROBE_POLL_INTERVAL);
};
let stdout = stdout_reader
.join()
.map_err(|_| io::Error::other("Agent detection output thread exited unexpectedly"))??;
let stderr = stderr_reader
.join()
.map_err(|_| io::Error::other("Agent detection error thread exited unexpectedly"))??;
Ok(status.map(|status| std::process::Output {
status,
stdout,
stderr,
}))
})
}
fn terminate_agent_probe_process(child: &mut Child) {
#[cfg(target_os = "windows")]
{
let process_id = child.id().to_string();
let mut command = Command::new("taskkill");
command
.args(["/PID", &process_id, "/T", "/F"])
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::null());
configure_background_command(&mut command);
let _ = command.status();
}
let _ = child.kill();
let _ = child.wait();
}
pub(crate) fn read_agent_version(path: &Path, home: &Path) -> Option<String> {
#[cfg(target_os = "windows")]
let mut command = windows_command_for_executable(path, false);
#[cfg(not(target_os = "windows"))]
let mut command = Command::new(path);
command.arg("--version");
configure_background_command(&mut command);
configure_agent_command_environment(&mut command, home, path).ok()?;
let output = command_output_with_timeout(&mut command, AGENT_VERSION_PROBE_TIMEOUT).ok()??;
if !output.status.success() {
return None;
}
let stdout = String::from_utf8_lossy(&output.stdout);
let stderr = String::from_utf8_lossy(&output.stderr);
stdout
.lines()
.chain(stderr.lines())
.find_map(normalize_detected_agent_version)
}
pub(crate) fn normalize_detected_agent_version(value: &str) -> Option<String> {
let value = value.trim();
(!value.is_empty()
&& value.len() <= 256
&& value.chars().any(|character| character.is_ascii_digit())
&& !value.chars().any(char::is_control))
.then(|| value.to_string())
}
pub(crate) fn inspect_claude_agent_config(
path: &Path,
port: u16,
api_key: &str,
) -> Result<(bool, Option<String>), String> {
if !path.is_file() {
return Ok((false, None));
}
let root: serde_json::Value = serde_json::from_str(
&fs::read_to_string(path).map_err(|error| format!("Failed to read Claude Code configuration: {error}"))?,
)
.map_err(|error| format!("Failed to parse Claude Code configuration: {error}"))?;
let env = root.get("env").and_then(serde_json::Value::as_object);
let expected_base = managed_core_loopback_origin(port);
let configured = env
.and_then(|env| env.get("ANTHROPIC_BASE_URL"))
.and_then(serde_json::Value::as_str)
.map(str::trim)
== Some(expected_base.as_str())
&& env
.and_then(|env| env.get("ANTHROPIC_AUTH_TOKEN"))
.and_then(serde_json::Value::as_str)
== Some(api_key)
&& env
.and_then(|env| env.get(CLAUDE_CODE_ENABLE_GATEWAY_MODEL_DISCOVERY_ENV))
.and_then(serde_json::Value::as_str)
== Some("1")
&& env
.and_then(|env| env.get(CLAUDE_CODE_AUTO_MODE_SERVER_ENV))
.and_then(serde_json::Value::as_str)
== Some("0");
let model = env
.and_then(|env| env.get("ANTHROPIC_MODEL"))
.and_then(serde_json::Value::as_str)
.or_else(|| root.get("model").and_then(serde_json::Value::as_str))
.map(str::trim)
.filter(|value| !value.is_empty())
.map(strip_claude_code_context_suffix)
.map(str::to_string);
Ok((configured, model))
}
pub(crate) fn inspect_claude_code_model_mappings(
path: &Path,
) -> Result<Option<ClaudeDesktopModelMappings>, String> {
if !path.is_file() {
return Ok(None);
}
let root: serde_json::Value = serde_json::from_str(
&fs::read_to_string(path)
.map_err(|error| format!("Failed to read Claude Code configuration: {error}"))?,
)
.map_err(|error| format!("Failed to parse Claude Code configuration: {error}"))?;
let Some(env) = root.get("env").and_then(serde_json::Value::as_object) else {
return Ok(None);
};
let fallback = env
.get("ANTHROPIC_MODEL")
.and_then(serde_json::Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty());
let read_model = |key: &str| {
let value = env
.get(key)
.and_then(serde_json::Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
.or(fallback)?;
let normalized = strip_claude_code_context_suffix(value);
Some((normalized.to_string(), normalized.len() != value.len()))
};
let Some((opus, opus_had_1m)) = read_model("ANTHROPIC_DEFAULT_OPUS_MODEL") else {
return Ok(None);
};
let Some((sonnet, sonnet_had_1m)) = read_model("ANTHROPIC_DEFAULT_SONNET_MODEL") else {
return Ok(None);
};
let Some((haiku, haiku_had_1m)) = read_model("ANTHROPIC_DEFAULT_HAIKU_MODEL") else {
return Ok(None);
};
let legacy_1m = opus_had_1m || sonnet_had_1m || haiku_had_1m;
let max_context_tokens = env
.get(CLAUDE_CODE_MAX_CONTEXT_TOKENS_ENV)
.and_then(serde_json::Value::as_str)
.and_then(|value| value.trim().parse::<u64>().ok())
.unwrap_or_else(|| {
if legacy_1m {
CLAUDE_DESKTOP_EXTENDED_CONTEXT_WINDOW
} else {
default_claude_code_max_context_tokens()
}
});
let auto_compact_pct = env
.get(CLAUDE_AUTOCOMPACT_PCT_OVERRIDE_ENV)
.and_then(serde_json::Value::as_str)
.and_then(|value| value.trim().parse::<u8>().ok())
.unwrap_or_else(default_claude_auto_compact_pct);
let disable_auto_compact = env
.get(DISABLE_AUTO_COMPACT_ENV)
.and_then(serde_json::Value::as_str)
.is_some_and(|value| value == "1" || value.eq_ignore_ascii_case("true"));
Ok(Some(ClaudeDesktopModelMappings {
desktop_models: None,
opus,
sonnet,
haiku,
opus_1m: opus_had_1m,
sonnet_1m: sonnet_had_1m,
haiku_1m: haiku_had_1m,
max_context_tokens,
auto_compact_pct,
disable_auto_compact,
}))
}
pub(crate) fn strip_claude_code_context_suffix(value: &str) -> &str {
let mut value = value.trim();
loop {
let Some(suffix_start) = value.len().checked_sub(4) else {
return value;
};
if !value[suffix_start..].eq_ignore_ascii_case("[1m]") {
return value;
}
value = value[..suffix_start].trim_end();
}
}
pub(crate) fn agent_model_source_name<'a>(
models: &'a [AgentModelOption],
model_name: &'a str,
) -> &'a str {
let model_name = strip_claude_code_context_suffix(model_name);
let Some(model) = models
.iter()
.find(|model| model.name.eq_ignore_ascii_case(model_name))
else {
return model_name;
};
if model.is_alias {
model.alias.as_deref().unwrap_or(model.name.as_str())
} else {
model.name.as_str()
}
}
pub(crate) fn agent_model_context_window(
models: &[AgentModelOption],
model_name: &str,
) -> Option<u64> {
let model_name = strip_claude_code_context_suffix(model_name);
let selected = models
.iter()
.find(|model| model.name.eq_ignore_ascii_case(model_name))?;
if !selected.is_alias {
return Some(
selected
.context_window
.unwrap_or(DEFAULT_CLAUDE_CONTEXT_WINDOW),
);
}
selected
.alias
.as_deref()
.and_then(|source| {
models
.iter()
.find(|model| model.name.eq_ignore_ascii_case(source))
})
.and_then(|model| model.context_window)
.or(selected.context_window)
.or(Some(DEFAULT_CLAUDE_CONTEXT_WINDOW))
}
pub(crate) fn claude_effective_context_window(
models: &[AgentModelOption],
model_name: &str,
enable_1m: bool,
) -> Option<u64> {
let context_window = agent_model_context_window(models, model_name);
if enable_1m {
return Some(
context_window
.unwrap_or(DEFAULT_CLAUDE_CONTEXT_WINDOW)
.max(CLAUDE_DESKTOP_EXTENDED_CONTEXT_WINDOW),
);
}
context_window
}
pub(crate) fn agent_model_display_name<'a>(
models: &'a [AgentModelOption],
model_name: &'a str,
) -> &'a str {
let model_name = strip_claude_code_context_suffix(model_name);
let Some(model) = models
.iter()
.find(|model| model.name.eq_ignore_ascii_case(model_name))
else {
return model_name;
};
if model.is_alias {
model.name.as_str()
} else {
model.alias.as_deref().unwrap_or(model.name.as_str())
}
}
pub(crate) fn claude_code_model_effort_level(
models: &[AgentModelOption],
model_name: &str,
) -> Result<Option<String>, String> {
claude_catalog::claude_code_effort_level_for(agent_model_source_name(models, model_name))
}
pub(crate) fn claude_code_model_setting(model_name: &str, enable_1m_variant: bool) -> String {
let model_name = strip_claude_code_context_suffix(model_name);
if enable_1m_variant {
format!("{model_name}[1m]")
} else {
model_name.to_string()
}
}
pub(crate) fn claude_code_model_settings(
mappings: &ClaudeDesktopModelMappings,
) -> ClaudeDesktopModelMappings {
ClaudeDesktopModelMappings {
desktop_models: None,
opus: claude_code_model_setting(&mappings.opus, mappings.opus_1m),
sonnet: claude_code_model_setting(&mappings.sonnet, mappings.sonnet_1m),
haiku: claude_code_model_setting(&mappings.haiku, mappings.haiku_1m),
opus_1m: mappings.opus_1m,
sonnet_1m: mappings.sonnet_1m,
haiku_1m: mappings.haiku_1m,
max_context_tokens: mappings.max_context_tokens,
auto_compact_pct: mappings.auto_compact_pct,
disable_auto_compact: mappings.disable_auto_compact,
}
}
pub(crate) fn format_context_window(context_window: u64) -> String {
if context_window.is_multiple_of(1_000_000) {
format!("{}M", context_window / 1_000_000)
} else if context_window.is_multiple_of(1_000) {
format!("{}K", context_window / 1_000)
} else {
context_window.to_string()
}
}
pub(crate) fn claude_code_model_presentation(
models: &[AgentModelOption],
model_name: &str,
enable_1m: bool,
context_window_override: Option<u64>,
role: Option<&str>,
) -> (String, String) {
let display_name = agent_model_display_name(models, model_name);
let context_window = context_window_override
.or_else(|| claude_effective_context_window(models, model_name, enable_1m))
.unwrap_or(DEFAULT_CLAUDE_CONTEXT_WINDOW);
let context_label = format_context_window(context_window);
let name = match role {
Some(role) => format!("{display_name} ({role}, {context_label} context)"),
None => format!("{display_name} ({context_label} context)"),
};
let description = format!("CPA model {model_name} - {context_label} context window");
(name, description)
}
pub(crate) fn claude_code_model_presentation_environment(
mappings: &ClaudeDesktopModelMappings,
models: &[AgentModelOption],
) -> Result<Vec<(String, String)>, String> {
let opus = claude_code_model_presentation(
models,
&mappings.opus,
mappings.opus_1m,
Some(mappings.max_context_tokens),
Some("Opus mapping"),
);
let sonnet = claude_code_model_presentation(
models,
&mappings.sonnet,
mappings.sonnet_1m,
Some(mappings.max_context_tokens),
Some("Sonnet mapping"),
);
let haiku = claude_code_model_presentation(
models,
&mappings.haiku,
mappings.haiku_1m,
Some(mappings.max_context_tokens),
Some("Haiku mapping"),
);
let fable = claude_code_model_presentation(
models,
&mappings.sonnet,
mappings.sonnet_1m,
Some(mappings.max_context_tokens),
Some("Fable mapping"),
);
let custom = claude_code_model_presentation(
models,
&mappings.sonnet,
mappings.sonnet_1m,
Some(mappings.max_context_tokens),
None,
);
let custom_model = claude_code_model_setting(&mappings.sonnet, mappings.sonnet_1m);
Ok([
("ANTHROPIC_DEFAULT_OPUS_MODEL_NAME", opus.0),
("ANTHROPIC_DEFAULT_OPUS_MODEL_DESCRIPTION", opus.1),
("ANTHROPIC_DEFAULT_SONNET_MODEL_NAME", sonnet.0),
("ANTHROPIC_DEFAULT_SONNET_MODEL_DESCRIPTION", sonnet.1),
("ANTHROPIC_DEFAULT_HAIKU_MODEL_NAME", haiku.0),
("ANTHROPIC_DEFAULT_HAIKU_MODEL_DESCRIPTION", haiku.1),
("ANTHROPIC_DEFAULT_FABLE_MODEL_NAME", fable.0),
("ANTHROPIC_DEFAULT_FABLE_MODEL_DESCRIPTION", fable.1),
("ANTHROPIC_CUSTOM_MODEL_OPTION", custom_model),
("ANTHROPIC_CUSTOM_MODEL_OPTION_NAME", custom.0),
("ANTHROPIC_CUSTOM_MODEL_OPTION_DESCRIPTION", custom.1),
]
.into_iter()
.map(|(key, value)| (key.to_string(), value))
.collect())
}
pub(crate) fn inspect_claude_desktop_agent_config(
paths: &[PathBuf],
port: u16,
api_key: &str,
) -> Result<(bool, Option<String>), String> {
if paths.len() != 4 || !paths.iter().any(|path| path.is_file()) {
return Ok((false, None));
}
let normal = read_agent_json_or_empty(&paths[0], "Claude Desktop main configuration")?;
let threep = read_agent_json_or_empty(&paths[1], "Claude Desktop 3P configuration")?;
let profile = read_agent_json_or_empty(&paths[2], "Claude Desktop gateway configuration")?;
let meta = read_agent_json_or_empty(&paths[3], "Claude Desktop configuration index")?;
let expected_base = managed_core_loopback_origin(port);
let configured = normal
.get("deploymentMode")
.and_then(serde_json::Value::as_str)
== Some("3p")
&& threep
.get("deploymentMode")
.and_then(serde_json::Value::as_str)
== Some("3p")
&& profile
.get("inferenceGatewayBaseUrl")
.and_then(serde_json::Value::as_str)
== Some(expected_base.as_str())
&& profile
.get("inferenceGatewayApiKey")
.and_then(serde_json::Value::as_str)
== Some(api_key)
&& meta.get("appliedId").and_then(serde_json::Value::as_str)
== Some(CLAUDE_DESKTOP_PROFILE_ID);
let model = profile
.get("inferenceModels")
.and_then(serde_json::Value::as_array)
.and_then(|models| models.first())
.and_then(|model| {
model
.as_str()
.or_else(|| {
model
.get("labelOverride")
.and_then(serde_json::Value::as_str)
})
.or_else(|| model.get("name").and_then(serde_json::Value::as_str))
})
.map(str::trim)
.filter(|value| !value.is_empty())
.map(str::to_string);
Ok((configured, model))
}
pub(crate) fn read_agent_json_or_empty(
path: &Path,
label: &str,
) -> Result<serde_json::Value, String> {
if !path.is_file() {
return Ok(serde_json::json!({}));
}
let content =
fs::read_to_string(path).map_err(|error| format!("Failed to read {label}: {error}"))?;
if content.trim().is_empty() {
return Ok(serde_json::json!({}));
}
let value: serde_json::Value =
serde_json::from_str(&content).map_err(|error| format!("Failed to parse {label}: {error}"))?;
if !value.is_object() {
return Err(format!("{label} root must be an object"));
}
Ok(value)
}
pub(crate) fn read_agent_json5_or_empty(
path: &Path,
label: &str,
) -> Result<serde_json::Value, String> {
if !path.is_file() {
return Ok(serde_json::json!({}));
}
let content =
fs::read_to_string(path).map_err(|error| format!("Failed to read {label}: {error}"))?;
if content.trim().is_empty() {
return Ok(serde_json::json!({}));
}
let value: serde_json::Value =
json5::from_str(&content).map_err(|error| format!("Failed to parse {label}: {error}"))?;
if !value.is_object() {
return Err(format!("{label} root must be an object"));
}
Ok(value)
}
pub(crate) fn inspect_codex_agent_config(
path: &Path,
port: u16,
api_key: &str,
) -> Result<(bool, Option<String>, bool), String> {
if !path.is_file() {
return Ok((false, None, false));
}
let root: toml::Value = toml::from_str(
&fs::read_to_string(path).map_err(|error| format!("Failed to read Codex configuration: {error}"))?,
)
.map_err(|error| format!("Failed to parse Codex configuration: {error}"))?;
let expected_base = format!("{}/v1", managed_core_loopback_origin(port));
let provider = root
.get("model_providers")
.and_then(toml::Value::as_table)
.and_then(|providers| providers.get(MANAGED_AGENT_PROVIDER_ID))
.and_then(toml::Value::as_table);
let uses_api_key = provider
.and_then(|provider| provider.get("experimental_bearer_token"))
.and_then(toml::Value::as_str)
== Some(api_key);
let oauth_configuration = provider
.and_then(|provider| provider.get("requires_openai_auth"))
.and_then(toml::Value::as_bool)
== Some(true);
let auth_path = path.with_file_name("auth.json");
let auth_configured = if oauth_configuration {
codex_auth_file_has_oauth_tokens(&auth_path)
} else {
codex_auth_file_has_api_key(&auth_path, api_key)
};
let configured_without_auth = root.get("model_provider").and_then(toml::Value::as_str)
== Some(MANAGED_AGENT_PROVIDER_ID)
&& root.get("model_catalog_json").and_then(toml::Value::as_str)
== Some(CODEX_MODEL_CATALOG_FILE)
&& provider
.and_then(|provider| provider.get("name"))
.and_then(toml::Value::as_str)
== Some("EasyCLIProxyAPI")
&& provider
.and_then(|provider| provider.get("base_url"))
.and_then(toml::Value::as_str)
== Some(expected_base.as_str())
&& uses_api_key
&& provider
.and_then(|provider| provider.get("wire_api"))
.and_then(toml::Value::as_str)
== Some("responses");
let configured = configured_without_auth && auth_configured;
let model = root
.get("model")
.and_then(toml::Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
.map(str::to_string);
Ok((configured, model, oauth_configuration))
}
pub(crate) fn validate_codex_catalog_file(config_path: &Path, model: &str) -> Result<(), String> {
let catalog_path = config_path
.parent()
.unwrap_or_else(|| Path::new("."))
.join(CODEX_MODEL_CATALOG_FILE);
let catalog = fs::read_to_string(&catalog_path).map_err(|error| {
format!(
"Failed to read Codex model catalog {}: {error}",
path_to_string(&catalog_path)
)
})?;
validate_codex_catalog(&catalog, model)
}
pub(crate) fn inspect_opencode_agent_config(
path: &Path,
port: u16,
api_key: &str,
) -> Result<(bool, Option<String>), String> {
if !path.is_file() {
return Ok((false, None));
}
let root = read_agent_json5_or_empty(path, "OpenCode configuration")?;
let provider = root
.get("provider")
.and_then(|providers| providers.get(MANAGED_AGENT_PROVIDER_ID));
let expected_base = format!("{}/v1", managed_core_loopback_origin(port));
let configured = provider
.and_then(|provider| provider.get("options"))
.and_then(|options| options.get("baseURL"))
.and_then(serde_json::Value::as_str)
== Some(expected_base.as_str())
&& provider
.and_then(|provider| provider.get("options"))
.and_then(|options| options.get("apiKey"))
.and_then(serde_json::Value::as_str)
== Some(api_key);
let prefix = format!("{MANAGED_AGENT_PROVIDER_ID}/");
let model = root
.get("model")
.and_then(serde_json::Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
.map(|value| value.strip_prefix(&prefix).unwrap_or(value))
.map(str::to_string);
Ok((configured, model))
}
pub(crate) fn inspect_zcode_agent_config(
path: &Path,
port: u16,
api_key: &str,
) -> Result<(bool, Option<String>), String> {
if !path.is_file() {
return Ok((false, None));
}
let root: serde_json::Value = serde_json::from_str(
&fs::read_to_string(path).map_err(|error| format!("Failed to read ZCode provider configuration: {error}"))?,
)
.map_err(|error| format!("Failed to parse ZCode provider configuration: {error}"))?;
if root.get("schemaVersion").and_then(serde_json::Value::as_u64) != Some(1) {
return Ok((false, None));
}
let config = root.get("config");
let provider = config
.and_then(|config| config.get("providerConfigRules"))
.and_then(|rules| rules.get("providerRules"))
.and_then(serde_json::Value::as_array)
.and_then(|rules| rules.iter().find(|rule| {
rule.get("providerId").and_then(serde_json::Value::as_str)
== Some(MANAGED_AGENT_PROVIDER_ID)
}));
let selected = config
.and_then(|config| config.get("defaultModelSelection"));
let model = selected
.filter(|selection| {
selection.get("providerId").and_then(serde_json::Value::as_str)
== Some(MANAGED_AGENT_PROVIDER_ID)
})
.and_then(|selection| selection.get("modelId"))
.and_then(serde_json::Value::as_str)
.map(str::to_string);
let configured = model.as_deref().is_some_and(|model| {
provider
.and_then(|provider| provider.get("config"))
.and_then(|config| config.get("personalModelIds"))
.and_then(serde_json::Value::as_array)
.is_some_and(|models| models.iter().any(|value| value.as_str() == Some(model)))
}) && provider
.and_then(|provider| provider.get("enabled"))
.and_then(serde_json::Value::as_bool) == Some(true)
&& provider
.and_then(|provider| provider.get("config"))
.and_then(|config| config.get("group"))
.and_then(serde_json::Value::as_str) == Some("standard-personal")
&& provider
.and_then(|provider| provider.pointer("/config/access/type"))
.and_then(serde_json::Value::as_str) == Some("api-key")
&& provider
.and_then(|provider| provider.pointer("/config/access/apiKey"))
.and_then(serde_json::Value::as_str) == Some(api_key)
&& provider
.and_then(|provider| provider.pointer("/config/api/type"))
.and_then(serde_json::Value::as_str) == Some("anthropic-messages")
&& provider
.and_then(|provider| provider.pointer("/config/api/baseUrl"))
.and_then(serde_json::Value::as_str)
== Some(managed_core_loopback_origin(port).as_str());
Ok((configured, model))
}
pub(crate) fn inspect_kimi_code_agent_config(
path: &Path,
port: u16,
api_key: &str,
) -> Result<(bool, Option<String>), String> {
if !path.is_file() {
return Ok((false, None));
}
let root: toml::Value = toml::from_str(
&fs::read_to_string(path).map_err(|error| format!("Failed to read Kimi Code configuration: {error}"))?,
)
.map_err(|error| format!("Failed to parse Kimi Code configuration: {error}"))?;
let expected_base = format!("{}/v1", managed_core_loopback_origin(port));
let provider = root
.get("providers")
.and_then(toml::Value::as_table)
.and_then(|providers| providers.get(MANAGED_AGENT_PROVIDER_ID))
.and_then(toml::Value::as_table);
let prefix = format!("{MANAGED_AGENT_PROVIDER_ID}/");
let selected = root
.get("default_model")
.and_then(toml::Value::as_str)
.map(str::trim)
.filter(|value| value.starts_with(&prefix));
let model_entry = selected.and_then(|alias| {
root.get("models")
.and_then(toml::Value::as_table)
.and_then(|models| models.get(alias))
.and_then(toml::Value::as_table)
});
let model = model_entry
.and_then(|entry| entry.get("model"))
.and_then(toml::Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
.map(str::to_string);
let configured = selected.is_some()
&& model.is_some()
&& provider
.and_then(|provider| provider.get("type"))
.and_then(toml::Value::as_str)
== Some("openai")
&& provider
.and_then(|provider| provider.get("base_url"))
.and_then(toml::Value::as_str)
== Some(expected_base.as_str())
&& provider
.and_then(|provider| provider.get("api_key"))
.and_then(toml::Value::as_str)
== Some(api_key)
&& model_entry
.and_then(|entry| entry.get("provider"))
.and_then(toml::Value::as_str)
== Some(MANAGED_AGENT_PROVIDER_ID);
Ok((configured, model))
}
pub(crate) fn inspect_grok_build_agent_config(
path: &Path,
port: u16,
api_key: &str,
) -> Result<(bool, Option<String>), String> {
if !path.is_file() {
return Ok((false, None));
}
let root: toml::Value = toml::from_str(
&fs::read_to_string(path).map_err(|error| format!("Failed to read Grok Build configuration: {error}"))?,
)
.map_err(|error| format!("Failed to parse Grok Build configuration: {error}"))?;
let expected_base = format!("{}/v1", managed_core_loopback_origin(port));
let prefix = format!("{MANAGED_AGENT_PROVIDER_ID}/");
let selected = root
.get("models")
.and_then(toml::Value::as_table)
.and_then(|models| models.get("default"))
.and_then(toml::Value::as_str)
.map(str::trim)
.filter(|value| value.starts_with(&prefix));
let model_entry = selected.and_then(|alias| {
root.get("model")
.and_then(toml::Value::as_table)
.and_then(|models| models.get(alias))
.and_then(toml::Value::as_table)
});
let model = model_entry
.and_then(|entry| entry.get("model"))
.and_then(toml::Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
.map(str::to_string);
let configured = selected.is_some()
&& model.is_some()
&& model_entry
.and_then(|entry| entry.get("base_url"))
.and_then(toml::Value::as_str)
== Some(expected_base.as_str())
&& model_entry
.and_then(|entry| entry.get("api_key"))
.and_then(toml::Value::as_str)
== Some(api_key)
&& model_entry
.and_then(|entry| entry.get("api_backend"))
.and_then(toml::Value::as_str)
== Some("chat_completions");
Ok((configured, model))
}
pub(crate) fn inspect_openclaw_agent_config(
path: &Path,
port: u16,
api_key: &str,
) -> Result<(bool, Option<String>), String> {
if !path.is_file() {
return Ok((false, None));
}
let content =
fs::read_to_string(path).map_err(|error| format!("Failed to read OpenClaw configuration: {error}"))?;
let root: serde_json::Value = json5::from_str(&content)
.map_err(|error| format!("Failed to parse OpenClaw JSON5 configuration: {error}"))?;
let provider = root
.get("models")
.and_then(|models| models.get("providers"))
.and_then(|providers| providers.get(MANAGED_AGENT_PROVIDER_ID));
let expected_base = format!("{}/v1", managed_core_loopback_origin(port));
let configured = provider
.and_then(|provider| provider.get("baseUrl"))
.and_then(serde_json::Value::as_str)
== Some(expected_base.as_str())
&& provider
.and_then(|provider| provider.get("apiKey"))
.and_then(serde_json::Value::as_str)
== Some(api_key);
let prefix = format!("{MANAGED_AGENT_PROVIDER_ID}/");
let model = root
.get("agents")
.and_then(|agents| agents.get("defaults"))
.and_then(|defaults| defaults.get("model"))
.and_then(|model| model.get("primary"))
.and_then(serde_json::Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
.map(|value| value.strip_prefix(&prefix).unwrap_or(value))
.map(str::to_string);
Ok((configured, model))
}
pub(crate) fn inspect_hermes_agent_config(
path: &Path,
port: u16,
api_key: &str,
) -> Result<(bool, Option<String>), String> {
if !path.is_file() {
return Ok((false, None));
}
let root: serde_yaml::Value = serde_yaml::from_str(
&fs::read_to_string(path).map_err(|error| format!("Failed to read Hermes configuration: {error}"))?,
)
.map_err(|error| format!("Failed to parse Hermes YAML configuration: {error}"))?;
let provider = root
.get("custom_providers")
.and_then(serde_yaml::Value::as_sequence)
.and_then(|providers| {
providers.iter().find(|provider| {
provider.get("name").and_then(serde_yaml::Value::as_str)
== Some(MANAGED_AGENT_PROVIDER_ID)
})
});
let expected_base = format!("{}/v1", managed_core_loopback_origin(port));
let configured = provider
.and_then(|provider| provider.get("base_url"))
.and_then(serde_yaml::Value::as_str)
== Some(expected_base.as_str())
&& provider
.and_then(|provider| provider.get("api_key"))
.and_then(serde_yaml::Value::as_str)
== Some(api_key)
&& root
.get("model")
.and_then(|model| model.get("provider"))
.and_then(serde_yaml::Value::as_str)
== Some(MANAGED_AGENT_PROVIDER_ID);
let model = root
.get("model")
.and_then(|model| model.get("default"))
.and_then(serde_yaml::Value::as_str)
.or_else(|| provider.and_then(|provider| provider.get("model")?.as_str()))
.map(str::trim)
.filter(|value| !value.is_empty())
.map(str::to_string);
Ok((configured, model))
}
pub(crate) fn inspect_deepseek_harness_config(
paths: &[PathBuf],
port: u16,
api_key: &str,
) -> Result<(bool, Option<String>), String> {
if paths.len() != 2 {
return Err("Invalid number of DeepSeek Harness configuration paths".to_string());
}
let settings = read_agent_yaml_mapping_or_empty(&paths[0], "DeepSeek Harness settings")?;
let credentials = read_agent_yaml_mapping_or_empty(&paths[1], "DeepSeek Harness credentials")?;
let provider = yaml_mapping_value(&settings, "llm-pi-ai")
.and_then(serde_norway::Value::as_mapping)
.and_then(|section| yaml_mapping_value(section, "providers"))
.and_then(serde_norway::Value::as_mapping)
.and_then(|providers| yaml_mapping_value(providers, DEEPSEEK_HARNESS_PROVIDER_ID))
.and_then(serde_norway::Value::as_mapping);
let selection = yaml_mapping_value(&settings, "agent-default-model")
.and_then(serde_norway::Value::as_mapping);
let selected_provider = selection
.and_then(|selection| yaml_mapping_value(selection, "provider"))
.and_then(serde_norway::Value::as_str);
let model = (selected_provider == Some(DEEPSEEK_HARNESS_PROVIDER_ID))
.then(|| {
selection
.and_then(|selection| yaml_mapping_value(selection, "model"))
.and_then(serde_norway::Value::as_str)
.map(str::trim)
.filter(|model| !model.is_empty())
.map(str::to_string)
})
.flatten();
let expected_base = if provider
.and_then(|p| yaml_mapping_value(p, "api"))
.and_then(serde_norway::Value::as_str)
== Some("anthropic-messages")
{
managed_core_loopback_origin(port)
} else {
format!("{}/v1", managed_core_loopback_origin(port))
};
let credentials_layout_supported = deepseek_harness_credentials_layout_supported(&credentials);
let credential = yaml_mapping_value(&credentials, "refs")
.and_then(serde_norway::Value::as_mapping)
.and_then(|refs| yaml_mapping_value(refs, DEEPSEEK_HARNESS_CREDENTIAL))
.and_then(serde_norway::Value::as_str);
let model_is_published = model.as_deref().is_some_and(|selected| {
provider
.and_then(|provider| yaml_mapping_value(provider, "models"))
.and_then(serde_norway::Value::as_sequence)
.is_some_and(|models| {
models.iter().any(|model| {
model
.as_mapping()
.and_then(|model| yaml_mapping_value(model, "id"))
.and_then(serde_norway::Value::as_str)
.is_some_and(|id| id.eq_ignore_ascii_case(selected))
})
})
});
let configured = provider
.and_then(|provider| yaml_mapping_value(provider, "apiKeyEnv"))
.and_then(serde_norway::Value::as_str)
== Some(DEEPSEEK_HARNESS_CREDENTIAL)
&& provider
.and_then(|provider| yaml_mapping_value(provider, "api"))
.and_then(serde_norway::Value::as_str)
.is_some_and(|api| {
matches!(
api,
"openai-completions" | "openai-responses" | "anthropic-messages"
)
})
&& provider
.and_then(|provider| yaml_mapping_value(provider, "baseURL"))
.and_then(serde_norway::Value::as_str)
== Some(expected_base.as_str())
&& selected_provider == Some(DEEPSEEK_HARNESS_PROVIDER_ID)
&& model_is_published
&& credentials_layout_supported
&& credential == Some(api_key);
Ok((configured, model))
}
fn deepseek_harness_credentials_layout_supported(credentials: &serde_norway::Mapping) -> bool {
let Some(version) = yaml_mapping_value(credentials, "version") else {
return false;
};
let version_supported = version.as_u64() == Some(DEEPSEEK_HARNESS_CREDENTIALS_VERSION)
|| version.as_i64() == Some(DEEPSEEK_HARNESS_CREDENTIALS_VERSION as i64);
version_supported
&& credentials
.keys()
.all(|key| matches!(key.as_str(), Some("version" | "refs" | "records")))
&& ["refs", "records"].into_iter().all(|section| {
yaml_mapping_value(credentials, section).is_none_or(serde_norway::Value::is_mapping)
})
}
pub(crate) fn deepseek_harness_has_managed_marker(paths: &[PathBuf]) -> Result<bool, String> {
if paths.len() != 2 {
return Err("Invalid number of DeepSeek Harness configuration paths".to_string());
}
let settings = read_agent_yaml_mapping_or_empty(&paths[0], "DeepSeek Harness settings")?;
let credentials = read_agent_yaml_mapping_or_empty(&paths[1], "DeepSeek Harness credentials")?;
let provider_exists = yaml_mapping_value(&settings, "llm-pi-ai")
.and_then(serde_norway::Value::as_mapping)
.and_then(|section| yaml_mapping_value(section, "providers"))
.and_then(serde_norway::Value::as_mapping)
.is_some_and(|providers| {
yaml_mapping_value(providers, DEEPSEEK_HARNESS_PROVIDER_ID).is_some()
});
let default_selected = yaml_mapping_value(&settings, "agent-default-model")
.and_then(serde_norway::Value::as_mapping)
.and_then(|selection| yaml_mapping_value(selection, "provider"))
.and_then(serde_norway::Value::as_str)
== Some(DEEPSEEK_HARNESS_PROVIDER_ID);
let credential_exists = yaml_mapping_value(&credentials, "refs")
.and_then(serde_norway::Value::as_mapping)
.is_some_and(|refs| yaml_mapping_value(refs, DEEPSEEK_HARNESS_CREDENTIAL).is_some())
|| yaml_mapping_value(&credentials, DEEPSEEK_HARNESS_CREDENTIAL).is_some();
Ok(provider_exists || default_selected || credential_exists)
}