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((version, file_name.to_owned()));
109 } else {
110 to_delete.push(file_name.to_owned())
111 }
112 }
113
114 versions.sort();
115 let newest_version = if let Some((version, file_name)) = 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 log::debug!("existing version of {package_name}: {newest_version:?}");
124 to_delete.extend(versions.into_iter().map(|(_, file_name)| file_name));
125
126 cx.background_spawn({
127 let fs = fs.clone();
128 let dir = dir.clone();
129 async move {
130 for file_name in to_delete {
131 fs.remove_dir(
132 &dir.join(file_name),
133 RemoveOptions {
134 recursive: true,
135 ignore_if_not_exists: false,
136 },
137 )
138 .await
139 .ok();
140 }
141 }
142 })
143 .detach();
144
145 let version = if let Some(file_name) = newest_version {
146 cx.background_spawn({
147 let file_name = file_name.clone();
148 let dir = dir.clone();
149 async move {
150 let latest_version =
151 node_runtime.npm_package_latest_version(&package_name).await;
152 if let Ok(latest_version) = latest_version
153 && &latest_version != &file_name.to_string_lossy()
154 {
155 Self::download_latest_version(
156 fs,
157 dir.clone(),
158 node_runtime,
159 package_name,
160 )
161 .await
162 .log_err();
163 }
164 }
165 })
166 .detach();
167 file_name
168 } else {
169 if let Some(mut status_tx) = status_tx {
170 status_tx.send("Installing…".into()).ok();
171 }
172 let dir = dir.clone();
173 cx.background_spawn(Self::download_latest_version(
174 fs,
175 dir.clone(),
176 node_runtime,
177 package_name,
178 ))
179 .await?
180 .into()
181 };
182 anyhow::Ok(AgentServerCommand {
183 path: node_path,
184 args: vec![
185 dir.join(version)
186 .join(entrypoint_path)
187 .to_string_lossy()
188 .to_string(),
189 ],
190 env: Default::default(),
191 })
192 })
193 .await
194 .map_err(|e| LoadError::FailedToInstall(e.to_string().into()).into())
195 })
196 }
197
198 async fn download_latest_version(
199 fs: Arc<dyn Fs>,
200 dir: PathBuf,
201 node_runtime: NodeRuntime,
202 package_name: SharedString,
203 ) -> Result<String> {
204 log::debug!("downloading latest version of {package_name}");
205
206 let tmp_dir = tempfile::tempdir_in(&dir)?;
207
208 node_runtime
209 .npm_install_packages(tmp_dir.path(), &[(&package_name, "latest")])
210 .await?;
211
212 let version = node_runtime
213 .npm_package_installed_version(tmp_dir.path(), &package_name)
214 .await?
215 .context("expected package to be installed")?;
216
217 fs.rename(
218 &tmp_dir.keep(),
219 &dir.join(&version),
220 RenameOptions {
221 ignore_if_exists: true,
222 overwrite: false,
223 },
224 )
225 .await?;
226
227 anyhow::Ok(version)
228 }
229}
230
231pub trait AgentServer: Send {
232 fn logo(&self) -> ui::IconName;
233 fn name(&self) -> SharedString;
234 fn telemetry_id(&self) -> &'static str;
235
236 fn connect(
237 &self,
238 root_dir: &Path,
239 delegate: AgentServerDelegate,
240 cx: &mut App,
241 ) -> Task<Result<Rc<dyn AgentConnection>>>;
242
243 fn into_any(self: Rc<Self>) -> Rc<dyn Any>;
244}
245
246impl dyn AgentServer {
247 pub fn downcast<T: 'static + AgentServer + Sized>(self: Rc<Self>) -> Option<Rc<T>> {
248 self.into_any().downcast().ok()
249 }
250}
251
252impl std::fmt::Debug for AgentServerCommand {
253 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
254 let filtered_env = self.env.as_ref().map(|env| {
255 env.iter()
256 .map(|(k, v)| {
257 (
258 k,
259 if util::redact::should_redact(k) {
260 "[REDACTED]"
261 } else {
262 v
263 },
264 )
265 })
266 .collect::<Vec<_>>()
267 });
268
269 f.debug_struct("AgentServerCommand")
270 .field("path", &self.path)
271 .field("args", &self.args)
272 .field("env", &filtered_env)
273 .finish()
274 }
275}
276
277#[derive(Deserialize, Serialize, Clone, PartialEq, Eq, JsonSchema)]
278pub struct AgentServerCommand {
279 #[serde(rename = "command")]
280 pub path: PathBuf,
281 #[serde(default)]
282 pub args: Vec<String>,
283 pub env: Option<HashMap<String, String>>,
284}
285
286impl AgentServerCommand {
287 pub async fn resolve(
288 path_bin_name: &'static str,
289 extra_args: &[&'static str],
290 fallback_path: Option<&Path>,
291 settings: Option<BuiltinAgentServerSettings>,
292 project: &Entity<Project>,
293 cx: &mut AsyncApp,
294 ) -> Option<Self> {
295 if let Some(settings) = settings
296 && let Some(command) = settings.custom_command()
297 {
298 Some(command)
299 } else {
300 match find_bin_in_path(path_bin_name.into(), project, cx).await {
301 Some(path) => Some(Self {
302 path,
303 args: extra_args.iter().map(|arg| arg.to_string()).collect(),
304 env: None,
305 }),
306 None => fallback_path.and_then(|path| {
307 if path.exists() {
308 Some(Self {
309 path: path.to_path_buf(),
310 args: extra_args.iter().map(|arg| arg.to_string()).collect(),
311 env: None,
312 })
313 } else {
314 None
315 }
316 }),
317 }
318 }
319 }
320}
321
322async fn find_bin_in_path(
323 bin_name: SharedString,
324 project: &Entity<Project>,
325 cx: &mut AsyncApp,
326) -> Option<PathBuf> {
327 let (env_task, root_dir) = project
328 .update(cx, |project, cx| {
329 let worktree = project.visible_worktrees(cx).next();
330 match worktree {
331 Some(worktree) => {
332 let env_task = project.environment().update(cx, |env, cx| {
333 env.get_worktree_environment(worktree.clone(), cx)
334 });
335
336 let path = worktree.read(cx).abs_path();
337 (env_task, path)
338 }
339 None => {
340 let path: Arc<Path> = paths::home_dir().as_path().into();
341 let env_task = project.environment().update(cx, |env, cx| {
342 env.get_directory_environment(path.clone(), cx)
343 });
344 (env_task, path)
345 }
346 }
347 })
348 .log_err()?;
349
350 cx.background_executor()
351 .spawn(async move {
352 let which_result = if cfg!(windows) {
353 which::which(bin_name.as_str())
354 } else {
355 let env = env_task.await.unwrap_or_default();
356 let shell_path = env.get("PATH").cloned();
357 which::which_in(bin_name.as_str(), shell_path.as_ref(), root_dir.as_ref())
358 };
359
360 if let Err(which::Error::CannotFindBinaryPath) = which_result {
361 return None;
362 }
363
364 which_result.log_err()
365 })
366 .await
367}