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