use std::collections::HashSet;
#[cfg(windows)]
use std::ffi::OsString;
use std::io::Write;
use std::path::{Path, PathBuf};
use std::process::Command;
use std::time::Duration;
use anyhow::{bail, Context, Result};
use serde::Serialize;
use serde_json::{json, Value};
use crate::{ui, Config, HarnessOverride};
const PROVIDER_NAME: &str = "alexandria";
const PI_MIN_VERSION: Version = Version {
major: 0,
minor: 80,
patch: 0,
};
const FALLBACK_MODELS: &[&str] = &[
"claude-opus-4-8",
"claude-sonnet-5",
"claude-haiku-4-5",
"gpt-5.5",
"grok-code-fast-1",
"gemini-2.5-flash",
];
pub(crate) struct HarnessSpec {
pub(crate) name: &'static str,
pub(crate) binary: &'static str,
pub(crate) config_dir: fn(&Path) -> PathBuf,
pub(crate) version_args: &'static [&'static str],
pub(crate) supports_connect: bool,
}
pub(crate) const HARNESSES: &[HarnessSpec] = &[
HarnessSpec {
name: "pi",
binary: "pi",
config_dir: pi_config_dir_for_home,
version_args: &["--version"],
supports_connect: true,
},
HarnessSpec {
name: "claude",
binary: "claude",
config_dir: claude_config_dir_for_home,
version_args: &["--version"],
supports_connect: false,
},
HarnessSpec {
name: "codex",
binary: "codex",
config_dir: codex_config_dir_for_home,
version_args: &["--version"],
supports_connect: false,
},
HarnessSpec {
name: "gemini",
binary: "gemini",
config_dir: gemini_config_dir_for_home,
version_args: &["--version"],
supports_connect: false,
},
HarnessSpec {
name: "grok",
binary: "grok",
config_dir: grok_config_dir_for_home,
version_args: &["--version"],
supports_connect: false,
},
HarnessSpec {
name: "opencode",
binary: "opencode",
config_dir: opencode_config_dir_for_home,
version_args: &["--version"],
supports_connect: false,
},
];
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
pub(crate) struct Version {
major: u64,
minor: u64,
patch: u64,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct VersionCheck {
pub parsed: Option<Version>,
pub warning: Option<String>,
}
#[derive(Debug, Serialize)]
pub(crate) struct HarnessStatus {
pub(crate) name: &'static str,
pub(crate) installed: bool,
pub(crate) binary: Option<String>,
pub(crate) version: Option<String>,
pub(crate) version_warning: Option<String>,
pub(crate) config_dir: String,
pub(crate) config_dir_exists: bool,
pub(crate) connected: bool,
pub(crate) supports_connect: bool,
#[serde(rename = "override")]
pub(crate) override_: HarnessOverrideJson,
pub(crate) daemon_reachable: bool,
}
#[derive(Debug)]
struct HarnessDetection {
binary: Option<PathBuf>,
version: Option<String>,
version_warning: Option<String>,
}
#[derive(Debug, Serialize)]
pub(crate) struct HarnessOverrideJson {
pub(crate) binary: Option<String>,
pub(crate) config_dir: Option<String>,
}
#[derive(Debug)]
pub(crate) struct PiConnectSummary {
pub(crate) key_id: String,
pub(crate) models: Vec<String>,
pub(crate) config_path: PathBuf,
pub(crate) version: Option<String>,
pub(crate) base_url: String,
pub(crate) added: Vec<String>,
pub(crate) removed: Vec<String>,
pub(crate) unchanged: usize,
}
#[derive(Debug)]
struct VersionOutput {
version: Option<String>,
warning: Option<String>,
}
#[derive(Debug)]
struct PiDetection {
binary: Option<PathBuf>,
version_raw: Option<String>,
version_check: VersionCheck,
}
pub(crate) async fn connect_cmd(
config: &Config,
harness: Option<String>,
config_dir: Option<PathBuf>,
json: bool,
) -> Result<()> {
match harness.as_deref() {
None => connect_status(config, config_dir, json).await,
Some("pi") => connect_pi(config, config_dir, json).await,
Some(name) => bail!(
"unknown harness '{name}' (supported: {})",
HARNESSES
.iter()
.map(|h| h.name)
.collect::<Vec<_>>()
.join(", ")
),
}
}
pub(crate) async fn disconnect_cmd(
config: &Config,
harness: String,
config_dir: Option<PathBuf>,
) -> Result<()> {
match harness.as_str() {
"pi" => disconnect_pi(config, config_dir).await,
name => bail!(
"unknown harness '{name}' (supported: {})",
HARNESSES
.iter()
.map(|h| h.name)
.collect::<Vec<_>>()
.join(", ")
),
}
}
async fn connect_status(config: &Config, config_dir: Option<PathBuf>, json_out: bool) -> Result<()> {
let statuses = harness_statuses(config, config_dir, daemon_health(config).await).await?;
if json_out {
println!("{}", serde_json::to_string_pretty(&statuses)?);
return Ok(());
}
println!("{}", ui::section("harness connections"));
println!(
"{} {} {} {} {} {}",
ui::pad_right(&ui::column_header("harness"), 10),
ui::pad_right(&ui::column_header("installed"), 10),
ui::pad_right(&ui::column_header("version"), 14),
ui::pad_right(&ui::column_header("config"), 8),
ui::pad_right(&ui::column_header("connected"), 10),
ui::column_header("daemon")
);
for status in statuses {
let installed = if status.installed { "yes" } else { "no" };
let config_exists = if status.config_dir_exists { "yes" } else { "no" };
let connected = if status.connected { "yes" } else { "no" };
let daemon = if status.daemon_reachable { "up" } else { "down" };
println!(
"{} {} {} {} {} {}",
ui::pad_right(status.name, 10),
ui::pad_right(installed, 10),
ui::pad_right(status.version.as_deref().unwrap_or("-"), 14),
ui::pad_right(config_exists, 8),
ui::pad_right(connected, 10),
daemon
);
if let Some(warning) = status.version_warning {
println!(" {}", ui::amber(&warning));
}
if !status.config_dir_exists {
println!(" {}", ui::dim(&format!("config: {}", status.config_dir)));
}
}
Ok(())
}
pub(crate) async fn harness_statuses(
config: &Config,
pi_config_dir: Option<PathBuf>,
daemon_reachable: bool,
) -> Result<Vec<HarnessStatus>> {
let mut statuses = Vec::new();
for spec in HARNESSES {
let explicit_config_dir = if spec.name == "pi" {
pi_config_dir.clone()
} else {
None
};
statuses.push(harness_status(config, spec, explicit_config_dir, daemon_reachable).await?);
}
Ok(statuses)
}
pub(crate) async fn harness_status(
config: &Config,
spec: &'static HarnessSpec,
explicit_config_dir: Option<PathBuf>,
daemon_reachable: bool,
) -> Result<HarnessStatus> {
let detection = detect_harness(config, spec).await;
let config_dir = resolve_config_dir(config, spec, explicit_config_dir);
let config_dir_exists = config_dir.is_dir();
let connected = if spec.name == "pi" {
models_json_connected(&config_dir.join("models.json")).unwrap_or(false)
} else {
false
};
let override_ = override_json(config.harness_overrides.get(spec.name));
Ok(HarnessStatus {
name: spec.name,
installed: detection.binary.is_some(),
binary: detection
.binary
.as_ref()
.map(|p| p.to_string_lossy().to_string()),
version: detection.version,
version_warning: detection.version_warning,
config_dir: config_dir.to_string_lossy().to_string(),
config_dir_exists,
connected,
supports_connect: spec.supports_connect,
override_,
daemon_reachable,
})
}
async fn connect_pi(config: &Config, config_dir: Option<PathBuf>, json_out: bool) -> Result<()> {
let detection = detect_pi(config).await;
if detection.binary.is_none() {
bail!("pi is not installed or not on PATH; install it with `npm install -g @earendil-works/pi-coding-agent`");
}
if let Some(warning) = &detection.version_check.warning {
eprintln!("{}", ui::amber(warning));
}
let config_dir = resolve_config_dir(config, pi_spec(), config_dir);
if !config_dir.is_dir() {
bail!(
"pi config dir does not exist at {}; run pi once first (it creates ~/.pi/agent), or pass --config-dir",
config_dir.display()
);
}
if !daemon_health(config).await {
bail!(
"could not reach the alexandria daemon at {}; start it with `alex daemon --background`",
normalized_base_url(config)
);
}
let client = reqwest::Client::builder()
.timeout(std::time::Duration::from_secs(5))
.build()?;
revoke_pi_harness_keys(config, &client).await?;
let minted = mint_pi_key(config, &client).await?;
let models = fetch_models(config, &client)
.await
.unwrap_or_else(|| FALLBACK_MODELS.iter().map(|m| (*m).to_string()).collect());
let summary = write_pi_connection(
config_dir,
normalized_base_url(config),
minted.id,
minted.key,
models,
detection.version_raw,
)?;
if json_out {
println!(
"{}",
serde_json::to_string_pretty(&json!({
"harness": "pi",
"version": summary.version,
"config_path": summary.config_path,
"models": summary.models,
"key_id": summary.key_id,
}))?
);
} else {
println!("{}", ui::section("pi connected"));
println!("key id: {}", ui::amber(&summary.key_id));
println!("models: {}", summary.models.len());
println!();
println!("pi --provider alexandria --model claude-opus-4-8");
println!("or pick via /model inside pi — changes hot-reload");
}
Ok(())
}
async fn disconnect_pi(config: &Config, config_dir: Option<PathBuf>) -> Result<()> {
let config_dir = resolve_config_dir(config, pi_spec(), config_dir);
let was_connected = disconnect_pi_config(&config_dir)?;
if !was_connected {
println!("pi not connected");
return Ok(());
}
let mut revoked = 0usize;
if daemon_health(config).await {
let client = reqwest::Client::builder()
.timeout(std::time::Duration::from_secs(5))
.build()?;
match revoke_pi_harness_keys(config, &client).await {
Ok(count) => revoked = count,
Err(e) => eprintln!(
"{}",
ui::amber(&format!(
"removed local pi config, but could not revoke daemon keys: {e}"
))
),
}
} else {
eprintln!(
"{}",
ui::amber("removed local pi config, but the daemon is unreachable; harness keys remain, rerun disconnect with the daemon up")
);
}
println!("disconnected pi; revoked {revoked} harness key(s)");
Ok(())
}
pub(crate) fn write_pi_connection(
config_dir: PathBuf,
base_url: String,
key_id: String,
api_key: String,
models: Vec<String>,
version: Option<String>,
) -> Result<PiConnectSummary> {
let models = short_alex_model_ids(models);
let models_path = config_dir.join("models.json");
let before = read_pi_model_ids(&config_dir);
upsert_pi_provider(&models_path, &base_url, &api_key, &models)?;
let (added, removed, unchanged) = model_id_diff(&before, &models);
Ok(PiConnectSummary {
key_id,
models,
config_path: models_path,
version,
base_url,
added,
removed,
unchanged,
})
}
pub(crate) fn short_alex_model_ids(models: Vec<String>) -> Vec<String> {
models
.into_iter()
.map(|id| {
if id.starts_with("alex/") {
id
} else if let Some(rest) = id.strip_prefix("alexandria/") {
format!("alex/{rest}")
} else if let Some(rest) = id.strip_prefix("cove/") {
format!("alex/{rest}")
} else {
format!("alex/{id}")
}
})
.collect()
}
pub(crate) fn read_pi_api_key(config_dir: &Path) -> Option<String> {
read_pi_provider(config_dir)?["apiKey"]
.as_str()
.filter(|s| !s.is_empty())
.map(String::from)
}
pub(crate) fn read_pi_model_ids(config_dir: &Path) -> Vec<String> {
let Some(provider) = read_pi_provider(config_dir) else {
return Vec::new();
};
provider["models"]
.as_array()
.map(|rows| {
rows.iter()
.filter_map(|row| row["id"].as_str().map(String::from))
.collect()
})
.unwrap_or_default()
}
fn read_pi_provider(config_dir: &Path) -> Option<Value> {
let path = config_dir.join("models.json");
if !path.exists() {
return None;
}
let raw = std::fs::read_to_string(path).ok()?;
let value: Value = serde_json::from_str(&raw).ok()?;
value["providers"][PROVIDER_NAME].as_object().map(|o| Value::Object(o.clone()))
}
pub(crate) fn model_id_diff(before: &[String], after: &[String]) -> (Vec<String>, Vec<String>, usize) {
let before_set: HashSet<&str> = before.iter().map(String::as_str).collect();
let after_set: HashSet<&str> = after.iter().map(String::as_str).collect();
let mut added: Vec<String> = after_set
.difference(&before_set)
.map(|s| (*s).to_string())
.collect();
let mut removed: Vec<String> = before_set
.difference(&after_set)
.map(|s| (*s).to_string())
.collect();
added.sort();
removed.sort();
let unchanged = before_set.intersection(&after_set).count();
(added, removed, unchanged)
}
pub(crate) fn config_write_json(
summary: &PiConnectSummary,
key_status: &str,
refreshed: Option<bool>,
) -> Value {
let mut body = json!({
"path": summary.config_path.display().to_string(),
"models_total": summary.models.len(),
"added": summary.added,
"removed": summary.removed,
"unchanged": summary.unchanged,
"key": key_status,
"base_url": summary.base_url,
});
if let Some(refreshed) = refreshed {
body["refreshed"] = json!(refreshed);
}
if !summary.key_id.is_empty() {
body["key_id"] = json!(summary.key_id);
}
body
}
pub(crate) fn disconnect_summary_json(
models_path: &Path,
previous_models: Vec<String>,
base_url: &str,
revoked: usize,
was_connected: bool,
) -> Value {
json!({
"path": models_path.display().to_string(),
"models_total": 0,
"added": [],
"removed": previous_models,
"unchanged": 0,
"key": if revoked > 0 { "revoked" } else { "none" },
"base_url": base_url,
"revoked": revoked,
"was_connected": was_connected,
})
}
pub(crate) fn plan_connect(config_dir: &Path, model_count: usize, keys: &[(String, String)]) -> Value {
let models_path = config_dir.join("models.json");
let mut plan = Vec::new();
let connected = models_json_connected(&models_path).unwrap_or(false);
let file_exists = models_path.exists();
let action = if !file_exists {
"create"
} else {
"modify"
};
let detail = if connected {
format!("update provider 'alexandria' with {model_count} models")
} else {
format!("add provider 'alexandria' with {model_count} models")
};
plan.push(json!({
"path": models_path.display().to_string(),
"action": action,
"detail": detail,
}));
for (id, fp) in keys {
plan.push(json!({
"path": id,
"action": "delete",
"detail": format!("revoke harness key {fp}"),
}));
}
plan.push(json!({
"path": "run-keys",
"action": "create",
"detail": "mint harness key",
}));
json!({"plan": plan})
}
pub(crate) fn plan_disconnect(config_dir: &Path, keys: &[(String, String)]) -> Value {
let models_path = config_dir.join("models.json");
let mut plan = Vec::new();
if models_json_connected(&models_path).unwrap_or(false) {
plan.push(json!({
"path": models_path.display().to_string(),
"action": "modify",
"detail": "remove provider block",
}));
}
for (id, fp) in keys {
plan.push(json!({
"path": id,
"action": "delete",
"detail": format!("revoke harness key {fp}"),
}));
}
json!({"plan": plan})
}
pub(crate) fn disconnect_pi_config(config_dir: &Path) -> Result<bool> {
remove_pi_provider(&config_dir.join("models.json"))
}
#[derive(Debug)]
struct MintedKey {
id: String,
key: String,
}
async fn mint_pi_key(config: &Config, client: &reqwest::Client) -> Result<MintedKey> {
let body = json!({
"kind": "harness",
"label": "pi",
"tags": {"harness": "pi"},
});
let (status, value) = admin_send(config, client, reqwest::Method::POST, "/admin/run-keys", Some(body)).await?;
if !status.is_success() {
bail!(
"daemon could not mint a pi harness key ({status}): {}",
ui::truncate(&value.to_string(), 300)
);
}
let id = value["id"].as_str().unwrap_or("-").to_string();
if value["kind"].as_str() != Some("harness") || !value["expires_ms"].is_null() {
if id != "-" {
let _ = admin_send(
config,
client,
reqwest::Method::DELETE,
&format!("/admin/run-keys/{id}"),
None,
)
.await;
}
bail!("the running daemon does not support harness run keys; update alex and restart the daemon");
}
let key = value["key"]
.as_str()
.context("daemon response did not include the one-time run key")?
.to_string();
Ok(MintedKey { id, key })
}
async fn revoke_pi_harness_keys(config: &Config, client: &reqwest::Client) -> Result<usize> {
let value = admin_get(config, client, "/admin/run-keys", &[("all", "1")]).await?;
let rows = value["run_keys"].as_array().cloned().unwrap_or_default();
let ids: Vec<String> = rows
.iter()
.filter(|row| row["kind"].as_str() == Some("harness") && row["label"].as_str() == Some("pi"))
.filter_map(|row| row["id"].as_str().map(String::from))
.collect();
for id in &ids {
let (status, value) = admin_send(
config,
client,
reqwest::Method::DELETE,
&format!("/admin/run-keys/{id}"),
None,
)
.await?;
if !status.is_success() {
bail!(
"daemon could not revoke old pi harness key {id} ({status}): {}",
ui::truncate(&value.to_string(), 300)
);
}
}
Ok(ids.len())
}
async fn fetch_models(config: &Config, client: &reqwest::Client) -> Option<Vec<String>> {
let url = format!("{}/v1/models", normalized_base_url(config));
let resp = client
.get(url)
.header("x-api-key", &config.local_key)
.send()
.await
.ok()?;
if !resp.status().is_success() {
return None;
}
let value: Value = resp.json().await.ok()?;
let ids = value["data"]
.as_array()?
.iter()
.filter_map(|row| row["id"].as_str().map(String::from))
.collect();
let filtered = filter_model_ids(ids);
(!filtered.is_empty()).then_some(filtered)
}
async fn admin_get(
config: &Config,
client: &reqwest::Client,
path: &str,
params: &[(&str, &str)],
) -> Result<Value> {
let resp = client
.get(format!("{}{}", normalized_base_url(config), path))
.header("x-api-key", &config.local_key)
.query(params)
.send()
.await
.with_context(|| format!("could not reach the alexandria daemon at {}", normalized_base_url(config)))?;
let status = resp.status();
let value: Value = resp.json().await.unwrap_or_default();
if !status.is_success() {
bail!(
"daemon returned {status}: {}",
ui::truncate(&value.to_string(), 300)
);
}
Ok(value)
}
async fn admin_send(
config: &Config,
client: &reqwest::Client,
method: reqwest::Method,
path: &str,
body: Option<Value>,
) -> Result<(reqwest::StatusCode, Value)> {
let mut req = client
.request(method, format!("{}{}", normalized_base_url(config), path))
.header("x-api-key", &config.local_key);
if let Some(body) = body {
req = req.json(&body);
}
let resp = req
.send()
.await
.with_context(|| format!("could not reach the alexandria daemon at {}", normalized_base_url(config)))?;
let status = resp.status();
let value: Value = resp.json().await.unwrap_or_default();
Ok((status, value))
}
async fn daemon_health(config: &Config) -> bool {
let Ok(client) = reqwest::Client::builder()
.timeout(std::time::Duration::from_secs(2))
.build()
else {
return false;
};
client
.get(format!("{}/health", normalized_base_url(config)))
.send()
.await
.map(|r| r.status().is_success())
.unwrap_or(false)
}
fn normalized_base_url(config: &Config) -> String {
let host = if config.host == "0.0.0.0" {
"127.0.0.1"
} else {
config.host.as_str()
};
format!("http://{host}:{}", config.port)
}
pub(crate) fn pi_spec() -> &'static HarnessSpec {
HARNESSES
.iter()
.find(|spec| spec.name == "pi")
.expect("pi harness spec")
}
pub(crate) fn spec_by_name(name: &str) -> Option<&'static HarnessSpec> {
HARNESSES.iter().find(|spec| spec.name == name)
}
pub(crate) fn resolve_config_dir(
config: &Config,
spec: &HarnessSpec,
explicit: Option<PathBuf>,
) -> PathBuf {
explicit
.or_else(|| {
config
.harness_overrides
.get(spec.name)
.and_then(|override_| override_.config_dir.clone())
})
.unwrap_or_else(|| {
let home = dirs::home_dir().unwrap_or_else(|| PathBuf::from("."));
(spec.config_dir)(&home)
})
}
fn pi_config_dir_for_home(home: &Path) -> PathBuf {
home.join(".pi").join("agent")
}
fn claude_config_dir_for_home(home: &Path) -> PathBuf {
home.join(".claude")
}
fn codex_config_dir_for_home(home: &Path) -> PathBuf {
home.join(".codex")
}
fn gemini_config_dir_for_home(home: &Path) -> PathBuf {
home.join(".gemini")
}
fn grok_config_dir_for_home(home: &Path) -> PathBuf {
home.join(".grok")
}
fn opencode_config_dir_for_home(home: &Path) -> PathBuf {
home.join(".config").join("opencode")
}
fn override_json(override_: Option<&HarnessOverride>) -> HarnessOverrideJson {
HarnessOverrideJson {
binary: override_
.and_then(|o| o.binary.as_ref())
.map(|p| p.to_string_lossy().to_string()),
config_dir: override_
.and_then(|o| o.config_dir.as_ref())
.map(|p| p.to_string_lossy().to_string()),
}
}
async fn detect_pi(config: &Config) -> PiDetection {
let detection = detect_harness(config, pi_spec()).await;
let version_check = if detection.binary.is_some() {
check_version(detection.version.as_deref())
} else {
VersionCheck {
parsed: None,
warning: None,
}
};
PiDetection {
binary: detection.binary,
version_raw: detection.version,
version_check,
}
}
async fn detect_harness(config: &Config, spec: &HarnessSpec) -> HarnessDetection {
detect_harness_with_timeout(config, spec, Duration::from_secs(5)).await
}
async fn detect_harness_with_timeout(
config: &Config,
spec: &HarnessSpec,
timeout: Duration,
) -> HarnessDetection {
let override_binary = config
.harness_overrides
.get(spec.name)
.and_then(|override_| override_.binary.clone());
let binary = match override_binary {
Some(path) if is_executable_file(&path) => Some(path),
Some(_) => None,
None => find_on_path(spec.binary),
};
let Some(binary_path) = binary.clone() else {
return HarnessDetection {
binary: None,
version: None,
version_warning: None,
};
};
let version = command_version(&binary_path, spec.version_args, timeout).await;
let mut version_warning = version.warning.clone();
if spec.name == "pi" {
let check = check_version(version.version.as_deref());
version_warning = version_warning.or(check.warning);
}
HarnessDetection {
binary: Some(binary_path),
version: version.version,
version_warning,
}
}
pub(crate) fn find_on_path(bin: &str) -> Option<PathBuf> {
let path = std::env::var_os("PATH")?;
for dir in std::env::split_paths(&path) {
for candidate in executable_candidates(&dir, bin) {
if is_executable_file(&candidate) {
return Some(candidate);
}
}
}
None
}
fn executable_candidates(dir: &Path, bin: &str) -> Vec<PathBuf> {
#[cfg(windows)]
{
let pathext = std::env::var_os("PATHEXT").unwrap_or_else(|| OsString::from(".EXE;.CMD;.BAT"));
let mut out = vec![dir.join(bin)];
for ext in pathext.to_string_lossy().split(';') {
out.push(dir.join(format!("{bin}{ext}")));
}
out
}
#[cfg(not(windows))]
{
vec![dir.join(bin)]
}
}
fn is_executable_file(path: &Path) -> bool {
if !path.is_file() {
return false;
}
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
return std::fs::metadata(path)
.map(|m| m.permissions().mode() & 0o111 != 0)
.unwrap_or(false);
}
#[cfg(not(unix))]
{
true
}
}
async fn command_version(binary: &Path, args: &[&str], timeout: Duration) -> VersionOutput {
let binary = binary.to_path_buf();
let args: Vec<String> = args.iter().map(|arg| (*arg).to_string()).collect();
match tokio::time::timeout(
timeout,
tokio::task::spawn_blocking(move || {
let out = Command::new(binary).args(args).output().ok()?;
let raw = if out.stdout.is_empty() {
String::from_utf8_lossy(&out.stderr).to_string()
} else {
String::from_utf8_lossy(&out.stdout).to_string()
};
version_token(&raw)
}),
)
.await
{
Ok(Ok(version)) => VersionOutput {
version,
warning: None,
},
Ok(Err(e)) => VersionOutput {
version: None,
warning: Some(format!("version check failed: {e}")),
},
Err(_) => VersionOutput {
version: None,
warning: Some("version check timed out".into()),
},
}
}
fn version_token(raw: &str) -> Option<String> {
raw.split_whitespace()
.find(|part| part.chars().any(|c| c.is_ascii_digit()))
.map(|part| part.trim().to_string())
}
pub(crate) fn check_version(raw: Option<&str>) -> VersionCheck {
match raw.and_then(parse_version) {
Some(version) if version >= PI_MIN_VERSION => VersionCheck {
parsed: Some(version),
warning: None,
},
Some(version) => VersionCheck {
parsed: Some(version),
warning: Some(format!(
"pi version {version} is older than 0.80.0; continuing, but upgrade pi if connection fails"
)),
},
None => VersionCheck {
parsed: None,
warning: Some("could not parse `pi --version`; continuing".into()),
},
}
}
fn parse_version(raw: &str) -> Option<Version> {
let token = raw
.split_whitespace()
.find(|part| part.chars().any(|c| c.is_ascii_digit()))?;
let start = token.find(|c: char| c.is_ascii_digit())?;
let numeric: String = token[start..]
.chars()
.take_while(|c| c.is_ascii_digit() || *c == '.')
.collect();
let mut parts = numeric.split('.');
let major = parts.next()?.parse().ok()?;
let minor = parts.next()?.parse().ok()?;
let patch = parts
.next()
.and_then(|p| {
let digits: String = p.chars().take_while(|c| c.is_ascii_digit()).collect();
digits.parse().ok()
})
.unwrap_or(0);
Some(Version {
major,
minor,
patch,
})
}
impl std::fmt::Display for Version {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}.{}.{}", self.major, self.minor, self.patch)
}
}
fn models_json_connected(path: &Path) -> Result<bool> {
if !path.exists() {
return Ok(false);
}
let raw = std::fs::read_to_string(path)?;
let value: Value = serde_json::from_str(&raw)
.with_context(|| format!("could not parse {}; aborting without changes", path.display()))?;
Ok(value["providers"][PROVIDER_NAME].is_object())
}
pub(crate) fn upsert_pi_provider(
path: &Path,
base_url: &str,
api_key: &str,
model_ids: &[String],
) -> Result<()> {
let mut value = read_models_json(path)?;
let providers = ensure_providers_object(&mut value, path)?;
providers.insert(
PROVIDER_NAME.to_string(),
json!({
"baseUrl": base_url,
"api": "anthropic-messages",
"apiKey": api_key,
"headers": {
"x-alexandria-harness": "pi",
"x-alexandria-harness-version": "!pi --version",
},
"models": model_ids.iter().map(|id| {
json!({
"id": id,
"reasoning": reasoning_enabled(id),
"input": ["text", "image"],
"contextWindow": 200000,
"maxTokens": 16384,
})
}).collect::<Vec<_>>(),
}),
);
atomic_write_json(path, &value)
}
pub(crate) fn remove_pi_provider(path: &Path) -> Result<bool> {
if !path.exists() {
return Ok(false);
}
let mut value = read_models_json(path)?;
let providers = ensure_providers_object(&mut value, path)?;
if providers.remove(PROVIDER_NAME).is_none() {
return Ok(false);
}
atomic_write_json(path, &value)?;
Ok(true)
}
fn read_models_json(path: &Path) -> Result<Value> {
if !path.exists() {
return Ok(json!({"providers": {}}));
}
let raw = std::fs::read_to_string(path)?;
serde_json::from_str(&raw)
.with_context(|| format!("could not parse {}; aborting without changes", path.display()))
}
fn ensure_providers_object<'a>(
value: &'a mut Value,
path: &Path,
) -> Result<&'a mut serde_json::Map<String, Value>> {
if !value.is_object() {
bail!("{} must contain a JSON object; aborting without changes", path.display());
}
if value.get("providers").is_none() {
value["providers"] = json!({});
}
value["providers"]
.as_object_mut()
.with_context(|| format!("{}.providers must be an object; aborting without changes", path.display()))
}
fn atomic_write_json(path: &Path, value: &Value) -> Result<()> {
let parent = path
.parent()
.filter(|p| !p.as_os_str().is_empty())
.unwrap_or_else(|| Path::new("."));
let tmp = parent.join(format!(
".{}.tmp.{}",
path.file_name()
.and_then(|n| n.to_str())
.unwrap_or("models.json"),
std::process::id()
));
let data = serde_json::to_string_pretty(value)? + "\n";
{
let mut file = std::fs::File::create(&tmp)?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
file.set_permissions(std::fs::Permissions::from_mode(0o600))?;
}
file.write_all(data.as_bytes())?;
file.sync_all()?;
}
#[cfg(windows)]
if path.exists() {
std::fs::remove_file(path)?;
}
std::fs::rename(&tmp, path)?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600))?;
}
Ok(())
}
pub(crate) fn filter_model_ids(ids: Vec<String>) -> Vec<String> {
let mut seen = HashSet::new();
let mut out = Vec::new();
for id in ids {
if id.contains('/') || !allowed_model_prefix(&id) || !seen.insert(id.clone()) {
continue;
}
out.push(id);
}
out
}
fn allowed_model_prefix(id: &str) -> bool {
["claude-", "gpt-", "o3", "o4", "codex-", "grok-", "gemini-"]
.iter()
.any(|prefix| id.starts_with(prefix))
}
pub(crate) fn reasoning_enabled(id: &str) -> bool {
let id = id.to_ascii_lowercase();
let bare = id
.rsplit_once('/')
.map(|(_, rest)| rest)
.unwrap_or(id.as_str());
bare.contains("opus")
|| bare.contains("sonnet")
|| bare.contains("fable")
|| bare.contains("gpt-5")
|| bare.starts_with("o3")
|| bare.starts_with("o4")
|| bare.contains("grok")
|| (bare.starts_with("gemini-") && bare.contains("pro"))
}
#[cfg(test)]
mod tests {
use super::*;
use std::collections::BTreeMap;
fn tmpdir(name: &str) -> PathBuf {
let dir = std::env::temp_dir().join(format!(
"alex-harness-connect-{name}-{}-{}",
std::process::id(),
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos()
));
std::fs::create_dir_all(&dir).unwrap();
dir
}
fn model_ids() -> Vec<String> {
vec!["claude-opus-4-8".into(), "gpt-5.5".into()]
}
#[test]
fn short_alex_model_ids_prefixes_bare_and_normalizes_long_forms() {
assert_eq!(
short_alex_model_ids(vec![
"claude-opus-4-8".into(),
"alex/gpt-5.5".into(),
"alexandria/grok-4.5".into(),
"cove/claude-fable-5".into(),
]),
vec![
"alex/claude-opus-4-8",
"alex/gpt-5.5",
"alex/grok-4.5",
"alex/claude-fable-5",
]
);
}
#[test]
fn model_id_diff_reports_added_removed_unchanged() {
let before = vec!["alex/a".into(), "alex/b".into(), "alex/c".into()];
let after = vec!["alex/b".into(), "alex/c".into(), "alex/d".into()];
let (added, removed, unchanged) = model_id_diff(&before, &after);
assert_eq!(added, vec!["alex/d"]);
assert_eq!(removed, vec!["alex/a"]);
assert_eq!(unchanged, 2);
}
fn test_config() -> Config {
Config {
host: "127.0.0.1".into(),
port: 4100,
data_dir: tmpdir("data"),
local_key: "alx-test".into(),
heartbeat_minutes: crate::default_heartbeat_minutes(),
ping_anthropic_model: crate::default_ping_anthropic(),
ping_openai_model: crate::default_ping_openai(),
ping_xai_model: crate::default_ping_xai(),
ping_gemini_model: crate::default_ping_gemini(),
gemini_project: String::new(),
anthropic_upstream: crate::default_anthropic_upstream(),
dario_api_key: String::new(),
dario_update_check_minutes: crate::default_dario_update_minutes(),
dario_version: None,
dario_probe_seconds: crate::default_dario_probe_seconds(),
dario_probe_failures: crate::default_dario_probe_failures(),
dario_probe_model: crate::default_dario_probe_model(),
trace_body_retention_days: crate::default_trace_body_retention_days(),
trace_row_retention_days: 0,
update_check_hours: crate::default_update_check_hours(),
harness_overrides: BTreeMap::new(),
account_policy: BTreeMap::new(),
}
}
#[cfg(unix)]
fn fake_executable(dir: &Path, name: &str, body: &str) -> PathBuf {
use std::os::unix::fs::PermissionsExt;
let path = dir.join(name);
std::fs::write(&path, format!("#!/bin/sh\n{body}\n")).unwrap();
std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o755)).unwrap();
path
}
#[cfg(unix)]
#[tokio::test]
async fn catalog_detection_uses_overrides() {
let dir = tmpdir("override-detect");
let config_dir = dir.join("claude-config");
std::fs::create_dir_all(&config_dir).unwrap();
let binary = fake_executable(&dir, "claude-fake", "echo claude-code 1.2.3");
let mut config = test_config();
config.harness_overrides.insert(
"claude".into(),
HarnessOverride {
binary: Some(binary.clone()),
config_dir: Some(config_dir.clone()),
},
);
let status = harness_status(&config, spec_by_name("claude").unwrap(), None, true)
.await
.unwrap();
assert_eq!(status.name, "claude");
assert!(status.installed);
assert_eq!(status.binary.as_deref(), Some(binary.to_str().unwrap()));
assert_eq!(status.version.as_deref(), Some("1.2.3"));
assert_eq!(status.config_dir, config_dir.to_string_lossy());
assert!(status.config_dir_exists);
assert!(!status.connected);
assert!(!status.supports_connect);
assert_eq!(
status.override_.config_dir.as_deref(),
Some(config_dir.to_str().unwrap())
);
let statuses = harness_statuses(&config, None, true).await.unwrap();
assert_eq!(statuses.len(), 6);
assert!(statuses.iter().any(|s| s.name == "opencode"));
}
#[cfg(unix)]
#[tokio::test]
async fn version_detection_timeout_keeps_installed_true() {
let dir = tmpdir("timeout");
let binary = fake_executable(&dir, "codex-slow", "sleep 2\necho codex 9.9.9");
let mut config = test_config();
config.harness_overrides.insert(
"codex".into(),
HarnessOverride {
binary: Some(binary),
config_dir: None,
},
);
let detection = detect_harness_with_timeout(
&config,
spec_by_name("codex").unwrap(),
Duration::from_millis(100),
)
.await;
assert!(detection.binary.is_some());
assert!(detection.version.is_none());
assert_eq!(detection.version_warning.as_deref(), Some("version check timed out"));
}
#[test]
fn upsert_models_json_missing_file() {
let dir = tmpdir("missing");
let path = dir.join("models.json");
upsert_pi_provider(&path, "http://127.0.0.1:4100", "alxk-test", &model_ids()).unwrap();
let value: Value = serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
let provider = &value["providers"]["alexandria"];
assert_eq!(provider["baseUrl"], "http://127.0.0.1:4100");
assert_eq!(provider["api"], "anthropic-messages");
assert_eq!(provider["apiKey"], "alxk-test");
assert_eq!(provider["headers"]["x-alexandria-harness"], "pi");
assert_eq!(provider["headers"]["x-alexandria-harness-version"], "!pi --version");
assert_eq!(provider["models"].as_array().unwrap().len(), 2);
}
#[test]
fn upsert_preserves_other_providers_and_top_level_keys() {
let dir = tmpdir("preserve");
let path = dir.join("models.json");
std::fs::write(
&path,
r#"{"top":true,"providers":{"other":{"api":"openai","models":[{"id":"x"}]}}}"#,
)
.unwrap();
upsert_pi_provider(&path, "http://127.0.0.1:4100", "alxk-test", &model_ids()).unwrap();
let value: Value = serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
assert_eq!(value["top"], true);
assert_eq!(value["providers"]["other"]["api"], "openai");
assert!(value["providers"]["alexandria"].is_object());
}
#[test]
fn corrupt_json_errors_without_clobbering() {
let dir = tmpdir("corrupt");
let path = dir.join("models.json");
std::fs::write(&path, "{not json").unwrap();
let err = upsert_pi_provider(&path, "http://127.0.0.1:4100", "alxk-test", &model_ids())
.unwrap_err();
assert!(err.to_string().contains("could not parse"));
assert_eq!(std::fs::read_to_string(path).unwrap(), "{not json");
}
#[test]
fn disconnect_removal_round_trip_keeps_foreign_provider() {
let dir = tmpdir("disconnect");
let path = dir.join("models.json");
std::fs::write(
&path,
r#"{"providers":{"alexandria":{"api":"anthropic-messages"},"foreign":{"api":"x"}}}"#,
)
.unwrap();
assert!(remove_pi_provider(&path).unwrap());
let value: Value = serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
assert!(value["providers"]["alexandria"].is_null());
assert_eq!(value["providers"]["foreign"]["api"], "x");
assert!(!remove_pi_provider(&path).unwrap());
}
#[test]
fn model_id_filter_dedupes_and_rejects_wrapped_ids() {
let ids = vec![
"claude-opus-4-8".into(),
"alexandria/claude-opus-4-8".into(),
"gpt-5.5".into(),
"gpt-5.5".into(),
"random".into(),
"o3-mini".into(),
"codex-mini".into(),
"gemini-2.5-flash".into(),
];
assert_eq!(
filter_model_ids(ids),
vec![
"claude-opus-4-8",
"gpt-5.5",
"o3-mini",
"codex-mini",
"gemini-2.5-flash"
]
);
}
#[test]
fn version_parse_and_warning() {
let ok = check_version(Some("0.80.3"));
assert_eq!(ok.parsed.unwrap().to_string(), "0.80.3");
assert!(ok.warning.is_none());
let old = check_version(Some("0.79.0"));
assert_eq!(old.parsed.unwrap().to_string(), "0.79.0");
assert!(old.warning.unwrap().contains("older than"));
let garbage = check_version(Some("garbage"));
assert!(garbage.parsed.is_none());
assert!(garbage.warning.unwrap().contains("could not parse"));
}
#[test]
fn reasoning_flag_mapping() {
assert!(reasoning_enabled("claude-opus-4-8"));
assert!(reasoning_enabled("claude-sonnet-5"));
assert!(reasoning_enabled("gpt-5.5"));
assert!(reasoning_enabled("o3-mini"));
assert!(reasoning_enabled("o4-mini"));
assert!(reasoning_enabled("grok-code-fast-1"));
assert!(reasoning_enabled("gemini-2.5-pro"));
assert!(!reasoning_enabled("claude-haiku-4-5"));
assert!(!reasoning_enabled("gemini-2.5-flash"));
}
}