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