use std::time::Duration;
use bitrouter_sdk::config::Config;
use crate::agent_registry::{InstallSupport, Registry, RegistryAgent};
use crate::harness::{self, Harness};
#[derive(Debug, Clone)]
pub struct ListRow {
pub id: String,
pub configured: bool,
pub in_catalog: bool,
pub description: String,
}
#[derive(Debug, Clone)]
pub struct CheckRow {
pub id: String,
pub outcome: Result<Duration, String>,
}
pub fn list(config: &Config) -> Vec<ListRow> {
let mut ids: std::collections::BTreeMap<String, ListRow> = Default::default();
for h in harness::CATALOG.iter() {
ids.insert(
h.id.to_string(),
ListRow {
id: h.id.to_string(),
configured: false,
in_catalog: true,
description: h.description.to_string(),
},
);
}
for (id, cfg) in &config.agents {
ids.entry(id.clone())
.and_modify(|row| row.configured = true)
.or_insert_with(|| ListRow {
id: id.clone(),
configured: true,
in_catalog: false,
description: describe_invocation(cfg),
});
}
ids.into_values().collect()
}
fn describe_invocation(cfg: &bitrouter_sdk::acp::transport::AcpAgentConfig) -> String {
use bitrouter_sdk::acp::transport::AcpTransport;
let full = match &cfg.transport {
AcpTransport::Stdio { command, args, .. } => {
if args.is_empty() {
format!("stdio {command}")
} else {
format!("stdio {command} {}", args.join(" "))
}
}
};
let mut one_line = full.split_whitespace().collect::<Vec<_>>().join(" ");
const MAX: usize = 80;
if one_line.chars().count() > MAX {
one_line = one_line.chars().take(MAX - 1).collect::<String>() + "…";
}
one_line
}
pub async fn check(config: &Config) -> Vec<CheckRow> {
use bitrouter_sdk::acp::transport::AcpTransport;
let mut out = Vec::with_capacity(config.agents.len());
let mut sorted: Vec<_> = config.agents.iter().collect();
sorted.sort_by(|a, b| a.0.cmp(b.0));
for (id, cfg) in sorted {
let outcome = match &cfg.transport {
AcpTransport::Stdio { command, args, env } => {
bitrouter_sdk::acp::up::health_check(command, args, env).await
}
};
out.push(CheckRow {
id: id.clone(),
outcome,
});
}
out
}
#[derive(Debug, Clone)]
pub struct RemoteRow {
pub id: String,
pub version: String,
pub install: &'static str,
pub description: String,
}
pub fn registry_rows(registry: &Registry) -> Vec<RemoteRow> {
let mut rows: Vec<RemoteRow> = registry
.agents
.iter()
.map(|a| RemoteRow {
id: a.id.clone(),
version: a.version.clone().unwrap_or_else(|| "-".to_string()),
install: match a.install_support() {
InstallSupport::Stub(runner) => runner,
InstallSupport::Manual => "manual",
InstallSupport::None => "-",
},
description: a
.description
.clone()
.or_else(|| a.name.clone())
.unwrap_or_default(),
})
.collect();
rows.sort_by(|a, b| a.id.cmp(&b.id));
rows
}
pub fn install(id: &str) -> Result<String, String> {
let agent: &Harness = harness::by_id(id)
.ok_or_else(|| format!("'{id}' is not in the bundled catalog. Run `bitrouter agents list` (or `--remote` for the ACP registry) to see the available ids."))?;
let command = agent.acp_command.ok_or_else(|| {
format!(
"'{id}' is interactive-only (no ACP adapter to install) — drive it with `bitrouter launch --agent {}`",
agent.interactive_binary.unwrap_or(id)
)
})?;
Ok(render_stub(&StubSpec {
id: agent.id,
comment: agent.description,
source: agent.project_url,
command,
args: agent.acp_args.iter().map(|a| a.to_string()).collect(),
env: Vec::new(),
}))
}
pub fn install_from_registry(registry: &Registry, id: &str) -> Result<String, String> {
let agent: &RegistryAgent = registry
.agents
.iter()
.find(|a| a.id == id)
.ok_or_else(|| format!("'{id}' is not in the ACP registry either. Run `bitrouter agents list --remote` to see the available ids."))?;
let Some(invocation) = agent.stdio_invocation() else {
let hint = agent
.repository
.as_deref()
.unwrap_or("the project's documentation");
return Err(format!(
"'{id}' is distributed as a platform binary; the registry carries no checksums, so install it manually from {hint} and add a stdio `agents:` entry pointing at the installed command."
));
};
let comment = agent
.description
.clone()
.or_else(|| agent.name.clone())
.unwrap_or_else(|| agent.id.clone());
Ok(render_stub(&StubSpec {
id: &agent.id,
comment: &comment,
source: agent.repository.as_deref().unwrap_or("ACP registry"),
command: invocation.command,
args: invocation.args,
env: invocation.env.into_iter().collect(),
}))
}
struct StubSpec<'a> {
id: &'a str,
comment: &'a str,
source: &'a str,
command: &'a str,
args: Vec<String>,
env: Vec<(String, String)>,
}
fn render_stub(spec: &StubSpec<'_>) -> String {
let mut out = String::new();
out.push_str(&format!(
"# {} — paste under `agents:` in bitrouter.yaml.\n",
spec.comment
));
out.push_str(&format!("# Source: {}\n", spec.source));
out.push_str(&format!("{}:\n", spec.id));
out.push_str(&format!(" name: {}\n", spec.id));
out.push_str(" transport:\n");
out.push_str(" type: stdio\n");
out.push_str(&format!(" command: {}\n", yaml_scalar(spec.command)));
out.push_str(" args:\n");
for a in &spec.args {
out.push_str(&format!(" - {}\n", yaml_scalar(a)));
}
if !spec.env.is_empty() {
out.push_str(" env:\n");
for (k, v) in &spec.env {
out.push_str(&format!(" {}: {}\n", yaml_scalar(k), yaml_scalar(v)));
}
}
out
}
pub(crate) fn yaml_scalar(s: &str) -> String {
if s.is_empty() {
return "\"\"".to_string();
}
let first_special = s
.chars()
.next()
.map(|c| {
matches!(
c,
'@' | '`'
| '!'
| '&'
| '*'
| '|'
| '>'
| '\''
| '"'
| '%'
| '#'
| '?'
| ':'
| '-'
| ','
| '{'
| '}'
| '['
| ']'
)
})
.unwrap_or(false);
let needs_quotes = first_special
|| s.chars().any(|c| c.is_whitespace() || c.is_control())
|| s.contains('#')
|| s.contains(':')
|| parses_as_non_string(s);
if needs_quotes {
serde_json::Value::String(s.to_string()).to_string()
} else {
s.to_string()
}
}
fn parses_as_non_string(s: &str) -> bool {
let lower = s.to_ascii_lowercase();
if matches!(
lower.as_str(),
"true"
| "false"
| "yes"
| "no"
| "on"
| "off"
| "null"
| "~"
| ".nan"
| ".inf"
| "+.inf"
| "-.inf"
) {
return true;
}
if s.parse::<i64>().is_ok() || s.parse::<f64>().is_ok() {
return true;
}
let digits = s.strip_prefix(['-', '+']).unwrap_or(s);
if !digits.is_empty()
&& digits.chars().any(|c| c.is_ascii_digit())
&& digits.chars().all(|c| c.is_ascii_digit() || c == '_')
{
return true;
}
if let Some(hex) = digits
.strip_prefix("0x")
.or_else(|| digits.strip_prefix("0X"))
{
return !hex.is_empty() && hex.chars().all(|c| c.is_ascii_hexdigit());
}
if let Some(oct) = digits
.strip_prefix("0o")
.or_else(|| digits.strip_prefix("0O"))
{
return !oct.is_empty() && oct.chars().all(|c| matches!(c, '0'..='7'));
}
false
}
#[cfg(test)]
mod tests {
use super::*;
use bitrouter_sdk::acp::transport::{AcpAgentConfig, AcpTransport};
use std::collections::HashMap;
fn agent(name: &str, cmd: &str) -> AcpAgentConfig {
agent_with_args(name, cmd, &[])
}
fn agent_with_args(name: &str, cmd: &str, args: &[&str]) -> AcpAgentConfig {
AcpAgentConfig {
name: name.into(),
transport: AcpTransport::Stdio {
command: cmd.into(),
args: args.iter().map(|s| s.to_string()).collect(),
env: HashMap::new(),
},
}
}
#[test]
fn list_returns_catalog_when_no_agents_configured() {
let cfg = Config::default();
let rows = list(&cfg);
assert!(rows.len() >= 3, "catalog should have at least 3 agents");
assert!(rows.iter().all(|r| r.in_catalog));
assert!(rows.iter().all(|r| !r.configured));
assert!(rows.iter().any(|r| r.id == "claude-acp"));
}
#[test]
fn list_marks_configured_catalog_entries() {
let mut cfg = Config::default();
cfg.agents
.insert("claude-acp".into(), agent("claude-acp", "npx"));
let rows = list(&cfg);
let claude = rows.iter().find(|r| r.id == "claude-acp").unwrap();
assert!(claude.configured);
assert!(claude.in_catalog);
}
#[test]
fn list_includes_custom_agents_not_in_catalog() {
let mut cfg = Config::default();
cfg.agents.insert("my-bot".into(), agent("my-bot", "./bot"));
let rows = list(&cfg);
let custom = rows.iter().find(|r| r.id == "my-bot").unwrap();
assert!(custom.configured);
assert!(!custom.in_catalog);
assert!(custom.description.contains("stdio ./bot"));
}
#[cfg(unix)]
const ERROR_STUB: &str = r#"
while read line; do
id=$(echo "$line" | sed -n 's/.*"id":"\([^"]*\)".*/\1/p')
case "$line" in
*initialize*) printf '{"jsonrpc":"2.0","id":"%s","error":{"code":-32600,"message":"not supported"}}\n' "$id";;
esac
done
"#;
#[cfg(unix)]
#[tokio::test]
async fn check_reports_per_agent_failure_independently() {
let mut cfg = Config::default();
cfg.agents.insert(
"a".into(),
agent_with_args("a", "bash", &["-c", ERROR_STUB]),
);
cfg.agents.insert(
"b".into(),
agent_with_args("b", "bash", &["-c", ERROR_STUB]),
);
let rows = check(&cfg).await;
assert_eq!(rows.len(), 2);
assert_eq!(rows[0].id, "a");
assert_eq!(rows[1].id, "b");
assert!(rows[0].outcome.is_err());
assert!(rows[1].outcome.is_err());
}
#[test]
fn install_unknown_id_errors_with_hint() {
let err = install("nonexistent").unwrap_err();
assert!(err.contains("not in the bundled catalog"));
assert!(err.contains("bitrouter agents list"));
}
const REGISTRY_FIXTURE: &str = r#"{
"agents": [
{
"id": "gemini",
"name": "Gemini CLI",
"version": "0.50.0",
"description": "Google's official CLI for Gemini",
"repository": "https://github.com/google-gemini/gemini-cli",
"distribution": { "npx": { "package": "@google/gemini-cli@0.50.0", "args": ["--acp"] } }
},
{
"id": "pytool",
"name": "PyTool",
"distribution": { "uvx": { "package": "pytool-acp@1.2.0", "env": { "PYTOOL_API_KEY": "" } } }
},
{
"id": "opencode",
"name": "OpenCode",
"version": "1.17.15",
"repository": "https://github.com/anomalyco/opencode",
"distribution": { "binary": { "darwin-aarch64": { "archive": "https://example.com/o.zip", "cmd": "./opencode", "args": ["acp"] } } }
}
]
}"#;
fn fixture_registry() -> crate::agent_registry::Registry {
crate::agent_registry::parse(REGISTRY_FIXTURE).expect("fixture parses")
}
#[test]
fn registry_rows_classify_install_support() {
let rows = registry_rows(&fixture_registry());
assert_eq!(rows.len(), 3);
let by_id = |id: &str| rows.iter().find(|r| r.id == id).expect("row");
assert_eq!(by_id("gemini").install, "npx");
assert_eq!(by_id("gemini").version, "0.50.0");
assert_eq!(by_id("pytool").install, "uvx");
assert_eq!(by_id("opencode").install, "manual");
}
#[test]
fn install_from_registry_emits_schema_valid_stub_with_env() {
let out = install_from_registry(&fixture_registry(), "pytool").expect("stub");
let body: HashMap<String, AcpAgentConfig> =
serde_saphyr::from_str(&out).expect("stub must deserialise into the config schema");
let entry = body.get("pytool").expect("pytool key present");
match &entry.transport {
AcpTransport::Stdio { command, args, env } => {
assert_eq!(command, "uvx");
assert_eq!(args, &["pytool-acp@1.2.0".to_string()]);
assert!(env.contains_key("PYTOOL_API_KEY"));
}
}
}
#[test]
fn install_from_registry_npx_pins_version() {
let out = install_from_registry(&fixture_registry(), "gemini").expect("stub");
assert!(out.contains("@google/gemini-cli@0.50.0"));
assert!(
out.contains("- \"--acp\"") || out.contains("- --acp"),
"{out}"
);
}
#[test]
fn install_from_registry_refuses_binary_only_with_pointer() {
let err = install_from_registry(&fixture_registry(), "opencode").unwrap_err();
assert!(err.contains("platform binary"));
assert!(err.contains("github.com/anomalyco/opencode"));
}
#[test]
fn install_from_registry_unknown_id_hints_remote_list() {
let err = install_from_registry(&fixture_registry(), "nope").unwrap_err();
assert!(err.contains("agents list --remote"));
}
#[test]
fn install_known_id_emits_parseable_yaml_stub() {
let out = install("claude-acp").unwrap();
assert!(out.contains("claude-acp:"));
assert!(out.contains("type: stdio"));
assert!(out.contains("command: npx"));
assert!(out.contains("@agentclientprotocol/claude-agent-acp@0.75.1"));
let parsed: serde_json::Value =
serde_saphyr::from_str(&out).expect("stub must parse as YAML");
assert!(parsed.is_object(), "expected mapping, got {parsed:?}");
}
#[test]
fn install_emits_project_url_for_attribution() {
let out = install("gemini-cli").unwrap();
assert!(out.contains("github.com"));
}
#[test]
fn catalog_includes_pi_acp() {
let a = harness::by_id("pi-acp").expect("pi-acp is in the bundled catalog");
assert_eq!(a.acp_command, Some("npx"));
assert_eq!(a.acp_args, &["-y", "pi-acp@latest"]);
let out = install("pi-acp").unwrap();
assert!(out.contains("pi-acp:"));
assert!(out.contains("pi-acp@latest"));
assert!(out.contains("github.com/svkozak/pi-acp"));
}
#[test]
fn yaml_scalar_quotes_empty_string() {
assert_eq!(yaml_scalar(""), "\"\"");
}
#[test]
fn yaml_scalar_quotes_non_string_scalars() {
for s in [
"false", "true", "yes", "no", "on", "off", "null", "Null", "~", "123", "-1", "+1",
"1.5", ".5", "1e3", "0x1F", "0o17", "1_000", ".nan", "-.inf",
] {
let quoted = yaml_scalar(s);
assert!(
quoted.starts_with('"') && quoted.ends_with('"'),
"{s} must be quoted, got {quoted}"
);
}
for s in ["1.2.3", "v2", "npx", "false-positive"] {
assert_eq!(yaml_scalar(s), s, "{s} must stay unquoted");
}
}
#[test]
fn yaml_scalar_round_trips_control_characters() {
for value in ["line one\nline two", "tab\tseparated", "carriage\rreturn"] {
let yaml = format!("value: {}\n", yaml_scalar(value));
let parsed: std::collections::HashMap<String, String> =
serde_saphyr::from_str(&yaml).expect("rendered scalar must be valid YAML");
assert_eq!(parsed.get("value").map(String::as_str), Some(value));
}
}
#[test]
fn install_round_trips_through_acp_agent_config_schema() {
let out = install("codex-acp").unwrap();
let body: HashMap<String, AcpAgentConfig> =
serde_saphyr::from_str(&out).expect("stub must deserialise into the config schema");
let entry = body.get("codex-acp").expect("codex-acp key present");
match &entry.transport {
AcpTransport::Stdio { command, args, .. } => {
assert_eq!(command, "npx");
assert!(args.iter().any(|a| a.contains("codex-acp")));
}
}
}
}