1use remote::Interactive;
2use std::{
3 any::Any,
4 borrow::Borrow,
5 path::{Path, PathBuf},
6 str::FromStr as _,
7 sync::Arc,
8 time::Duration,
9};
10
11use anyhow::{Context as _, Result, bail};
12use collections::HashMap;
13use fs::{Fs, RemoveOptions, RenameOptions};
14use futures::StreamExt as _;
15use gpui::{
16 AppContext as _, AsyncApp, Context, Entity, EventEmitter, SharedString, Subscription, Task,
17};
18use http_client::{HttpClient, github::AssetKind};
19use node_runtime::NodeRuntime;
20use remote::RemoteClient;
21use rpc::{
22 AnyProtoClient, TypedEnvelope,
23 proto::{self, ExternalExtensionAgent},
24};
25use schemars::JsonSchema;
26use semver::Version;
27use serde::{Deserialize, Serialize};
28use settings::{RegisterSetting, SettingsStore};
29use task::{Shell, SpawnInTerminal};
30use util::{ResultExt as _, debug_panic};
31
32use crate::ProjectEnvironment;
33use crate::agent_registry_store::{AgentRegistryStore, RegistryAgent, RegistryTargetConfig};
34
35#[derive(Deserialize, Serialize, Clone, PartialEq, Eq, JsonSchema)]
36pub struct AgentServerCommand {
37 #[serde(rename = "command")]
38 pub path: PathBuf,
39 #[serde(default)]
40 pub args: Vec<String>,
41 pub env: Option<HashMap<String, String>>,
42}
43
44impl std::fmt::Debug for AgentServerCommand {
45 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
46 let filtered_env = self.env.as_ref().map(|env| {
47 env.iter()
48 .map(|(k, v)| {
49 (
50 k,
51 if util::redact::should_redact(k) {
52 "[REDACTED]"
53 } else {
54 v
55 },
56 )
57 })
58 .collect::<Vec<_>>()
59 });
60
61 f.debug_struct("AgentServerCommand")
62 .field("path", &self.path)
63 .field("args", &self.args)
64 .field("env", &filtered_env)
65 .finish()
66 }
67}
68
69#[derive(Clone, Debug, PartialEq, Eq, Hash)]
70pub struct ExternalAgentServerName(pub SharedString);
71
72impl std::fmt::Display for ExternalAgentServerName {
73 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
74 write!(f, "{}", self.0)
75 }
76}
77
78impl From<&'static str> for ExternalAgentServerName {
79 fn from(value: &'static str) -> Self {
80 ExternalAgentServerName(value.into())
81 }
82}
83
84impl From<ExternalAgentServerName> for SharedString {
85 fn from(value: ExternalAgentServerName) -> Self {
86 value.0
87 }
88}
89
90impl Borrow<str> for ExternalAgentServerName {
91 fn borrow(&self) -> &str {
92 &self.0
93 }
94}
95
96#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
97pub enum ExternalAgentSource {
98 Builtin,
99 #[default]
100 Custom,
101 Extension,
102 Registry,
103}
104
105pub trait ExternalAgentServer {
106 fn get_command(
107 &mut self,
108 extra_env: HashMap<String, String>,
109 status_tx: Option<watch::Sender<SharedString>>,
110 new_version_available_tx: Option<watch::Sender<Option<String>>>,
111 cx: &mut AsyncApp,
112 ) -> Task<Result<(AgentServerCommand, Option<task::SpawnInTerminal>)>>;
113
114 fn as_any_mut(&mut self) -> &mut dyn Any;
115}
116
117impl dyn ExternalAgentServer {
118 fn downcast_mut<T: ExternalAgentServer + 'static>(&mut self) -> Option<&mut T> {
119 self.as_any_mut().downcast_mut()
120 }
121}
122
123enum AgentServerStoreState {
124 Local {
125 node_runtime: NodeRuntime,
126 fs: Arc<dyn Fs>,
127 project_environment: Entity<ProjectEnvironment>,
128 downstream_client: Option<(u64, AnyProtoClient)>,
129 settings: Option<AllAgentServersSettings>,
130 http_client: Arc<dyn HttpClient>,
131 extension_agents: Vec<(
132 Arc<str>,
133 String,
134 HashMap<String, extension::TargetConfig>,
135 HashMap<String, String>,
136 Option<String>,
137 Option<SharedString>,
138 )>,
139 _subscriptions: Vec<Subscription>,
140 },
141 Remote {
142 project_id: u64,
143 upstream_client: Entity<RemoteClient>,
144 },
145 Collab,
146}
147
148pub struct ExternalAgentEntry {
149 server: Box<dyn ExternalAgentServer>,
150 icon: Option<SharedString>,
151 display_name: Option<SharedString>,
152 pub source: ExternalAgentSource,
153}
154
155impl ExternalAgentEntry {
156 pub fn new(
157 server: Box<dyn ExternalAgentServer>,
158 source: ExternalAgentSource,
159 icon: Option<SharedString>,
160 display_name: Option<SharedString>,
161 ) -> Self {
162 Self {
163 server,
164 icon,
165 display_name,
166 source,
167 }
168 }
169}
170
171pub struct AgentServerStore {
172 state: AgentServerStoreState,
173 pub external_agents: HashMap<ExternalAgentServerName, ExternalAgentEntry>,
174}
175
176pub struct AgentServersUpdated;
177
178impl EventEmitter<AgentServersUpdated> for AgentServerStore {}
179
180impl AgentServerStore {
181 /// Synchronizes extension-provided agent servers with the store.
182 pub fn sync_extension_agents<'a, I>(
183 &mut self,
184 manifests: I,
185 extensions_dir: PathBuf,
186 cx: &mut Context<Self>,
187 ) where
188 I: IntoIterator<Item = (&'a str, &'a extension::ExtensionManifest)>,
189 {
190 // Collect manifests first so we can iterate twice
191 let manifests: Vec<_> = manifests.into_iter().collect();
192
193 // Remove all extension-provided agents
194 // (They will be re-added below if they're in the currently installed extensions)
195 self.external_agents
196 .retain(|_, entry| entry.source != ExternalAgentSource::Extension);
197
198 // Insert agent servers from extension manifests
199 match &mut self.state {
200 AgentServerStoreState::Local {
201 extension_agents, ..
202 } => {
203 extension_agents.clear();
204 for (ext_id, manifest) in manifests {
205 for (agent_name, agent_entry) in &manifest.agent_servers {
206 let display_name = SharedString::from(agent_entry.name.clone());
207 let icon_path = agent_entry.icon.as_ref().and_then(|icon| {
208 resolve_extension_icon_path(&extensions_dir, ext_id, icon)
209 });
210
211 extension_agents.push((
212 agent_name.clone(),
213 ext_id.to_owned(),
214 agent_entry.targets.clone(),
215 agent_entry.env.clone(),
216 icon_path,
217 Some(display_name),
218 ));
219 }
220 }
221 self.reregister_agents(cx);
222 }
223 AgentServerStoreState::Remote {
224 project_id,
225 upstream_client,
226 } => {
227 let mut agents = vec![];
228 for (ext_id, manifest) in manifests {
229 for (agent_name, agent_entry) in &manifest.agent_servers {
230 let display_name = SharedString::from(agent_entry.name.clone());
231 let icon_path = agent_entry.icon.as_ref().and_then(|icon| {
232 resolve_extension_icon_path(&extensions_dir, ext_id, icon)
233 });
234 let icon_shared = icon_path
235 .as_ref()
236 .map(|path| SharedString::from(path.clone()));
237 let icon = icon_path;
238 let agent_server_name = ExternalAgentServerName(agent_name.clone().into());
239 self.external_agents
240 .entry(agent_server_name.clone())
241 .and_modify(|entry| {
242 entry.icon = icon_shared.clone();
243 entry.display_name = Some(display_name.clone());
244 entry.source = ExternalAgentSource::Extension;
245 })
246 .or_insert_with(|| {
247 ExternalAgentEntry::new(
248 Box::new(RemoteExternalAgentServer {
249 project_id: *project_id,
250 upstream_client: upstream_client.clone(),
251 name: agent_server_name.clone(),
252 status_tx: None,
253 new_version_available_tx: None,
254 })
255 as Box<dyn ExternalAgentServer>,
256 ExternalAgentSource::Extension,
257 icon_shared.clone(),
258 Some(display_name.clone()),
259 )
260 });
261
262 agents.push(ExternalExtensionAgent {
263 name: agent_name.to_string(),
264 icon_path: icon,
265 extension_id: ext_id.to_string(),
266 targets: agent_entry
267 .targets
268 .iter()
269 .map(|(k, v)| (k.clone(), v.to_proto()))
270 .collect(),
271 env: agent_entry
272 .env
273 .iter()
274 .map(|(k, v)| (k.clone(), v.clone()))
275 .collect(),
276 });
277 }
278 }
279 upstream_client
280 .read(cx)
281 .proto_client()
282 .send(proto::ExternalExtensionAgentsUpdated {
283 project_id: *project_id,
284 agents,
285 })
286 .log_err();
287 }
288 AgentServerStoreState::Collab => {
289 // Do nothing
290 }
291 }
292
293 cx.emit(AgentServersUpdated);
294 }
295
296 pub fn agent_icon(&self, name: &ExternalAgentServerName) -> Option<SharedString> {
297 self.external_agents
298 .get(name)
299 .and_then(|entry| entry.icon.clone())
300 }
301
302 pub fn agent_source(&self, name: &ExternalAgentServerName) -> Option<ExternalAgentSource> {
303 self.external_agents.get(name).map(|entry| entry.source)
304 }
305}
306
307/// Safely resolves an extension icon path, ensuring it stays within the extension directory.
308/// Returns `None` if the path would escape the extension directory (path traversal attack).
309pub fn resolve_extension_icon_path(
310 extensions_dir: &Path,
311 extension_id: &str,
312 icon_relative_path: &str,
313) -> Option<String> {
314 let extension_root = extensions_dir.join(extension_id);
315 let icon_path = extension_root.join(icon_relative_path);
316
317 // Canonicalize both paths to resolve symlinks and normalize the paths.
318 // For the extension root, we need to handle the case where it might be a symlink
319 // (common for dev extensions).
320 let canonical_extension_root = extension_root.canonicalize().unwrap_or(extension_root);
321 let canonical_icon_path = match icon_path.canonicalize() {
322 Ok(path) => path,
323 Err(err) => {
324 log::warn!(
325 "Failed to canonicalize icon path for extension '{}': {} (path: {})",
326 extension_id,
327 err,
328 icon_relative_path
329 );
330 return None;
331 }
332 };
333
334 // Verify the resolved icon path is within the extension directory
335 if canonical_icon_path.starts_with(&canonical_extension_root) {
336 Some(canonical_icon_path.to_string_lossy().to_string())
337 } else {
338 log::warn!(
339 "Icon path '{}' for extension '{}' escapes extension directory, ignoring for security",
340 icon_relative_path,
341 extension_id
342 );
343 None
344 }
345}
346
347impl AgentServerStore {
348 pub fn agent_display_name(&self, name: &ExternalAgentServerName) -> Option<SharedString> {
349 self.external_agents
350 .get(name)
351 .and_then(|entry| entry.display_name.clone())
352 }
353
354 pub fn init_remote(session: &AnyProtoClient) {
355 session.add_entity_message_handler(Self::handle_external_agents_updated);
356 session.add_entity_message_handler(Self::handle_loading_status_updated);
357 session.add_entity_message_handler(Self::handle_new_version_available);
358 }
359
360 pub fn init_headless(session: &AnyProtoClient) {
361 session.add_entity_message_handler(Self::handle_external_extension_agents_updated);
362 session.add_entity_request_handler(Self::handle_get_agent_server_command);
363 }
364
365 fn agent_servers_settings_changed(&mut self, cx: &mut Context<Self>) {
366 let AgentServerStoreState::Local {
367 settings: old_settings,
368 ..
369 } = &mut self.state
370 else {
371 debug_panic!(
372 "should not be subscribed to agent server settings changes in non-local project"
373 );
374 return;
375 };
376
377 let new_settings = cx
378 .global::<SettingsStore>()
379 .get::<AllAgentServersSettings>(None)
380 .clone();
381 if Some(&new_settings) == old_settings.as_ref() {
382 return;
383 }
384
385 self.reregister_agents(cx);
386 }
387
388 fn reregister_agents(&mut self, cx: &mut Context<Self>) {
389 let AgentServerStoreState::Local {
390 node_runtime,
391 fs,
392 project_environment,
393 downstream_client,
394 settings: old_settings,
395 http_client,
396 extension_agents,
397 ..
398 } = &mut self.state
399 else {
400 debug_panic!("Non-local projects should never attempt to reregister. This is a bug!");
401
402 return;
403 };
404
405 let new_settings = cx
406 .global::<SettingsStore>()
407 .get::<AllAgentServersSettings>(None)
408 .clone();
409
410 // If we don't have agents from the registry loaded yet, trigger a
411 // refresh, which will cause this function to be called again
412 if new_settings.has_registry_agents()
413 && let Some(registry) = AgentRegistryStore::try_global(cx)
414 {
415 registry.update(cx, |registry, cx| registry.refresh_if_stale(cx));
416 }
417
418 self.external_agents.clear();
419 self.external_agents.insert(
420 GEMINI_NAME.into(),
421 ExternalAgentEntry::new(
422 Box::new(LocalGemini {
423 fs: fs.clone(),
424 node_runtime: node_runtime.clone(),
425 project_environment: project_environment.clone(),
426 custom_command: new_settings
427 .gemini
428 .clone()
429 .and_then(|settings| settings.custom_command()),
430 settings_env: new_settings
431 .gemini
432 .as_ref()
433 .and_then(|settings| settings.env.clone()),
434 ignore_system_version: new_settings
435 .gemini
436 .as_ref()
437 .and_then(|settings| settings.ignore_system_version)
438 .unwrap_or(true),
439 }),
440 ExternalAgentSource::Builtin,
441 None,
442 None,
443 ),
444 );
445 self.external_agents.insert(
446 CODEX_NAME.into(),
447 ExternalAgentEntry::new(
448 Box::new(LocalCodex {
449 fs: fs.clone(),
450 project_environment: project_environment.clone(),
451 custom_command: new_settings
452 .codex
453 .clone()
454 .and_then(|settings| settings.custom_command()),
455 settings_env: new_settings
456 .codex
457 .as_ref()
458 .and_then(|settings| settings.env.clone()),
459 http_client: http_client.clone(),
460 no_browser: downstream_client
461 .as_ref()
462 .is_some_and(|(_, client)| !client.has_wsl_interop()),
463 }),
464 ExternalAgentSource::Builtin,
465 None,
466 None,
467 ),
468 );
469 self.external_agents.insert(
470 CLAUDE_AGENT_NAME.into(),
471 ExternalAgentEntry::new(
472 Box::new(LocalClaudeCode {
473 fs: fs.clone(),
474 node_runtime: node_runtime.clone(),
475 project_environment: project_environment.clone(),
476 custom_command: new_settings
477 .claude
478 .clone()
479 .and_then(|settings| settings.custom_command()),
480 settings_env: new_settings
481 .claude
482 .as_ref()
483 .and_then(|settings| settings.env.clone()),
484 }),
485 ExternalAgentSource::Builtin,
486 None,
487 None,
488 ),
489 );
490
491 let registry_store = AgentRegistryStore::try_global(cx);
492 let registry_agents_by_id = registry_store
493 .as_ref()
494 .map(|store| {
495 store
496 .read(cx)
497 .agents()
498 .iter()
499 .cloned()
500 .map(|agent| (agent.id().to_string(), agent))
501 .collect::<HashMap<_, _>>()
502 })
503 .unwrap_or_default();
504
505 // Insert extension agents before custom/registry so registry entries override extensions.
506 for (agent_name, ext_id, targets, env, icon_path, display_name) in extension_agents.iter() {
507 let name = ExternalAgentServerName(agent_name.clone().into());
508 let mut env = env.clone();
509 if let Some(settings_env) =
510 new_settings
511 .custom
512 .get(agent_name.as_ref())
513 .and_then(|settings| match settings {
514 CustomAgentServerSettings::Extension { env, .. } => Some(env.clone()),
515 _ => None,
516 })
517 {
518 env.extend(settings_env);
519 }
520 let icon = icon_path
521 .as_ref()
522 .map(|path| SharedString::from(path.clone()));
523
524 self.external_agents.insert(
525 name.clone(),
526 ExternalAgentEntry::new(
527 Box::new(LocalExtensionArchiveAgent {
528 fs: fs.clone(),
529 http_client: http_client.clone(),
530 node_runtime: node_runtime.clone(),
531 project_environment: project_environment.clone(),
532 extension_id: Arc::from(&**ext_id),
533 targets: targets.clone(),
534 env,
535 agent_id: agent_name.clone(),
536 }) as Box<dyn ExternalAgentServer>,
537 ExternalAgentSource::Extension,
538 icon,
539 display_name.clone(),
540 ),
541 );
542 }
543
544 for (name, settings) in &new_settings.custom {
545 match settings {
546 CustomAgentServerSettings::Custom { command, .. } => {
547 let agent_name = ExternalAgentServerName(name.clone().into());
548 self.external_agents.insert(
549 agent_name.clone(),
550 ExternalAgentEntry::new(
551 Box::new(LocalCustomAgent {
552 command: command.clone(),
553 project_environment: project_environment.clone(),
554 }) as Box<dyn ExternalAgentServer>,
555 ExternalAgentSource::Custom,
556 None,
557 None,
558 ),
559 );
560 }
561 CustomAgentServerSettings::Registry { env, .. } => {
562 let Some(agent) = registry_agents_by_id.get(name) else {
563 if registry_store.is_some() {
564 log::debug!("Registry agent '{}' not found in ACP registry", name);
565 }
566 continue;
567 };
568
569 let agent_name = ExternalAgentServerName(name.clone().into());
570 match agent {
571 RegistryAgent::Binary(agent) => {
572 if !agent.supports_current_platform {
573 log::warn!(
574 "Registry agent '{}' has no compatible binary for this platform",
575 name
576 );
577 continue;
578 }
579
580 self.external_agents.insert(
581 agent_name.clone(),
582 ExternalAgentEntry::new(
583 Box::new(LocalRegistryArchiveAgent {
584 fs: fs.clone(),
585 http_client: http_client.clone(),
586 node_runtime: node_runtime.clone(),
587 project_environment: project_environment.clone(),
588 registry_id: Arc::from(name.as_str()),
589 targets: agent.targets.clone(),
590 env: env.clone(),
591 })
592 as Box<dyn ExternalAgentServer>,
593 ExternalAgentSource::Registry,
594 agent.metadata.icon_path.clone(),
595 Some(agent.metadata.name.clone()),
596 ),
597 );
598 }
599 RegistryAgent::Npx(agent) => {
600 self.external_agents.insert(
601 agent_name.clone(),
602 ExternalAgentEntry::new(
603 Box::new(LocalRegistryNpxAgent {
604 node_runtime: node_runtime.clone(),
605 project_environment: project_environment.clone(),
606 package: agent.package.clone(),
607 args: agent.args.clone(),
608 distribution_env: agent.env.clone(),
609 settings_env: env.clone(),
610 })
611 as Box<dyn ExternalAgentServer>,
612 ExternalAgentSource::Registry,
613 agent.metadata.icon_path.clone(),
614 Some(agent.metadata.name.clone()),
615 ),
616 );
617 }
618 }
619 }
620 CustomAgentServerSettings::Extension { .. } => {}
621 }
622 }
623
624 *old_settings = Some(new_settings);
625
626 if let Some((project_id, downstream_client)) = downstream_client {
627 downstream_client
628 .send(proto::ExternalAgentsUpdated {
629 project_id: *project_id,
630 names: self
631 .external_agents
632 .keys()
633 .map(|name| name.to_string())
634 .collect(),
635 })
636 .log_err();
637 }
638 cx.emit(AgentServersUpdated);
639 }
640
641 pub fn node_runtime(&self) -> Option<NodeRuntime> {
642 match &self.state {
643 AgentServerStoreState::Local { node_runtime, .. } => Some(node_runtime.clone()),
644 _ => None,
645 }
646 }
647
648 pub fn local(
649 node_runtime: NodeRuntime,
650 fs: Arc<dyn Fs>,
651 project_environment: Entity<ProjectEnvironment>,
652 http_client: Arc<dyn HttpClient>,
653 cx: &mut Context<Self>,
654 ) -> Self {
655 let mut subscriptions = vec![cx.observe_global::<SettingsStore>(|this, cx| {
656 this.agent_servers_settings_changed(cx);
657 })];
658 if let Some(registry_store) = AgentRegistryStore::try_global(cx) {
659 subscriptions.push(cx.observe(®istry_store, |this, _, cx| {
660 this.reregister_agents(cx);
661 }));
662 }
663 let mut this = Self {
664 state: AgentServerStoreState::Local {
665 node_runtime,
666 fs,
667 project_environment,
668 http_client,
669 downstream_client: None,
670 settings: None,
671 extension_agents: vec![],
672 _subscriptions: subscriptions,
673 },
674 external_agents: Default::default(),
675 };
676 if let Some(_events) = extension::ExtensionEvents::try_global(cx) {}
677 this.agent_servers_settings_changed(cx);
678 this
679 }
680
681 pub(crate) fn remote(project_id: u64, upstream_client: Entity<RemoteClient>) -> Self {
682 // Set up the builtin agents here so they're immediately available in
683 // remote projects--we know that the HeadlessProject on the other end
684 // will have them.
685 let external_agents: [(ExternalAgentServerName, ExternalAgentEntry); 3] = [
686 (
687 CLAUDE_AGENT_NAME.into(),
688 ExternalAgentEntry::new(
689 Box::new(RemoteExternalAgentServer {
690 project_id,
691 upstream_client: upstream_client.clone(),
692 name: CLAUDE_AGENT_NAME.into(),
693 status_tx: None,
694 new_version_available_tx: None,
695 }) as Box<dyn ExternalAgentServer>,
696 ExternalAgentSource::Builtin,
697 None,
698 None,
699 ),
700 ),
701 (
702 CODEX_NAME.into(),
703 ExternalAgentEntry::new(
704 Box::new(RemoteExternalAgentServer {
705 project_id,
706 upstream_client: upstream_client.clone(),
707 name: CODEX_NAME.into(),
708 status_tx: None,
709 new_version_available_tx: None,
710 }) as Box<dyn ExternalAgentServer>,
711 ExternalAgentSource::Builtin,
712 None,
713 None,
714 ),
715 ),
716 (
717 GEMINI_NAME.into(),
718 ExternalAgentEntry::new(
719 Box::new(RemoteExternalAgentServer {
720 project_id,
721 upstream_client: upstream_client.clone(),
722 name: GEMINI_NAME.into(),
723 status_tx: None,
724 new_version_available_tx: None,
725 }) as Box<dyn ExternalAgentServer>,
726 ExternalAgentSource::Builtin,
727 None,
728 None,
729 ),
730 ),
731 ];
732
733 Self {
734 state: AgentServerStoreState::Remote {
735 project_id,
736 upstream_client,
737 },
738 external_agents: external_agents.into_iter().collect(),
739 }
740 }
741
742 pub fn collab() -> Self {
743 Self {
744 state: AgentServerStoreState::Collab,
745 external_agents: Default::default(),
746 }
747 }
748
749 pub fn shared(&mut self, project_id: u64, client: AnyProtoClient, cx: &mut Context<Self>) {
750 match &mut self.state {
751 AgentServerStoreState::Local {
752 downstream_client, ..
753 } => {
754 *downstream_client = Some((project_id, client.clone()));
755 // Send the current list of external agents downstream, but only after a delay,
756 // to avoid having the message arrive before the downstream project's agent server store
757 // sets up its handlers.
758 cx.spawn(async move |this, cx| {
759 cx.background_executor().timer(Duration::from_secs(1)).await;
760 let names = this.update(cx, |this, _| {
761 this.external_agents()
762 .map(|name| name.to_string())
763 .collect()
764 })?;
765 client
766 .send(proto::ExternalAgentsUpdated { project_id, names })
767 .log_err();
768 anyhow::Ok(())
769 })
770 .detach();
771 }
772 AgentServerStoreState::Remote { .. } => {
773 debug_panic!(
774 "external agents over collab not implemented, remote project should not be shared"
775 );
776 }
777 AgentServerStoreState::Collab => {
778 debug_panic!("external agents over collab not implemented, should not be shared");
779 }
780 }
781 }
782
783 pub fn get_external_agent(
784 &mut self,
785 name: &ExternalAgentServerName,
786 ) -> Option<&mut (dyn ExternalAgentServer + 'static)> {
787 self.external_agents
788 .get_mut(name)
789 .map(|entry| entry.server.as_mut())
790 }
791
792 pub fn external_agents(&self) -> impl Iterator<Item = &ExternalAgentServerName> {
793 self.external_agents.keys()
794 }
795
796 async fn handle_get_agent_server_command(
797 this: Entity<Self>,
798 envelope: TypedEnvelope<proto::GetAgentServerCommand>,
799 mut cx: AsyncApp,
800 ) -> Result<proto::AgentServerCommand> {
801 let (command, login_command) = this
802 .update(&mut cx, |this, cx| {
803 let AgentServerStoreState::Local {
804 downstream_client, ..
805 } = &this.state
806 else {
807 debug_panic!("should not receive GetAgentServerCommand in a non-local project");
808 bail!("unexpected GetAgentServerCommand request in a non-local project");
809 };
810 let agent = this
811 .external_agents
812 .get_mut(&*envelope.payload.name)
813 .map(|entry| entry.server.as_mut())
814 .with_context(|| format!("agent `{}` not found", envelope.payload.name))?;
815 let (status_tx, new_version_available_tx) = downstream_client
816 .clone()
817 .map(|(project_id, downstream_client)| {
818 let (status_tx, mut status_rx) = watch::channel(SharedString::from(""));
819 let (new_version_available_tx, mut new_version_available_rx) =
820 watch::channel(None);
821 cx.spawn({
822 let downstream_client = downstream_client.clone();
823 let name = envelope.payload.name.clone();
824 async move |_, _| {
825 while let Some(status) = status_rx.recv().await.ok() {
826 downstream_client.send(
827 proto::ExternalAgentLoadingStatusUpdated {
828 project_id,
829 name: name.clone(),
830 status: status.to_string(),
831 },
832 )?;
833 }
834 anyhow::Ok(())
835 }
836 })
837 .detach_and_log_err(cx);
838 cx.spawn({
839 let name = envelope.payload.name.clone();
840 async move |_, _| {
841 if let Some(version) =
842 new_version_available_rx.recv().await.ok().flatten()
843 {
844 downstream_client.send(
845 proto::NewExternalAgentVersionAvailable {
846 project_id,
847 name: name.clone(),
848 version,
849 },
850 )?;
851 }
852 anyhow::Ok(())
853 }
854 })
855 .detach_and_log_err(cx);
856 (status_tx, new_version_available_tx)
857 })
858 .unzip();
859 anyhow::Ok(agent.get_command(
860 HashMap::default(),
861 status_tx,
862 new_version_available_tx,
863 &mut cx.to_async(),
864 ))
865 })?
866 .await?;
867 Ok(proto::AgentServerCommand {
868 path: command.path.to_string_lossy().into_owned(),
869 args: command.args,
870 env: command
871 .env
872 .map(|env| env.into_iter().collect())
873 .unwrap_or_default(),
874 // This is no longer used, but returned for backwards compatibility
875 root_dir: paths::home_dir().to_string_lossy().to_string(),
876 login: login_command.map(|cmd| cmd.to_proto()),
877 })
878 }
879
880 async fn handle_external_agents_updated(
881 this: Entity<Self>,
882 envelope: TypedEnvelope<proto::ExternalAgentsUpdated>,
883 mut cx: AsyncApp,
884 ) -> Result<()> {
885 this.update(&mut cx, |this, cx| {
886 let AgentServerStoreState::Remote {
887 project_id,
888 upstream_client,
889 } = &this.state
890 else {
891 debug_panic!(
892 "handle_external_agents_updated should not be called for a non-remote project"
893 );
894 bail!("unexpected ExternalAgentsUpdated message")
895 };
896
897 let mut previous_entries = std::mem::take(&mut this.external_agents);
898 let mut status_txs = HashMap::default();
899 let mut new_version_available_txs = HashMap::default();
900 let mut metadata = HashMap::default();
901
902 for (name, mut entry) in previous_entries.drain() {
903 if let Some(agent) = entry.server.downcast_mut::<RemoteExternalAgentServer>() {
904 status_txs.insert(name.clone(), agent.status_tx.take());
905 new_version_available_txs
906 .insert(name.clone(), agent.new_version_available_tx.take());
907 }
908
909 metadata.insert(name, (entry.icon, entry.display_name, entry.source));
910 }
911
912 this.external_agents = envelope
913 .payload
914 .names
915 .into_iter()
916 .map(|name| {
917 let agent_name = ExternalAgentServerName(name.clone().into());
918 let fallback_source =
919 if name == GEMINI_NAME || name == CLAUDE_AGENT_NAME || name == CODEX_NAME {
920 ExternalAgentSource::Builtin
921 } else {
922 ExternalAgentSource::Custom
923 };
924 let (icon, display_name, source) = metadata
925 .remove(&agent_name)
926 .or_else(|| {
927 AgentRegistryStore::try_global(cx)
928 .and_then(|store| store.read(cx).agent(&agent_name.0))
929 .map(|s| {
930 (
931 s.icon_path().cloned(),
932 Some(s.name().clone()),
933 ExternalAgentSource::Registry,
934 )
935 })
936 })
937 .unwrap_or((None, None, fallback_source));
938 let source = if fallback_source == ExternalAgentSource::Builtin {
939 ExternalAgentSource::Builtin
940 } else {
941 source
942 };
943 let agent = RemoteExternalAgentServer {
944 project_id: *project_id,
945 upstream_client: upstream_client.clone(),
946 name: agent_name.clone(),
947 status_tx: status_txs.remove(&agent_name).flatten(),
948 new_version_available_tx: new_version_available_txs
949 .remove(&agent_name)
950 .flatten(),
951 };
952 (
953 agent_name,
954 ExternalAgentEntry::new(
955 Box::new(agent) as Box<dyn ExternalAgentServer>,
956 source,
957 icon,
958 display_name,
959 ),
960 )
961 })
962 .collect();
963 cx.emit(AgentServersUpdated);
964 Ok(())
965 })
966 }
967
968 async fn handle_external_extension_agents_updated(
969 this: Entity<Self>,
970 envelope: TypedEnvelope<proto::ExternalExtensionAgentsUpdated>,
971 mut cx: AsyncApp,
972 ) -> Result<()> {
973 this.update(&mut cx, |this, cx| {
974 let AgentServerStoreState::Local {
975 extension_agents, ..
976 } = &mut this.state
977 else {
978 panic!(
979 "handle_external_extension_agents_updated \
980 should not be called for a non-remote project"
981 );
982 };
983
984 for ExternalExtensionAgent {
985 name,
986 icon_path,
987 extension_id,
988 targets,
989 env,
990 } in envelope.payload.agents
991 {
992 extension_agents.push((
993 Arc::from(&*name),
994 extension_id,
995 targets
996 .into_iter()
997 .map(|(k, v)| (k, extension::TargetConfig::from_proto(v)))
998 .collect(),
999 env.into_iter().collect(),
1000 icon_path,
1001 None,
1002 ));
1003 }
1004
1005 this.reregister_agents(cx);
1006 cx.emit(AgentServersUpdated);
1007 Ok(())
1008 })
1009 }
1010
1011 async fn handle_loading_status_updated(
1012 this: Entity<Self>,
1013 envelope: TypedEnvelope<proto::ExternalAgentLoadingStatusUpdated>,
1014 mut cx: AsyncApp,
1015 ) -> Result<()> {
1016 this.update(&mut cx, |this, _| {
1017 if let Some(agent) = this.external_agents.get_mut(&*envelope.payload.name)
1018 && let Some(agent) = agent.server.downcast_mut::<RemoteExternalAgentServer>()
1019 && let Some(status_tx) = &mut agent.status_tx
1020 {
1021 status_tx.send(envelope.payload.status.into()).ok();
1022 }
1023 });
1024 Ok(())
1025 }
1026
1027 async fn handle_new_version_available(
1028 this: Entity<Self>,
1029 envelope: TypedEnvelope<proto::NewExternalAgentVersionAvailable>,
1030 mut cx: AsyncApp,
1031 ) -> Result<()> {
1032 this.update(&mut cx, |this, _| {
1033 if let Some(agent) = this.external_agents.get_mut(&*envelope.payload.name)
1034 && let Some(agent) = agent.server.downcast_mut::<RemoteExternalAgentServer>()
1035 && let Some(new_version_available_tx) = &mut agent.new_version_available_tx
1036 {
1037 new_version_available_tx
1038 .send(Some(envelope.payload.version))
1039 .ok();
1040 }
1041 });
1042 Ok(())
1043 }
1044
1045 pub fn get_extension_id_for_agent(
1046 &mut self,
1047 name: &ExternalAgentServerName,
1048 ) -> Option<Arc<str>> {
1049 self.external_agents.get_mut(name).and_then(|entry| {
1050 entry
1051 .server
1052 .as_any_mut()
1053 .downcast_ref::<LocalExtensionArchiveAgent>()
1054 .map(|ext_agent| ext_agent.extension_id.clone())
1055 })
1056 }
1057}
1058
1059fn get_or_npm_install_builtin_agent(
1060 binary_name: SharedString,
1061 package_name: SharedString,
1062 entrypoint_path: PathBuf,
1063 minimum_version: Option<semver::Version>,
1064 status_tx: Option<watch::Sender<SharedString>>,
1065 new_version_available: Option<watch::Sender<Option<String>>>,
1066 fs: Arc<dyn Fs>,
1067 node_runtime: NodeRuntime,
1068 cx: &mut AsyncApp,
1069) -> Task<std::result::Result<AgentServerCommand, anyhow::Error>> {
1070 cx.spawn(async move |cx| {
1071 let node_path = node_runtime.binary_path().await?;
1072 let dir = paths::external_agents_dir().join(binary_name.as_str());
1073 fs.create_dir(&dir).await?;
1074
1075 let mut stream = fs.read_dir(&dir).await?;
1076 let mut versions = Vec::new();
1077 let mut to_delete = Vec::new();
1078 while let Some(entry) = stream.next().await {
1079 let Ok(entry) = entry else { continue };
1080 let Some(file_name) = entry.file_name() else {
1081 continue;
1082 };
1083
1084 if let Some(name) = file_name.to_str()
1085 && let Some(version) = semver::Version::from_str(name).ok()
1086 && fs
1087 .is_file(&dir.join(file_name).join(&entrypoint_path))
1088 .await
1089 {
1090 versions.push((version, file_name.to_owned()));
1091 } else {
1092 to_delete.push(file_name.to_owned())
1093 }
1094 }
1095
1096 versions.sort();
1097 let newest_version = if let Some((version, _)) = versions.last().cloned()
1098 && minimum_version.is_none_or(|minimum_version| version >= minimum_version)
1099 {
1100 versions.pop()
1101 } else {
1102 None
1103 };
1104 log::debug!("existing version of {package_name}: {newest_version:?}");
1105 to_delete.extend(versions.into_iter().map(|(_, file_name)| file_name));
1106
1107 cx.background_spawn({
1108 let fs = fs.clone();
1109 let dir = dir.clone();
1110 async move {
1111 for file_name in to_delete {
1112 fs.remove_dir(
1113 &dir.join(file_name),
1114 RemoveOptions {
1115 recursive: true,
1116 ignore_if_not_exists: false,
1117 },
1118 )
1119 .await
1120 .ok();
1121 }
1122 }
1123 })
1124 .detach();
1125
1126 let version = if let Some((version, file_name)) = newest_version {
1127 cx.background_spawn({
1128 let dir = dir.clone();
1129 let fs = fs.clone();
1130 async move {
1131 let latest_version = node_runtime
1132 .npm_package_latest_version(&package_name)
1133 .await
1134 .ok();
1135 if let Some(latest_version) = latest_version
1136 && latest_version != version
1137 {
1138 let download_result = download_latest_version(
1139 fs,
1140 dir.clone(),
1141 node_runtime,
1142 package_name.clone(),
1143 )
1144 .await
1145 .log_err();
1146 if let Some(mut new_version_available) = new_version_available
1147 && download_result.is_some()
1148 {
1149 new_version_available
1150 .send(Some(latest_version.to_string()))
1151 .ok();
1152 }
1153 }
1154 }
1155 })
1156 .detach();
1157 file_name
1158 } else {
1159 if let Some(mut status_tx) = status_tx {
1160 status_tx.send("Installing…".into()).ok();
1161 }
1162 let dir = dir.clone();
1163 cx.background_spawn(download_latest_version(
1164 fs.clone(),
1165 dir.clone(),
1166 node_runtime,
1167 package_name.clone(),
1168 ))
1169 .await?
1170 .to_string()
1171 .into()
1172 };
1173
1174 let agent_server_path = dir.join(version).join(entrypoint_path);
1175 let agent_server_path_exists = fs.is_file(&agent_server_path).await;
1176 anyhow::ensure!(
1177 agent_server_path_exists,
1178 "Missing entrypoint path {} after installation",
1179 agent_server_path.to_string_lossy()
1180 );
1181
1182 anyhow::Ok(AgentServerCommand {
1183 path: node_path,
1184 args: vec![agent_server_path.to_string_lossy().into_owned()],
1185 env: None,
1186 })
1187 })
1188}
1189
1190fn find_bin_in_path(
1191 bin_name: SharedString,
1192 root_dir: PathBuf,
1193 env: HashMap<String, String>,
1194 cx: &mut AsyncApp,
1195) -> Task<Option<PathBuf>> {
1196 cx.background_executor().spawn(async move {
1197 let which_result = if cfg!(windows) {
1198 which::which(bin_name.as_str())
1199 } else {
1200 let shell_path = env.get("PATH").cloned();
1201 which::which_in(bin_name.as_str(), shell_path.as_ref(), &root_dir)
1202 };
1203
1204 if let Err(which::Error::CannotFindBinaryPath) = which_result {
1205 return None;
1206 }
1207
1208 which_result.log_err()
1209 })
1210}
1211
1212async fn download_latest_version(
1213 fs: Arc<dyn Fs>,
1214 dir: PathBuf,
1215 node_runtime: NodeRuntime,
1216 package_name: SharedString,
1217) -> Result<Version> {
1218 log::debug!("downloading latest version of {package_name}");
1219
1220 let tmp_dir = tempfile::tempdir_in(&dir)?;
1221
1222 node_runtime
1223 .npm_install_packages(tmp_dir.path(), &[(&package_name, "latest")])
1224 .await?;
1225
1226 let version = node_runtime
1227 .npm_package_installed_version(tmp_dir.path(), &package_name)
1228 .await?
1229 .context("expected package to be installed")?;
1230
1231 fs.rename(
1232 &tmp_dir.keep(),
1233 &dir.join(version.to_string()),
1234 RenameOptions {
1235 ignore_if_exists: true,
1236 overwrite: true,
1237 create_parents: false,
1238 },
1239 )
1240 .await?;
1241
1242 anyhow::Ok(version)
1243}
1244
1245struct RemoteExternalAgentServer {
1246 project_id: u64,
1247 upstream_client: Entity<RemoteClient>,
1248 name: ExternalAgentServerName,
1249 status_tx: Option<watch::Sender<SharedString>>,
1250 new_version_available_tx: Option<watch::Sender<Option<String>>>,
1251}
1252
1253impl ExternalAgentServer for RemoteExternalAgentServer {
1254 fn get_command(
1255 &mut self,
1256 extra_env: HashMap<String, String>,
1257 status_tx: Option<watch::Sender<SharedString>>,
1258 new_version_available_tx: Option<watch::Sender<Option<String>>>,
1259 cx: &mut AsyncApp,
1260 ) -> Task<Result<(AgentServerCommand, Option<task::SpawnInTerminal>)>> {
1261 let project_id = self.project_id;
1262 let name = self.name.to_string();
1263 let upstream_client = self.upstream_client.downgrade();
1264 self.status_tx = status_tx;
1265 self.new_version_available_tx = new_version_available_tx;
1266 cx.spawn(async move |cx| {
1267 let mut response = upstream_client
1268 .update(cx, |upstream_client, _| {
1269 upstream_client
1270 .proto_client()
1271 .request(proto::GetAgentServerCommand {
1272 project_id,
1273 name,
1274 root_dir: None,
1275 })
1276 })?
1277 .await?;
1278 let root_dir = response.root_dir;
1279 response.env.extend(extra_env);
1280 let command = upstream_client.update(cx, |client, _| {
1281 client.build_command_with_options(
1282 Some(response.path),
1283 &response.args,
1284 &response.env.into_iter().collect(),
1285 Some(root_dir.clone()),
1286 None,
1287 Interactive::No,
1288 )
1289 })??;
1290 Ok((
1291 AgentServerCommand {
1292 path: command.program.into(),
1293 args: command.args,
1294 env: Some(command.env),
1295 },
1296 response.login.map(SpawnInTerminal::from_proto),
1297 ))
1298 })
1299 }
1300
1301 fn as_any_mut(&mut self) -> &mut dyn Any {
1302 self
1303 }
1304}
1305
1306struct LocalGemini {
1307 fs: Arc<dyn Fs>,
1308 node_runtime: NodeRuntime,
1309 project_environment: Entity<ProjectEnvironment>,
1310 custom_command: Option<AgentServerCommand>,
1311 settings_env: Option<HashMap<String, String>>,
1312 ignore_system_version: bool,
1313}
1314
1315impl ExternalAgentServer for LocalGemini {
1316 fn get_command(
1317 &mut self,
1318 extra_env: HashMap<String, String>,
1319 status_tx: Option<watch::Sender<SharedString>>,
1320 new_version_available_tx: Option<watch::Sender<Option<String>>>,
1321 cx: &mut AsyncApp,
1322 ) -> Task<Result<(AgentServerCommand, Option<task::SpawnInTerminal>)>> {
1323 let fs = self.fs.clone();
1324 let node_runtime = self.node_runtime.clone();
1325 let project_environment = self.project_environment.downgrade();
1326 let custom_command = self.custom_command.clone();
1327 let settings_env = self.settings_env.clone();
1328 let ignore_system_version = self.ignore_system_version;
1329 let home_dir = paths::home_dir();
1330
1331 cx.spawn(async move |cx| {
1332 let mut env = project_environment
1333 .update(cx, |project_environment, cx| {
1334 project_environment.local_directory_environment(
1335 &Shell::System,
1336 home_dir.as_path().into(),
1337 cx,
1338 )
1339 })?
1340 .await
1341 .unwrap_or_default();
1342
1343 env.extend(settings_env.unwrap_or_default());
1344
1345 let mut command = if let Some(mut custom_command) = custom_command {
1346 custom_command.env = Some(env);
1347 custom_command
1348 } else if !ignore_system_version
1349 && let Some(bin) =
1350 find_bin_in_path("gemini".into(), home_dir.to_path_buf(), env.clone(), cx).await
1351 {
1352 AgentServerCommand {
1353 path: bin,
1354 args: Vec::new(),
1355 env: Some(env),
1356 }
1357 } else {
1358 let mut command = get_or_npm_install_builtin_agent(
1359 GEMINI_NAME.into(),
1360 "@google/gemini-cli".into(),
1361 "node_modules/@google/gemini-cli/dist/index.js".into(),
1362 if cfg!(windows) {
1363 // v0.8.x on Windows has a bug that causes the initialize request to hang forever
1364 Some("0.9.0".parse().unwrap())
1365 } else {
1366 Some("0.2.1".parse().unwrap())
1367 },
1368 status_tx,
1369 new_version_available_tx,
1370 fs,
1371 node_runtime,
1372 cx,
1373 )
1374 .await?;
1375 command.env = Some(env);
1376 command
1377 };
1378
1379 // Gemini CLI doesn't seem to have a dedicated invocation for logging in--we just run it normally without any arguments.
1380 let login = task::SpawnInTerminal {
1381 command: Some(command.path.to_string_lossy().into_owned()),
1382 args: command.args.clone(),
1383 env: command.env.clone().unwrap_or_default(),
1384 label: "gemini /auth".into(),
1385 ..Default::default()
1386 };
1387
1388 command.env.get_or_insert_default().extend(extra_env);
1389 command.args.push("--experimental-acp".into());
1390 Ok((command, Some(login)))
1391 })
1392 }
1393
1394 fn as_any_mut(&mut self) -> &mut dyn Any {
1395 self
1396 }
1397}
1398
1399struct LocalClaudeCode {
1400 fs: Arc<dyn Fs>,
1401 node_runtime: NodeRuntime,
1402 project_environment: Entity<ProjectEnvironment>,
1403 custom_command: Option<AgentServerCommand>,
1404 settings_env: Option<HashMap<String, String>>,
1405}
1406
1407impl ExternalAgentServer for LocalClaudeCode {
1408 fn get_command(
1409 &mut self,
1410 extra_env: HashMap<String, String>,
1411 status_tx: Option<watch::Sender<SharedString>>,
1412 new_version_available_tx: Option<watch::Sender<Option<String>>>,
1413 cx: &mut AsyncApp,
1414 ) -> Task<Result<(AgentServerCommand, Option<task::SpawnInTerminal>)>> {
1415 let fs = self.fs.clone();
1416 let node_runtime = self.node_runtime.clone();
1417 let project_environment = self.project_environment.downgrade();
1418 let custom_command = self.custom_command.clone();
1419 let settings_env = self.settings_env.clone();
1420
1421 cx.spawn(async move |cx| {
1422 let mut env = project_environment
1423 .update(cx, |project_environment, cx| {
1424 project_environment.local_directory_environment(
1425 &Shell::System,
1426 paths::home_dir().as_path().into(),
1427 cx,
1428 )
1429 })?
1430 .await
1431 .unwrap_or_default();
1432 env.insert("ANTHROPIC_API_KEY".into(), "".into());
1433
1434 env.extend(settings_env.unwrap_or_default());
1435
1436 let (mut command, login_command) = if let Some(mut custom_command) = custom_command {
1437 custom_command.env = Some(env);
1438 (custom_command, None)
1439 } else {
1440 let mut command = get_or_npm_install_builtin_agent(
1441 "claude-agent-acp".into(),
1442 "@zed-industries/claude-agent-acp".into(),
1443 "node_modules/@zed-industries/claude-agent-acp/dist/index.js".into(),
1444 Some("0.17.0".parse().unwrap()),
1445 status_tx,
1446 new_version_available_tx,
1447 fs,
1448 node_runtime,
1449 cx,
1450 )
1451 .await?;
1452 command.env = Some(env);
1453
1454 (command, None)
1455 };
1456
1457 command.env.get_or_insert_default().extend(extra_env);
1458 Ok((command, login_command))
1459 })
1460 }
1461
1462 fn as_any_mut(&mut self) -> &mut dyn Any {
1463 self
1464 }
1465}
1466
1467struct LocalCodex {
1468 fs: Arc<dyn Fs>,
1469 project_environment: Entity<ProjectEnvironment>,
1470 http_client: Arc<dyn HttpClient>,
1471 custom_command: Option<AgentServerCommand>,
1472 settings_env: Option<HashMap<String, String>>,
1473 no_browser: bool,
1474}
1475
1476impl ExternalAgentServer for LocalCodex {
1477 fn get_command(
1478 &mut self,
1479 extra_env: HashMap<String, String>,
1480 mut status_tx: Option<watch::Sender<SharedString>>,
1481 _new_version_available_tx: Option<watch::Sender<Option<String>>>,
1482 cx: &mut AsyncApp,
1483 ) -> Task<Result<(AgentServerCommand, Option<task::SpawnInTerminal>)>> {
1484 let fs = self.fs.clone();
1485 let project_environment = self.project_environment.downgrade();
1486 let http = self.http_client.clone();
1487 let custom_command = self.custom_command.clone();
1488 let settings_env = self.settings_env.clone();
1489 let no_browser = self.no_browser;
1490
1491 cx.spawn(async move |cx| {
1492 let mut env = project_environment
1493 .update(cx, |project_environment, cx| {
1494 project_environment.local_directory_environment(
1495 &Shell::System,
1496 paths::home_dir().as_path().into(),
1497 cx,
1498 )
1499 })?
1500 .await
1501 .unwrap_or_default();
1502 if no_browser {
1503 env.insert("NO_BROWSER".to_owned(), "1".to_owned());
1504 }
1505
1506 env.extend(settings_env.unwrap_or_default());
1507
1508 let mut command = if let Some(mut custom_command) = custom_command {
1509 custom_command.env = Some(env);
1510 custom_command
1511 } else {
1512 let dir = paths::external_agents_dir().join(CODEX_NAME);
1513 fs.create_dir(&dir).await?;
1514
1515 let bin_name = if cfg!(windows) {
1516 "codex-acp.exe"
1517 } else {
1518 "codex-acp"
1519 };
1520
1521 let find_latest_local_version = async || -> Option<PathBuf> {
1522 let mut local_versions: Vec<(semver::Version, String)> = Vec::new();
1523 let mut stream = fs.read_dir(&dir).await.ok()?;
1524 while let Some(entry) = stream.next().await {
1525 let Ok(entry) = entry else { continue };
1526 let Some(file_name) = entry.file_name() else {
1527 continue;
1528 };
1529 let version_path = dir.join(&file_name);
1530 if fs.is_file(&version_path.join(bin_name)).await {
1531 let version_str = file_name.to_string_lossy();
1532 if let Ok(version) =
1533 semver::Version::from_str(version_str.trim_start_matches('v'))
1534 {
1535 local_versions.push((version, version_str.into_owned()));
1536 }
1537 }
1538 }
1539 local_versions.sort_by(|(a, _), (b, _)| a.cmp(b));
1540 local_versions.last().map(|(_, v)| dir.join(v))
1541 };
1542
1543 let fallback_to_latest_local_version =
1544 async |err: anyhow::Error| -> Result<PathBuf, anyhow::Error> {
1545 if let Some(local) = find_latest_local_version().await {
1546 log::info!(
1547 "Falling back to locally installed Codex version: {}",
1548 local.display()
1549 );
1550 Ok(local)
1551 } else {
1552 Err(err)
1553 }
1554 };
1555
1556 let version_dir = match ::http_client::github::latest_github_release(
1557 CODEX_ACP_REPO,
1558 true,
1559 false,
1560 http.clone(),
1561 )
1562 .await
1563 {
1564 Ok(release) => {
1565 let version_dir = dir.join(&release.tag_name);
1566 if !fs.is_dir(&version_dir).await {
1567 if let Some(ref mut status_tx) = status_tx {
1568 status_tx.send("Installing…".into()).ok();
1569 }
1570
1571 let tag = release.tag_name.clone();
1572 let version_number = tag.trim_start_matches('v');
1573 let asset_name = asset_name(version_number)
1574 .context("codex acp is not supported for this architecture")?;
1575 let asset = release
1576 .assets
1577 .into_iter()
1578 .find(|asset| asset.name == asset_name)
1579 .with_context(|| {
1580 format!("no asset found matching `{asset_name:?}`")
1581 })?;
1582 // Strip "sha256:" prefix from digest if present (GitHub API format)
1583 let digest = asset
1584 .digest
1585 .as_deref()
1586 .and_then(|d| d.strip_prefix("sha256:").or(Some(d)));
1587 match ::http_client::github_download::download_server_binary(
1588 &*http,
1589 &asset.browser_download_url,
1590 digest,
1591 &version_dir,
1592 if cfg!(target_os = "windows") {
1593 AssetKind::Zip
1594 } else {
1595 AssetKind::TarGz
1596 },
1597 )
1598 .await
1599 {
1600 Ok(()) => {
1601 // remove older versions
1602 util::fs::remove_matching(&dir, |entry| entry != version_dir)
1603 .await;
1604 version_dir
1605 }
1606 Err(err) => {
1607 log::error!(
1608 "Failed to download Codex release {}: {err:#}",
1609 release.tag_name
1610 );
1611 fallback_to_latest_local_version(err).await?
1612 }
1613 }
1614 } else {
1615 version_dir
1616 }
1617 }
1618 Err(err) => {
1619 log::error!("Failed to fetch Codex latest release: {err:#}");
1620 fallback_to_latest_local_version(err).await?
1621 }
1622 };
1623
1624 let bin_path = version_dir.join(bin_name);
1625 anyhow::ensure!(
1626 fs.is_file(&bin_path).await,
1627 "Missing Codex binary at {} after installation",
1628 bin_path.to_string_lossy()
1629 );
1630
1631 let mut cmd = AgentServerCommand {
1632 path: bin_path,
1633 args: Vec::new(),
1634 env: None,
1635 };
1636 cmd.env = Some(env);
1637 cmd
1638 };
1639
1640 command.env.get_or_insert_default().extend(extra_env);
1641 Ok((command, None))
1642 })
1643 }
1644
1645 fn as_any_mut(&mut self) -> &mut dyn Any {
1646 self
1647 }
1648}
1649
1650pub const CODEX_ACP_REPO: &str = "zed-industries/codex-acp";
1651
1652fn get_platform_info() -> Option<(&'static str, &'static str, &'static str)> {
1653 let arch = if cfg!(target_arch = "x86_64") {
1654 "x86_64"
1655 } else if cfg!(target_arch = "aarch64") {
1656 "aarch64"
1657 } else {
1658 return None;
1659 };
1660
1661 let platform = if cfg!(target_os = "macos") {
1662 "apple-darwin"
1663 } else if cfg!(target_os = "windows") {
1664 "pc-windows-msvc"
1665 } else if cfg!(target_os = "linux") {
1666 "unknown-linux-gnu"
1667 } else {
1668 return None;
1669 };
1670
1671 // Windows uses .zip in release assets
1672 let ext = if cfg!(target_os = "windows") {
1673 "zip"
1674 } else {
1675 "tar.gz"
1676 };
1677
1678 Some((arch, platform, ext))
1679}
1680
1681fn asset_name(version: &str) -> Option<String> {
1682 let (arch, platform, ext) = get_platform_info()?;
1683 Some(format!("codex-acp-{version}-{arch}-{platform}.{ext}"))
1684}
1685
1686pub struct LocalExtensionArchiveAgent {
1687 pub fs: Arc<dyn Fs>,
1688 pub http_client: Arc<dyn HttpClient>,
1689 pub node_runtime: NodeRuntime,
1690 pub project_environment: Entity<ProjectEnvironment>,
1691 pub extension_id: Arc<str>,
1692 pub agent_id: Arc<str>,
1693 pub targets: HashMap<String, extension::TargetConfig>,
1694 pub env: HashMap<String, String>,
1695}
1696
1697impl ExternalAgentServer for LocalExtensionArchiveAgent {
1698 fn get_command(
1699 &mut self,
1700 extra_env: HashMap<String, String>,
1701 _status_tx: Option<watch::Sender<SharedString>>,
1702 _new_version_available_tx: Option<watch::Sender<Option<String>>>,
1703 cx: &mut AsyncApp,
1704 ) -> Task<Result<(AgentServerCommand, Option<task::SpawnInTerminal>)>> {
1705 let fs = self.fs.clone();
1706 let http_client = self.http_client.clone();
1707 let node_runtime = self.node_runtime.clone();
1708 let project_environment = self.project_environment.downgrade();
1709 let extension_id = self.extension_id.clone();
1710 let agent_id = self.agent_id.clone();
1711 let targets = self.targets.clone();
1712 let base_env = self.env.clone();
1713
1714 cx.spawn(async move |cx| {
1715 // Get project environment
1716 let mut env = project_environment
1717 .update(cx, |project_environment, cx| {
1718 project_environment.local_directory_environment(
1719 &Shell::System,
1720 paths::home_dir().as_path().into(),
1721 cx,
1722 )
1723 })?
1724 .await
1725 .unwrap_or_default();
1726
1727 // Merge manifest env and extra env
1728 env.extend(base_env);
1729 env.extend(extra_env);
1730
1731 let cache_key = format!("{}/{}", extension_id, agent_id);
1732 let dir = paths::external_agents_dir().join(&cache_key);
1733 fs.create_dir(&dir).await?;
1734
1735 // Determine platform key
1736 let os = if cfg!(target_os = "macos") {
1737 "darwin"
1738 } else if cfg!(target_os = "linux") {
1739 "linux"
1740 } else if cfg!(target_os = "windows") {
1741 "windows"
1742 } else {
1743 anyhow::bail!("unsupported OS");
1744 };
1745
1746 let arch = if cfg!(target_arch = "aarch64") {
1747 "aarch64"
1748 } else if cfg!(target_arch = "x86_64") {
1749 "x86_64"
1750 } else {
1751 anyhow::bail!("unsupported architecture");
1752 };
1753
1754 let platform_key = format!("{}-{}", os, arch);
1755 let target_config = targets.get(&platform_key).with_context(|| {
1756 format!(
1757 "no target specified for platform '{}'. Available platforms: {}",
1758 platform_key,
1759 targets
1760 .keys()
1761 .map(|k| k.as_str())
1762 .collect::<Vec<_>>()
1763 .join(", ")
1764 )
1765 })?;
1766
1767 let archive_url = &target_config.archive;
1768
1769 // Use URL as version identifier for caching
1770 // Hash the URL to get a stable directory name
1771 use std::collections::hash_map::DefaultHasher;
1772 use std::hash::{Hash, Hasher};
1773 let mut hasher = DefaultHasher::new();
1774 archive_url.hash(&mut hasher);
1775 let url_hash = hasher.finish();
1776 let version_dir = dir.join(format!("v_{:x}", url_hash));
1777
1778 if !fs.is_dir(&version_dir).await {
1779 // Determine SHA256 for verification
1780 let sha256 = if let Some(provided_sha) = &target_config.sha256 {
1781 // Use provided SHA256
1782 Some(provided_sha.clone())
1783 } else if archive_url.starts_with("https://github.com/") {
1784 // Try to fetch SHA256 from GitHub API
1785 // Parse URL to extract repo and tag/file info
1786 // Format: https://github.com/owner/repo/releases/download/tag/file.zip
1787 if let Some(caps) = archive_url.strip_prefix("https://github.com/") {
1788 let parts: Vec<&str> = caps.split('/').collect();
1789 if parts.len() >= 6 && parts[2] == "releases" && parts[3] == "download" {
1790 let repo = format!("{}/{}", parts[0], parts[1]);
1791 let tag = parts[4];
1792 let filename = parts[5..].join("/");
1793
1794 // Try to get release info from GitHub
1795 if let Ok(release) = ::http_client::github::get_release_by_tag_name(
1796 &repo,
1797 tag,
1798 http_client.clone(),
1799 )
1800 .await
1801 {
1802 // Find matching asset
1803 if let Some(asset) =
1804 release.assets.iter().find(|a| a.name == filename)
1805 {
1806 // Strip "sha256:" prefix if present
1807 asset.digest.as_ref().map(|d| {
1808 d.strip_prefix("sha256:")
1809 .map(|s| s.to_string())
1810 .unwrap_or_else(|| d.clone())
1811 })
1812 } else {
1813 None
1814 }
1815 } else {
1816 None
1817 }
1818 } else {
1819 None
1820 }
1821 } else {
1822 None
1823 }
1824 } else {
1825 None
1826 };
1827
1828 // Determine archive type from URL
1829 let asset_kind = if archive_url.ends_with(".zip") {
1830 AssetKind::Zip
1831 } else if archive_url.ends_with(".tar.gz") || archive_url.ends_with(".tgz") {
1832 AssetKind::TarGz
1833 } else {
1834 anyhow::bail!("unsupported archive type in URL: {}", archive_url);
1835 };
1836
1837 // Download and extract
1838 ::http_client::github_download::download_server_binary(
1839 &*http_client,
1840 archive_url,
1841 sha256.as_deref(),
1842 &version_dir,
1843 asset_kind,
1844 )
1845 .await?;
1846 }
1847
1848 // Validate and resolve cmd path
1849 let cmd = &target_config.cmd;
1850
1851 let cmd_path = if cmd == "node" {
1852 // Use Zed's managed Node.js runtime
1853 node_runtime.binary_path().await?
1854 } else {
1855 if cmd.contains("..") {
1856 anyhow::bail!("command path cannot contain '..': {}", cmd);
1857 }
1858
1859 if cmd.starts_with("./") || cmd.starts_with(".\\") {
1860 // Relative to extraction directory
1861 let cmd_path = version_dir.join(&cmd[2..]);
1862 anyhow::ensure!(
1863 fs.is_file(&cmd_path).await,
1864 "Missing command {} after extraction",
1865 cmd_path.to_string_lossy()
1866 );
1867 cmd_path
1868 } else {
1869 // On PATH
1870 anyhow::bail!("command must be relative (start with './'): {}", cmd);
1871 }
1872 };
1873
1874 let command = AgentServerCommand {
1875 path: cmd_path,
1876 args: target_config.args.clone(),
1877 env: Some(env),
1878 };
1879
1880 Ok((command, None))
1881 })
1882 }
1883
1884 fn as_any_mut(&mut self) -> &mut dyn Any {
1885 self
1886 }
1887}
1888
1889struct LocalRegistryArchiveAgent {
1890 fs: Arc<dyn Fs>,
1891 http_client: Arc<dyn HttpClient>,
1892 node_runtime: NodeRuntime,
1893 project_environment: Entity<ProjectEnvironment>,
1894 registry_id: Arc<str>,
1895 targets: HashMap<String, RegistryTargetConfig>,
1896 env: HashMap<String, String>,
1897}
1898
1899impl ExternalAgentServer for LocalRegistryArchiveAgent {
1900 fn get_command(
1901 &mut self,
1902 extra_env: HashMap<String, String>,
1903 _status_tx: Option<watch::Sender<SharedString>>,
1904 _new_version_available_tx: Option<watch::Sender<Option<String>>>,
1905 cx: &mut AsyncApp,
1906 ) -> Task<Result<(AgentServerCommand, Option<task::SpawnInTerminal>)>> {
1907 let fs = self.fs.clone();
1908 let http_client = self.http_client.clone();
1909 let node_runtime = self.node_runtime.clone();
1910 let project_environment = self.project_environment.downgrade();
1911 let registry_id = self.registry_id.clone();
1912 let targets = self.targets.clone();
1913 let settings_env = self.env.clone();
1914
1915 cx.spawn(async move |cx| {
1916 let mut env = project_environment
1917 .update(cx, |project_environment, cx| {
1918 project_environment.local_directory_environment(
1919 &Shell::System,
1920 paths::home_dir().as_path().into(),
1921 cx,
1922 )
1923 })?
1924 .await
1925 .unwrap_or_default();
1926
1927 let dir = paths::external_agents_dir()
1928 .join("registry")
1929 .join(registry_id.as_ref());
1930 fs.create_dir(&dir).await?;
1931
1932 let os = if cfg!(target_os = "macos") {
1933 "darwin"
1934 } else if cfg!(target_os = "linux") {
1935 "linux"
1936 } else if cfg!(target_os = "windows") {
1937 "windows"
1938 } else {
1939 anyhow::bail!("unsupported OS");
1940 };
1941
1942 let arch = if cfg!(target_arch = "aarch64") {
1943 "aarch64"
1944 } else if cfg!(target_arch = "x86_64") {
1945 "x86_64"
1946 } else {
1947 anyhow::bail!("unsupported architecture");
1948 };
1949
1950 let platform_key = format!("{}-{}", os, arch);
1951 let target_config = targets.get(&platform_key).with_context(|| {
1952 format!(
1953 "no target specified for platform '{}'. Available platforms: {}",
1954 platform_key,
1955 targets
1956 .keys()
1957 .map(|k| k.as_str())
1958 .collect::<Vec<_>>()
1959 .join(", ")
1960 )
1961 })?;
1962
1963 env.extend(target_config.env.clone());
1964 env.extend(extra_env);
1965 env.extend(settings_env);
1966
1967 let archive_url = &target_config.archive;
1968
1969 use std::collections::hash_map::DefaultHasher;
1970 use std::hash::{Hash, Hasher};
1971 let mut hasher = DefaultHasher::new();
1972 archive_url.hash(&mut hasher);
1973 let url_hash = hasher.finish();
1974 let version_dir = dir.join(format!("v_{:x}", url_hash));
1975
1976 if !fs.is_dir(&version_dir).await {
1977 let sha256 = if let Some(provided_sha) = &target_config.sha256 {
1978 Some(provided_sha.clone())
1979 } else if archive_url.starts_with("https://github.com/") {
1980 if let Some(caps) = archive_url.strip_prefix("https://github.com/") {
1981 let parts: Vec<&str> = caps.split('/').collect();
1982 if parts.len() >= 6 && parts[2] == "releases" && parts[3] == "download" {
1983 let repo = format!("{}/{}", parts[0], parts[1]);
1984 let tag = parts[4];
1985 let filename = parts[5..].join("/");
1986
1987 if let Ok(release) = ::http_client::github::get_release_by_tag_name(
1988 &repo,
1989 tag,
1990 http_client.clone(),
1991 )
1992 .await
1993 {
1994 if let Some(asset) =
1995 release.assets.iter().find(|a| a.name == filename)
1996 {
1997 asset.digest.as_ref().and_then(|d| {
1998 d.strip_prefix("sha256:")
1999 .map(|s| s.to_string())
2000 .or_else(|| Some(d.clone()))
2001 })
2002 } else {
2003 None
2004 }
2005 } else {
2006 None
2007 }
2008 } else {
2009 None
2010 }
2011 } else {
2012 None
2013 }
2014 } else {
2015 None
2016 };
2017
2018 let asset_kind = if archive_url.ends_with(".zip") {
2019 AssetKind::Zip
2020 } else if archive_url.ends_with(".tar.gz") || archive_url.ends_with(".tgz") {
2021 AssetKind::TarGz
2022 } else {
2023 anyhow::bail!("unsupported archive type in URL: {}", archive_url);
2024 };
2025
2026 ::http_client::github_download::download_server_binary(
2027 &*http_client,
2028 archive_url,
2029 sha256.as_deref(),
2030 &version_dir,
2031 asset_kind,
2032 )
2033 .await?;
2034 }
2035
2036 let cmd = &target_config.cmd;
2037
2038 let cmd_path = if cmd == "node" {
2039 node_runtime.binary_path().await?
2040 } else {
2041 if cmd.contains("..") {
2042 anyhow::bail!("command path cannot contain '..': {}", cmd);
2043 }
2044
2045 if cmd.starts_with("./") || cmd.starts_with(".\\") {
2046 let cmd_path = version_dir.join(&cmd[2..]);
2047 anyhow::ensure!(
2048 fs.is_file(&cmd_path).await,
2049 "Missing command {} after extraction",
2050 cmd_path.to_string_lossy()
2051 );
2052 cmd_path
2053 } else {
2054 anyhow::bail!("command must be relative (start with './'): {}", cmd);
2055 }
2056 };
2057
2058 let command = AgentServerCommand {
2059 path: cmd_path,
2060 args: target_config.args.clone(),
2061 env: Some(env),
2062 };
2063
2064 Ok((command, None))
2065 })
2066 }
2067
2068 fn as_any_mut(&mut self) -> &mut dyn Any {
2069 self
2070 }
2071}
2072
2073struct LocalRegistryNpxAgent {
2074 node_runtime: NodeRuntime,
2075 project_environment: Entity<ProjectEnvironment>,
2076 package: SharedString,
2077 args: Vec<String>,
2078 distribution_env: HashMap<String, String>,
2079 settings_env: HashMap<String, String>,
2080}
2081
2082impl ExternalAgentServer for LocalRegistryNpxAgent {
2083 fn get_command(
2084 &mut self,
2085 extra_env: HashMap<String, String>,
2086 _status_tx: Option<watch::Sender<SharedString>>,
2087 _new_version_available_tx: Option<watch::Sender<Option<String>>>,
2088 cx: &mut AsyncApp,
2089 ) -> Task<Result<(AgentServerCommand, Option<task::SpawnInTerminal>)>> {
2090 let node_runtime = self.node_runtime.clone();
2091 let project_environment = self.project_environment.downgrade();
2092 let package = self.package.clone();
2093 let args = self.args.clone();
2094 let distribution_env = self.distribution_env.clone();
2095 let settings_env = self.settings_env.clone();
2096
2097 cx.spawn(async move |cx| {
2098 let mut env = project_environment
2099 .update(cx, |project_environment, cx| {
2100 project_environment.local_directory_environment(
2101 &Shell::System,
2102 paths::home_dir().as_path().into(),
2103 cx,
2104 )
2105 })?
2106 .await
2107 .unwrap_or_default();
2108
2109 let mut exec_args = Vec::new();
2110 exec_args.push("--yes".to_string());
2111 exec_args.push(package.to_string());
2112 if !args.is_empty() {
2113 exec_args.push("--".to_string());
2114 exec_args.extend(args);
2115 }
2116
2117 let npm_command = node_runtime
2118 .npm_command(
2119 "exec",
2120 &exec_args.iter().map(|a| a.as_str()).collect::<Vec<_>>(),
2121 )
2122 .await?;
2123
2124 env.extend(npm_command.env);
2125 env.extend(distribution_env);
2126 env.extend(extra_env);
2127 env.extend(settings_env);
2128
2129 let command = AgentServerCommand {
2130 path: npm_command.path,
2131 args: npm_command.args,
2132 env: Some(env),
2133 };
2134
2135 Ok((command, None))
2136 })
2137 }
2138
2139 fn as_any_mut(&mut self) -> &mut dyn Any {
2140 self
2141 }
2142}
2143
2144struct LocalCustomAgent {
2145 project_environment: Entity<ProjectEnvironment>,
2146 command: AgentServerCommand,
2147}
2148
2149impl ExternalAgentServer for LocalCustomAgent {
2150 fn get_command(
2151 &mut self,
2152 extra_env: HashMap<String, String>,
2153 _status_tx: Option<watch::Sender<SharedString>>,
2154 _new_version_available_tx: Option<watch::Sender<Option<String>>>,
2155 cx: &mut AsyncApp,
2156 ) -> Task<Result<(AgentServerCommand, Option<task::SpawnInTerminal>)>> {
2157 let mut command = self.command.clone();
2158 let project_environment = self.project_environment.downgrade();
2159 cx.spawn(async move |cx| {
2160 let mut env = project_environment
2161 .update(cx, |project_environment, cx| {
2162 project_environment.local_directory_environment(
2163 &Shell::System,
2164 paths::home_dir().as_path().into(),
2165 cx,
2166 )
2167 })?
2168 .await
2169 .unwrap_or_default();
2170 env.extend(command.env.unwrap_or_default());
2171 env.extend(extra_env);
2172 command.env = Some(env);
2173 Ok((command, None))
2174 })
2175 }
2176
2177 fn as_any_mut(&mut self) -> &mut dyn Any {
2178 self
2179 }
2180}
2181
2182pub const GEMINI_NAME: &'static str = "gemini";
2183pub const CLAUDE_AGENT_NAME: &'static str = "claude";
2184pub const CODEX_NAME: &'static str = "codex";
2185
2186#[derive(Default, Clone, JsonSchema, Debug, PartialEq, RegisterSetting)]
2187pub struct AllAgentServersSettings {
2188 pub gemini: Option<BuiltinAgentServerSettings>,
2189 pub claude: Option<BuiltinAgentServerSettings>,
2190 pub codex: Option<BuiltinAgentServerSettings>,
2191 pub custom: HashMap<String, CustomAgentServerSettings>,
2192}
2193
2194impl AllAgentServersSettings {
2195 pub fn has_registry_agents(&self) -> bool {
2196 self.custom
2197 .values()
2198 .any(|s| matches!(s, CustomAgentServerSettings::Registry { .. }))
2199 }
2200}
2201
2202#[derive(Default, Clone, JsonSchema, Debug, PartialEq)]
2203pub struct BuiltinAgentServerSettings {
2204 pub path: Option<PathBuf>,
2205 pub args: Option<Vec<String>>,
2206 pub env: Option<HashMap<String, String>>,
2207 pub ignore_system_version: Option<bool>,
2208 pub default_mode: Option<String>,
2209 pub default_model: Option<String>,
2210 pub favorite_models: Vec<String>,
2211 pub default_config_options: HashMap<String, String>,
2212 pub favorite_config_option_values: HashMap<String, Vec<String>>,
2213}
2214
2215impl BuiltinAgentServerSettings {
2216 fn custom_command(self) -> Option<AgentServerCommand> {
2217 self.path.map(|path| AgentServerCommand {
2218 path,
2219 args: self.args.unwrap_or_default(),
2220 // Settings env are always applied, so we don't need to supply them here as well
2221 env: None,
2222 })
2223 }
2224}
2225
2226impl From<settings::BuiltinAgentServerSettings> for BuiltinAgentServerSettings {
2227 fn from(value: settings::BuiltinAgentServerSettings) -> Self {
2228 BuiltinAgentServerSettings {
2229 path: value
2230 .path
2231 .map(|p| PathBuf::from(shellexpand::tilde(&p.to_string_lossy()).as_ref())),
2232 args: value.args,
2233 env: value.env,
2234 ignore_system_version: value.ignore_system_version,
2235 default_mode: value.default_mode,
2236 default_model: value.default_model,
2237 favorite_models: value.favorite_models,
2238 default_config_options: value.default_config_options,
2239 favorite_config_option_values: value.favorite_config_option_values,
2240 }
2241 }
2242}
2243
2244impl From<AgentServerCommand> for BuiltinAgentServerSettings {
2245 fn from(value: AgentServerCommand) -> Self {
2246 BuiltinAgentServerSettings {
2247 path: Some(value.path),
2248 args: Some(value.args),
2249 env: value.env,
2250 ..Default::default()
2251 }
2252 }
2253}
2254
2255#[derive(Clone, JsonSchema, Debug, PartialEq)]
2256pub enum CustomAgentServerSettings {
2257 Custom {
2258 command: AgentServerCommand,
2259 /// The default mode to use for this agent.
2260 ///
2261 /// Note: Not only all agents support modes.
2262 ///
2263 /// Default: None
2264 default_mode: Option<String>,
2265 /// The default model to use for this agent.
2266 ///
2267 /// This should be the model ID as reported by the agent.
2268 ///
2269 /// Default: None
2270 default_model: Option<String>,
2271 /// The favorite models for this agent.
2272 ///
2273 /// Default: []
2274 favorite_models: Vec<String>,
2275 /// Default values for session config options.
2276 ///
2277 /// This is a map from config option ID to value ID.
2278 ///
2279 /// Default: {}
2280 default_config_options: HashMap<String, String>,
2281 /// Favorited values for session config options.
2282 ///
2283 /// This is a map from config option ID to a list of favorited value IDs.
2284 ///
2285 /// Default: {}
2286 favorite_config_option_values: HashMap<String, Vec<String>>,
2287 },
2288 Extension {
2289 /// Additional environment variables to pass to the agent.
2290 ///
2291 /// Default: {}
2292 env: HashMap<String, String>,
2293 /// The default mode to use for this agent.
2294 ///
2295 /// Note: Not only all agents support modes.
2296 ///
2297 /// Default: None
2298 default_mode: Option<String>,
2299 /// The default model to use for this agent.
2300 ///
2301 /// This should be the model ID as reported by the agent.
2302 ///
2303 /// Default: None
2304 default_model: Option<String>,
2305 /// The favorite models for this agent.
2306 ///
2307 /// Default: []
2308 favorite_models: Vec<String>,
2309 /// Default values for session config options.
2310 ///
2311 /// This is a map from config option ID to value ID.
2312 ///
2313 /// Default: {}
2314 default_config_options: HashMap<String, String>,
2315 /// Favorited values for session config options.
2316 ///
2317 /// This is a map from config option ID to a list of favorited value IDs.
2318 ///
2319 /// Default: {}
2320 favorite_config_option_values: HashMap<String, Vec<String>>,
2321 },
2322 Registry {
2323 /// Additional environment variables to pass to the agent.
2324 ///
2325 /// Default: {}
2326 env: HashMap<String, String>,
2327 /// The default mode to use for this agent.
2328 ///
2329 /// Note: Not only all agents support modes.
2330 ///
2331 /// Default: None
2332 default_mode: Option<String>,
2333 /// The default model to use for this agent.
2334 ///
2335 /// This should be the model ID as reported by the agent.
2336 ///
2337 /// Default: None
2338 default_model: Option<String>,
2339 /// The favorite models for this agent.
2340 ///
2341 /// Default: []
2342 favorite_models: Vec<String>,
2343 /// Default values for session config options.
2344 ///
2345 /// This is a map from config option ID to value ID.
2346 ///
2347 /// Default: {}
2348 default_config_options: HashMap<String, String>,
2349 /// Favorited values for session config options.
2350 ///
2351 /// This is a map from config option ID to a list of favorited value IDs.
2352 ///
2353 /// Default: {}
2354 favorite_config_option_values: HashMap<String, Vec<String>>,
2355 },
2356}
2357
2358impl CustomAgentServerSettings {
2359 pub fn command(&self) -> Option<&AgentServerCommand> {
2360 match self {
2361 CustomAgentServerSettings::Custom { command, .. } => Some(command),
2362 CustomAgentServerSettings::Extension { .. }
2363 | CustomAgentServerSettings::Registry { .. } => None,
2364 }
2365 }
2366
2367 pub fn default_mode(&self) -> Option<&str> {
2368 match self {
2369 CustomAgentServerSettings::Custom { default_mode, .. }
2370 | CustomAgentServerSettings::Extension { default_mode, .. }
2371 | CustomAgentServerSettings::Registry { default_mode, .. } => default_mode.as_deref(),
2372 }
2373 }
2374
2375 pub fn default_model(&self) -> Option<&str> {
2376 match self {
2377 CustomAgentServerSettings::Custom { default_model, .. }
2378 | CustomAgentServerSettings::Extension { default_model, .. }
2379 | CustomAgentServerSettings::Registry { default_model, .. } => default_model.as_deref(),
2380 }
2381 }
2382
2383 pub fn favorite_models(&self) -> &[String] {
2384 match self {
2385 CustomAgentServerSettings::Custom {
2386 favorite_models, ..
2387 }
2388 | CustomAgentServerSettings::Extension {
2389 favorite_models, ..
2390 }
2391 | CustomAgentServerSettings::Registry {
2392 favorite_models, ..
2393 } => favorite_models,
2394 }
2395 }
2396
2397 pub fn default_config_option(&self, config_id: &str) -> Option<&str> {
2398 match self {
2399 CustomAgentServerSettings::Custom {
2400 default_config_options,
2401 ..
2402 }
2403 | CustomAgentServerSettings::Extension {
2404 default_config_options,
2405 ..
2406 }
2407 | CustomAgentServerSettings::Registry {
2408 default_config_options,
2409 ..
2410 } => default_config_options.get(config_id).map(|s| s.as_str()),
2411 }
2412 }
2413
2414 pub fn favorite_config_option_values(&self, config_id: &str) -> Option<&[String]> {
2415 match self {
2416 CustomAgentServerSettings::Custom {
2417 favorite_config_option_values,
2418 ..
2419 }
2420 | CustomAgentServerSettings::Extension {
2421 favorite_config_option_values,
2422 ..
2423 }
2424 | CustomAgentServerSettings::Registry {
2425 favorite_config_option_values,
2426 ..
2427 } => favorite_config_option_values
2428 .get(config_id)
2429 .map(|v| v.as_slice()),
2430 }
2431 }
2432}
2433
2434impl From<settings::CustomAgentServerSettings> for CustomAgentServerSettings {
2435 fn from(value: settings::CustomAgentServerSettings) -> Self {
2436 match value {
2437 settings::CustomAgentServerSettings::Custom {
2438 path,
2439 args,
2440 env,
2441 default_mode,
2442 default_model,
2443 favorite_models,
2444 default_config_options,
2445 favorite_config_option_values,
2446 } => CustomAgentServerSettings::Custom {
2447 command: AgentServerCommand {
2448 path: PathBuf::from(shellexpand::tilde(&path.to_string_lossy()).as_ref()),
2449 args,
2450 env: Some(env),
2451 },
2452 default_mode,
2453 default_model,
2454 favorite_models,
2455 default_config_options,
2456 favorite_config_option_values,
2457 },
2458 settings::CustomAgentServerSettings::Extension {
2459 env,
2460 default_mode,
2461 default_model,
2462 default_config_options,
2463 favorite_models,
2464 favorite_config_option_values,
2465 } => CustomAgentServerSettings::Extension {
2466 env,
2467 default_mode,
2468 default_model,
2469 default_config_options,
2470 favorite_models,
2471 favorite_config_option_values,
2472 },
2473 settings::CustomAgentServerSettings::Registry {
2474 env,
2475 default_mode,
2476 default_model,
2477 default_config_options,
2478 favorite_models,
2479 favorite_config_option_values,
2480 } => CustomAgentServerSettings::Registry {
2481 env,
2482 default_mode,
2483 default_model,
2484 default_config_options,
2485 favorite_models,
2486 favorite_config_option_values,
2487 },
2488 }
2489 }
2490}
2491
2492impl settings::Settings for AllAgentServersSettings {
2493 fn from_settings(content: &settings::SettingsContent) -> Self {
2494 let agent_settings = content.agent_servers.clone().unwrap();
2495 Self {
2496 gemini: agent_settings.gemini.map(Into::into),
2497 claude: agent_settings.claude.map(Into::into),
2498 codex: agent_settings.codex.map(Into::into),
2499 custom: agent_settings
2500 .custom
2501 .into_iter()
2502 .map(|(k, v)| (k, v.into()))
2503 .collect(),
2504 }
2505 }
2506}