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