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