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