1use std::{
2 any::Any,
3 borrow::Borrow,
4 path::{Path, PathBuf},
5 str::FromStr as _,
6 sync::Arc,
7 time::Duration,
8};
9
10use anyhow::{Context as _, Result, bail};
11use collections::HashMap;
12use fs::{Fs, RemoveOptions, RenameOptions};
13use futures::StreamExt as _;
14use gpui::{
15 App, AppContext as _, AsyncApp, Context, Entity, EventEmitter, SharedString, Subscription, Task,
16};
17use node_runtime::NodeRuntime;
18use remote::RemoteClient;
19use rpc::{
20 AnyProtoClient, TypedEnvelope,
21 proto::{self, ToProto},
22};
23use schemars::JsonSchema;
24use serde::{Deserialize, Serialize};
25use settings::{SettingsKey, SettingsSources, SettingsStore, SettingsUi};
26use util::{ResultExt as _, debug_panic};
27
28use crate::ProjectEnvironment;
29
30#[derive(Deserialize, Serialize, Clone, PartialEq, Eq, JsonSchema)]
31pub struct AgentServerCommand {
32 #[serde(rename = "command")]
33 pub path: PathBuf,
34 #[serde(default)]
35 pub args: Vec<String>,
36 pub env: Option<HashMap<String, String>>,
37}
38
39impl std::fmt::Debug for AgentServerCommand {
40 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
41 let filtered_env = self.env.as_ref().map(|env| {
42 env.iter()
43 .map(|(k, v)| {
44 (
45 k,
46 if util::redact::should_redact(k) {
47 "[REDACTED]"
48 } else {
49 v
50 },
51 )
52 })
53 .collect::<Vec<_>>()
54 });
55
56 f.debug_struct("AgentServerCommand")
57 .field("path", &self.path)
58 .field("args", &self.args)
59 .field("env", &filtered_env)
60 .finish()
61 }
62}
63
64#[derive(Clone, Debug, PartialEq, Eq, Hash)]
65pub struct ExternalAgentServerName(pub SharedString);
66
67impl std::fmt::Display for ExternalAgentServerName {
68 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
69 write!(f, "{}", self.0)
70 }
71}
72
73impl From<&'static str> for ExternalAgentServerName {
74 fn from(value: &'static str) -> Self {
75 ExternalAgentServerName(value.into())
76 }
77}
78
79impl From<ExternalAgentServerName> for SharedString {
80 fn from(value: ExternalAgentServerName) -> Self {
81 value.0
82 }
83}
84
85impl Borrow<str> for ExternalAgentServerName {
86 fn borrow(&self) -> &str {
87 &self.0
88 }
89}
90
91pub trait ExternalAgentServer {
92 fn get_command(
93 &mut self,
94 root_dir: Option<&str>,
95 extra_env: HashMap<String, String>,
96 status_tx: Option<watch::Sender<SharedString>>,
97 new_version_available_tx: Option<watch::Sender<Option<String>>>,
98 cx: &mut AsyncApp,
99 ) -> Task<Result<(AgentServerCommand, String, Option<task::SpawnInTerminal>)>>;
100
101 fn as_any_mut(&mut self) -> &mut dyn Any;
102}
103
104impl dyn ExternalAgentServer {
105 fn downcast_mut<T: ExternalAgentServer + 'static>(&mut self) -> Option<&mut T> {
106 self.as_any_mut().downcast_mut()
107 }
108}
109
110enum AgentServerStoreState {
111 Local {
112 node_runtime: NodeRuntime,
113 fs: Arc<dyn Fs>,
114 project_environment: Entity<ProjectEnvironment>,
115 downstream_client: Option<(u64, AnyProtoClient)>,
116 settings: Option<AllAgentServersSettings>,
117 _subscriptions: [Subscription; 1],
118 },
119 Remote {
120 project_id: u64,
121 upstream_client: Entity<RemoteClient>,
122 },
123 Collab,
124}
125
126pub struct AgentServerStore {
127 state: AgentServerStoreState,
128 external_agents: HashMap<ExternalAgentServerName, Box<dyn ExternalAgentServer>>,
129}
130
131pub struct AgentServersUpdated;
132
133impl EventEmitter<AgentServersUpdated> for AgentServerStore {}
134
135impl AgentServerStore {
136 pub fn init_remote(session: &AnyProtoClient) {
137 session.add_entity_message_handler(Self::handle_external_agents_updated);
138 session.add_entity_message_handler(Self::handle_loading_status_updated);
139 session.add_entity_message_handler(Self::handle_new_version_available);
140 }
141
142 pub fn init_headless(session: &AnyProtoClient) {
143 session.add_entity_request_handler(Self::handle_get_agent_server_command);
144 }
145
146 fn agent_servers_settings_changed(&mut self, cx: &mut Context<Self>) {
147 let AgentServerStoreState::Local {
148 node_runtime,
149 fs,
150 project_environment,
151 downstream_client,
152 settings: old_settings,
153 ..
154 } = &mut self.state
155 else {
156 debug_panic!(
157 "should not be subscribed to agent server settings changes in non-local project"
158 );
159 return;
160 };
161
162 let new_settings = cx
163 .global::<SettingsStore>()
164 .get::<AllAgentServersSettings>(None)
165 .clone();
166 if Some(&new_settings) == old_settings.as_ref() {
167 return;
168 }
169
170 self.external_agents.clear();
171 self.external_agents.insert(
172 GEMINI_NAME.into(),
173 Box::new(LocalGemini {
174 fs: fs.clone(),
175 node_runtime: node_runtime.clone(),
176 project_environment: project_environment.clone(),
177 custom_command: new_settings
178 .gemini
179 .clone()
180 .and_then(|settings| settings.custom_command()),
181 ignore_system_version: new_settings
182 .gemini
183 .as_ref()
184 .and_then(|settings| settings.ignore_system_version)
185 .unwrap_or(true),
186 }),
187 );
188 self.external_agents.insert(
189 CLAUDE_CODE_NAME.into(),
190 Box::new(LocalClaudeCode {
191 fs: fs.clone(),
192 node_runtime: node_runtime.clone(),
193 project_environment: project_environment.clone(),
194 custom_command: new_settings.claude.clone().map(|settings| settings.command),
195 }),
196 );
197 self.external_agents
198 .extend(new_settings.custom.iter().map(|(name, settings)| {
199 (
200 ExternalAgentServerName(name.clone()),
201 Box::new(LocalCustomAgent {
202 command: settings.command.clone(),
203 project_environment: project_environment.clone(),
204 }) as Box<dyn ExternalAgentServer>,
205 )
206 }));
207
208 *old_settings = Some(new_settings.clone());
209
210 if let Some((project_id, downstream_client)) = downstream_client {
211 downstream_client
212 .send(proto::ExternalAgentsUpdated {
213 project_id: *project_id,
214 names: self
215 .external_agents
216 .keys()
217 .map(|name| name.to_string())
218 .collect(),
219 })
220 .log_err();
221 }
222 cx.emit(AgentServersUpdated);
223 }
224
225 pub fn local(
226 node_runtime: NodeRuntime,
227 fs: Arc<dyn Fs>,
228 project_environment: Entity<ProjectEnvironment>,
229 cx: &mut Context<Self>,
230 ) -> Self {
231 let subscription = cx.observe_global::<SettingsStore>(|this, cx| {
232 this.agent_servers_settings_changed(cx);
233 });
234 let this = Self {
235 state: AgentServerStoreState::Local {
236 node_runtime,
237 fs,
238 project_environment,
239 downstream_client: None,
240 settings: None,
241 _subscriptions: [subscription],
242 },
243 external_agents: Default::default(),
244 };
245 cx.spawn(async move |this, cx| {
246 cx.background_executor().timer(Duration::from_secs(1)).await;
247 this.update(cx, |this, cx| {
248 this.agent_servers_settings_changed(cx);
249 })
250 .ok();
251 })
252 .detach();
253 this
254 }
255
256 pub(crate) fn remote(
257 project_id: u64,
258 upstream_client: Entity<RemoteClient>,
259 _cx: &mut Context<Self>,
260 ) -> Self {
261 // Set up the builtin agents here so they're immediately available in
262 // remote projects--we know that the HeadlessProject on the other end
263 // will have them.
264 let external_agents = [
265 (
266 GEMINI_NAME.into(),
267 Box::new(RemoteExternalAgentServer {
268 project_id,
269 upstream_client: upstream_client.clone(),
270 name: GEMINI_NAME.into(),
271 status_tx: None,
272 new_version_available_tx: None,
273 }) as Box<dyn ExternalAgentServer>,
274 ),
275 (
276 CLAUDE_CODE_NAME.into(),
277 Box::new(RemoteExternalAgentServer {
278 project_id,
279 upstream_client: upstream_client.clone(),
280 name: CLAUDE_CODE_NAME.into(),
281 status_tx: None,
282 new_version_available_tx: None,
283 }) as Box<dyn ExternalAgentServer>,
284 ),
285 ]
286 .into_iter()
287 .collect();
288
289 Self {
290 state: AgentServerStoreState::Remote {
291 project_id,
292 upstream_client,
293 },
294 external_agents,
295 }
296 }
297
298 pub(crate) fn collab(_cx: &mut Context<Self>) -> Self {
299 Self {
300 state: AgentServerStoreState::Collab,
301 external_agents: Default::default(),
302 }
303 }
304
305 pub fn shared(&mut self, project_id: u64, client: AnyProtoClient) {
306 match &mut self.state {
307 AgentServerStoreState::Local {
308 downstream_client, ..
309 } => {
310 client
311 .send(proto::ExternalAgentsUpdated {
312 project_id,
313 names: self
314 .external_agents
315 .keys()
316 .map(|name| name.to_string())
317 .collect(),
318 })
319 .log_err();
320 *downstream_client = Some((project_id, client));
321 }
322 AgentServerStoreState::Remote { .. } => {
323 debug_panic!(
324 "external agents over collab not implemented, remote project should not be shared"
325 );
326 }
327 AgentServerStoreState::Collab => {
328 debug_panic!("external agents over collab not implemented, should not be shared");
329 }
330 }
331 }
332
333 pub fn get_external_agent(
334 &mut self,
335 name: &ExternalAgentServerName,
336 ) -> Option<&mut (dyn ExternalAgentServer + 'static)> {
337 self.external_agents
338 .get_mut(name)
339 .map(|agent| agent.as_mut())
340 }
341
342 pub fn external_agents(&self) -> impl Iterator<Item = &ExternalAgentServerName> {
343 self.external_agents.keys()
344 }
345
346 async fn handle_get_agent_server_command(
347 this: Entity<Self>,
348 envelope: TypedEnvelope<proto::GetAgentServerCommand>,
349 mut cx: AsyncApp,
350 ) -> Result<proto::AgentServerCommand> {
351 let (command, root_dir, login) = this
352 .update(&mut cx, |this, cx| {
353 let AgentServerStoreState::Local {
354 downstream_client, ..
355 } = &this.state
356 else {
357 debug_panic!("should not receive GetAgentServerCommand in a non-local project");
358 bail!("unexpected GetAgentServerCommand request in a non-local project");
359 };
360 let agent = this
361 .external_agents
362 .get_mut(&*envelope.payload.name)
363 .with_context(|| format!("agent `{}` not found", envelope.payload.name))?;
364 let (status_tx, new_version_available_tx) = downstream_client
365 .clone()
366 .map(|(project_id, downstream_client)| {
367 let (status_tx, mut status_rx) = watch::channel(SharedString::from(""));
368 let (new_version_available_tx, mut new_version_available_rx) =
369 watch::channel(None);
370 cx.spawn({
371 let downstream_client = downstream_client.clone();
372 let name = envelope.payload.name.clone();
373 async move |_, _| {
374 while let Some(status) = status_rx.recv().await.ok() {
375 downstream_client.send(
376 proto::ExternalAgentLoadingStatusUpdated {
377 project_id,
378 name: name.clone(),
379 status: status.to_string(),
380 },
381 )?;
382 }
383 anyhow::Ok(())
384 }
385 })
386 .detach_and_log_err(cx);
387 cx.spawn({
388 let name = envelope.payload.name.clone();
389 async move |_, _| {
390 if let Some(version) =
391 new_version_available_rx.recv().await.ok().flatten()
392 {
393 downstream_client.send(
394 proto::NewExternalAgentVersionAvailable {
395 project_id,
396 name: name.clone(),
397 version,
398 },
399 )?;
400 }
401 anyhow::Ok(())
402 }
403 })
404 .detach_and_log_err(cx);
405 (status_tx, new_version_available_tx)
406 })
407 .unzip();
408 anyhow::Ok(agent.get_command(
409 envelope.payload.root_dir.as_deref(),
410 HashMap::default(),
411 status_tx,
412 new_version_available_tx,
413 &mut cx.to_async(),
414 ))
415 })??
416 .await?;
417 Ok(proto::AgentServerCommand {
418 path: command.path.to_string_lossy().to_string(),
419 args: command.args,
420 env: command
421 .env
422 .map(|env| env.into_iter().collect())
423 .unwrap_or_default(),
424 root_dir: root_dir,
425 login: login.map(|login| login.to_proto()),
426 })
427 }
428
429 async fn handle_external_agents_updated(
430 this: Entity<Self>,
431 envelope: TypedEnvelope<proto::ExternalAgentsUpdated>,
432 mut cx: AsyncApp,
433 ) -> Result<()> {
434 this.update(&mut cx, |this, cx| {
435 let AgentServerStoreState::Remote {
436 project_id,
437 upstream_client,
438 } = &this.state
439 else {
440 debug_panic!(
441 "handle_external_agents_updated should not be called for a non-remote project"
442 );
443 bail!("unexpected ExternalAgentsUpdated message")
444 };
445
446 let mut status_txs = this
447 .external_agents
448 .iter_mut()
449 .filter_map(|(name, agent)| {
450 Some((
451 name.clone(),
452 agent
453 .downcast_mut::<RemoteExternalAgentServer>()?
454 .status_tx
455 .take(),
456 ))
457 })
458 .collect::<HashMap<_, _>>();
459 let mut new_version_available_txs = this
460 .external_agents
461 .iter_mut()
462 .filter_map(|(name, agent)| {
463 Some((
464 name.clone(),
465 agent
466 .downcast_mut::<RemoteExternalAgentServer>()?
467 .new_version_available_tx
468 .take(),
469 ))
470 })
471 .collect::<HashMap<_, _>>();
472
473 this.external_agents = envelope
474 .payload
475 .names
476 .into_iter()
477 .map(|name| {
478 let agent = RemoteExternalAgentServer {
479 project_id: *project_id,
480 upstream_client: upstream_client.clone(),
481 name: ExternalAgentServerName(name.clone().into()),
482 status_tx: status_txs.remove(&*name).flatten(),
483 new_version_available_tx: new_version_available_txs
484 .remove(&*name)
485 .flatten(),
486 };
487 (
488 ExternalAgentServerName(name.into()),
489 Box::new(agent) as Box<dyn ExternalAgentServer>,
490 )
491 })
492 .collect();
493 cx.emit(AgentServersUpdated);
494 Ok(())
495 })?
496 }
497
498 async fn handle_loading_status_updated(
499 this: Entity<Self>,
500 envelope: TypedEnvelope<proto::ExternalAgentLoadingStatusUpdated>,
501 mut cx: AsyncApp,
502 ) -> Result<()> {
503 this.update(&mut cx, |this, _| {
504 if let Some(agent) = this.external_agents.get_mut(&*envelope.payload.name)
505 && let Some(agent) = agent.downcast_mut::<RemoteExternalAgentServer>()
506 && let Some(status_tx) = &mut agent.status_tx
507 {
508 status_tx.send(envelope.payload.status.into()).ok();
509 }
510 })
511 }
512
513 async fn handle_new_version_available(
514 this: Entity<Self>,
515 envelope: TypedEnvelope<proto::NewExternalAgentVersionAvailable>,
516 mut cx: AsyncApp,
517 ) -> Result<()> {
518 this.update(&mut cx, |this, _| {
519 if let Some(agent) = this.external_agents.get_mut(&*envelope.payload.name)
520 && let Some(agent) = agent.downcast_mut::<RemoteExternalAgentServer>()
521 && let Some(new_version_available_tx) = &mut agent.new_version_available_tx
522 {
523 new_version_available_tx
524 .send(Some(envelope.payload.version))
525 .ok();
526 }
527 })
528 }
529}
530
531fn get_or_npm_install_builtin_agent(
532 binary_name: SharedString,
533 package_name: SharedString,
534 entrypoint_path: PathBuf,
535 minimum_version: Option<semver::Version>,
536 status_tx: Option<watch::Sender<SharedString>>,
537 new_version_available: Option<watch::Sender<Option<String>>>,
538 fs: Arc<dyn Fs>,
539 node_runtime: NodeRuntime,
540 cx: &mut AsyncApp,
541) -> Task<std::result::Result<AgentServerCommand, anyhow::Error>> {
542 cx.spawn(async move |cx| {
543 let node_path = node_runtime.binary_path().await?;
544 let dir = paths::data_dir()
545 .join("external_agents")
546 .join(binary_name.as_str());
547 fs.create_dir(&dir).await?;
548
549 let mut stream = fs.read_dir(&dir).await?;
550 let mut versions = Vec::new();
551 let mut to_delete = Vec::new();
552 while let Some(entry) = stream.next().await {
553 let Ok(entry) = entry else { continue };
554 let Some(file_name) = entry.file_name() else {
555 continue;
556 };
557
558 if let Some(name) = file_name.to_str()
559 && let Some(version) = semver::Version::from_str(name).ok()
560 && fs
561 .is_file(&dir.join(file_name).join(&entrypoint_path))
562 .await
563 {
564 versions.push((version, file_name.to_owned()));
565 } else {
566 to_delete.push(file_name.to_owned())
567 }
568 }
569
570 versions.sort();
571 let newest_version = if let Some((version, file_name)) = versions.last().cloned()
572 && minimum_version.is_none_or(|minimum_version| version >= minimum_version)
573 {
574 versions.pop();
575 Some(file_name)
576 } else {
577 None
578 };
579 log::debug!("existing version of {package_name}: {newest_version:?}");
580 to_delete.extend(versions.into_iter().map(|(_, file_name)| file_name));
581
582 cx.background_spawn({
583 let fs = fs.clone();
584 let dir = dir.clone();
585 async move {
586 for file_name in to_delete {
587 fs.remove_dir(
588 &dir.join(file_name),
589 RemoveOptions {
590 recursive: true,
591 ignore_if_not_exists: false,
592 },
593 )
594 .await
595 .ok();
596 }
597 }
598 })
599 .detach();
600
601 let version = if let Some(file_name) = newest_version {
602 cx.background_spawn({
603 let file_name = file_name.clone();
604 let dir = dir.clone();
605 let fs = fs.clone();
606 async move {
607 let latest_version =
608 node_runtime.npm_package_latest_version(&package_name).await;
609 if let Ok(latest_version) = latest_version
610 && &latest_version != &file_name.to_string_lossy()
611 {
612 download_latest_version(
613 fs,
614 dir.clone(),
615 node_runtime,
616 package_name.clone(),
617 )
618 .await
619 .log_err();
620 if let Some(mut new_version_available) = new_version_available {
621 new_version_available.send(Some(latest_version)).ok();
622 }
623 }
624 }
625 })
626 .detach();
627 file_name
628 } else {
629 if let Some(mut status_tx) = status_tx {
630 status_tx.send("Installing…".into()).ok();
631 }
632 let dir = dir.clone();
633 cx.background_spawn(download_latest_version(
634 fs.clone(),
635 dir.clone(),
636 node_runtime,
637 package_name.clone(),
638 ))
639 .await?
640 .into()
641 };
642
643 let agent_server_path = dir.join(version).join(entrypoint_path);
644 let agent_server_path_exists = fs.is_file(&agent_server_path).await;
645 anyhow::ensure!(
646 agent_server_path_exists,
647 "Missing entrypoint path {} after installation",
648 agent_server_path.to_string_lossy()
649 );
650
651 anyhow::Ok(AgentServerCommand {
652 path: node_path,
653 args: vec![agent_server_path.to_string_lossy().to_string()],
654 env: None,
655 })
656 })
657}
658
659fn find_bin_in_path(
660 bin_name: SharedString,
661 root_dir: PathBuf,
662 env: HashMap<String, String>,
663 cx: &mut AsyncApp,
664) -> Task<Option<PathBuf>> {
665 cx.background_executor().spawn(async move {
666 let which_result = if cfg!(windows) {
667 which::which(bin_name.as_str())
668 } else {
669 let shell_path = env.get("PATH").cloned();
670 which::which_in(bin_name.as_str(), shell_path.as_ref(), &root_dir)
671 };
672
673 if let Err(which::Error::CannotFindBinaryPath) = which_result {
674 return None;
675 }
676
677 which_result.log_err()
678 })
679}
680
681async fn download_latest_version(
682 fs: Arc<dyn Fs>,
683 dir: PathBuf,
684 node_runtime: NodeRuntime,
685 package_name: SharedString,
686) -> Result<String> {
687 log::debug!("downloading latest version of {package_name}");
688
689 let tmp_dir = tempfile::tempdir_in(&dir)?;
690
691 node_runtime
692 .npm_install_packages(tmp_dir.path(), &[(&package_name, "latest")])
693 .await?;
694
695 let version = node_runtime
696 .npm_package_installed_version(tmp_dir.path(), &package_name)
697 .await?
698 .context("expected package to be installed")?;
699
700 fs.rename(
701 &tmp_dir.keep(),
702 &dir.join(&version),
703 RenameOptions {
704 ignore_if_exists: true,
705 overwrite: false,
706 },
707 )
708 .await?;
709
710 anyhow::Ok(version)
711}
712
713struct RemoteExternalAgentServer {
714 project_id: u64,
715 upstream_client: Entity<RemoteClient>,
716 name: ExternalAgentServerName,
717 status_tx: Option<watch::Sender<SharedString>>,
718 new_version_available_tx: Option<watch::Sender<Option<String>>>,
719}
720
721// new method: status_updated
722// does nothing in the all-local case
723// for RemoteExternalAgentServer, sends on the stored tx
724// etc.
725
726impl ExternalAgentServer for RemoteExternalAgentServer {
727 fn get_command(
728 &mut self,
729 root_dir: Option<&str>,
730 extra_env: HashMap<String, String>,
731 status_tx: Option<watch::Sender<SharedString>>,
732 new_version_available_tx: Option<watch::Sender<Option<String>>>,
733 cx: &mut AsyncApp,
734 ) -> Task<Result<(AgentServerCommand, String, Option<task::SpawnInTerminal>)>> {
735 let project_id = self.project_id;
736 let name = self.name.to_string();
737 let upstream_client = self.upstream_client.downgrade();
738 let root_dir = root_dir.map(|root_dir| root_dir.to_owned());
739 self.status_tx = status_tx;
740 self.new_version_available_tx = new_version_available_tx;
741 cx.spawn(async move |cx| {
742 let mut response = upstream_client
743 .update(cx, |upstream_client, _| {
744 upstream_client
745 .proto_client()
746 .request(proto::GetAgentServerCommand {
747 project_id,
748 name,
749 root_dir: root_dir.clone(),
750 })
751 })?
752 .await?;
753 let root_dir = response.root_dir;
754 response.env.extend(extra_env);
755 let command = upstream_client.update(cx, |client, _| {
756 client.build_command(
757 Some(response.path),
758 &response.args,
759 &response.env.into_iter().collect(),
760 Some(root_dir.clone()),
761 None,
762 )
763 })??;
764 Ok((
765 AgentServerCommand {
766 path: command.program.into(),
767 args: command.args,
768 env: Some(command.env),
769 },
770 root_dir,
771 response
772 .login
773 .map(|login| task::SpawnInTerminal::from_proto(login)),
774 ))
775 })
776 }
777
778 fn as_any_mut(&mut self) -> &mut dyn Any {
779 self
780 }
781}
782
783struct LocalGemini {
784 fs: Arc<dyn Fs>,
785 node_runtime: NodeRuntime,
786 project_environment: Entity<ProjectEnvironment>,
787 custom_command: Option<AgentServerCommand>,
788 ignore_system_version: bool,
789}
790
791impl ExternalAgentServer for LocalGemini {
792 fn get_command(
793 &mut self,
794 root_dir: Option<&str>,
795 extra_env: HashMap<String, String>,
796 status_tx: Option<watch::Sender<SharedString>>,
797 new_version_available_tx: Option<watch::Sender<Option<String>>>,
798 cx: &mut AsyncApp,
799 ) -> Task<Result<(AgentServerCommand, String, Option<task::SpawnInTerminal>)>> {
800 let fs = self.fs.clone();
801 let node_runtime = self.node_runtime.clone();
802 let project_environment = self.project_environment.downgrade();
803 let custom_command = self.custom_command.clone();
804 let ignore_system_version = self.ignore_system_version;
805 let root_dir: Arc<Path> = root_dir
806 .map(|root_dir| Path::new(root_dir))
807 .unwrap_or(paths::home_dir())
808 .into();
809
810 cx.spawn(async move |cx| {
811 let mut env = project_environment
812 .update(cx, |project_environment, cx| {
813 project_environment.get_directory_environment(root_dir.clone(), cx)
814 })?
815 .await
816 .unwrap_or_default();
817
818 let mut command = if let Some(mut custom_command) = custom_command {
819 env.extend(custom_command.env.unwrap_or_default());
820 custom_command.env = Some(env);
821 custom_command
822 } else if !ignore_system_version
823 && let Some(bin) =
824 find_bin_in_path("gemini".into(), root_dir.to_path_buf(), env.clone(), cx).await
825 {
826 AgentServerCommand {
827 path: bin,
828 args: Vec::new(),
829 env: Some(env),
830 }
831 } else {
832 let mut command = get_or_npm_install_builtin_agent(
833 GEMINI_NAME.into(),
834 "@google/gemini-cli".into(),
835 "node_modules/@google/gemini-cli/dist/index.js".into(),
836 Some("0.2.1".parse().unwrap()),
837 status_tx,
838 new_version_available_tx,
839 fs,
840 node_runtime,
841 cx,
842 )
843 .await?;
844 command.env = Some(env);
845 command
846 };
847
848 // Gemini CLI doesn't seem to have a dedicated invocation for logging in--we just run it normally without any arguments.
849 let login = task::SpawnInTerminal {
850 command: Some(command.path.clone().to_proto()),
851 args: command.args.clone(),
852 env: command.env.clone().unwrap_or_default(),
853 label: "gemini /auth".into(),
854 ..Default::default()
855 };
856
857 command.env.get_or_insert_default().extend(extra_env);
858 command.args.push("--experimental-acp".into());
859 Ok((command, root_dir.to_proto(), Some(login)))
860 })
861 }
862
863 fn as_any_mut(&mut self) -> &mut dyn Any {
864 self
865 }
866}
867
868struct LocalClaudeCode {
869 fs: Arc<dyn Fs>,
870 node_runtime: NodeRuntime,
871 project_environment: Entity<ProjectEnvironment>,
872 custom_command: Option<AgentServerCommand>,
873}
874
875impl ExternalAgentServer for LocalClaudeCode {
876 fn get_command(
877 &mut self,
878 root_dir: Option<&str>,
879 extra_env: HashMap<String, String>,
880 status_tx: Option<watch::Sender<SharedString>>,
881 new_version_available_tx: Option<watch::Sender<Option<String>>>,
882 cx: &mut AsyncApp,
883 ) -> Task<Result<(AgentServerCommand, String, Option<task::SpawnInTerminal>)>> {
884 let fs = self.fs.clone();
885 let node_runtime = self.node_runtime.clone();
886 let project_environment = self.project_environment.downgrade();
887 let custom_command = self.custom_command.clone();
888 let root_dir: Arc<Path> = root_dir
889 .map(|root_dir| Path::new(root_dir))
890 .unwrap_or(paths::home_dir())
891 .into();
892
893 cx.spawn(async move |cx| {
894 let mut env = project_environment
895 .update(cx, |project_environment, cx| {
896 project_environment.get_directory_environment(root_dir.clone(), cx)
897 })?
898 .await
899 .unwrap_or_default();
900 env.insert("ANTHROPIC_API_KEY".into(), "".into());
901
902 let (mut command, login) = if let Some(mut custom_command) = custom_command {
903 env.extend(custom_command.env.unwrap_or_default());
904 custom_command.env = Some(env);
905 (custom_command, None)
906 } else {
907 let mut command = get_or_npm_install_builtin_agent(
908 "claude-code-acp".into(),
909 "@zed-industries/claude-code-acp".into(),
910 "node_modules/@zed-industries/claude-code-acp/dist/index.js".into(),
911 Some("0.2.5".parse().unwrap()),
912 status_tx,
913 new_version_available_tx,
914 fs,
915 node_runtime,
916 cx,
917 )
918 .await?;
919 command.env = Some(env);
920 let login = command
921 .args
922 .first()
923 .and_then(|path| {
924 path.strip_suffix("/@zed-industries/claude-code-acp/dist/index.js")
925 })
926 .map(|path_prefix| task::SpawnInTerminal {
927 command: Some(command.path.clone().to_proto()),
928 args: vec![
929 Path::new(path_prefix)
930 .join("@anthropic-ai/claude-code/cli.js")
931 .to_string_lossy()
932 .to_string(),
933 "/login".into(),
934 ],
935 env: command.env.clone().unwrap_or_default(),
936 label: "claude /login".into(),
937 ..Default::default()
938 });
939 (command, login)
940 };
941
942 command.env.get_or_insert_default().extend(extra_env);
943 Ok((command, root_dir.to_proto(), login))
944 })
945 }
946
947 fn as_any_mut(&mut self) -> &mut dyn Any {
948 self
949 }
950}
951
952struct LocalCustomAgent {
953 project_environment: Entity<ProjectEnvironment>,
954 command: AgentServerCommand,
955}
956
957impl ExternalAgentServer for LocalCustomAgent {
958 fn get_command(
959 &mut self,
960 root_dir: Option<&str>,
961 extra_env: HashMap<String, String>,
962 _status_tx: Option<watch::Sender<SharedString>>,
963 _new_version_available_tx: Option<watch::Sender<Option<String>>>,
964 cx: &mut AsyncApp,
965 ) -> Task<Result<(AgentServerCommand, String, Option<task::SpawnInTerminal>)>> {
966 let mut command = self.command.clone();
967 let root_dir: Arc<Path> = root_dir
968 .map(|root_dir| Path::new(root_dir))
969 .unwrap_or(paths::home_dir())
970 .into();
971 let project_environment = self.project_environment.downgrade();
972 cx.spawn(async move |cx| {
973 let mut env = project_environment
974 .update(cx, |project_environment, cx| {
975 project_environment.get_directory_environment(root_dir.clone(), cx)
976 })?
977 .await
978 .unwrap_or_default();
979 env.extend(command.env.unwrap_or_default());
980 env.extend(extra_env);
981 command.env = Some(env);
982 Ok((command, root_dir.to_proto(), None))
983 })
984 }
985
986 fn as_any_mut(&mut self) -> &mut dyn Any {
987 self
988 }
989}
990
991pub const GEMINI_NAME: &'static str = "gemini";
992pub const CLAUDE_CODE_NAME: &'static str = "claude";
993
994#[derive(
995 Default, Deserialize, Serialize, Clone, JsonSchema, Debug, SettingsUi, SettingsKey, PartialEq,
996)]
997#[settings_key(key = "agent_servers")]
998pub struct AllAgentServersSettings {
999 pub gemini: Option<BuiltinAgentServerSettings>,
1000 pub claude: Option<CustomAgentServerSettings>,
1001
1002 /// Custom agent servers configured by the user
1003 #[serde(flatten)]
1004 pub custom: HashMap<SharedString, CustomAgentServerSettings>,
1005}
1006
1007#[derive(Default, Deserialize, Serialize, Clone, JsonSchema, Debug, PartialEq)]
1008pub struct BuiltinAgentServerSettings {
1009 /// Absolute path to a binary to be used when launching this agent.
1010 ///
1011 /// This can be used to run a specific binary without automatic downloads or searching `$PATH`.
1012 #[serde(rename = "command")]
1013 pub path: Option<PathBuf>,
1014 /// If a binary is specified in `command`, it will be passed these arguments.
1015 pub args: Option<Vec<String>>,
1016 /// If a binary is specified in `command`, it will be passed these environment variables.
1017 pub env: Option<HashMap<String, String>>,
1018 /// Whether to skip searching `$PATH` for an agent server binary when
1019 /// launching this agent.
1020 ///
1021 /// This has no effect if a `command` is specified. Otherwise, when this is
1022 /// `false`, Zed will search `$PATH` for an agent server binary and, if one
1023 /// is found, use it for threads with this agent. If no agent binary is
1024 /// found on `$PATH`, Zed will automatically install and use its own binary.
1025 /// When this is `true`, Zed will not search `$PATH`, and will always use
1026 /// its own binary.
1027 ///
1028 /// Default: true
1029 pub ignore_system_version: Option<bool>,
1030}
1031
1032impl BuiltinAgentServerSettings {
1033 pub(crate) fn custom_command(self) -> Option<AgentServerCommand> {
1034 self.path.map(|path| AgentServerCommand {
1035 path,
1036 args: self.args.unwrap_or_default(),
1037 env: self.env,
1038 })
1039 }
1040}
1041
1042impl From<AgentServerCommand> for BuiltinAgentServerSettings {
1043 fn from(value: AgentServerCommand) -> Self {
1044 BuiltinAgentServerSettings {
1045 path: Some(value.path),
1046 args: Some(value.args),
1047 env: value.env,
1048 ..Default::default()
1049 }
1050 }
1051}
1052
1053#[derive(Deserialize, Serialize, Clone, JsonSchema, Debug, PartialEq)]
1054pub struct CustomAgentServerSettings {
1055 #[serde(flatten)]
1056 pub command: AgentServerCommand,
1057}
1058
1059impl settings::Settings for AllAgentServersSettings {
1060 type FileContent = Self;
1061
1062 fn load(sources: SettingsSources<Self::FileContent>, _: &mut App) -> Result<Self> {
1063 let mut settings = AllAgentServersSettings::default();
1064
1065 for AllAgentServersSettings {
1066 gemini,
1067 claude,
1068 custom,
1069 } in sources.defaults_and_customizations()
1070 {
1071 if gemini.is_some() {
1072 settings.gemini = gemini.clone();
1073 }
1074 if claude.is_some() {
1075 settings.claude = claude.clone();
1076 }
1077
1078 // Merge custom agents
1079 for (name, config) in custom {
1080 // Skip built-in agent names to avoid conflicts
1081 if name != GEMINI_NAME && name != CLAUDE_CODE_NAME {
1082 settings.custom.insert(name.clone(), config.clone());
1083 }
1084 }
1085 }
1086
1087 Ok(settings)
1088 }
1089
1090 fn import_from_vscode(_vscode: &settings::VsCodeSettings, _current: &mut Self::FileContent) {}
1091}