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