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