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