1mod acp;
2mod claude;
3mod custom;
4mod gemini;
5mod settings;
6
7#[cfg(any(test, feature = "test-support"))]
8pub mod e2e_tests;
9
10use anyhow::Context as _;
11pub use claude::*;
12pub use custom::*;
13use fs::Fs;
14use fs::RemoveOptions;
15use fs::RenameOptions;
16use futures::StreamExt as _;
17pub use gemini::*;
18use gpui::AppContext;
19use node_runtime::NodeRuntime;
20pub use settings::*;
21
22use acp_thread::AgentConnection;
23use acp_thread::LoadError;
24use anyhow::Result;
25use anyhow::anyhow;
26use collections::HashMap;
27use gpui::{App, AsyncApp, Entity, SharedString, Task};
28use project::Project;
29use schemars::JsonSchema;
30use semver::Version;
31use serde::{Deserialize, Serialize};
32use std::str::FromStr as _;
33use std::{
34 any::Any,
35 path::{Path, PathBuf},
36 rc::Rc,
37 sync::Arc,
38};
39use util::ResultExt as _;
40
41pub fn init(cx: &mut App) {
42 settings::init(cx);
43}
44
45pub struct AgentServerDelegate {
46 project: Entity<Project>,
47 status_tx: Option<watch::Sender<SharedString>>,
48}
49
50impl AgentServerDelegate {
51 pub fn new(project: Entity<Project>, status_tx: Option<watch::Sender<SharedString>>) -> Self {
52 Self { project, status_tx }
53 }
54
55 pub fn project(&self) -> &Entity<Project> {
56 &self.project
57 }
58
59 fn get_or_npm_install_builtin_agent(
60 self,
61 binary_name: SharedString,
62 package_name: SharedString,
63 entrypoint_path: PathBuf,
64 ignore_system_version: bool,
65 minimum_version: Option<Version>,
66 cx: &mut App,
67 ) -> Task<Result<AgentServerCommand>> {
68 let project = self.project;
69 let fs = project.read(cx).fs().clone();
70 let Some(node_runtime) = project.read(cx).node_runtime().cloned() else {
71 return Task::ready(Err(anyhow!(
72 "External agents are not yet available in remote projects."
73 )));
74 };
75 let status_tx = self.status_tx;
76
77 cx.spawn(async move |cx| {
78 if !ignore_system_version {
79 if let Some(bin) = find_bin_in_path(binary_name.clone(), &project, cx).await {
80 return Ok(AgentServerCommand {
81 path: bin,
82 args: Vec::new(),
83 env: Default::default(),
84 });
85 }
86 }
87
88 cx.spawn(async move |cx| {
89 let node_path = node_runtime.binary_path().await?;
90 let dir = paths::data_dir()
91 .join("external_agents")
92 .join(binary_name.as_str());
93 fs.create_dir(&dir).await?;
94
95 let mut stream = fs.read_dir(&dir).await?;
96 let mut versions = Vec::new();
97 let mut to_delete = Vec::new();
98 while let Some(entry) = stream.next().await {
99 let Ok(entry) = entry else { continue };
100 let Some(file_name) = entry.file_name() else {
101 continue;
102 };
103
104 if let Some(version) = file_name
105 .to_str()
106 .and_then(|name| semver::Version::from_str(&name).ok())
107 {
108 versions.push((file_name.to_owned(), version));
109 } else {
110 to_delete.push(file_name.to_owned())
111 }
112 }
113
114 versions.sort();
115 let newest_version = if let Some((file_name, version)) = versions.last().cloned()
116 && minimum_version.is_none_or(|minimum_version| version > minimum_version)
117 {
118 versions.pop();
119 Some(file_name)
120 } else {
121 None
122 };
123 to_delete.extend(versions.into_iter().map(|(file_name, _)| file_name));
124
125 cx.background_spawn({
126 let fs = fs.clone();
127 let dir = dir.clone();
128 async move {
129 for file_name in to_delete {
130 fs.remove_dir(
131 &dir.join(file_name),
132 RemoveOptions {
133 recursive: true,
134 ignore_if_not_exists: false,
135 },
136 )
137 .await
138 .ok();
139 }
140 }
141 })
142 .detach();
143
144 let version = if let Some(file_name) = newest_version {
145 cx.background_spawn({
146 let file_name = file_name.clone();
147 let dir = dir.clone();
148 async move {
149 let latest_version =
150 node_runtime.npm_package_latest_version(&package_name).await;
151 if let Ok(latest_version) = latest_version
152 && &latest_version != &file_name.to_string_lossy()
153 {
154 Self::download_latest_version(
155 fs,
156 dir.clone(),
157 node_runtime,
158 package_name,
159 )
160 .await
161 .log_err();
162 }
163 }
164 })
165 .detach();
166 file_name
167 } else {
168 if let Some(mut status_tx) = status_tx {
169 status_tx.send("Installing…".into()).ok();
170 }
171 let dir = dir.clone();
172 cx.background_spawn(Self::download_latest_version(
173 fs,
174 dir.clone(),
175 node_runtime,
176 package_name,
177 ))
178 .await?
179 .into()
180 };
181 anyhow::Ok(AgentServerCommand {
182 path: node_path,
183 args: vec![
184 dir.join(version)
185 .join(entrypoint_path)
186 .to_string_lossy()
187 .to_string(),
188 ],
189 env: Default::default(),
190 })
191 })
192 .await
193 .map_err(|e| LoadError::FailedToInstall(e.to_string().into()).into())
194 })
195 }
196
197 async fn download_latest_version(
198 fs: Arc<dyn Fs>,
199 dir: PathBuf,
200 node_runtime: NodeRuntime,
201 package_name: SharedString,
202 ) -> Result<String> {
203 let tmp_dir = tempfile::tempdir_in(&dir)?;
204
205 node_runtime
206 .npm_install_packages(tmp_dir.path(), &[(&package_name, "latest")])
207 .await?;
208
209 let version = node_runtime
210 .npm_package_installed_version(tmp_dir.path(), &package_name)
211 .await?
212 .context("expected package to be installed")?;
213
214 fs.rename(
215 &tmp_dir.keep(),
216 &dir.join(&version),
217 RenameOptions {
218 ignore_if_exists: true,
219 overwrite: false,
220 },
221 )
222 .await?;
223
224 anyhow::Ok(version)
225 }
226}
227
228pub trait AgentServer: Send {
229 fn logo(&self) -> ui::IconName;
230 fn name(&self) -> SharedString;
231 fn telemetry_id(&self) -> &'static str;
232
233 fn connect(
234 &self,
235 root_dir: &Path,
236 delegate: AgentServerDelegate,
237 cx: &mut App,
238 ) -> Task<Result<Rc<dyn AgentConnection>>>;
239
240 fn into_any(self: Rc<Self>) -> Rc<dyn Any>;
241}
242
243impl dyn AgentServer {
244 pub fn downcast<T: 'static + AgentServer + Sized>(self: Rc<Self>) -> Option<Rc<T>> {
245 self.into_any().downcast().ok()
246 }
247}
248
249impl std::fmt::Debug for AgentServerCommand {
250 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
251 let filtered_env = self.env.as_ref().map(|env| {
252 env.iter()
253 .map(|(k, v)| {
254 (
255 k,
256 if util::redact::should_redact(k) {
257 "[REDACTED]"
258 } else {
259 v
260 },
261 )
262 })
263 .collect::<Vec<_>>()
264 });
265
266 f.debug_struct("AgentServerCommand")
267 .field("path", &self.path)
268 .field("args", &self.args)
269 .field("env", &filtered_env)
270 .finish()
271 }
272}
273
274#[derive(Deserialize, Serialize, Clone, PartialEq, Eq, JsonSchema)]
275pub struct AgentServerCommand {
276 #[serde(rename = "command")]
277 pub path: PathBuf,
278 #[serde(default)]
279 pub args: Vec<String>,
280 pub env: Option<HashMap<String, String>>,
281}
282
283impl AgentServerCommand {
284 pub async fn resolve(
285 path_bin_name: &'static str,
286 extra_args: &[&'static str],
287 fallback_path: Option<&Path>,
288 settings: Option<BuiltinAgentServerSettings>,
289 project: &Entity<Project>,
290 cx: &mut AsyncApp,
291 ) -> Option<Self> {
292 if let Some(settings) = settings
293 && let Some(command) = settings.custom_command()
294 {
295 Some(command)
296 } else {
297 match find_bin_in_path(path_bin_name.into(), project, cx).await {
298 Some(path) => Some(Self {
299 path,
300 args: extra_args.iter().map(|arg| arg.to_string()).collect(),
301 env: None,
302 }),
303 None => fallback_path.and_then(|path| {
304 if path.exists() {
305 Some(Self {
306 path: path.to_path_buf(),
307 args: extra_args.iter().map(|arg| arg.to_string()).collect(),
308 env: None,
309 })
310 } else {
311 None
312 }
313 }),
314 }
315 }
316 }
317}
318
319async fn find_bin_in_path(
320 bin_name: SharedString,
321 project: &Entity<Project>,
322 cx: &mut AsyncApp,
323) -> Option<PathBuf> {
324 let (env_task, root_dir) = project
325 .update(cx, |project, cx| {
326 let worktree = project.visible_worktrees(cx).next();
327 match worktree {
328 Some(worktree) => {
329 let env_task = project.environment().update(cx, |env, cx| {
330 env.get_worktree_environment(worktree.clone(), cx)
331 });
332
333 let path = worktree.read(cx).abs_path();
334 (env_task, path)
335 }
336 None => {
337 let path: Arc<Path> = paths::home_dir().as_path().into();
338 let env_task = project.environment().update(cx, |env, cx| {
339 env.get_directory_environment(path.clone(), cx)
340 });
341 (env_task, path)
342 }
343 }
344 })
345 .log_err()?;
346
347 cx.background_executor()
348 .spawn(async move {
349 let which_result = if cfg!(windows) {
350 which::which(bin_name.as_str())
351 } else {
352 let env = env_task.await.unwrap_or_default();
353 let shell_path = env.get("PATH").cloned();
354 which::which_in(bin_name.as_str(), shell_path.as_ref(), root_dir.as_ref())
355 };
356
357 if let Err(which::Error::CannotFindBinaryPath) = which_result {
358 return None;
359 }
360
361 which_result.log_err()
362 })
363 .await
364}