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 App, 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::{SettingsContent, 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 channel: &'static str,
581 status_tx: Option<watch::Sender<SharedString>>,
582 new_version_available: Option<watch::Sender<Option<String>>>,
583 fs: Arc<dyn Fs>,
584 node_runtime: NodeRuntime,
585 cx: &mut AsyncApp,
586) -> Task<std::result::Result<AgentServerCommand, anyhow::Error>> {
587 cx.spawn(async move |cx| {
588 let node_path = node_runtime.binary_path().await?;
589 let dir = paths::data_dir()
590 .join("external_agents")
591 .join(binary_name.as_str());
592 fs.create_dir(&dir).await?;
593
594 let mut stream = fs.read_dir(&dir).await?;
595 let mut versions = Vec::new();
596 let mut to_delete = Vec::new();
597 while let Some(entry) = stream.next().await {
598 let Ok(entry) = entry else { continue };
599 let Some(file_name) = entry.file_name() else {
600 continue;
601 };
602
603 if let Some(name) = file_name.to_str()
604 && let Some(version) = semver::Version::from_str(name).ok()
605 && fs
606 .is_file(&dir.join(file_name).join(&entrypoint_path))
607 .await
608 {
609 versions.push((version, file_name.to_owned()));
610 } else {
611 to_delete.push(file_name.to_owned())
612 }
613 }
614
615 versions.sort();
616 let newest_version = if let Some((version, file_name)) = versions.last().cloned()
617 && minimum_version.is_none_or(|minimum_version| version >= minimum_version)
618 {
619 versions.pop();
620 Some(file_name)
621 } else {
622 None
623 };
624 log::debug!("existing version of {package_name}: {newest_version:?}");
625 to_delete.extend(versions.into_iter().map(|(_, file_name)| file_name));
626
627 cx.background_spawn({
628 let fs = fs.clone();
629 let dir = dir.clone();
630 async move {
631 for file_name in to_delete {
632 fs.remove_dir(
633 &dir.join(file_name),
634 RemoveOptions {
635 recursive: true,
636 ignore_if_not_exists: false,
637 },
638 )
639 .await
640 .ok();
641 }
642 }
643 })
644 .detach();
645
646 let version = if let Some(file_name) = newest_version {
647 cx.background_spawn({
648 let file_name = file_name.clone();
649 let dir = dir.clone();
650 let fs = fs.clone();
651 async move {
652 // TODO remove the filter
653 let latest_version = node_runtime
654 .npm_package_latest_version(&package_name)
655 .await
656 .ok()
657 .filter(|_| channel == "latest");
658 if let Some(latest_version) = latest_version
659 && &latest_version != &file_name.to_string_lossy()
660 {
661 let download_result = download_latest_version(
662 fs,
663 dir.clone(),
664 node_runtime,
665 package_name.clone(),
666 channel,
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 channel,
691 ))
692 .await?
693 .into()
694 };
695
696 let agent_server_path = dir.join(version).join(entrypoint_path);
697 let agent_server_path_exists = fs.is_file(&agent_server_path).await;
698 anyhow::ensure!(
699 agent_server_path_exists,
700 "Missing entrypoint path {} after installation",
701 agent_server_path.to_string_lossy()
702 );
703
704 anyhow::Ok(AgentServerCommand {
705 path: node_path,
706 args: vec![agent_server_path.to_string_lossy().into_owned()],
707 env: None,
708 })
709 })
710}
711
712fn find_bin_in_path(
713 bin_name: SharedString,
714 root_dir: PathBuf,
715 env: HashMap<String, String>,
716 cx: &mut AsyncApp,
717) -> Task<Option<PathBuf>> {
718 cx.background_executor().spawn(async move {
719 let which_result = if cfg!(windows) {
720 which::which(bin_name.as_str())
721 } else {
722 let shell_path = env.get("PATH").cloned();
723 which::which_in(bin_name.as_str(), shell_path.as_ref(), &root_dir)
724 };
725
726 if let Err(which::Error::CannotFindBinaryPath) = which_result {
727 return None;
728 }
729
730 which_result.log_err()
731 })
732}
733
734async fn download_latest_version(
735 fs: Arc<dyn Fs>,
736 dir: PathBuf,
737 node_runtime: NodeRuntime,
738 package_name: SharedString,
739 channel: &'static str,
740) -> Result<String> {
741 log::debug!("downloading latest version of {package_name}");
742
743 let tmp_dir = tempfile::tempdir_in(&dir)?;
744
745 node_runtime
746 .npm_install_packages(tmp_dir.path(), &[(&package_name, channel)])
747 .await?;
748
749 let version = node_runtime
750 .npm_package_installed_version(tmp_dir.path(), &package_name)
751 .await?
752 .context("expected package to be installed")?;
753
754 fs.rename(
755 &tmp_dir.keep(),
756 &dir.join(&version),
757 RenameOptions {
758 ignore_if_exists: true,
759 overwrite: true,
760 },
761 )
762 .await?;
763
764 anyhow::Ok(version)
765}
766
767struct RemoteExternalAgentServer {
768 project_id: u64,
769 upstream_client: Entity<RemoteClient>,
770 name: ExternalAgentServerName,
771 status_tx: Option<watch::Sender<SharedString>>,
772 new_version_available_tx: Option<watch::Sender<Option<String>>>,
773}
774
775impl ExternalAgentServer for RemoteExternalAgentServer {
776 fn get_command(
777 &mut self,
778 root_dir: Option<&str>,
779 extra_env: HashMap<String, String>,
780 status_tx: Option<watch::Sender<SharedString>>,
781 new_version_available_tx: Option<watch::Sender<Option<String>>>,
782 cx: &mut AsyncApp,
783 ) -> Task<Result<(AgentServerCommand, String, Option<task::SpawnInTerminal>)>> {
784 let project_id = self.project_id;
785 let name = self.name.to_string();
786 let upstream_client = self.upstream_client.downgrade();
787 let root_dir = root_dir.map(|root_dir| root_dir.to_owned());
788 self.status_tx = status_tx;
789 self.new_version_available_tx = new_version_available_tx;
790 cx.spawn(async move |cx| {
791 let mut response = upstream_client
792 .update(cx, |upstream_client, _| {
793 upstream_client
794 .proto_client()
795 .request(proto::GetAgentServerCommand {
796 project_id,
797 name,
798 root_dir: root_dir.clone(),
799 })
800 })?
801 .await?;
802 let root_dir = response.root_dir;
803 response.env.extend(extra_env);
804 let command = upstream_client.update(cx, |client, _| {
805 client.build_command(
806 Some(response.path),
807 &response.args,
808 &response.env.into_iter().collect(),
809 Some(root_dir.clone()),
810 None,
811 )
812 })??;
813 Ok((
814 AgentServerCommand {
815 path: command.program.into(),
816 args: command.args,
817 env: Some(command.env),
818 },
819 root_dir,
820 response
821 .login
822 .map(|login| task::SpawnInTerminal::from_proto(login)),
823 ))
824 })
825 }
826
827 fn as_any_mut(&mut self) -> &mut dyn Any {
828 self
829 }
830}
831
832struct LocalGemini {
833 fs: Arc<dyn Fs>,
834 node_runtime: NodeRuntime,
835 project_environment: Entity<ProjectEnvironment>,
836 custom_command: Option<AgentServerCommand>,
837 ignore_system_version: bool,
838}
839
840impl ExternalAgentServer for LocalGemini {
841 fn get_command(
842 &mut self,
843 root_dir: Option<&str>,
844 extra_env: HashMap<String, String>,
845 status_tx: Option<watch::Sender<SharedString>>,
846 new_version_available_tx: Option<watch::Sender<Option<String>>>,
847 cx: &mut AsyncApp,
848 ) -> Task<Result<(AgentServerCommand, String, Option<task::SpawnInTerminal>)>> {
849 let fs = self.fs.clone();
850 let node_runtime = self.node_runtime.clone();
851 let project_environment = self.project_environment.downgrade();
852 let custom_command = self.custom_command.clone();
853 let ignore_system_version = self.ignore_system_version;
854 let root_dir: Arc<Path> = root_dir
855 .map(|root_dir| Path::new(root_dir))
856 .unwrap_or(paths::home_dir())
857 .into();
858
859 cx.spawn(async move |cx| {
860 let mut env = project_environment
861 .update(cx, |project_environment, cx| {
862 project_environment.get_local_directory_environment(
863 &Shell::System,
864 root_dir.clone(),
865 cx,
866 )
867 })?
868 .await
869 .unwrap_or_default();
870
871 let mut command = if let Some(mut custom_command) = custom_command {
872 env.extend(custom_command.env.unwrap_or_default());
873 custom_command.env = Some(env);
874 custom_command
875 } else if !ignore_system_version
876 && let Some(bin) =
877 find_bin_in_path("gemini".into(), root_dir.to_path_buf(), env.clone(), cx).await
878 {
879 AgentServerCommand {
880 path: bin,
881 args: Vec::new(),
882 env: Some(env),
883 }
884 } else {
885 let mut command = get_or_npm_install_builtin_agent(
886 GEMINI_NAME.into(),
887 "@google/gemini-cli".into(),
888 "node_modules/@google/gemini-cli/dist/index.js".into(),
889 // TODO remove these windows-specific workarounds once v0.9.0 stable is released
890 if cfg!(windows) {
891 Some("0.9.0-preview.4".parse().unwrap())
892 } else {
893 Some("0.2.1".parse().unwrap())
894 },
895 if cfg!(windows) {
896 "0.9.0-preview.4"
897 } else {
898 "latest"
899 },
900 status_tx,
901 new_version_available_tx,
902 fs,
903 node_runtime,
904 cx,
905 )
906 .await?;
907 command.env = Some(env);
908 command
909 };
910
911 // Gemini CLI doesn't seem to have a dedicated invocation for logging in--we just run it normally without any arguments.
912 let login = task::SpawnInTerminal {
913 command: Some(command.path.to_string_lossy().into_owned()),
914 args: command.args.clone(),
915 env: command.env.clone().unwrap_or_default(),
916 label: "gemini /auth".into(),
917 ..Default::default()
918 };
919
920 command.env.get_or_insert_default().extend(extra_env);
921 command.args.push("--experimental-acp".into());
922 Ok((
923 command,
924 root_dir.to_string_lossy().into_owned(),
925 Some(login),
926 ))
927 })
928 }
929
930 fn as_any_mut(&mut self) -> &mut dyn Any {
931 self
932 }
933}
934
935struct LocalClaudeCode {
936 fs: Arc<dyn Fs>,
937 node_runtime: NodeRuntime,
938 project_environment: Entity<ProjectEnvironment>,
939 custom_command: Option<AgentServerCommand>,
940}
941
942impl ExternalAgentServer for LocalClaudeCode {
943 fn get_command(
944 &mut self,
945 root_dir: Option<&str>,
946 extra_env: HashMap<String, String>,
947 status_tx: Option<watch::Sender<SharedString>>,
948 new_version_available_tx: Option<watch::Sender<Option<String>>>,
949 cx: &mut AsyncApp,
950 ) -> Task<Result<(AgentServerCommand, String, Option<task::SpawnInTerminal>)>> {
951 let fs = self.fs.clone();
952 let node_runtime = self.node_runtime.clone();
953 let project_environment = self.project_environment.downgrade();
954 let custom_command = self.custom_command.clone();
955 let root_dir: Arc<Path> = root_dir
956 .map(|root_dir| Path::new(root_dir))
957 .unwrap_or(paths::home_dir())
958 .into();
959
960 cx.spawn(async move |cx| {
961 let mut env = project_environment
962 .update(cx, |project_environment, cx| {
963 project_environment.get_local_directory_environment(
964 &Shell::System,
965 root_dir.clone(),
966 cx,
967 )
968 })?
969 .await
970 .unwrap_or_default();
971 env.insert("ANTHROPIC_API_KEY".into(), "".into());
972
973 let (mut command, login) = if let Some(mut custom_command) = custom_command {
974 env.extend(custom_command.env.unwrap_or_default());
975 custom_command.env = Some(env);
976 (custom_command, None)
977 } else {
978 let mut command = get_or_npm_install_builtin_agent(
979 "claude-code-acp".into(),
980 "@zed-industries/claude-code-acp".into(),
981 "node_modules/@zed-industries/claude-code-acp/dist/index.js".into(),
982 Some("0.5.2".parse().unwrap()),
983 "latest",
984 status_tx,
985 new_version_available_tx,
986 fs,
987 node_runtime,
988 cx,
989 )
990 .await?;
991 command.env = Some(env);
992 let login = command
993 .args
994 .first()
995 .and_then(|path| {
996 path.strip_suffix("/@zed-industries/claude-code-acp/dist/index.js")
997 })
998 .map(|path_prefix| task::SpawnInTerminal {
999 command: Some(command.path.to_string_lossy().into_owned()),
1000 args: vec![
1001 Path::new(path_prefix)
1002 .join("@anthropic-ai/claude-agent-sdk/cli.js")
1003 .to_string_lossy()
1004 .to_string(),
1005 "/login".into(),
1006 ],
1007 env: command.env.clone().unwrap_or_default(),
1008 label: "claude /login".into(),
1009 ..Default::default()
1010 });
1011 (command, login)
1012 };
1013
1014 command.env.get_or_insert_default().extend(extra_env);
1015 Ok((command, root_dir.to_string_lossy().into_owned(), login))
1016 })
1017 }
1018
1019 fn as_any_mut(&mut self) -> &mut dyn Any {
1020 self
1021 }
1022}
1023
1024struct LocalCodex {
1025 fs: Arc<dyn Fs>,
1026 project_environment: Entity<ProjectEnvironment>,
1027 http_client: Arc<dyn HttpClient>,
1028 custom_command: Option<AgentServerCommand>,
1029 is_remote: bool,
1030}
1031
1032impl ExternalAgentServer for LocalCodex {
1033 fn get_command(
1034 &mut self,
1035 root_dir: Option<&str>,
1036 extra_env: HashMap<String, String>,
1037 _status_tx: Option<watch::Sender<SharedString>>,
1038 _new_version_available_tx: Option<watch::Sender<Option<String>>>,
1039 cx: &mut AsyncApp,
1040 ) -> Task<Result<(AgentServerCommand, String, Option<task::SpawnInTerminal>)>> {
1041 let fs = self.fs.clone();
1042 let project_environment = self.project_environment.downgrade();
1043 let http = self.http_client.clone();
1044 let custom_command = self.custom_command.clone();
1045 let root_dir: Arc<Path> = root_dir
1046 .map(|root_dir| Path::new(root_dir))
1047 .unwrap_or(paths::home_dir())
1048 .into();
1049 let is_remote = self.is_remote;
1050
1051 cx.spawn(async move |cx| {
1052 let mut env = project_environment
1053 .update(cx, |project_environment, cx| {
1054 project_environment.get_local_directory_environment(
1055 &Shell::System,
1056 root_dir.clone(),
1057 cx,
1058 )
1059 })?
1060 .await
1061 .unwrap_or_default();
1062 if is_remote {
1063 env.insert("NO_BROWSER".to_owned(), "1".to_owned());
1064 }
1065
1066 let mut command = if let Some(mut custom_command) = custom_command {
1067 env.extend(custom_command.env.unwrap_or_default());
1068 custom_command.env = Some(env);
1069 custom_command
1070 } else {
1071 let dir = paths::data_dir().join("external_agents").join(CODEX_NAME);
1072 fs.create_dir(&dir).await?;
1073
1074 // Find or install the latest Codex release (no update checks for now).
1075 let release = ::http_client::github::latest_github_release(
1076 CODEX_ACP_REPO,
1077 true,
1078 false,
1079 http.clone(),
1080 )
1081 .await
1082 .context("fetching Codex latest release")?;
1083
1084 let version_dir = dir.join(&release.tag_name);
1085 if !fs.is_dir(&version_dir).await {
1086 let tag = release.tag_name.clone();
1087 let version_number = tag.trim_start_matches('v');
1088 let asset_name = asset_name(version_number)
1089 .context("codex acp is not supported for this architecture")?;
1090 let asset = release
1091 .assets
1092 .into_iter()
1093 .find(|asset| asset.name == asset_name)
1094 .with_context(|| format!("no asset found matching `{asset_name:?}`"))?;
1095 ::http_client::github_download::download_server_binary(
1096 &*http,
1097 &asset.browser_download_url,
1098 asset.digest.as_deref(),
1099 &version_dir,
1100 if cfg!(target_os = "windows") && cfg!(target_arch = "x86_64") {
1101 AssetKind::Zip
1102 } else {
1103 AssetKind::TarGz
1104 },
1105 )
1106 .await?;
1107 }
1108
1109 let bin_name = if cfg!(windows) {
1110 "codex-acp.exe"
1111 } else {
1112 "codex-acp"
1113 };
1114 let bin_path = version_dir.join(bin_name);
1115 anyhow::ensure!(
1116 fs.is_file(&bin_path).await,
1117 "Missing Codex binary at {} after installation",
1118 bin_path.to_string_lossy()
1119 );
1120
1121 let mut cmd = AgentServerCommand {
1122 path: bin_path,
1123 args: Vec::new(),
1124 env: None,
1125 };
1126 cmd.env = Some(env);
1127 cmd
1128 };
1129
1130 command.env.get_or_insert_default().extend(extra_env);
1131 Ok((command, root_dir.to_string_lossy().into_owned(), None))
1132 })
1133 }
1134
1135 fn as_any_mut(&mut self) -> &mut dyn Any {
1136 self
1137 }
1138}
1139
1140pub const CODEX_ACP_REPO: &str = "zed-industries/codex-acp";
1141
1142/// Assemble Codex release URL for the current OS/arch and the given version number.
1143/// Returns None if the current target is unsupported.
1144/// Example output:
1145/// https://github.com/zed-industries/codex-acp/releases/download/v{version}/codex-acp-{version}-{arch}-{platform}.{ext}
1146fn asset_name(version: &str) -> Option<String> {
1147 let arch = if cfg!(target_arch = "x86_64") {
1148 "x86_64"
1149 } else if cfg!(target_arch = "aarch64") {
1150 "aarch64"
1151 } else {
1152 return None;
1153 };
1154
1155 let platform = if cfg!(target_os = "macos") {
1156 "apple-darwin"
1157 } else if cfg!(target_os = "windows") {
1158 "pc-windows-msvc"
1159 } else if cfg!(target_os = "linux") {
1160 "unknown-linux-gnu"
1161 } else {
1162 return None;
1163 };
1164
1165 // Only Windows x86_64 uses .zip in release assets
1166 let ext = if cfg!(target_os = "windows") && cfg!(target_arch = "x86_64") {
1167 "zip"
1168 } else {
1169 "tar.gz"
1170 };
1171
1172 Some(format!("codex-acp-{version}-{arch}-{platform}.{ext}"))
1173}
1174
1175struct LocalCustomAgent {
1176 project_environment: Entity<ProjectEnvironment>,
1177 command: AgentServerCommand,
1178}
1179
1180impl ExternalAgentServer for LocalCustomAgent {
1181 fn get_command(
1182 &mut self,
1183 root_dir: Option<&str>,
1184 extra_env: HashMap<String, String>,
1185 _status_tx: Option<watch::Sender<SharedString>>,
1186 _new_version_available_tx: Option<watch::Sender<Option<String>>>,
1187 cx: &mut AsyncApp,
1188 ) -> Task<Result<(AgentServerCommand, String, Option<task::SpawnInTerminal>)>> {
1189 let mut command = self.command.clone();
1190 let root_dir: Arc<Path> = root_dir
1191 .map(|root_dir| Path::new(root_dir))
1192 .unwrap_or(paths::home_dir())
1193 .into();
1194 let project_environment = self.project_environment.downgrade();
1195 cx.spawn(async move |cx| {
1196 let mut env = project_environment
1197 .update(cx, |project_environment, cx| {
1198 project_environment.get_local_directory_environment(
1199 &Shell::System,
1200 root_dir.clone(),
1201 cx,
1202 )
1203 })?
1204 .await
1205 .unwrap_or_default();
1206 env.extend(command.env.unwrap_or_default());
1207 env.extend(extra_env);
1208 command.env = Some(env);
1209 Ok((command, root_dir.to_string_lossy().into_owned(), None))
1210 })
1211 }
1212
1213 fn as_any_mut(&mut self) -> &mut dyn Any {
1214 self
1215 }
1216}
1217
1218#[cfg(test)]
1219mod tests {
1220 #[test]
1221 fn assembles_codex_release_url_for_current_target() {
1222 let version_number = "0.1.0";
1223
1224 // This test fails the build if we are building a version of Zed
1225 // which does not have a known build of codex-acp, to prevent us
1226 // from accidentally doing a release on a new target without
1227 // realizing that codex-acp support will not work on that target!
1228 //
1229 // Additionally, it verifies that our logic for assembling URLs
1230 // correctly resolves to a known-good URL on each of our targets.
1231 let allowed = [
1232 "codex-acp-0.1.0-aarch64-apple-darwin.tar.gz",
1233 "codex-acp-0.1.0-aarch64-pc-windows-msvc.tar.gz",
1234 "codex-acp-0.1.0-aarch64-unknown-linux-gnu.tar.gz",
1235 "codex-acp-0.1.0-x86_64-apple-darwin.tar.gz",
1236 "codex-acp-0.1.0-x86_64-pc-windows-msvc.zip",
1237 "codex-acp-0.1.0-x86_64-unknown-linux-gnu.tar.gz",
1238 ];
1239
1240 if let Some(url) = super::asset_name(version_number) {
1241 assert!(
1242 allowed.contains(&url.as_str()),
1243 "Assembled asset name {} not in allowed list",
1244 url
1245 );
1246 } else {
1247 panic!(
1248 "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."
1249 );
1250 }
1251 }
1252}
1253
1254pub const GEMINI_NAME: &'static str = "gemini";
1255pub const CLAUDE_CODE_NAME: &'static str = "claude";
1256pub const CODEX_NAME: &'static str = "codex";
1257
1258#[derive(Default, Clone, JsonSchema, Debug, PartialEq)]
1259pub struct AllAgentServersSettings {
1260 pub gemini: Option<BuiltinAgentServerSettings>,
1261 pub claude: Option<BuiltinAgentServerSettings>,
1262 pub codex: Option<BuiltinAgentServerSettings>,
1263 pub custom: HashMap<SharedString, CustomAgentServerSettings>,
1264}
1265#[derive(Default, Clone, JsonSchema, Debug, PartialEq)]
1266pub struct BuiltinAgentServerSettings {
1267 pub path: Option<PathBuf>,
1268 pub args: Option<Vec<String>>,
1269 pub env: Option<HashMap<String, String>>,
1270 pub ignore_system_version: Option<bool>,
1271 pub default_mode: Option<String>,
1272}
1273
1274impl BuiltinAgentServerSettings {
1275 pub(crate) fn custom_command(self) -> Option<AgentServerCommand> {
1276 self.path.map(|path| AgentServerCommand {
1277 path,
1278 args: self.args.unwrap_or_default(),
1279 env: self.env,
1280 })
1281 }
1282}
1283
1284impl From<settings::BuiltinAgentServerSettings> for BuiltinAgentServerSettings {
1285 fn from(value: settings::BuiltinAgentServerSettings) -> Self {
1286 BuiltinAgentServerSettings {
1287 path: value.path,
1288 args: value.args,
1289 env: value.env,
1290 ignore_system_version: value.ignore_system_version,
1291 default_mode: value.default_mode,
1292 }
1293 }
1294}
1295
1296impl From<AgentServerCommand> for BuiltinAgentServerSettings {
1297 fn from(value: AgentServerCommand) -> Self {
1298 BuiltinAgentServerSettings {
1299 path: Some(value.path),
1300 args: Some(value.args),
1301 env: value.env,
1302 ..Default::default()
1303 }
1304 }
1305}
1306
1307#[derive(Clone, JsonSchema, Debug, PartialEq)]
1308pub struct CustomAgentServerSettings {
1309 pub command: AgentServerCommand,
1310 /// The default mode to use for this agent.
1311 ///
1312 /// Note: Not only all agents support modes.
1313 ///
1314 /// Default: None
1315 pub default_mode: Option<String>,
1316}
1317
1318impl From<settings::CustomAgentServerSettings> for CustomAgentServerSettings {
1319 fn from(value: settings::CustomAgentServerSettings) -> Self {
1320 CustomAgentServerSettings {
1321 command: AgentServerCommand {
1322 path: value.path,
1323 args: value.args,
1324 env: value.env,
1325 },
1326 default_mode: value.default_mode,
1327 }
1328 }
1329}
1330
1331impl settings::Settings for AllAgentServersSettings {
1332 fn from_settings(content: &settings::SettingsContent, _cx: &mut App) -> Self {
1333 let agent_settings = content.agent_servers.clone().unwrap();
1334 Self {
1335 gemini: agent_settings.gemini.map(Into::into),
1336 claude: agent_settings.claude.map(Into::into),
1337 codex: agent_settings.codex.map(Into::into),
1338 custom: agent_settings
1339 .custom
1340 .into_iter()
1341 .map(|(k, v)| (k, v.into()))
1342 .collect(),
1343 }
1344 }
1345
1346 fn import_from_vscode(_vscode: &settings::VsCodeSettings, _current: &mut SettingsContent) {}
1347}