mirror of
https://github.com/aaif-goose/goose.git
synced 2026-07-03 14:10:03 +02:00
feat: use acp-client from builderbot repo as git dependency (#20)
* feat: use acp-client from builderbot repo as git dependency Remove the local acp-client crate (crates/acp-client/) and replace it with a git dependency pointing to block/builderbot on the main branch. The builderbot version is a strict superset of the local copy — same public API (AcpDriver, AgentDriver, MessageWriter, Store, discover_providers) plus remote workspace support, concurrent provider discovery, structured logging, and graceful shutdown. Changes: - Remove 'crates/acp-client' from workspace.members in Cargo.toml - Replace path dependency with git dependency (block/builderbot, main) - Delete src-tauri/crates/acp-client/ directory (5 files, ~1640 lines) - Verified cargo check passes with no code changes needed in goose2 * fix: pin acp-client to rev and clean up config - Pin acp-client git dependency to rev 5af819e instead of branch=main for reproducible builds - Remove redundant single-member [workspace] table from Cargo.toml - Remove deprecated baseUrl from tsconfig.json (TS 5.9+ deprecation) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: update acp-client pin to latest merge commit Pin acp-client to rev 17e6098 (builderbot PR #554 merge commit) to pick up the latest acp crate changes. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Generated
+37
@@ -5,10 +5,14 @@ version = 4
|
||||
[[package]]
|
||||
name = "acp-client"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/block/builderbot?rev=17e609814900afdb2a437523a9756f7ef1b80469#17e609814900afdb2a437523a9756f7ef1b80469"
|
||||
dependencies = [
|
||||
"agent-client-protocol",
|
||||
"anyhow",
|
||||
"async-trait",
|
||||
"blox-cli",
|
||||
"log",
|
||||
"nix",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"tokio",
|
||||
@@ -352,6 +356,18 @@ dependencies = [
|
||||
"piper",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "blox-cli"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/block/builderbot?rev=17e609814900afdb2a437523a9756f7ef1b80469#17e609814900afdb2a437523a9756f7ef1b80469"
|
||||
dependencies = [
|
||||
"log",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"thiserror 2.0.18",
|
||||
"wait-timeout",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "brotli"
|
||||
version = "8.0.2"
|
||||
@@ -2394,6 +2410,18 @@ version = "1.0.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086"
|
||||
|
||||
[[package]]
|
||||
name = "nix"
|
||||
version = "0.29.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "71e2746dc3a24dd78b3cfcb7be93368c6de9963d30f43a6a73998a9cf4b17b46"
|
||||
dependencies = [
|
||||
"bitflags 2.11.0",
|
||||
"cfg-if",
|
||||
"cfg_aliases",
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "nodrop"
|
||||
version = "0.1.14"
|
||||
@@ -5083,6 +5111,15 @@ dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wait-timeout"
|
||||
version = "0.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "09ac3b126d3914f9849036f826e054cbabdc8519970b8998ddaf3b5bd3c65f11"
|
||||
dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "walkdir"
|
||||
version = "2.5.0"
|
||||
|
||||
@@ -1,6 +1,3 @@
|
||||
[workspace]
|
||||
members = [".", "crates/acp-client"]
|
||||
|
||||
[package]
|
||||
name = "goose2"
|
||||
version = "0.1.0"
|
||||
@@ -34,4 +31,4 @@ futures = "0.3"
|
||||
tokio-stream = "0.1"
|
||||
tokio-util = { version = "0.7", features = ["rt"] }
|
||||
async-trait = "0.1"
|
||||
acp-client = { path = "crates/acp-client" }
|
||||
acp-client = { git = "https://github.com/block/builderbot", rev = "17e609814900afdb2a437523a9756f7ef1b80469" }
|
||||
|
||||
@@ -1,14 +0,0 @@
|
||||
[package]
|
||||
name = "acp-client"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
description = "Full-featured client for ACP (Agent Client Protocol) agents like Goose and Claude Code"
|
||||
|
||||
[dependencies]
|
||||
agent-client-protocol = { version = "0.10", features = ["unstable"] }
|
||||
tokio = { version = "1", features = ["process", "io-util", "time", "sync", "rt", "macros"] }
|
||||
tokio-util = { version = "0.7", features = ["compat"] }
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
anyhow = "1"
|
||||
async-trait = "0.1"
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,47 +0,0 @@
|
||||
//! ACP Client - Full-featured client for Agent Client Protocol (ACP)
|
||||
//!
|
||||
//! This library provides comprehensive ACP support including:
|
||||
//! - Agent discovery and binary lookup
|
||||
//! - Full session management with history restoration
|
||||
//! - Streaming events (text, tool calls, updates)
|
||||
//! - Permission handling
|
||||
//! - Cancellation and graceful shutdown
|
||||
//!
|
||||
//! # Architecture
|
||||
//!
|
||||
//! The library provides two main interfaces:
|
||||
//!
|
||||
//! 1. **High-level driver interface** (`AcpDriver`) - For applications that need
|
||||
//! full session orchestration, streaming, and database integration
|
||||
//! 2. **Simple one-shot interface** (`run_acp_prompt`) - For simple prompting
|
||||
//! without session management
|
||||
//!
|
||||
//! # Example (Simple)
|
||||
//!
|
||||
//! ```rust,no_run
|
||||
//! use acp_client::{find_acp_agent, run_acp_prompt};
|
||||
//! use std::path::Path;
|
||||
//!
|
||||
//! #[tokio::main(flavor = "current_thread")]
|
||||
//! async fn main() -> anyhow::Result<()> {
|
||||
//! let agent = find_acp_agent().ok_or_else(|| anyhow::anyhow!("No ACP agent found"))?;
|
||||
//! let response = run_acp_prompt(&agent, Path::new("."), "Hello!").await?;
|
||||
//! println!("Agent response: {}", response);
|
||||
//! Ok(())
|
||||
//! }
|
||||
//! ```
|
||||
|
||||
mod driver;
|
||||
mod simple;
|
||||
mod types;
|
||||
|
||||
// Re-export the main API
|
||||
pub use agent_client_protocol::{McpServer, McpServerHttp, McpServerSse};
|
||||
pub use driver::{
|
||||
strip_code_fences, AcpDriver, AgentDriver, BasicMessageWriter, MessageWriter, Store,
|
||||
};
|
||||
pub use simple::run_acp_prompt;
|
||||
pub use types::{
|
||||
discover_providers, find_acp_agent, find_acp_agent_by_id, find_command, AcpAgent,
|
||||
AcpProviderInfo,
|
||||
};
|
||||
@@ -1,331 +0,0 @@
|
||||
//! Simple one-shot ACP prompting without session management.
|
||||
//!
|
||||
//! This module provides a convenience wrapper around the full-featured
|
||||
//! AcpDriver for simple use cases that don't need session persistence.
|
||||
|
||||
use std::path::Path;
|
||||
use std::sync::Arc;
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use async_trait::async_trait;
|
||||
use tokio_util::sync::CancellationToken;
|
||||
|
||||
use crate::driver::{AgentDriver, BasicMessageWriter, MessageWriter};
|
||||
use crate::types::AcpAgent;
|
||||
|
||||
/// Minimal store implementation for simple prompting (no persistence).
|
||||
struct NoOpStore;
|
||||
|
||||
#[async_trait]
|
||||
impl crate::driver::Store for NoOpStore {
|
||||
fn set_agent_session_id(
|
||||
&self,
|
||||
_session_id: &str,
|
||||
_agent_session_id: &str,
|
||||
) -> Result<(), String> {
|
||||
// No-op: simple prompting doesn't persist sessions
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// Internal driver wrapper for simple prompting.
|
||||
///
|
||||
/// This wraps the binary path and args from AcpAgent into an AgentDriver
|
||||
/// implementation compatible with the driver module's interface.
|
||||
struct SimpleDriverWrapper {
|
||||
binary_path: std::path::PathBuf,
|
||||
acp_args: Vec<String>,
|
||||
agent_label: String,
|
||||
}
|
||||
|
||||
impl SimpleDriverWrapper {
|
||||
fn from_agent(agent: &AcpAgent) -> Self {
|
||||
Self {
|
||||
binary_path: agent.binary_path.clone(),
|
||||
acp_args: agent.acp_args.clone(),
|
||||
agent_label: agent.label.clone(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait(?Send)]
|
||||
impl AgentDriver for SimpleDriverWrapper {
|
||||
async fn run(
|
||||
&self,
|
||||
session_id: &str,
|
||||
prompt: &str,
|
||||
images: &[(String, String)],
|
||||
working_dir: &Path,
|
||||
store: &Arc<dyn crate::driver::Store>,
|
||||
writer: &Arc<dyn MessageWriter>,
|
||||
cancel_token: &CancellationToken,
|
||||
agent_session_id: Option<&str>,
|
||||
) -> Result<(), String> {
|
||||
if !images.is_empty() {
|
||||
eprintln!(
|
||||
"SimpleDriverWrapper: discarding {} image(s) - not supported in simple mode",
|
||||
images.len()
|
||||
);
|
||||
}
|
||||
|
||||
// Use the same implementation as AcpDriver, but with our own binary/args
|
||||
use agent_client_protocol::{
|
||||
Agent, ClientSideConnection, ContentBlock as AcpContentBlock, Implementation,
|
||||
InitializeRequest, LoadSessionRequest, NewSessionRequest, PermissionOptionId,
|
||||
PromptRequest, ProtocolVersion, RequestPermissionOutcome, RequestPermissionRequest,
|
||||
RequestPermissionResponse, SelectedPermissionOutcome, SessionNotification,
|
||||
SessionUpdate, TextContent,
|
||||
};
|
||||
use std::process::Stdio;
|
||||
use tokio::process::Command;
|
||||
use tokio::sync::Mutex;
|
||||
use tokio_util::compat::{TokioAsyncReadCompatExt, TokioAsyncWriteCompatExt};
|
||||
|
||||
let mut child = Command::new(&self.binary_path)
|
||||
.args(&self.acp_args)
|
||||
.current_dir(working_dir)
|
||||
.stdin(Stdio::piped())
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::null())
|
||||
.kill_on_drop(true)
|
||||
.spawn()
|
||||
.map_err(|e| format!("Failed to spawn {}: {e}", self.agent_label))?;
|
||||
|
||||
let stdin = child
|
||||
.stdin
|
||||
.take()
|
||||
.ok_or_else(|| "Failed to get stdin".to_string())?;
|
||||
let stdout = child
|
||||
.stdout
|
||||
.take()
|
||||
.ok_or_else(|| "Failed to get stdout".to_string())?;
|
||||
|
||||
let stdin_compat = stdin.compat_write();
|
||||
let stdout_compat = stdout.compat();
|
||||
|
||||
// Phase-based handler for simple prompting.
|
||||
// With empty db_messages, replay completes immediately.
|
||||
enum SimpleHandlerPhase {
|
||||
Replaying,
|
||||
WaitingForPrompt,
|
||||
Live,
|
||||
}
|
||||
|
||||
struct SimpleHandler {
|
||||
writer: Arc<dyn MessageWriter>,
|
||||
phase: Mutex<SimpleHandlerPhase>,
|
||||
}
|
||||
|
||||
impl SimpleHandler {
|
||||
async fn transition_to_waiting(&self) {
|
||||
let mut phase = self.phase.lock().await;
|
||||
*phase = SimpleHandlerPhase::WaitingForPrompt;
|
||||
}
|
||||
|
||||
async fn transition_to_live(&self) {
|
||||
let mut phase = self.phase.lock().await;
|
||||
*phase = SimpleHandlerPhase::Live;
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait(?Send)]
|
||||
impl agent_client_protocol::Client for SimpleHandler {
|
||||
async fn request_permission(
|
||||
&self,
|
||||
args: RequestPermissionRequest,
|
||||
) -> agent_client_protocol::Result<RequestPermissionResponse> {
|
||||
let option_id = args
|
||||
.options
|
||||
.first()
|
||||
.map(|opt| opt.option_id.clone())
|
||||
.unwrap_or_else(|| PermissionOptionId::new("approve"));
|
||||
|
||||
Ok(RequestPermissionResponse::new(
|
||||
RequestPermissionOutcome::Selected(SelectedPermissionOutcome::new(option_id)),
|
||||
))
|
||||
}
|
||||
|
||||
async fn session_notification(
|
||||
&self,
|
||||
notification: SessionNotification,
|
||||
) -> agent_client_protocol::Result<()> {
|
||||
let phase = self.phase.lock().await;
|
||||
|
||||
match &*phase {
|
||||
SimpleHandlerPhase::Replaying | SimpleHandlerPhase::WaitingForPrompt => {
|
||||
return Ok(());
|
||||
}
|
||||
SimpleHandlerPhase::Live => {
|
||||
// Drop the lock before calling writer
|
||||
drop(phase);
|
||||
match ¬ification.update {
|
||||
SessionUpdate::AgentMessageChunk(chunk) => {
|
||||
if let AcpContentBlock::Text(text) = &chunk.content {
|
||||
self.writer.append_text(&text.text).await;
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
// Ignore other updates for simple use
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
let is_resuming = agent_session_id.is_some();
|
||||
let handler = Arc::new(SimpleHandler {
|
||||
writer: Arc::clone(writer),
|
||||
phase: Mutex::new(if is_resuming {
|
||||
SimpleHandlerPhase::Replaying
|
||||
} else {
|
||||
SimpleHandlerPhase::Live
|
||||
}),
|
||||
});
|
||||
let handler_for_conn = Arc::clone(&handler);
|
||||
|
||||
let (connection, io_future) =
|
||||
ClientSideConnection::new(handler_for_conn, stdin_compat, stdout_compat, |fut| {
|
||||
tokio::task::spawn_local(fut);
|
||||
});
|
||||
|
||||
tokio::task::spawn_local(async move {
|
||||
if let Err(e) = io_future.await {
|
||||
eprintln!("ACP IO error: {e:?}");
|
||||
}
|
||||
});
|
||||
|
||||
// Protocol execution
|
||||
let protocol_result = tokio::select! {
|
||||
_ = cancel_token.cancelled() => {
|
||||
writer.finalize().await;
|
||||
return Ok(());
|
||||
}
|
||||
result = async {
|
||||
// Initialize
|
||||
let client_info = Implementation::new("acp-client", env!("CARGO_PKG_VERSION"));
|
||||
let init_request = InitializeRequest::new(ProtocolVersion::LATEST)
|
||||
.client_info(client_info);
|
||||
|
||||
let init_response = connection
|
||||
.initialize(init_request)
|
||||
.await
|
||||
.map_err(|e| format!("ACP init failed: {e:?}"))?;
|
||||
|
||||
// Create or resume session
|
||||
let agent_session_id = match agent_session_id {
|
||||
Some(existing_id) => {
|
||||
if !init_response.agent_capabilities.load_session {
|
||||
return Err("Agent does not support load_session".to_string());
|
||||
}
|
||||
connection
|
||||
.load_session(LoadSessionRequest::new(
|
||||
existing_id.to_string(),
|
||||
working_dir.to_path_buf(),
|
||||
))
|
||||
.await
|
||||
.map_err(|e| format!("Failed to load session: {e:?}"))?;
|
||||
existing_id.to_string()
|
||||
}
|
||||
None => {
|
||||
let session_response = connection
|
||||
.new_session(NewSessionRequest::new(working_dir.to_path_buf()))
|
||||
.await
|
||||
.map_err(|e| format!("Failed to create session: {e:?}"))?;
|
||||
let new_id = session_response.session_id.to_string();
|
||||
store
|
||||
.set_agent_session_id(session_id, &new_id)
|
||||
.map_err(|e| format!("Failed to save session ID: {e}"))?;
|
||||
new_id
|
||||
}
|
||||
};
|
||||
|
||||
// Transition: Replaying -> WaitingForPrompt -> Live
|
||||
if is_resuming {
|
||||
handler.transition_to_waiting().await;
|
||||
}
|
||||
|
||||
// Build and send prompt
|
||||
let prompt_request = PromptRequest::new(
|
||||
agent_session_id,
|
||||
vec![AcpContentBlock::Text(TextContent::new(prompt))],
|
||||
);
|
||||
|
||||
if is_resuming {
|
||||
handler.transition_to_live().await;
|
||||
}
|
||||
|
||||
connection
|
||||
.prompt(prompt_request)
|
||||
.await
|
||||
.map_err(|e| format!("Prompt failed: {e:?}"))?;
|
||||
|
||||
Ok::<_, String>(())
|
||||
} => result,
|
||||
};
|
||||
|
||||
writer.finalize().await;
|
||||
let _ = child.kill().await;
|
||||
|
||||
protocol_result
|
||||
}
|
||||
}
|
||||
|
||||
/// Run a one-shot prompt through ACP and return the response.
|
||||
///
|
||||
/// This is a convenience wrapper around the full-featured AcpDriver that
|
||||
/// handles session setup/teardown automatically. Use this for simple
|
||||
/// one-shot queries without session persistence.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `agent` - The ACP agent to use
|
||||
/// * `working_dir` - The working directory for the agent
|
||||
/// * `prompt` - The prompt to send to the agent
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// The agent's text response
|
||||
pub async fn run_acp_prompt(agent: &AcpAgent, working_dir: &Path, prompt: &str) -> Result<String> {
|
||||
let working_dir = working_dir.to_path_buf();
|
||||
let prompt = prompt.to_string();
|
||||
let driver = SimpleDriverWrapper::from_agent(agent);
|
||||
|
||||
// Run the ACP session in a blocking task with its own runtime
|
||||
// This is needed because ACP uses !Send futures (LocalSet)
|
||||
tokio::task::spawn_blocking(move || {
|
||||
// Create a new runtime for this thread
|
||||
let rt = tokio::runtime::Builder::new_current_thread()
|
||||
.enable_all()
|
||||
.build()
|
||||
.context("Failed to create runtime")?;
|
||||
|
||||
// Run the ACP session on a LocalSet
|
||||
let local = tokio::task::LocalSet::new();
|
||||
local.block_on(&rt, async move {
|
||||
let writer_impl = Arc::new(BasicMessageWriter::new());
|
||||
let writer = writer_impl.clone() as Arc<dyn MessageWriter>;
|
||||
let store = Arc::new(NoOpStore) as Arc<dyn crate::driver::Store>;
|
||||
let cancel_token = CancellationToken::new();
|
||||
|
||||
driver
|
||||
.run(
|
||||
"simple-session",
|
||||
&prompt,
|
||||
&[],
|
||||
&working_dir,
|
||||
&store,
|
||||
&writer,
|
||||
&cancel_token,
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.map_err(|e| anyhow::anyhow!("ACP driver error: {e}"))?;
|
||||
|
||||
Ok(writer_impl.get_text().await)
|
||||
})
|
||||
})
|
||||
.await
|
||||
.context("Task join error")?
|
||||
}
|
||||
@@ -1,202 +0,0 @@
|
||||
//! Agent types, discovery, and metadata.
|
||||
|
||||
use serde::Serialize;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
// =============================================================================
|
||||
// Known agents — the registry of ACP-compatible providers
|
||||
// =============================================================================
|
||||
|
||||
/// Static metadata for each known ACP agent.
|
||||
pub(crate) struct KnownAgent {
|
||||
/// Unique identifier used in preferences and IPC.
|
||||
pub id: &'static str,
|
||||
/// Human-readable label for the UI.
|
||||
pub label: &'static str,
|
||||
/// CLI command name to search for.
|
||||
pub command: &'static str,
|
||||
/// Arguments to pass when spawning in ACP mode.
|
||||
pub acp_args: &'static [&'static str],
|
||||
}
|
||||
|
||||
/// All agents we know how to talk to, in display order.
|
||||
pub(crate) const KNOWN_AGENTS: &[KnownAgent] = &[
|
||||
KnownAgent {
|
||||
id: "goose",
|
||||
label: "Goose",
|
||||
command: "goose",
|
||||
acp_args: &[
|
||||
"acp",
|
||||
"--with-builtin",
|
||||
"developer",
|
||||
"--with-builtin",
|
||||
"extensionmanager",
|
||||
],
|
||||
},
|
||||
KnownAgent {
|
||||
id: "claude",
|
||||
label: "Claude Code",
|
||||
command: "claude-agent-acp",
|
||||
acp_args: &[],
|
||||
},
|
||||
KnownAgent {
|
||||
id: "codex",
|
||||
label: "Codex",
|
||||
command: "codex-acp",
|
||||
acp_args: &[],
|
||||
},
|
||||
KnownAgent {
|
||||
id: "pi",
|
||||
label: "Pi",
|
||||
command: "pi-acp",
|
||||
acp_args: &[],
|
||||
},
|
||||
KnownAgent {
|
||||
id: "amp",
|
||||
label: "Amp",
|
||||
command: "amp-acp",
|
||||
acp_args: &[],
|
||||
},
|
||||
];
|
||||
|
||||
fn find_known_agent_binary(agent: &KnownAgent) -> Option<PathBuf> {
|
||||
find_command(agent.command)
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Provider discovery — public API
|
||||
// =============================================================================
|
||||
|
||||
/// Information about a discovered ACP provider.
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct AcpProviderInfo {
|
||||
pub id: String,
|
||||
pub label: String,
|
||||
}
|
||||
|
||||
/// Scan the system for all known ACP agents that are installed.
|
||||
///
|
||||
/// Returns only agents whose CLI binary can be found. The order matches
|
||||
/// `KNOWN_AGENTS` (display order).
|
||||
pub fn discover_providers() -> Vec<AcpProviderInfo> {
|
||||
KNOWN_AGENTS
|
||||
.iter()
|
||||
.filter(|agent| find_known_agent_binary(agent).is_some())
|
||||
.map(|agent| AcpProviderInfo {
|
||||
id: agent.id.to_string(),
|
||||
label: agent.label.to_string(),
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Find a specific ACP agent by provider ID (e.g., "goose", "claude").
|
||||
pub fn find_acp_agent_by_id(provider_id: &str) -> Option<AcpAgent> {
|
||||
KNOWN_AGENTS
|
||||
.iter()
|
||||
.find(|a| a.id == provider_id)
|
||||
.and_then(|agent| {
|
||||
find_known_agent_binary(agent).map(|path| AcpAgent {
|
||||
binary_path: path,
|
||||
acp_args: agent.acp_args.iter().map(|s| s.to_string()).collect(),
|
||||
label: agent.label.to_string(),
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
/// Find the first available ACP agent.
|
||||
///
|
||||
/// Tries each known agent in order and returns the first one found.
|
||||
pub fn find_acp_agent() -> Option<AcpAgent> {
|
||||
for agent in KNOWN_AGENTS {
|
||||
if let Some(path) = find_known_agent_binary(agent) {
|
||||
return Some(AcpAgent {
|
||||
binary_path: path,
|
||||
acp_args: agent.acp_args.iter().map(|s| s.to_string()).collect(),
|
||||
label: agent.label.to_string(),
|
||||
});
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// ACP Agent
|
||||
// =============================================================================
|
||||
|
||||
/// An ACP-compatible agent with its binary path and configuration.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct AcpAgent {
|
||||
pub binary_path: PathBuf,
|
||||
pub acp_args: Vec<String>,
|
||||
pub label: String,
|
||||
}
|
||||
|
||||
impl AcpAgent {
|
||||
/// Get the path to the agent executable.
|
||||
pub fn path(&self) -> &Path {
|
||||
&self.binary_path
|
||||
}
|
||||
|
||||
/// Get the agent's label.
|
||||
pub fn name(&self) -> &str {
|
||||
&self.label
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Binary discovery
|
||||
// =============================================================================
|
||||
|
||||
/// Common paths where CLIs might be installed (GUI apps don't inherit shell PATH).
|
||||
const COMMON_PATHS: &[&str] = &[
|
||||
"/opt/homebrew/bin",
|
||||
"/usr/local/bin",
|
||||
"/usr/bin",
|
||||
"/home/linuxbrew/.linuxbrew/bin",
|
||||
];
|
||||
|
||||
/// Find a CLI binary by command name.
|
||||
///
|
||||
/// Searches in order:
|
||||
/// 1. Login shell `which` (picks up user's PATH from `.zshrc` / `.bashrc`)
|
||||
/// 2. Common install locations
|
||||
pub fn find_command(cmd: &str) -> Option<PathBuf> {
|
||||
// Strategy 1: Login shell `which`
|
||||
if let Some(path) = find_via_login_shell(cmd) {
|
||||
if path.exists() {
|
||||
return Some(path);
|
||||
}
|
||||
}
|
||||
|
||||
// Strategy 2: Common paths
|
||||
for dir in COMMON_PATHS {
|
||||
let path = PathBuf::from(dir).join(cmd);
|
||||
if path.exists() {
|
||||
return Some(path);
|
||||
}
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
fn find_via_login_shell(cmd: &str) -> Option<PathBuf> {
|
||||
let which_cmd = format!("which {cmd}");
|
||||
|
||||
for shell in &["/bin/zsh", "/bin/bash"] {
|
||||
if let Ok(output) = std::process::Command::new(shell)
|
||||
.args(["-l", "-c", &which_cmd])
|
||||
.output()
|
||||
{
|
||||
if output.status.success() {
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
if let Some(path_str) = stdout.lines().rfind(|l| !l.is_empty()) {
|
||||
let path_str = path_str.trim();
|
||||
if !path_str.is_empty() && path_str.starts_with('/') {
|
||||
return Some(PathBuf::from(path_str));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
@@ -5,7 +5,6 @@
|
||||
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"skipLibCheck": true,
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user