Skip to content
Open
22 changes: 5 additions & 17 deletions desktop/src-tauri/src/commands/agent_discovery.rs
Original file line number Diff line number Diff line change
Expand Up @@ -154,26 +154,13 @@ fn install_acp_runtime_blocking(runtime_id: &str) -> Result<InstallRuntimeResult

let mut steps = Vec::new();

// Phase 1: Install CLI if missing and commands are available.
// Phase 1: Install CLI if missing (or repair outdated Claude Code).
// Today every entry in `cli_install_commands` is a curl-pipe; npm-backed
// adapter installs live in Phase 2 below where they are rewritten to a
// Buzz-private prefix before execution.
if let Some(cli) = runtime.underlying_cli {
if crate::managed_agents::resolve_command(cli).is_none() {
for cmd in runtime.cli_install_commands_for_os() {
let result = run_install_command_with_retry("cli", cmd);
let success = result.success;
steps.push(result);
if !success {
return Ok(InstallRuntimeResult {
success: false,
steps,
restarted_count: 0,
failed_restart_count: 0,
});
}
}
}
match cli_install::install_or_repair_underlying_cli(runtime_id, runtime) {
Ok(cli_steps) => steps.extend(cli_steps),
Err(failed) => return Ok(failed),
}

// Phase 2: Install adapter if missing (or outdated) and commands are available.
Expand Down Expand Up @@ -1042,6 +1029,7 @@ fn floor_char_boundary(s: &str, mut index: usize) -> usize {
}

// ── managed Node/npm runtime ──────────────────────────────────────────────────
mod cli_install;
mod managed_node;
use managed_node::{
ensure_managed_node_runtime_blocking, managed_node_runtime_supported, managed_npm_command,
Expand Down
63 changes: 63 additions & 0 deletions desktop/src-tauri/src/commands/agent_discovery/cli_install.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
//! Phase-1 underlying CLI install / repair for ACP runtime setup.

use crate::managed_agents::{
readiness::cli_probe, InstallRuntimeResult, InstallStepResult, KnownAcpRuntime,
};

use super::run_install_command_with_retry;

/// Install a missing underlying CLI, or repair an outdated Claude Code build.
///
/// Returns `Err(result)` when a step fails (caller should return that result
/// immediately). Returns `Ok(steps)` with zero or more completed install steps.
pub(super) fn install_or_repair_underlying_cli(
runtime_id: &str,
runtime: &KnownAcpRuntime,
) -> Result<Vec<InstallStepResult>, InstallRuntimeResult> {
let Some(cli) = runtime.underlying_cli else {
return Ok(Vec::new());
};

let mut steps = Vec::new();
match crate::managed_agents::resolve_command(cli) {
None => {
for cmd in runtime.cli_install_commands_for_os() {
let result = run_install_command_with_retry("cli", cmd);
let success = result.success;
steps.push(result);
if !success {
return Err(InstallRuntimeResult {
success: false,
steps,
restarted_count: 0,
failed_restart_count: 0,
});
}
}
}
// Claude Code may already be present but too old to expose
// `claude auth status` (older builds treat those args as a prompt).
// Repair with `claude update` before adapter install so onboarding
// auth probes can succeed.
Some(cli_path) if runtime_id == "claude" => {
let augmented = cli_probe::augmented_path();
if cli_probe::claude_auth_status_needs_upgrade(&cli_path, augmented.as_deref()) {
let result = run_install_command_with_retry("cli", "claude update");
let success = result.success;
steps.push(result);
if !success {
return Err(InstallRuntimeResult {
success: false,
steps,
restarted_count: 0,
failed_restart_count: 0,
});
}
crate::managed_agents::clear_resolve_cache();
}
}
Some(_) => {}
}

Ok(steps)
}
58 changes: 41 additions & 17 deletions desktop/src-tauri/src/managed_agents/discovery.rs
Original file line number Diff line number Diff line change
Expand Up @@ -126,7 +126,9 @@ const KNOWN_ACP_RUNTIMES: &[KnownAcpRuntime] = &[
max_tokens_env_var: None,
context_limit_env_var: None,
required_normalized_fields: &[],
login_hint: Some("Run the Claude CLI to complete authentication."),
login_hint: Some(
"Sign in with Claude Code, or run `claude update` if status checks fail.",
),
auth_probe_args: Some(&["claude", "auth", "status"]),
},
KnownAcpRuntime {
Expand Down Expand Up @@ -905,6 +907,10 @@ pub(crate) fn is_npm_global_install(cmd: &str) -> bool {
|| t.starts_with("npm uninstall -g ")
}

fn unknown_auth_status(diagnostic: Option<String>) -> AuthStatus {
AuthStatus::Unknown { diagnostic }
}

/// Run a CLI auth probe with a 10-second process-level timeout.
///
/// Spawns the probe CLI as a child process. Stdout and stderr are drained on
Expand All @@ -915,6 +921,9 @@ fn probe_auth_status(binary_path: &Path, probe_args: &[&str]) -> AuthStatus {
use crate::managed_agents::readiness::cli_probe;

let augmented_path = cli_probe::augmented_path();
let claude_probe = cli_probe::is_claude_auth_status_probe(probe_args);
let timeout_diagnostic =
claude_probe.then(|| cli_probe::CLAUDE_AUTH_PROBE_UPDATE_HINT.to_string());

let mut command = std::process::Command::new(binary_path);
command.args(&probe_args[1..]);
Expand All @@ -929,7 +938,7 @@ fn probe_auth_status(binary_path: &Path, probe_args: &[&str]) -> AuthStatus {

let mut child = match command.spawn() {
Ok(c) => c,
Err(_) => return AuthStatus::Unknown,
Err(_) => return unknown_auth_status(timeout_diagnostic),
};

// Drain stdout/stderr on background threads to prevent pipe-buffer deadlock.
Expand All @@ -941,6 +950,7 @@ fn probe_auth_status(binary_path: &Path, probe_args: &[&str]) -> AuthStatus {
if let Some(mut pipe) = stdout_pipe {
let _ = pipe.read_to_end(&mut buf);
}
buf
});
let stderr_thread = std::thread::spawn(move || {
let mut buf = Vec::new();
Expand Down Expand Up @@ -972,35 +982,43 @@ fn probe_auth_status(binary_path: &Path, probe_args: &[&str]) -> AuthStatus {
let _ = wait_thread.join();
let _ = stdout_thread.join();
let _ = stderr_thread.join();
return AuthStatus::Unknown;
return unknown_auth_status(timeout_diagnostic);
}
match rx.recv_timeout(Duration::from_millis(100).min(remaining)) {
Ok(Ok(status)) => break status,
Ok(Err(_)) => {
let _ = wait_thread.join();
let _ = stdout_thread.join();
let _ = stderr_thread.join();
return AuthStatus::Unknown;
return unknown_auth_status(timeout_diagnostic);
}
Err(std::sync::mpsc::RecvTimeoutError::Timeout) => continue,
Err(std::sync::mpsc::RecvTimeoutError::Disconnected) => {
let _ = stdout_thread.join();
let _ = stderr_thread.join();
return AuthStatus::Unknown;
return unknown_auth_status(timeout_diagnostic);
}
}
};

let _ = wait_thread.join();
let _ = stdout_thread.join();
let stdout_bytes = stdout_thread.join().unwrap_or_default();
let stderr_bytes = stderr_thread.join().unwrap_or_default();

match cli_probe::classify_probe_output(&stderr_bytes, exit_status.success()) {
match cli_probe::classify_auth_probe_output(
probe_args,
&stdout_bytes,
&stderr_bytes,
exit_status.success(),
) {
cli_probe::ProbeOutcome::LoggedIn => AuthStatus::LoggedIn,
cli_probe::ProbeOutcome::LoggedOut => AuthStatus::LoggedOut,
cli_probe::ProbeOutcome::ConfigInvalid { stderr_excerpt } => AuthStatus::ConfigInvalid {
diagnostic: stderr_excerpt,
},
cli_probe::ProbeOutcome::Unsupported { diagnostic } => {
unknown_auth_status(Some(diagnostic))
}
}
}

Expand Down Expand Up @@ -1284,7 +1302,7 @@ fn discover_acp_runtime_phase1(runtime: &'static KnownAcpRuntime) -> PartialEntr
underlying_cli_path,
node_required,
// Filled in by the auth-probe phase in full catalog discovery.
auth_status: AuthStatus::Unknown,
auth_status: AuthStatus::Unknown { diagnostic: None },
login_hint: None,
},
}
Expand Down Expand Up @@ -1331,27 +1349,33 @@ pub fn discover_acp_runtimes() -> Vec<AcpRuntimeCatalogEntry> {

// Collect probe results and patch entries.
for (idx, handle) in probe_handles {
let status = handle.join().unwrap_or(AuthStatus::Unknown);
let status = handle
.join()
.unwrap_or(AuthStatus::Unknown { diagnostic: None });
let partial = &mut partials[idx];
partial.entry.login_hint =
if matches!(status, AuthStatus::LoggedIn | AuthStatus::NotApplicable) {
None
} else {
partial.runtime.login_hint.map(str::to_string)
};
partial.entry.login_hint = match &status {
AuthStatus::LoggedIn | AuthStatus::NotApplicable => None,
AuthStatus::Unknown {
diagnostic: Some(diagnostic),
} => Some(diagnostic.clone()),
_ => partial.runtime.login_hint.map(str::to_string),
};
partial.entry.auth_status = status;
}

// Fill NotApplicable / Unknown for non-probed entries.
for partial in &mut partials {
if partial.entry.auth_status == AuthStatus::Unknown {
if matches!(
partial.entry.auth_status,
AuthStatus::Unknown { diagnostic: None }
) {
partial.entry.auth_status = if partial.entry.availability
== AcpAvailabilityStatus::Available
&& partial.runtime.auth_probe_args.is_none()
{
AuthStatus::NotApplicable
} else {
AuthStatus::Unknown
AuthStatus::Unknown { diagnostic: None }
};
}
}
Expand Down
9 changes: 9 additions & 0 deletions desktop/src-tauri/src/managed_agents/readiness/cli_login.rs
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,15 @@ pub(super) fn requirements(
diagnostic: stderr_excerpt,
}]
}
// Outdated Claude Code (etc.): surface the update hint via the
// same CliLogin nudge so Doctor / setup can point at repair.
cli_probe::ProbeOutcome::Unsupported { diagnostic } => {
vec![missing_requirement(
probe_args,
&diagnostic,
AcpAvailabilityStatus::Available,
)]
}
}
}
other => vec![missing_requirement(probe_args, setup_copy, other)],
Expand Down
Loading