fix(agents): preserve vendor CLI availability checks

Signed-off-by: Salman Mohammed <smohammed@squareup.com>
This commit is contained in:
Salman Mohammed
2026-09-09 09:52:55 -04:00
parent 3ae5509729
commit ef21680b70

View File

@@ -16,11 +16,10 @@ pub(super) struct PresetHarness {
install_instructions_url: &'static str,
install_hint: &'static str,
/// Vendor CLI the ACP command wraps, when the preset is an adapter.
///
/// Consulted only when the adapter is absent, so `AdapterMissing` replaces
/// `NotInstalled` when the CLI is present but the adapter is not. `None`
/// when the command is itself the vendor CLI.
underlying_cli: Option<&'static str>,
/// State-specific setup guidance for the wrapped vendor CLI.
underlying_cli_install_hint: Option<&'static str>,
underlying_cli_install_instructions_url: Option<&'static str>,
}
/// Build one preset catalog entry through an injectable command resolver.
@@ -28,28 +27,44 @@ pub(super) fn preset_catalog_entry(
def: &PresetHarness,
resolve: impl Fn(&str) -> Option<PathBuf>,
) -> AcpRuntimeCatalogEntry {
let (availability, command, binary_path) = match resolve(def.command) {
Some(path) => (
AcpAvailabilityStatus::Available,
Some(def.command.to_string()),
Some(path.display().to_string()),
),
None => {
let underlying_cli_found = def
.underlying_cli
.map(|cli| resolve(cli).is_some())
.unwrap_or(false);
if underlying_cli_found {
(AcpAvailabilityStatus::AdapterMissing, None, None)
} else {
(AcpAvailabilityStatus::NotInstalled, None, None)
}
}
};
let underlying_cli_path = def
.underlying_cli
.and_then(resolve)
.and_then(&resolve)
.map(|path| path.display().to_string());
let (availability, command, binary_path) = super::classify_runtime(
resolve(def.command).map(|path| (def.command, path)),
def.underlying_cli,
underlying_cli_path.is_some(),
);
let cli_install_hint = def.underlying_cli.map(|cli| {
def.underlying_cli_install_hint
.map(str::to_string)
.unwrap_or_else(|| {
format!(
"Install the {} CLI and make sure {} is on your PATH.",
def.label, cli
)
})
});
let install_hint = match availability {
AcpAvailabilityStatus::Available if def.underlying_cli.is_some() => String::new(),
AcpAvailabilityStatus::CliMissing => cli_install_hint.unwrap_or_default(),
AcpAvailabilityStatus::NotInstalled if def.underlying_cli.is_some() => {
format!(
"{} {}",
cli_install_hint.unwrap_or_default(),
def.install_hint
)
}
_ => def.install_hint.to_string(),
};
let install_instructions_url = match availability {
AcpAvailabilityStatus::CliMissing | AcpAvailabilityStatus::NotInstalled => def
.underlying_cli_install_instructions_url
.unwrap_or(def.install_instructions_url),
_ => def.install_instructions_url,
};
AcpRuntimeCatalogEntry {
id: def.id.to_string(),
@@ -71,12 +86,10 @@ pub(super) fn preset_catalog_entry(
max_tokens_env_var: None,
context_limit_env_var: None,
max_rounds_env_var: None,
install_hint: def.install_hint.to_string(),
install_instructions_url: def.install_instructions_url.to_string(),
install_hint,
install_instructions_url: install_instructions_url.to_string(),
can_auto_install: false,
// Presets carry one flat install hint, so builtin external-CLI copy
// would name the wrong missing component for adapter presets.
requires_external_cli: false,
requires_external_cli: def.underlying_cli.is_some(),
underlying_cli_path,
node_required: false,
auth_status: AuthStatus::NotApplicable,
@@ -97,8 +110,14 @@ pub(super) const PRESET_HARNESSES: &[PresetHarness] = &[
command: "pi-acp",
args: &[],
install_instructions_url: "https://github.com/salman1993/pi-acp",
install_hint: "Buzz talks to Pi through the pi-acp adapter. Install Pi with `npm install -g --ignore-scripts @earendil-works/pi-coding-agent`, then clone https://github.com/salman1993/pi-acp and run `npm ci && npm run build && npm install -g .` in the checkout.",
install_hint: "Install the Pi ACP adapter by cloning https://github.com/salman1993/pi-acp and running `npm ci && npm run build && npm install -g .` in the checkout.",
underlying_cli: Some("pi"),
underlying_cli_install_hint: Some(
"Install Pi with npm install -g --ignore-scripts @earendil-works/pi-coding-agent.",
),
underlying_cli_install_instructions_url: Some(
"https://github.com/badlogic/pi-mono/tree/main/packages/coding-agent",
),
},
PresetHarness {
id: "devin",
@@ -108,6 +127,8 @@ pub(super) const PRESET_HARNESSES: &[PresetHarness] = &[
install_instructions_url: "https://docs.devin.ai/cli",
install_hint: "Buzz talks to Devin through the official Devin CLI's ACP mode (devin acp).",
underlying_cli: None,
underlying_cli_install_hint: None,
underlying_cli_install_instructions_url: None,
},
PresetHarness {
id: "cursor",
@@ -117,6 +138,8 @@ pub(super) const PRESET_HARNESSES: &[PresetHarness] = &[
install_instructions_url: "https://cursor.com/downloads",
install_hint: "Buzz talks to Cursor through the cursor-agent CLI's ACP mode.",
underlying_cli: None,
underlying_cli_install_hint: None,
underlying_cli_install_instructions_url: None,
},
PresetHarness {
id: "omp",
@@ -126,6 +149,8 @@ pub(super) const PRESET_HARNESSES: &[PresetHarness] = &[
install_instructions_url: "https://omp.sh/",
install_hint: "Buzz talks to Oh My Pi through its CLI's ACP mode (omp acp).",
underlying_cli: None,
underlying_cli_install_hint: None,
underlying_cli_install_instructions_url: None,
},
PresetHarness {
id: "grok",
@@ -135,6 +160,8 @@ pub(super) const PRESET_HARNESSES: &[PresetHarness] = &[
install_instructions_url: "https://build.x.ai/docs",
install_hint: "Buzz talks to Grok Build through its CLI's agent stdio mode.",
underlying_cli: None,
underlying_cli_install_hint: None,
underlying_cli_install_instructions_url: None,
},
PresetHarness {
id: "opencode",
@@ -144,6 +171,8 @@ pub(super) const PRESET_HARNESSES: &[PresetHarness] = &[
install_instructions_url: "https://opencode.ai/docs",
install_hint: "Buzz talks to OpenCode through its CLI's ACP mode (opencode acp).",
underlying_cli: None,
underlying_cli_install_hint: None,
underlying_cli_install_instructions_url: None,
},
PresetHarness {
id: "kimi",
@@ -153,6 +182,8 @@ pub(super) const PRESET_HARNESSES: &[PresetHarness] = &[
install_instructions_url: "https://kimi.ai/download",
install_hint: "Buzz talks to Kimi Code through its CLI's ACP mode (kimi acp).",
underlying_cli: None,
underlying_cli_install_hint: None,
underlying_cli_install_instructions_url: None,
},
PresetHarness {
id: "amp",
@@ -162,6 +193,8 @@ pub(super) const PRESET_HARNESSES: &[PresetHarness] = &[
install_instructions_url: "https://github.com/tao12345666333/amp-acp",
install_hint: "Buzz talks to the Amp CLI through the amp-acp adapter. Follow the setup guide to install the adapter so the amp-acp command is on your PATH.",
underlying_cli: Some("amp"),
underlying_cli_install_hint: None,
underlying_cli_install_instructions_url: None,
},
PresetHarness {
id: "hermes",
@@ -171,6 +204,8 @@ pub(super) const PRESET_HARNESSES: &[PresetHarness] = &[
install_instructions_url: "https://hermes-agent.nousresearch.com",
install_hint: "Buzz talks to Hermes Agent through its hermes-acp command.",
underlying_cli: None,
underlying_cli_install_hint: None,
underlying_cli_install_instructions_url: None,
},
PresetHarness {
id: "openclaw",
@@ -187,6 +222,8 @@ pub(super) const PRESET_HARNESSES: &[PresetHarness] = &[
needs BUZZ_* credentials at execution time, set them on the \
Gateway's own environment separately.",
underlying_cli: None,
underlying_cli_install_hint: None,
underlying_cli_install_instructions_url: None,
},
];
@@ -303,6 +340,8 @@ mod tests {
install_instructions_url: "https://example.com/install",
install_hint: "Install the amp-acp npm adapter.",
underlying_cli: Some("amp"),
underlying_cli_install_hint: Some("Install the Amp Test CLI."),
underlying_cli_install_instructions_url: Some("https://example.com/amp"),
};
#[test]
@@ -366,13 +405,6 @@ mod tests {
assert_eq!(preset.label, "Pi");
assert_eq!(preset.command, "pi-acp");
assert_eq!(
preset.install_instructions_url,
"https://github.com/salman1993/pi-acp"
);
assert!(preset
.install_hint
.contains("npm ci && npm run build && npm install -g ."));
assert!(preset.args.is_empty());
assert_eq!(preset.underlying_cli, Some("pi"));
@@ -384,6 +416,8 @@ mod tests {
assert_eq!(available.availability, AcpAvailabilityStatus::Available);
assert_eq!(available.command.as_deref(), Some("pi-acp"));
assert!(available.default_args.is_empty());
assert!(available.install_hint.is_empty());
assert!(available.requires_external_cli);
assert_eq!(
available.underlying_cli_path.as_deref(),
Some("/usr/local/bin/pi")
@@ -398,12 +432,38 @@ mod tests {
);
assert!(adapter_missing.command.is_none());
assert!(adapter_missing.default_args.is_empty());
assert_eq!(
adapter_missing.install_hint,
"Install the Pi ACP adapter by cloning https://github.com/salman1993/pi-acp and running `npm ci && npm run build && npm install -g .` in the checkout."
);
assert_eq!(
adapter_missing.install_instructions_url,
"https://github.com/salman1993/pi-acp"
);
let cli_missing = preset_catalog_entry(preset, |command| {
(command == "pi-acp").then(|| PathBuf::from("/usr/local/bin/pi-acp"))
});
assert_eq!(cli_missing.availability, AcpAvailabilityStatus::CliMissing);
assert_eq!(cli_missing.command.as_deref(), Some("pi-acp"));
assert_eq!(
cli_missing.install_hint,
"Install Pi with npm install -g --ignore-scripts @earendil-works/pi-coding-agent."
);
assert_eq!(
cli_missing.install_instructions_url,
"https://github.com/badlogic/pi-mono/tree/main/packages/coding-agent"
);
let not_installed = preset_catalog_entry(preset, |_| None);
assert_eq!(
not_installed.availability,
AcpAvailabilityStatus::NotInstalled
);
assert_eq!(
not_installed.install_hint,
"Install Pi with npm install -g --ignore-scripts @earendil-works/pi-coding-agent. Install the Pi ACP adapter by cloning https://github.com/salman1993/pi-acp and running `npm ci && npm run build && npm install -g .` in the checkout."
);
}
#[test]
@@ -418,8 +478,12 @@ mod tests {
entry.underlying_cli_path.as_deref(),
Some("/usr/local/bin/amp")
);
assert!(!entry.requires_external_cli);
assert!(entry.requires_external_cli);
assert_eq!(entry.install_hint, "Install the amp-acp npm adapter.");
assert_eq!(
entry.install_instructions_url,
"https://example.com/install"
);
}
#[test]
@@ -427,7 +491,12 @@ mod tests {
let entry = preset_catalog_entry(&ADAPTER_PRESET, |_| None);
assert_eq!(entry.availability, AcpAvailabilityStatus::NotInstalled);
assert!(entry.underlying_cli_path.is_none());
assert!(!entry.requires_external_cli);
assert!(entry.requires_external_cli);
assert_eq!(
entry.install_hint,
"Install the Amp Test CLI. Install the amp-acp npm adapter."
);
assert_eq!(entry.install_instructions_url, "https://example.com/amp");
}
#[test]
@@ -444,17 +513,20 @@ mod tests {
entry.underlying_cli_path.as_deref(),
Some("/usr/local/bin/amp")
);
assert!(entry.install_hint.is_empty());
}
#[test]
fn adapter_presence_is_enough_for_availability() {
fn adapter_without_underlying_cli_reports_cli_missing() {
let entry = preset_catalog_entry(&ADAPTER_PRESET, |command| {
(command == "amp-acp").then(|| PathBuf::from("/usr/local/bin/amp-acp"))
});
assert_eq!(entry.availability, AcpAvailabilityStatus::Available);
assert_eq!(entry.availability, AcpAvailabilityStatus::CliMissing);
assert_eq!(entry.command.as_deref(), Some("amp-acp"));
assert_eq!(entry.binary_path.as_deref(), Some("/usr/local/bin/amp-acp"));
assert!(entry.underlying_cli_path.is_none());
assert_eq!(entry.install_hint, "Install the Amp Test CLI.");
assert_eq!(entry.install_instructions_url, "https://example.com/amp");
}
#[test]