claude_code_rust/acp/
connection.rs1use agent_client_protocol::{self as acp};
18use anyhow::Context as _;
19use std::collections::HashSet;
20use std::path::{Path, PathBuf};
21use std::process::Stdio;
22use tokio::process::{Child, Command};
23use tokio_util::compat::{TokioAsyncReadCompatExt, TokioAsyncWriteCompatExt};
24
25pub const ADAPTER_NPM_PACKAGE: &str = "@zed-industries/claude-code-acp";
26const ADAPTER_BIN_ENV: &str = "CLAUDE_CODE_ACP_BIN";
27const GLOBAL_ADAPTER_BIN_CANDIDATES: [&str; 2] = ["claude-code-acp", "zed-claude-code-acp"];
28
29#[derive(Clone, Debug, PartialEq, Eq)]
30pub enum AdapterLauncher {
31 Direct(PathBuf),
33 Global(PathBuf),
35 Npx(PathBuf),
37}
38
39impl AdapterLauncher {
40 #[must_use]
41 pub fn command_path(&self) -> &Path {
42 match self {
43 Self::Direct(path) | Self::Global(path) | Self::Npx(path) => path,
44 }
45 }
46
47 #[must_use]
48 pub fn label(&self) -> &'static str {
49 match self {
50 Self::Direct(_) => "direct",
51 Self::Global(_) => "global",
52 Self::Npx(_) => "npx",
53 }
54 }
55
56 #[must_use]
57 pub fn describe(&self) -> String {
58 format!("{} ({})", self.label(), self.command_path().display())
59 }
60}
61
62pub fn resolve_adapter_launchers(
68 cli_adapter_bin: Option<&Path>,
69) -> anyhow::Result<Vec<AdapterLauncher>> {
70 let env_adapter_bin =
71 std::env::var_os(ADAPTER_BIN_ENV).filter(|v| !v.is_empty()).map(PathBuf::from);
72 let global_bins = GLOBAL_ADAPTER_BIN_CANDIDATES
73 .iter()
74 .filter_map(|bin| which::which(bin).ok())
75 .collect::<Vec<_>>();
76 let npx_path = which::which("npx").ok();
77
78 let launchers = build_adapter_launchers(
79 cli_adapter_bin.map(Path::to_path_buf),
80 env_adapter_bin,
81 global_bins,
82 npx_path,
83 );
84
85 if launchers.is_empty() {
86 anyhow::bail!(
87 "No ACP adapter launcher found. Set --adapter-bin, set {ADAPTER_BIN_ENV}, install a global \
88 adapter binary, or install Node.js/npx for {ADAPTER_NPM_PACKAGE}."
89 );
90 }
91
92 Ok(launchers)
93}
94
95fn push_unique_launcher(
96 launchers: &mut Vec<AdapterLauncher>,
97 seen_paths: &mut HashSet<PathBuf>,
98 launcher: AdapterLauncher,
99) {
100 let path = launcher.command_path().to_path_buf();
101 if seen_paths.insert(path) {
102 launchers.push(launcher);
103 }
104}
105
106fn build_adapter_launchers(
107 cli_adapter_bin: Option<PathBuf>,
108 env_adapter_bin: Option<PathBuf>,
109 global_bins: Vec<PathBuf>,
110 npx_path: Option<PathBuf>,
111) -> Vec<AdapterLauncher> {
112 let mut launchers = Vec::new();
113 let mut seen_paths = HashSet::new();
114
115 if let Some(path) = cli_adapter_bin {
116 push_unique_launcher(&mut launchers, &mut seen_paths, AdapterLauncher::Direct(path));
117 }
118 if let Some(path) = env_adapter_bin {
119 push_unique_launcher(&mut launchers, &mut seen_paths, AdapterLauncher::Direct(path));
120 }
121 for path in global_bins {
122 push_unique_launcher(&mut launchers, &mut seen_paths, AdapterLauncher::Global(path));
123 }
124 if let Some(path) = npx_path {
125 push_unique_launcher(&mut launchers, &mut seen_paths, AdapterLauncher::Npx(path));
126 }
127
128 launchers
129}
130
131pub struct AdapterProcess {
132 pub child: Child,
133 pub connection: acp::ClientSideConnection,
134}
135
136#[allow(clippy::unused_async)]
143pub async fn spawn_adapter(
144 client: impl acp::Client + 'static,
145 launcher: &AdapterLauncher,
146 cwd: &Path,
147) -> anyhow::Result<AdapterProcess> {
148 let mut command = match launcher {
149 AdapterLauncher::Npx(npx_path) => {
150 let mut command_builder = Command::new(npx_path);
151 command_builder
152 .arg(ADAPTER_NPM_PACKAGE)
153 .env("NO_UPDATE_NOTIFIER", "1")
154 .env("NPM_CONFIG_UPDATE_NOTIFIER", "false")
155 .env("NPM_CONFIG_FUND", "false")
156 .env("NPM_CONFIG_AUDIT", "false");
157 command_builder
158 }
159 AdapterLauncher::Direct(path) | AdapterLauncher::Global(path) => Command::new(path),
160 };
161
162 let mut child = command
163 .current_dir(cwd)
164 .stdin(Stdio::piped())
165 .stdout(Stdio::piped())
166 .stderr(Stdio::piped())
167 .kill_on_drop(true)
168 .spawn()
169 .with_context(|| format!("failed to spawn adapter via {}", launcher.describe()))?;
170
171 let child_stdin =
172 child.stdin.take().ok_or_else(|| anyhow::anyhow!("Failed to capture adapter stdin"))?;
173 let child_stdout =
174 child.stdout.take().ok_or_else(|| anyhow::anyhow!("Failed to capture adapter stdout"))?;
175
176 let stdin_compat = child_stdin.compat_write();
177 let stdout_compat = child_stdout.compat();
178
179 let (connection, io_future) =
180 acp::ClientSideConnection::new(client, stdin_compat, stdout_compat, |fut| {
181 tokio::task::spawn_local(fut);
182 });
183
184 tokio::task::spawn_local(async move {
186 if let Err(e) = io_future.await {
187 tracing::error!("ACP I/O error: {e}");
188 }
189 });
190
191 Ok(AdapterProcess { child, connection })
192}
193
194#[cfg(test)]
195mod tests {
196 use super::{AdapterLauncher, build_adapter_launchers};
197 use std::path::PathBuf;
198
199 #[test]
200 fn launcher_order_prefers_cli_then_env_then_global_then_npx() {
201 let launchers = build_adapter_launchers(
202 Some(PathBuf::from("C:/custom/adapter")),
203 Some(PathBuf::from("C:/env/adapter")),
204 vec![PathBuf::from("C:/global/adapter")],
205 Some(PathBuf::from("C:/node/npx")),
206 );
207
208 assert_eq!(
209 launchers,
210 vec![
211 AdapterLauncher::Direct(PathBuf::from("C:/custom/adapter")),
212 AdapterLauncher::Direct(PathBuf::from("C:/env/adapter")),
213 AdapterLauncher::Global(PathBuf::from("C:/global/adapter")),
214 AdapterLauncher::Npx(PathBuf::from("C:/node/npx"))
215 ]
216 );
217 }
218
219 #[test]
220 fn duplicate_paths_are_removed() {
221 let launchers = build_adapter_launchers(
222 Some(PathBuf::from("C:/same/adapter")),
223 Some(PathBuf::from("C:/same/adapter")),
224 vec![PathBuf::from("C:/same/adapter"), PathBuf::from("C:/global/adapter")],
225 Some(PathBuf::from("C:/node/npx")),
226 );
227
228 assert_eq!(
229 launchers,
230 vec![
231 AdapterLauncher::Direct(PathBuf::from("C:/same/adapter")),
232 AdapterLauncher::Global(PathBuf::from("C:/global/adapter")),
233 AdapterLauncher::Npx(PathBuf::from("C:/node/npx"))
234 ]
235 );
236 }
237}