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