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