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