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