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 feature_flags::FeatureFlagAppExt as _;
14use fs::{Fs, RemoveOptions, RenameOptions};
15use futures::StreamExt as _;
16use gpui::{
17 App, AppContext as _, AsyncApp, Context, Entity, EventEmitter, SharedString, Subscription, Task,
18};
19use http_client::github::AssetKind;
20use node_runtime::NodeRuntime;
21use remote::RemoteClient;
22use rpc::{AnyProtoClient, TypedEnvelope, proto};
23use schemars::JsonSchema;
24use serde::{Deserialize, Serialize};
25use settings::{SettingsContent, SettingsStore};
26use util::{ResultExt as _, debug_panic};
27
28use crate::ProjectEnvironment;
29
30#[derive(Deserialize, Serialize, Clone, PartialEq, Eq, JsonSchema)]
31pub struct AgentServerCommand {
32 #[serde(rename = "command")]
33 pub path: PathBuf,
34 #[serde(default)]
35 pub args: Vec<String>,
36 pub env: Option<HashMap<String, String>>,
37}
38
39impl std::fmt::Debug for AgentServerCommand {
40 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
41 let filtered_env = self.env.as_ref().map(|env| {
42 env.iter()
43 .map(|(k, v)| {
44 (
45 k,
46 if util::redact::should_redact(k) {
47 "[REDACTED]"
48 } else {
49 v
50 },
51 )
52 })
53 .collect::<Vec<_>>()
54 });
55
56 f.debug_struct("AgentServerCommand")
57 .field("path", &self.path)
58 .field("args", &self.args)
59 .field("env", &filtered_env)
60 .finish()
61 }
62}
63
64#[derive(Clone, Debug, PartialEq, Eq, Hash)]
65pub struct ExternalAgentServerName(pub SharedString);
66
67impl std::fmt::Display for ExternalAgentServerName {
68 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
69 write!(f, "{}", self.0)
70 }
71}
72
73impl From<&'static str> for ExternalAgentServerName {
74 fn from(value: &'static str) -> Self {
75 ExternalAgentServerName(value.into())
76 }
77}
78
79impl From<ExternalAgentServerName> for SharedString {
80 fn from(value: ExternalAgentServerName) -> Self {
81 value.0
82 }
83}
84
85impl Borrow<str> for ExternalAgentServerName {
86 fn borrow(&self) -> &str {
87 &self.0
88 }
89}
90
91pub trait ExternalAgentServer {
92 fn get_command(
93 &mut self,
94 root_dir: Option<&str>,
95 extra_env: HashMap<String, String>,
96 status_tx: Option<watch::Sender<SharedString>>,
97 new_version_available_tx: Option<watch::Sender<Option<String>>>,
98 cx: &mut AsyncApp,
99 ) -> Task<Result<(AgentServerCommand, String, Option<task::SpawnInTerminal>)>>;
100
101 fn as_any_mut(&mut self) -> &mut dyn Any;
102}
103
104impl dyn ExternalAgentServer {
105 fn downcast_mut<T: ExternalAgentServer + 'static>(&mut self) -> Option<&mut T> {
106 self.as_any_mut().downcast_mut()
107 }
108}
109
110enum AgentServerStoreState {
111 Local {
112 node_runtime: NodeRuntime,
113 fs: Arc<dyn Fs>,
114 project_environment: Entity<ProjectEnvironment>,
115 downstream_client: Option<(u64, AnyProtoClient)>,
116 settings: Option<AllAgentServersSettings>,
117 _subscriptions: [Subscription; 1],
118 },
119 Remote {
120 project_id: u64,
121 upstream_client: Entity<RemoteClient>,
122 },
123 Collab,
124}
125
126pub struct AgentServerStore {
127 state: AgentServerStoreState,
128 external_agents: HashMap<ExternalAgentServerName, Box<dyn ExternalAgentServer>>,
129 _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 ..
178 } = &mut self.state
179 else {
180 debug_panic!("Non-local projects should never attempt to reregister. This is a bug!");
181
182 return;
183 };
184
185 let new_settings = cx
186 .global::<SettingsStore>()
187 .get::<AllAgentServersSettings>(None)
188 .clone();
189
190 self.external_agents.clear();
191 self.external_agents.insert(
192 GEMINI_NAME.into(),
193 Box::new(LocalGemini {
194 fs: fs.clone(),
195 node_runtime: node_runtime.clone(),
196 project_environment: project_environment.clone(),
197 custom_command: new_settings
198 .gemini
199 .clone()
200 .and_then(|settings| settings.custom_command()),
201 ignore_system_version: new_settings
202 .gemini
203 .as_ref()
204 .and_then(|settings| settings.ignore_system_version)
205 .unwrap_or(true),
206 }),
207 );
208 self.external_agents
209 .extend(new_settings.custom.iter().map(|(name, settings)| {
210 (
211 ExternalAgentServerName(name.clone()),
212 Box::new(LocalCustomAgent {
213 command: settings.command.clone(),
214 project_environment: project_environment.clone(),
215 }) as Box<dyn ExternalAgentServer>,
216 )
217 }));
218
219 use feature_flags::FeatureFlagAppExt as _;
220 if cx.has_flag::<feature_flags::CodexAcpFeatureFlag>() || new_settings.codex.is_some() {
221 self.external_agents.insert(
222 CODEX_NAME.into(),
223 Box::new(LocalCodex {
224 fs: fs.clone(),
225 project_environment: project_environment.clone(),
226 custom_command: new_settings
227 .codex
228 .clone()
229 .and_then(|settings| settings.custom_command()),
230 }),
231 );
232 }
233
234 self.external_agents.insert(
235 CLAUDE_CODE_NAME.into(),
236 Box::new(LocalClaudeCode {
237 fs: fs.clone(),
238 node_runtime: node_runtime.clone(),
239 project_environment: project_environment.clone(),
240 custom_command: new_settings
241 .claude
242 .clone()
243 .and_then(|settings| settings.custom_command()),
244 }),
245 );
246
247 *old_settings = Some(new_settings.clone());
248
249 if let Some((project_id, downstream_client)) = downstream_client {
250 downstream_client
251 .send(proto::ExternalAgentsUpdated {
252 project_id: *project_id,
253 names: self
254 .external_agents
255 .keys()
256 .filter(|name| name.0 != CODEX_NAME)
257 .map(|name| name.to_string())
258 .collect(),
259 })
260 .log_err();
261 }
262 cx.emit(AgentServersUpdated);
263 }
264
265 pub fn local(
266 node_runtime: NodeRuntime,
267 fs: Arc<dyn Fs>,
268 project_environment: Entity<ProjectEnvironment>,
269 cx: &mut Context<Self>,
270 ) -> Self {
271 let subscription = cx.observe_global::<SettingsStore>(|this, cx| {
272 this.agent_servers_settings_changed(cx);
273 });
274 let this_handle = cx.weak_entity();
275 let feature_flags_subscription =
276 cx.observe_flag::<feature_flags::CodexAcpFeatureFlag, _>(move |_enabled, cx| {
277 let _ = this_handle.update(cx, |this, cx| {
278 this.reregister_agents(cx);
279 });
280 });
281 let mut this = Self {
282 state: AgentServerStoreState::Local {
283 node_runtime,
284 fs,
285 project_environment,
286 downstream_client: None,
287 settings: None,
288 _subscriptions: [subscription],
289 },
290 external_agents: Default::default(),
291 _feature_flag_subscription: Some(feature_flags_subscription),
292 };
293 this.agent_servers_settings_changed(cx);
294 this
295 }
296
297 pub(crate) fn remote(
298 project_id: u64,
299 upstream_client: Entity<RemoteClient>,
300 _cx: &mut Context<Self>,
301 ) -> Self {
302 // Set up the builtin agents here so they're immediately available in
303 // remote projects--we know that the HeadlessProject on the other end
304 // will have them.
305 let external_agents = [
306 (
307 GEMINI_NAME.into(),
308 Box::new(RemoteExternalAgentServer {
309 project_id,
310 upstream_client: upstream_client.clone(),
311 name: GEMINI_NAME.into(),
312 status_tx: None,
313 new_version_available_tx: None,
314 }) as Box<dyn ExternalAgentServer>,
315 ),
316 (
317 CLAUDE_CODE_NAME.into(),
318 Box::new(RemoteExternalAgentServer {
319 project_id,
320 upstream_client: upstream_client.clone(),
321 name: CLAUDE_CODE_NAME.into(),
322 status_tx: None,
323 new_version_available_tx: None,
324 }) as Box<dyn ExternalAgentServer>,
325 ),
326 ]
327 .into_iter()
328 .collect();
329
330 Self {
331 state: AgentServerStoreState::Remote {
332 project_id,
333 upstream_client,
334 },
335 external_agents,
336 _feature_flag_subscription: None,
337 }
338 }
339
340 pub(crate) fn collab(_cx: &mut Context<Self>) -> Self {
341 Self {
342 state: AgentServerStoreState::Collab,
343 external_agents: Default::default(),
344 _feature_flag_subscription: None,
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: login.map(|login| login.to_proto()),
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 =
658 node_runtime.npm_package_latest_version(&package_name).await;
659 if let Ok(latest_version) = latest_version
660 && &latest_version != &file_name.to_string_lossy()
661 {
662 let download_result = download_latest_version(
663 fs,
664 dir.clone(),
665 node_runtime,
666 package_name.clone(),
667 )
668 .await
669 .log_err();
670 if let Some(mut new_version_available) = new_version_available
671 && download_result.is_some()
672 {
673 new_version_available.send(Some(latest_version)).ok();
674 }
675 }
676 }
677 })
678 .detach();
679 file_name
680 } else {
681 if let Some(mut status_tx) = status_tx {
682 status_tx.send("Installing…".into()).ok();
683 }
684 let dir = dir.clone();
685 cx.background_spawn(download_latest_version(
686 fs.clone(),
687 dir.clone(),
688 node_runtime,
689 package_name.clone(),
690 ))
691 .await?
692 .into()
693 };
694
695 let agent_server_path = dir.join(version).join(entrypoint_path);
696 let agent_server_path_exists = fs.is_file(&agent_server_path).await;
697 anyhow::ensure!(
698 agent_server_path_exists,
699 "Missing entrypoint path {} after installation",
700 agent_server_path.to_string_lossy()
701 );
702
703 anyhow::Ok(AgentServerCommand {
704 path: node_path,
705 args: vec![agent_server_path.to_string_lossy().into_owned()],
706 env: None,
707 })
708 })
709}
710
711fn find_bin_in_path(
712 bin_name: SharedString,
713 root_dir: PathBuf,
714 env: HashMap<String, String>,
715 cx: &mut AsyncApp,
716) -> Task<Option<PathBuf>> {
717 cx.background_executor().spawn(async move {
718 let which_result = if cfg!(windows) {
719 which::which(bin_name.as_str())
720 } else {
721 let shell_path = env.get("PATH").cloned();
722 which::which_in(bin_name.as_str(), shell_path.as_ref(), &root_dir)
723 };
724
725 if let Err(which::Error::CannotFindBinaryPath) = which_result {
726 return None;
727 }
728
729 which_result.log_err()
730 })
731}
732
733async fn download_latest_version(
734 fs: Arc<dyn Fs>,
735 dir: PathBuf,
736 node_runtime: NodeRuntime,
737 package_name: SharedString,
738) -> Result<String> {
739 log::debug!("downloading latest version of {package_name}");
740
741 let tmp_dir = tempfile::tempdir_in(&dir)?;
742
743 node_runtime
744 .npm_install_packages(tmp_dir.path(), &[(&package_name, "latest")])
745 .await?;
746
747 let version = node_runtime
748 .npm_package_installed_version(tmp_dir.path(), &package_name)
749 .await?
750 .context("expected package to be installed")?;
751
752 fs.rename(
753 &tmp_dir.keep(),
754 &dir.join(&version),
755 RenameOptions {
756 ignore_if_exists: true,
757 overwrite: true,
758 },
759 )
760 .await?;
761
762 anyhow::Ok(version)
763}
764
765struct RemoteExternalAgentServer {
766 project_id: u64,
767 upstream_client: Entity<RemoteClient>,
768 name: ExternalAgentServerName,
769 status_tx: Option<watch::Sender<SharedString>>,
770 new_version_available_tx: Option<watch::Sender<Option<String>>>,
771}
772
773impl ExternalAgentServer for RemoteExternalAgentServer {
774 fn get_command(
775 &mut self,
776 root_dir: Option<&str>,
777 extra_env: HashMap<String, String>,
778 status_tx: Option<watch::Sender<SharedString>>,
779 new_version_available_tx: Option<watch::Sender<Option<String>>>,
780 cx: &mut AsyncApp,
781 ) -> Task<Result<(AgentServerCommand, String, Option<task::SpawnInTerminal>)>> {
782 let project_id = self.project_id;
783 let name = self.name.to_string();
784 let upstream_client = self.upstream_client.downgrade();
785 let root_dir = root_dir.map(|root_dir| root_dir.to_owned());
786 self.status_tx = status_tx;
787 self.new_version_available_tx = new_version_available_tx;
788 cx.spawn(async move |cx| {
789 let mut response = upstream_client
790 .update(cx, |upstream_client, _| {
791 upstream_client
792 .proto_client()
793 .request(proto::GetAgentServerCommand {
794 project_id,
795 name,
796 root_dir: root_dir.clone(),
797 })
798 })?
799 .await?;
800 let root_dir = response.root_dir;
801 response.env.extend(extra_env);
802 let command = upstream_client.update(cx, |client, _| {
803 client.build_command(
804 Some(response.path),
805 &response.args,
806 &response.env.into_iter().collect(),
807 Some(root_dir.clone()),
808 None,
809 )
810 })??;
811 Ok((
812 AgentServerCommand {
813 path: command.program.into(),
814 args: command.args,
815 env: Some(command.env),
816 },
817 root_dir,
818 response
819 .login
820 .map(|login| task::SpawnInTerminal::from_proto(login)),
821 ))
822 })
823 }
824
825 fn as_any_mut(&mut self) -> &mut dyn Any {
826 self
827 }
828}
829
830struct LocalGemini {
831 fs: Arc<dyn Fs>,
832 node_runtime: NodeRuntime,
833 project_environment: Entity<ProjectEnvironment>,
834 custom_command: Option<AgentServerCommand>,
835 ignore_system_version: bool,
836}
837
838impl ExternalAgentServer for LocalGemini {
839 fn get_command(
840 &mut self,
841 root_dir: Option<&str>,
842 extra_env: HashMap<String, String>,
843 status_tx: Option<watch::Sender<SharedString>>,
844 new_version_available_tx: Option<watch::Sender<Option<String>>>,
845 cx: &mut AsyncApp,
846 ) -> Task<Result<(AgentServerCommand, String, Option<task::SpawnInTerminal>)>> {
847 let fs = self.fs.clone();
848 let node_runtime = self.node_runtime.clone();
849 let project_environment = self.project_environment.downgrade();
850 let custom_command = self.custom_command.clone();
851 let ignore_system_version = self.ignore_system_version;
852 let root_dir: Arc<Path> = root_dir
853 .map(|root_dir| Path::new(root_dir))
854 .unwrap_or(paths::home_dir())
855 .into();
856
857 cx.spawn(async move |cx| {
858 let mut env = project_environment
859 .update(cx, |project_environment, cx| {
860 project_environment.get_directory_environment(root_dir.clone(), cx)
861 })?
862 .await
863 .unwrap_or_default();
864
865 let mut command = if let Some(mut custom_command) = custom_command {
866 env.extend(custom_command.env.unwrap_or_default());
867 custom_command.env = Some(env);
868 custom_command
869 } else if !ignore_system_version
870 && let Some(bin) =
871 find_bin_in_path("gemini".into(), root_dir.to_path_buf(), env.clone(), cx).await
872 {
873 AgentServerCommand {
874 path: bin,
875 args: Vec::new(),
876 env: Some(env),
877 }
878 } else {
879 let mut command = get_or_npm_install_builtin_agent(
880 GEMINI_NAME.into(),
881 "@google/gemini-cli".into(),
882 "node_modules/@google/gemini-cli/dist/index.js".into(),
883 Some("0.2.1".parse().unwrap()),
884 status_tx,
885 new_version_available_tx,
886 fs,
887 node_runtime,
888 cx,
889 )
890 .await?;
891 command.env = Some(env);
892 command
893 };
894
895 // Gemini CLI doesn't seem to have a dedicated invocation for logging in--we just run it normally without any arguments.
896 let login = task::SpawnInTerminal {
897 command: Some(command.path.to_string_lossy().into_owned()),
898 args: command.args.clone(),
899 env: command.env.clone().unwrap_or_default(),
900 label: "gemini /auth".into(),
901 ..Default::default()
902 };
903
904 command.env.get_or_insert_default().extend(extra_env);
905 command.args.push("--experimental-acp".into());
906 Ok((
907 command,
908 root_dir.to_string_lossy().into_owned(),
909 Some(login),
910 ))
911 })
912 }
913
914 fn as_any_mut(&mut self) -> &mut dyn Any {
915 self
916 }
917}
918
919struct LocalClaudeCode {
920 fs: Arc<dyn Fs>,
921 node_runtime: NodeRuntime,
922 project_environment: Entity<ProjectEnvironment>,
923 custom_command: Option<AgentServerCommand>,
924}
925
926impl ExternalAgentServer for LocalClaudeCode {
927 fn get_command(
928 &mut self,
929 root_dir: Option<&str>,
930 extra_env: HashMap<String, String>,
931 status_tx: Option<watch::Sender<SharedString>>,
932 new_version_available_tx: Option<watch::Sender<Option<String>>>,
933 cx: &mut AsyncApp,
934 ) -> Task<Result<(AgentServerCommand, String, Option<task::SpawnInTerminal>)>> {
935 let fs = self.fs.clone();
936 let node_runtime = self.node_runtime.clone();
937 let project_environment = self.project_environment.downgrade();
938 let custom_command = self.custom_command.clone();
939 let root_dir: Arc<Path> = root_dir
940 .map(|root_dir| Path::new(root_dir))
941 .unwrap_or(paths::home_dir())
942 .into();
943
944 cx.spawn(async move |cx| {
945 let mut env = project_environment
946 .update(cx, |project_environment, cx| {
947 project_environment.get_directory_environment(root_dir.clone(), cx)
948 })?
949 .await
950 .unwrap_or_default();
951 env.insert("ANTHROPIC_API_KEY".into(), "".into());
952
953 let (mut command, login) = if let Some(mut custom_command) = custom_command {
954 env.extend(custom_command.env.unwrap_or_default());
955 custom_command.env = Some(env);
956 (custom_command, None)
957 } else {
958 let mut command = get_or_npm_install_builtin_agent(
959 "claude-code-acp".into(),
960 "@zed-industries/claude-code-acp".into(),
961 "node_modules/@zed-industries/claude-code-acp/dist/index.js".into(),
962 Some("0.5.2".parse().unwrap()),
963 status_tx,
964 new_version_available_tx,
965 fs,
966 node_runtime,
967 cx,
968 )
969 .await?;
970 command.env = Some(env);
971 let login = command
972 .args
973 .first()
974 .and_then(|path| {
975 path.strip_suffix("/@zed-industries/claude-code-acp/dist/index.js")
976 })
977 .map(|path_prefix| task::SpawnInTerminal {
978 command: Some(command.path.to_string_lossy().into_owned()),
979 args: vec![
980 Path::new(path_prefix)
981 .join("@anthropic-ai/claude-agent-sdk/cli.js")
982 .to_string_lossy()
983 .to_string(),
984 "/login".into(),
985 ],
986 env: command.env.clone().unwrap_or_default(),
987 label: "claude /login".into(),
988 ..Default::default()
989 });
990 (command, login)
991 };
992
993 command.env.get_or_insert_default().extend(extra_env);
994 Ok((command, root_dir.to_string_lossy().into_owned(), login))
995 })
996 }
997
998 fn as_any_mut(&mut self) -> &mut dyn Any {
999 self
1000 }
1001}
1002
1003struct LocalCodex {
1004 fs: Arc<dyn Fs>,
1005 project_environment: Entity<ProjectEnvironment>,
1006 custom_command: Option<AgentServerCommand>,
1007}
1008
1009impl ExternalAgentServer for LocalCodex {
1010 fn get_command(
1011 &mut self,
1012 root_dir: Option<&str>,
1013 extra_env: HashMap<String, String>,
1014 _status_tx: Option<watch::Sender<SharedString>>,
1015 _new_version_available_tx: Option<watch::Sender<Option<String>>>,
1016 cx: &mut AsyncApp,
1017 ) -> Task<Result<(AgentServerCommand, String, Option<task::SpawnInTerminal>)>> {
1018 let fs = self.fs.clone();
1019 let project_environment = self.project_environment.downgrade();
1020 let custom_command = self.custom_command.clone();
1021 let root_dir: Arc<Path> = root_dir
1022 .map(|root_dir| Path::new(root_dir))
1023 .unwrap_or(paths::home_dir())
1024 .into();
1025
1026 cx.spawn(async move |cx| {
1027 let mut env = project_environment
1028 .update(cx, |project_environment, cx| {
1029 project_environment.get_directory_environment(root_dir.clone(), cx)
1030 })?
1031 .await
1032 .unwrap_or_default();
1033
1034 let mut command = if let Some(mut custom_command) = custom_command {
1035 env.extend(custom_command.env.unwrap_or_default());
1036 custom_command.env = Some(env);
1037 custom_command
1038 } else {
1039 let dir = paths::data_dir().join("external_agents").join(CODEX_NAME);
1040 fs.create_dir(&dir).await?;
1041
1042 // Find or install the latest Codex release (no update checks for now).
1043 let http = cx.update(|cx| Client::global(cx).http_client())?;
1044 let release = ::http_client::github::latest_github_release(
1045 CODEX_ACP_REPO,
1046 true,
1047 false,
1048 http.clone(),
1049 )
1050 .await
1051 .context("fetching Codex latest release")?;
1052
1053 let version_dir = dir.join(&release.tag_name);
1054 if !fs.is_dir(&version_dir).await {
1055 let tag = release.tag_name.clone();
1056 let version_number = tag.trim_start_matches('v');
1057 let asset_name = asset_name(version_number)
1058 .context("codex acp is not supported for this architecture")?;
1059 let asset = release
1060 .assets
1061 .into_iter()
1062 .find(|asset| asset.name == asset_name)
1063 .with_context(|| format!("no asset found matching `{asset_name:?}`"))?;
1064 ::http_client::github_download::download_server_binary(
1065 &*http,
1066 &asset.browser_download_url,
1067 asset.digest.as_deref(),
1068 &version_dir,
1069 if cfg!(target_os = "windows") && cfg!(target_arch = "x86_64") {
1070 AssetKind::Zip
1071 } else {
1072 AssetKind::TarGz
1073 },
1074 )
1075 .await?;
1076 }
1077
1078 let bin_name = if cfg!(windows) {
1079 "codex-acp.exe"
1080 } else {
1081 "codex-acp"
1082 };
1083 let bin_path = version_dir.join(bin_name);
1084 anyhow::ensure!(
1085 fs.is_file(&bin_path).await,
1086 "Missing Codex binary at {} after installation",
1087 bin_path.to_string_lossy()
1088 );
1089
1090 let mut cmd = AgentServerCommand {
1091 path: bin_path,
1092 args: Vec::new(),
1093 env: None,
1094 };
1095 cmd.env = Some(env);
1096 cmd
1097 };
1098
1099 command.env.get_or_insert_default().extend(extra_env);
1100 Ok((command, root_dir.to_string_lossy().into_owned(), None))
1101 })
1102 }
1103
1104 fn as_any_mut(&mut self) -> &mut dyn Any {
1105 self
1106 }
1107}
1108
1109pub const CODEX_ACP_REPO: &str = "zed-industries/codex-acp";
1110
1111/// Assemble Codex release URL for the current OS/arch and the given version number.
1112/// Returns None if the current target is unsupported.
1113/// Example output:
1114/// https://github.com/zed-industries/codex-acp/releases/download/v{version}/codex-acp-{version}-{arch}-{platform}.{ext}
1115fn asset_name(version: &str) -> Option<String> {
1116 let arch = if cfg!(target_arch = "x86_64") {
1117 "x86_64"
1118 } else if cfg!(target_arch = "aarch64") {
1119 "aarch64"
1120 } else {
1121 return None;
1122 };
1123
1124 let platform = if cfg!(target_os = "macos") {
1125 "apple-darwin"
1126 } else if cfg!(target_os = "windows") {
1127 "pc-windows-msvc"
1128 } else if cfg!(target_os = "linux") {
1129 "unknown-linux-gnu"
1130 } else {
1131 return None;
1132 };
1133
1134 // Only Windows x86_64 uses .zip in release assets
1135 let ext = if cfg!(target_os = "windows") && cfg!(target_arch = "x86_64") {
1136 "zip"
1137 } else {
1138 "tar.gz"
1139 };
1140
1141 Some(format!("codex-acp-{version}-{arch}-{platform}.{ext}"))
1142}
1143
1144struct LocalCustomAgent {
1145 project_environment: Entity<ProjectEnvironment>,
1146 command: AgentServerCommand,
1147}
1148
1149impl ExternalAgentServer for LocalCustomAgent {
1150 fn get_command(
1151 &mut self,
1152 root_dir: Option<&str>,
1153 extra_env: HashMap<String, String>,
1154 _status_tx: Option<watch::Sender<SharedString>>,
1155 _new_version_available_tx: Option<watch::Sender<Option<String>>>,
1156 cx: &mut AsyncApp,
1157 ) -> Task<Result<(AgentServerCommand, String, Option<task::SpawnInTerminal>)>> {
1158 let mut command = self.command.clone();
1159 let root_dir: Arc<Path> = root_dir
1160 .map(|root_dir| Path::new(root_dir))
1161 .unwrap_or(paths::home_dir())
1162 .into();
1163 let project_environment = self.project_environment.downgrade();
1164 cx.spawn(async move |cx| {
1165 let mut env = project_environment
1166 .update(cx, |project_environment, cx| {
1167 project_environment.get_directory_environment(root_dir.clone(), cx)
1168 })?
1169 .await
1170 .unwrap_or_default();
1171 env.extend(command.env.unwrap_or_default());
1172 env.extend(extra_env);
1173 command.env = Some(env);
1174 Ok((command, root_dir.to_string_lossy().into_owned(), None))
1175 })
1176 }
1177
1178 fn as_any_mut(&mut self) -> &mut dyn Any {
1179 self
1180 }
1181}
1182
1183#[cfg(test)]
1184mod tests {
1185 #[test]
1186 fn assembles_codex_release_url_for_current_target() {
1187 let version_number = "0.1.0";
1188
1189 // This test fails the build if we are building a version of Zed
1190 // which does not have a known build of codex-acp, to prevent us
1191 // from accidentally doing a release on a new target without
1192 // realizing that codex-acp support will not work on that target!
1193 //
1194 // Additionally, it verifies that our logic for assembling URLs
1195 // correctly resolves to a known-good URL on each of our targets.
1196 let allowed = [
1197 "codex-acp-0.1.0-aarch64-apple-darwin.tar.gz",
1198 "codex-acp-0.1.0-aarch64-pc-windows-msvc.tar.gz",
1199 "codex-acp-0.1.0-aarch64-unknown-linux-gnu.tar.gz",
1200 "codex-acp-0.1.0-x86_64-apple-darwin.tar.gz",
1201 "codex-acp-0.1.0-x86_64-pc-windows-msvc.zip",
1202 "codex-acp-0.1.0-x86_64-unknown-linux-gnu.tar.gz",
1203 ];
1204
1205 if let Some(url) = super::asset_name(version_number) {
1206 assert!(
1207 allowed.contains(&url.as_str()),
1208 "Assembled asset name {} not in allowed list",
1209 url
1210 );
1211 } else {
1212 panic!(
1213 "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."
1214 );
1215 }
1216 }
1217}
1218
1219pub const GEMINI_NAME: &'static str = "gemini";
1220pub const CLAUDE_CODE_NAME: &'static str = "claude";
1221pub const CODEX_NAME: &'static str = "codex";
1222
1223#[derive(Default, Clone, JsonSchema, Debug, PartialEq)]
1224pub struct AllAgentServersSettings {
1225 pub gemini: Option<BuiltinAgentServerSettings>,
1226 pub claude: Option<BuiltinAgentServerSettings>,
1227 pub codex: Option<BuiltinAgentServerSettings>,
1228 pub custom: HashMap<SharedString, CustomAgentServerSettings>,
1229}
1230#[derive(Default, Clone, JsonSchema, Debug, PartialEq)]
1231pub struct BuiltinAgentServerSettings {
1232 pub path: Option<PathBuf>,
1233 pub args: Option<Vec<String>>,
1234 pub env: Option<HashMap<String, String>>,
1235 pub ignore_system_version: Option<bool>,
1236 pub default_mode: Option<String>,
1237}
1238
1239impl BuiltinAgentServerSettings {
1240 pub(crate) fn custom_command(self) -> Option<AgentServerCommand> {
1241 self.path.map(|path| AgentServerCommand {
1242 path,
1243 args: self.args.unwrap_or_default(),
1244 env: self.env,
1245 })
1246 }
1247}
1248
1249impl From<settings::BuiltinAgentServerSettings> for BuiltinAgentServerSettings {
1250 fn from(value: settings::BuiltinAgentServerSettings) -> Self {
1251 BuiltinAgentServerSettings {
1252 path: value.path,
1253 args: value.args,
1254 env: value.env,
1255 ignore_system_version: value.ignore_system_version,
1256 default_mode: value.default_mode,
1257 }
1258 }
1259}
1260
1261impl From<AgentServerCommand> for BuiltinAgentServerSettings {
1262 fn from(value: AgentServerCommand) -> Self {
1263 BuiltinAgentServerSettings {
1264 path: Some(value.path),
1265 args: Some(value.args),
1266 env: value.env,
1267 ..Default::default()
1268 }
1269 }
1270}
1271
1272#[derive(Clone, JsonSchema, Debug, PartialEq)]
1273pub struct CustomAgentServerSettings {
1274 pub command: AgentServerCommand,
1275 /// The default mode to use for this agent.
1276 ///
1277 /// Note: Not only all agents support modes.
1278 ///
1279 /// Default: None
1280 pub default_mode: Option<String>,
1281}
1282
1283impl From<settings::CustomAgentServerSettings> for CustomAgentServerSettings {
1284 fn from(value: settings::CustomAgentServerSettings) -> Self {
1285 CustomAgentServerSettings {
1286 command: AgentServerCommand {
1287 path: value.path,
1288 args: value.args,
1289 env: value.env,
1290 },
1291 default_mode: value.default_mode,
1292 }
1293 }
1294}
1295
1296impl settings::Settings for AllAgentServersSettings {
1297 fn from_settings(content: &settings::SettingsContent, _cx: &mut App) -> Self {
1298 let agent_settings = content.agent_servers.clone().unwrap();
1299 Self {
1300 gemini: agent_settings.gemini.map(Into::into),
1301 claude: agent_settings.claude.map(Into::into),
1302 codex: agent_settings.codex.map(Into::into),
1303 custom: agent_settings
1304 .custom
1305 .into_iter()
1306 .map(|(k, v)| (k, v.into()))
1307 .collect(),
1308 }
1309 }
1310
1311 fn import_from_vscode(_vscode: &settings::VsCodeSettings, _current: &mut SettingsContent) {}
1312}