Skip to main content

claude_code_rust/acp/
connection.rs

1// Claude Code Rust - A native Rust terminal interface for Claude Code
2// Copyright (C) 2025  Simon Peter Rothgang
3//
4// This program is free software: you can redistribute it and/or modify
5// it under the terms of the GNU Affero General Public License as
6// published by the Free Software Foundation, either version 3 of the
7// License, or (at your option) any later version.
8//
9// This program is distributed in the hope that it will be useful,
10// but WITHOUT ANY WARRANTY; without even the implied warranty of
11// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
12// GNU Affero General Public License for more details.
13//
14// You should have received a copy of the GNU Affero General Public License
15// along with this program.  If not, see <https://www.gnu.org/licenses/>.
16
17use 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    /// Launch a specific adapter binary path (CLI/env override).
32    Direct(PathBuf),
33    /// Launch a globally installed adapter binary discovered via PATH.
34    Global(PathBuf),
35    /// Launch via `npx @zed-industries/claude-code-acp` as the final fallback.
36    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
62/// Resolve all adapter launchers in priority order:
63/// 1) `--adapter-bin`
64/// 2) `CLAUDE_CODE_ACP_BIN`
65/// 3) globally installed adapter binaries from PATH
66/// 4) `npx @zed-industries/claude-code-acp`
67pub 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/// Spawn the ACP adapter as a child process and establish the connection.
137///
138/// `launcher` should be resolved once at startup and reused.
139///
140/// Must be called from within a `tokio::task::LocalSet` context because
141/// ACP futures are `!Send`.
142#[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    // Spawn the I/O handler on the LocalSet
185    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}