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_CODE_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_CODE_NAME.into(),
689 ExternalAgentEntry::new(
690 Box::new(RemoteExternalAgentServer {
691 project_id,
692 upstream_client: upstream_client.clone(),
693 name: CLAUDE_CODE_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_CODE_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-code-acp".into(),
1459 "@zed-industries/claude-code-acp".into(),
1460 "node_modules/@zed-industries/claude-code-acp/dist/index.js".into(),
1461 Some("0.5.2".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 let login = command
1471 .args
1472 .first()
1473 .and_then(|path| {
1474 path.strip_suffix("/@zed-industries/claude-code-acp/dist/index.js")
1475 })
1476 .map(|path_prefix| task::SpawnInTerminal {
1477 command: Some(command.path.to_string_lossy().into_owned()),
1478 args: vec![
1479 Path::new(path_prefix)
1480 .join("@anthropic-ai/claude-agent-sdk/cli.js")
1481 .to_string_lossy()
1482 .to_string(),
1483 "/login".into(),
1484 ],
1485 env: command.env.clone().unwrap_or_default(),
1486 label: "claude /login".into(),
1487 ..Default::default()
1488 });
1489 (command, login)
1490 };
1491
1492 command.env.get_or_insert_default().extend(extra_env);
1493 Ok((
1494 command,
1495 root_dir.to_string_lossy().into_owned(),
1496 login_command,
1497 ))
1498 })
1499 }
1500
1501 fn as_any_mut(&mut self) -> &mut dyn Any {
1502 self
1503 }
1504}
1505
1506struct LocalCodex {
1507 fs: Arc<dyn Fs>,
1508 project_environment: Entity<ProjectEnvironment>,
1509 http_client: Arc<dyn HttpClient>,
1510 custom_command: Option<AgentServerCommand>,
1511 settings_env: Option<HashMap<String, String>>,
1512 no_browser: bool,
1513}
1514
1515impl ExternalAgentServer for LocalCodex {
1516 fn get_command(
1517 &mut self,
1518 root_dir: Option<&str>,
1519 extra_env: HashMap<String, String>,
1520 mut status_tx: Option<watch::Sender<SharedString>>,
1521 _new_version_available_tx: Option<watch::Sender<Option<String>>>,
1522 cx: &mut AsyncApp,
1523 ) -> Task<Result<(AgentServerCommand, String, Option<task::SpawnInTerminal>)>> {
1524 let fs = self.fs.clone();
1525 let project_environment = self.project_environment.downgrade();
1526 let http = self.http_client.clone();
1527 let custom_command = self.custom_command.clone();
1528 let settings_env = self.settings_env.clone();
1529 let root_dir: Arc<Path> = root_dir
1530 .map(|root_dir| Path::new(root_dir))
1531 .unwrap_or(paths::home_dir())
1532 .into();
1533 let no_browser = self.no_browser;
1534
1535 cx.spawn(async move |cx| {
1536 let mut env = project_environment
1537 .update(cx, |project_environment, cx| {
1538 project_environment.local_directory_environment(
1539 &Shell::System,
1540 root_dir.clone(),
1541 cx,
1542 )
1543 })?
1544 .await
1545 .unwrap_or_default();
1546 if no_browser {
1547 env.insert("NO_BROWSER".to_owned(), "1".to_owned());
1548 }
1549
1550 env.extend(settings_env.unwrap_or_default());
1551
1552 let mut command = if let Some(mut custom_command) = custom_command {
1553 custom_command.env = Some(env);
1554 custom_command
1555 } else {
1556 let dir = paths::external_agents_dir().join(CODEX_NAME);
1557 fs.create_dir(&dir).await?;
1558
1559 let bin_name = if cfg!(windows) {
1560 "codex-acp.exe"
1561 } else {
1562 "codex-acp"
1563 };
1564
1565 let find_latest_local_version = async || -> Option<PathBuf> {
1566 let mut local_versions: Vec<(semver::Version, String)> = Vec::new();
1567 let mut stream = fs.read_dir(&dir).await.ok()?;
1568 while let Some(entry) = stream.next().await {
1569 let Ok(entry) = entry else { continue };
1570 let Some(file_name) = entry.file_name() else {
1571 continue;
1572 };
1573 let version_path = dir.join(&file_name);
1574 if fs.is_file(&version_path.join(bin_name)).await {
1575 let version_str = file_name.to_string_lossy();
1576 if let Ok(version) =
1577 semver::Version::from_str(version_str.trim_start_matches('v'))
1578 {
1579 local_versions.push((version, version_str.into_owned()));
1580 }
1581 }
1582 }
1583 local_versions.sort_by(|(a, _), (b, _)| a.cmp(b));
1584 local_versions.last().map(|(_, v)| dir.join(v))
1585 };
1586
1587 let fallback_to_latest_local_version =
1588 async |err: anyhow::Error| -> Result<PathBuf, anyhow::Error> {
1589 if let Some(local) = find_latest_local_version().await {
1590 log::info!(
1591 "Falling back to locally installed Codex version: {}",
1592 local.display()
1593 );
1594 Ok(local)
1595 } else {
1596 Err(err)
1597 }
1598 };
1599
1600 let version_dir = match ::http_client::github::latest_github_release(
1601 CODEX_ACP_REPO,
1602 true,
1603 false,
1604 http.clone(),
1605 )
1606 .await
1607 {
1608 Ok(release) => {
1609 let version_dir = dir.join(&release.tag_name);
1610 if !fs.is_dir(&version_dir).await {
1611 if let Some(ref mut status_tx) = status_tx {
1612 status_tx.send("Installing…".into()).ok();
1613 }
1614
1615 let tag = release.tag_name.clone();
1616 let version_number = tag.trim_start_matches('v');
1617 let asset_name = asset_name(version_number)
1618 .context("codex acp is not supported for this architecture")?;
1619 let asset = release
1620 .assets
1621 .into_iter()
1622 .find(|asset| asset.name == asset_name)
1623 .with_context(|| {
1624 format!("no asset found matching `{asset_name:?}`")
1625 })?;
1626 // Strip "sha256:" prefix from digest if present (GitHub API format)
1627 let digest = asset
1628 .digest
1629 .as_deref()
1630 .and_then(|d| d.strip_prefix("sha256:").or(Some(d)));
1631 match ::http_client::github_download::download_server_binary(
1632 &*http,
1633 &asset.browser_download_url,
1634 digest,
1635 &version_dir,
1636 if cfg!(target_os = "windows") && cfg!(target_arch = "x86_64") {
1637 AssetKind::Zip
1638 } else {
1639 AssetKind::TarGz
1640 },
1641 )
1642 .await
1643 {
1644 Ok(()) => {
1645 // remove older versions
1646 util::fs::remove_matching(&dir, |entry| entry != version_dir)
1647 .await;
1648 version_dir
1649 }
1650 Err(err) => {
1651 log::error!(
1652 "Failed to download Codex release {}: {err:#}",
1653 release.tag_name
1654 );
1655 fallback_to_latest_local_version(err).await?
1656 }
1657 }
1658 } else {
1659 version_dir
1660 }
1661 }
1662 Err(err) => {
1663 log::error!("Failed to fetch Codex latest release: {err:#}");
1664 fallback_to_latest_local_version(err).await?
1665 }
1666 };
1667
1668 let bin_path = version_dir.join(bin_name);
1669 anyhow::ensure!(
1670 fs.is_file(&bin_path).await,
1671 "Missing Codex binary at {} after installation",
1672 bin_path.to_string_lossy()
1673 );
1674
1675 let mut cmd = AgentServerCommand {
1676 path: bin_path,
1677 args: Vec::new(),
1678 env: None,
1679 };
1680 cmd.env = Some(env);
1681 cmd
1682 };
1683
1684 command.env.get_or_insert_default().extend(extra_env);
1685 Ok((command, root_dir.to_string_lossy().into_owned(), None))
1686 })
1687 }
1688
1689 fn as_any_mut(&mut self) -> &mut dyn Any {
1690 self
1691 }
1692}
1693
1694pub const CODEX_ACP_REPO: &str = "zed-industries/codex-acp";
1695
1696fn get_platform_info() -> Option<(&'static str, &'static str, &'static str)> {
1697 let arch = if cfg!(target_arch = "x86_64") {
1698 "x86_64"
1699 } else if cfg!(target_arch = "aarch64") {
1700 "aarch64"
1701 } else {
1702 return None;
1703 };
1704
1705 let platform = if cfg!(target_os = "macos") {
1706 "apple-darwin"
1707 } else if cfg!(target_os = "windows") {
1708 "pc-windows-msvc"
1709 } else if cfg!(target_os = "linux") {
1710 "unknown-linux-gnu"
1711 } else {
1712 return None;
1713 };
1714
1715 // Windows uses .zip in release assets
1716 let ext = if cfg!(target_os = "windows") {
1717 "zip"
1718 } else {
1719 "tar.gz"
1720 };
1721
1722 Some((arch, platform, ext))
1723}
1724
1725fn asset_name(version: &str) -> Option<String> {
1726 let (arch, platform, ext) = get_platform_info()?;
1727 Some(format!("codex-acp-{version}-{arch}-{platform}.{ext}"))
1728}
1729
1730pub struct LocalExtensionArchiveAgent {
1731 pub fs: Arc<dyn Fs>,
1732 pub http_client: Arc<dyn HttpClient>,
1733 pub node_runtime: NodeRuntime,
1734 pub project_environment: Entity<ProjectEnvironment>,
1735 pub extension_id: Arc<str>,
1736 pub agent_id: Arc<str>,
1737 pub targets: HashMap<String, extension::TargetConfig>,
1738 pub env: HashMap<String, String>,
1739}
1740
1741impl ExternalAgentServer for LocalExtensionArchiveAgent {
1742 fn get_command(
1743 &mut self,
1744 root_dir: Option<&str>,
1745 extra_env: HashMap<String, String>,
1746 _status_tx: Option<watch::Sender<SharedString>>,
1747 _new_version_available_tx: Option<watch::Sender<Option<String>>>,
1748 cx: &mut AsyncApp,
1749 ) -> Task<Result<(AgentServerCommand, String, Option<task::SpawnInTerminal>)>> {
1750 let fs = self.fs.clone();
1751 let http_client = self.http_client.clone();
1752 let node_runtime = self.node_runtime.clone();
1753 let project_environment = self.project_environment.downgrade();
1754 let extension_id = self.extension_id.clone();
1755 let agent_id = self.agent_id.clone();
1756 let targets = self.targets.clone();
1757 let base_env = self.env.clone();
1758
1759 let root_dir: Arc<Path> = root_dir
1760 .map(|root_dir| Path::new(root_dir))
1761 .unwrap_or(paths::home_dir())
1762 .into();
1763
1764 cx.spawn(async move |cx| {
1765 // Get project environment
1766 let mut env = project_environment
1767 .update(cx, |project_environment, cx| {
1768 project_environment.local_directory_environment(
1769 &Shell::System,
1770 root_dir.clone(),
1771 cx,
1772 )
1773 })?
1774 .await
1775 .unwrap_or_default();
1776
1777 // Merge manifest env and extra env
1778 env.extend(base_env);
1779 env.extend(extra_env);
1780
1781 let cache_key = format!("{}/{}", extension_id, agent_id);
1782 let dir = paths::external_agents_dir().join(&cache_key);
1783 fs.create_dir(&dir).await?;
1784
1785 // Determine platform key
1786 let os = if cfg!(target_os = "macos") {
1787 "darwin"
1788 } else if cfg!(target_os = "linux") {
1789 "linux"
1790 } else if cfg!(target_os = "windows") {
1791 "windows"
1792 } else {
1793 anyhow::bail!("unsupported OS");
1794 };
1795
1796 let arch = if cfg!(target_arch = "aarch64") {
1797 "aarch64"
1798 } else if cfg!(target_arch = "x86_64") {
1799 "x86_64"
1800 } else {
1801 anyhow::bail!("unsupported architecture");
1802 };
1803
1804 let platform_key = format!("{}-{}", os, arch);
1805 let target_config = targets.get(&platform_key).with_context(|| {
1806 format!(
1807 "no target specified for platform '{}'. Available platforms: {}",
1808 platform_key,
1809 targets
1810 .keys()
1811 .map(|k| k.as_str())
1812 .collect::<Vec<_>>()
1813 .join(", ")
1814 )
1815 })?;
1816
1817 let archive_url = &target_config.archive;
1818
1819 // Use URL as version identifier for caching
1820 // Hash the URL to get a stable directory name
1821 use std::collections::hash_map::DefaultHasher;
1822 use std::hash::{Hash, Hasher};
1823 let mut hasher = DefaultHasher::new();
1824 archive_url.hash(&mut hasher);
1825 let url_hash = hasher.finish();
1826 let version_dir = dir.join(format!("v_{:x}", url_hash));
1827
1828 if !fs.is_dir(&version_dir).await {
1829 // Determine SHA256 for verification
1830 let sha256 = if let Some(provided_sha) = &target_config.sha256 {
1831 // Use provided SHA256
1832 Some(provided_sha.clone())
1833 } else if archive_url.starts_with("https://github.com/") {
1834 // Try to fetch SHA256 from GitHub API
1835 // Parse URL to extract repo and tag/file info
1836 // Format: https://github.com/owner/repo/releases/download/tag/file.zip
1837 if let Some(caps) = archive_url.strip_prefix("https://github.com/") {
1838 let parts: Vec<&str> = caps.split('/').collect();
1839 if parts.len() >= 6 && parts[2] == "releases" && parts[3] == "download" {
1840 let repo = format!("{}/{}", parts[0], parts[1]);
1841 let tag = parts[4];
1842 let filename = parts[5..].join("/");
1843
1844 // Try to get release info from GitHub
1845 if let Ok(release) = ::http_client::github::get_release_by_tag_name(
1846 &repo,
1847 tag,
1848 http_client.clone(),
1849 )
1850 .await
1851 {
1852 // Find matching asset
1853 if let Some(asset) =
1854 release.assets.iter().find(|a| a.name == filename)
1855 {
1856 // Strip "sha256:" prefix if present
1857 asset.digest.as_ref().map(|d| {
1858 d.strip_prefix("sha256:")
1859 .map(|s| s.to_string())
1860 .unwrap_or_else(|| d.clone())
1861 })
1862 } else {
1863 None
1864 }
1865 } else {
1866 None
1867 }
1868 } else {
1869 None
1870 }
1871 } else {
1872 None
1873 }
1874 } else {
1875 None
1876 };
1877
1878 // Determine archive type from URL
1879 let asset_kind = if archive_url.ends_with(".zip") {
1880 AssetKind::Zip
1881 } else if archive_url.ends_with(".tar.gz") || archive_url.ends_with(".tgz") {
1882 AssetKind::TarGz
1883 } else {
1884 anyhow::bail!("unsupported archive type in URL: {}", archive_url);
1885 };
1886
1887 // Download and extract
1888 ::http_client::github_download::download_server_binary(
1889 &*http_client,
1890 archive_url,
1891 sha256.as_deref(),
1892 &version_dir,
1893 asset_kind,
1894 )
1895 .await?;
1896 }
1897
1898 // Validate and resolve cmd path
1899 let cmd = &target_config.cmd;
1900
1901 let cmd_path = if cmd == "node" {
1902 // Use Zed's managed Node.js runtime
1903 node_runtime.binary_path().await?
1904 } else {
1905 if cmd.contains("..") {
1906 anyhow::bail!("command path cannot contain '..': {}", cmd);
1907 }
1908
1909 if cmd.starts_with("./") || cmd.starts_with(".\\") {
1910 // Relative to extraction directory
1911 let cmd_path = version_dir.join(&cmd[2..]);
1912 anyhow::ensure!(
1913 fs.is_file(&cmd_path).await,
1914 "Missing command {} after extraction",
1915 cmd_path.to_string_lossy()
1916 );
1917 cmd_path
1918 } else {
1919 // On PATH
1920 anyhow::bail!("command must be relative (start with './'): {}", cmd);
1921 }
1922 };
1923
1924 let command = AgentServerCommand {
1925 path: cmd_path,
1926 args: target_config.args.clone(),
1927 env: Some(env),
1928 };
1929
1930 Ok((command, version_dir.to_string_lossy().into_owned(), None))
1931 })
1932 }
1933
1934 fn as_any_mut(&mut self) -> &mut dyn Any {
1935 self
1936 }
1937}
1938
1939struct LocalRegistryArchiveAgent {
1940 fs: Arc<dyn Fs>,
1941 http_client: Arc<dyn HttpClient>,
1942 node_runtime: NodeRuntime,
1943 project_environment: Entity<ProjectEnvironment>,
1944 registry_id: Arc<str>,
1945 targets: HashMap<String, RegistryTargetConfig>,
1946 env: HashMap<String, String>,
1947}
1948
1949impl ExternalAgentServer for LocalRegistryArchiveAgent {
1950 fn get_command(
1951 &mut self,
1952 root_dir: Option<&str>,
1953 extra_env: HashMap<String, String>,
1954 _status_tx: Option<watch::Sender<SharedString>>,
1955 _new_version_available_tx: Option<watch::Sender<Option<String>>>,
1956 cx: &mut AsyncApp,
1957 ) -> Task<Result<(AgentServerCommand, String, Option<task::SpawnInTerminal>)>> {
1958 let fs = self.fs.clone();
1959 let http_client = self.http_client.clone();
1960 let node_runtime = self.node_runtime.clone();
1961 let project_environment = self.project_environment.downgrade();
1962 let registry_id = self.registry_id.clone();
1963 let targets = self.targets.clone();
1964 let settings_env = self.env.clone();
1965
1966 let root_dir: Arc<Path> = root_dir
1967 .map(|root_dir| Path::new(root_dir))
1968 .unwrap_or(paths::home_dir())
1969 .into();
1970
1971 cx.spawn(async move |cx| {
1972 let mut env = project_environment
1973 .update(cx, |project_environment, cx| {
1974 project_environment.local_directory_environment(
1975 &Shell::System,
1976 root_dir.clone(),
1977 cx,
1978 )
1979 })?
1980 .await
1981 .unwrap_or_default();
1982
1983 let dir = paths::external_agents_dir()
1984 .join("registry")
1985 .join(registry_id.as_ref());
1986 fs.create_dir(&dir).await?;
1987
1988 let os = if cfg!(target_os = "macos") {
1989 "darwin"
1990 } else if cfg!(target_os = "linux") {
1991 "linux"
1992 } else if cfg!(target_os = "windows") {
1993 "windows"
1994 } else {
1995 anyhow::bail!("unsupported OS");
1996 };
1997
1998 let arch = if cfg!(target_arch = "aarch64") {
1999 "aarch64"
2000 } else if cfg!(target_arch = "x86_64") {
2001 "x86_64"
2002 } else {
2003 anyhow::bail!("unsupported architecture");
2004 };
2005
2006 let platform_key = format!("{}-{}", os, arch);
2007 let target_config = targets.get(&platform_key).with_context(|| {
2008 format!(
2009 "no target specified for platform '{}'. Available platforms: {}",
2010 platform_key,
2011 targets
2012 .keys()
2013 .map(|k| k.as_str())
2014 .collect::<Vec<_>>()
2015 .join(", ")
2016 )
2017 })?;
2018
2019 env.extend(target_config.env.clone());
2020 env.extend(extra_env);
2021 env.extend(settings_env);
2022
2023 let archive_url = &target_config.archive;
2024
2025 use std::collections::hash_map::DefaultHasher;
2026 use std::hash::{Hash, Hasher};
2027 let mut hasher = DefaultHasher::new();
2028 archive_url.hash(&mut hasher);
2029 let url_hash = hasher.finish();
2030 let version_dir = dir.join(format!("v_{:x}", url_hash));
2031
2032 if !fs.is_dir(&version_dir).await {
2033 let sha256 = if let Some(provided_sha) = &target_config.sha256 {
2034 Some(provided_sha.clone())
2035 } else if archive_url.starts_with("https://github.com/") {
2036 if let Some(caps) = archive_url.strip_prefix("https://github.com/") {
2037 let parts: Vec<&str> = caps.split('/').collect();
2038 if parts.len() >= 6 && parts[2] == "releases" && parts[3] == "download" {
2039 let repo = format!("{}/{}", parts[0], parts[1]);
2040 let tag = parts[4];
2041 let filename = parts[5..].join("/");
2042
2043 if let Ok(release) = ::http_client::github::get_release_by_tag_name(
2044 &repo,
2045 tag,
2046 http_client.clone(),
2047 )
2048 .await
2049 {
2050 if let Some(asset) =
2051 release.assets.iter().find(|a| a.name == filename)
2052 {
2053 asset.digest.as_ref().and_then(|d| {
2054 d.strip_prefix("sha256:")
2055 .map(|s| s.to_string())
2056 .or_else(|| Some(d.clone()))
2057 })
2058 } else {
2059 None
2060 }
2061 } else {
2062 None
2063 }
2064 } else {
2065 None
2066 }
2067 } else {
2068 None
2069 }
2070 } else {
2071 None
2072 };
2073
2074 let asset_kind = if archive_url.ends_with(".zip") {
2075 AssetKind::Zip
2076 } else if archive_url.ends_with(".tar.gz") || archive_url.ends_with(".tgz") {
2077 AssetKind::TarGz
2078 } else {
2079 anyhow::bail!("unsupported archive type in URL: {}", archive_url);
2080 };
2081
2082 ::http_client::github_download::download_server_binary(
2083 &*http_client,
2084 archive_url,
2085 sha256.as_deref(),
2086 &version_dir,
2087 asset_kind,
2088 )
2089 .await?;
2090 }
2091
2092 let cmd = &target_config.cmd;
2093
2094 let cmd_path = if cmd == "node" {
2095 node_runtime.binary_path().await?
2096 } else {
2097 if cmd.contains("..") {
2098 anyhow::bail!("command path cannot contain '..': {}", cmd);
2099 }
2100
2101 if cmd.starts_with("./") || cmd.starts_with(".\\") {
2102 let cmd_path = version_dir.join(&cmd[2..]);
2103 anyhow::ensure!(
2104 fs.is_file(&cmd_path).await,
2105 "Missing command {} after extraction",
2106 cmd_path.to_string_lossy()
2107 );
2108 cmd_path
2109 } else {
2110 anyhow::bail!("command must be relative (start with './'): {}", cmd);
2111 }
2112 };
2113
2114 let command = AgentServerCommand {
2115 path: cmd_path,
2116 args: target_config.args.clone(),
2117 env: Some(env),
2118 };
2119
2120 Ok((command, version_dir.to_string_lossy().into_owned(), None))
2121 })
2122 }
2123
2124 fn as_any_mut(&mut self) -> &mut dyn Any {
2125 self
2126 }
2127}
2128
2129struct LocalRegistryNpxAgent {
2130 node_runtime: NodeRuntime,
2131 project_environment: Entity<ProjectEnvironment>,
2132 package: SharedString,
2133 args: Vec<String>,
2134 distribution_env: HashMap<String, String>,
2135 settings_env: HashMap<String, String>,
2136}
2137
2138impl ExternalAgentServer for LocalRegistryNpxAgent {
2139 fn get_command(
2140 &mut self,
2141 root_dir: Option<&str>,
2142 extra_env: HashMap<String, String>,
2143 _status_tx: Option<watch::Sender<SharedString>>,
2144 _new_version_available_tx: Option<watch::Sender<Option<String>>>,
2145 cx: &mut AsyncApp,
2146 ) -> Task<Result<(AgentServerCommand, String, Option<task::SpawnInTerminal>)>> {
2147 let node_runtime = self.node_runtime.clone();
2148 let project_environment = self.project_environment.downgrade();
2149 let package = self.package.clone();
2150 let args = self.args.clone();
2151 let distribution_env = self.distribution_env.clone();
2152 let settings_env = self.settings_env.clone();
2153
2154 let env_root_dir: Arc<Path> = root_dir
2155 .map(|root_dir| Path::new(root_dir))
2156 .unwrap_or(paths::home_dir())
2157 .into();
2158
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 env_root_dir.clone(),
2165 cx,
2166 )
2167 })?
2168 .await
2169 .unwrap_or_default();
2170
2171 let mut exec_args = Vec::new();
2172 exec_args.push("--yes".to_string());
2173 exec_args.push(package.to_string());
2174 if !args.is_empty() {
2175 exec_args.push("--".to_string());
2176 exec_args.extend(args);
2177 }
2178
2179 let npm_command = node_runtime
2180 .npm_command(
2181 "exec",
2182 &exec_args.iter().map(|a| a.as_str()).collect::<Vec<_>>(),
2183 )
2184 .await?;
2185
2186 env.extend(npm_command.env);
2187 env.extend(distribution_env);
2188 env.extend(extra_env);
2189 env.extend(settings_env);
2190
2191 let command = AgentServerCommand {
2192 path: npm_command.path,
2193 args: npm_command.args,
2194 env: Some(env),
2195 };
2196
2197 Ok((command, env_root_dir.to_string_lossy().into_owned(), None))
2198 })
2199 }
2200
2201 fn as_any_mut(&mut self) -> &mut dyn Any {
2202 self
2203 }
2204}
2205
2206struct LocalCustomAgent {
2207 project_environment: Entity<ProjectEnvironment>,
2208 command: AgentServerCommand,
2209}
2210
2211impl ExternalAgentServer for LocalCustomAgent {
2212 fn get_command(
2213 &mut self,
2214 root_dir: Option<&str>,
2215 extra_env: HashMap<String, String>,
2216 _status_tx: Option<watch::Sender<SharedString>>,
2217 _new_version_available_tx: Option<watch::Sender<Option<String>>>,
2218 cx: &mut AsyncApp,
2219 ) -> Task<Result<(AgentServerCommand, String, Option<task::SpawnInTerminal>)>> {
2220 let mut command = self.command.clone();
2221 let root_dir: Arc<Path> = root_dir
2222 .map(|root_dir| Path::new(root_dir))
2223 .unwrap_or(paths::home_dir())
2224 .into();
2225 let project_environment = self.project_environment.downgrade();
2226 cx.spawn(async move |cx| {
2227 let mut env = project_environment
2228 .update(cx, |project_environment, cx| {
2229 project_environment.local_directory_environment(
2230 &Shell::System,
2231 root_dir.clone(),
2232 cx,
2233 )
2234 })?
2235 .await
2236 .unwrap_or_default();
2237 env.extend(command.env.unwrap_or_default());
2238 env.extend(extra_env);
2239 command.env = Some(env);
2240 Ok((command, root_dir.to_string_lossy().into_owned(), None))
2241 })
2242 }
2243
2244 fn as_any_mut(&mut self) -> &mut dyn Any {
2245 self
2246 }
2247}
2248
2249pub const GEMINI_NAME: &'static str = "gemini";
2250pub const CLAUDE_CODE_NAME: &'static str = "claude";
2251pub const CODEX_NAME: &'static str = "codex";
2252
2253#[derive(Default, Clone, JsonSchema, Debug, PartialEq, RegisterSetting)]
2254pub struct AllAgentServersSettings {
2255 pub gemini: Option<BuiltinAgentServerSettings>,
2256 pub claude: Option<BuiltinAgentServerSettings>,
2257 pub codex: Option<BuiltinAgentServerSettings>,
2258 pub custom: HashMap<String, CustomAgentServerSettings>,
2259}
2260
2261impl AllAgentServersSettings {
2262 pub fn has_registry_agents(&self) -> bool {
2263 self.custom
2264 .values()
2265 .any(|s| matches!(s, CustomAgentServerSettings::Registry { .. }))
2266 }
2267}
2268
2269#[derive(Default, Clone, JsonSchema, Debug, PartialEq)]
2270pub struct BuiltinAgentServerSettings {
2271 pub path: Option<PathBuf>,
2272 pub args: Option<Vec<String>>,
2273 pub env: Option<HashMap<String, String>>,
2274 pub ignore_system_version: Option<bool>,
2275 pub default_mode: Option<String>,
2276 pub default_model: Option<String>,
2277 pub favorite_models: Vec<String>,
2278 pub default_config_options: HashMap<String, String>,
2279 pub favorite_config_option_values: HashMap<String, Vec<String>>,
2280}
2281
2282impl BuiltinAgentServerSettings {
2283 fn custom_command(self) -> Option<AgentServerCommand> {
2284 self.path.map(|path| AgentServerCommand {
2285 path,
2286 args: self.args.unwrap_or_default(),
2287 // Settings env are always applied, so we don't need to supply them here as well
2288 env: None,
2289 })
2290 }
2291}
2292
2293impl From<settings::BuiltinAgentServerSettings> for BuiltinAgentServerSettings {
2294 fn from(value: settings::BuiltinAgentServerSettings) -> Self {
2295 BuiltinAgentServerSettings {
2296 path: value
2297 .path
2298 .map(|p| PathBuf::from(shellexpand::tilde(&p.to_string_lossy()).as_ref())),
2299 args: value.args,
2300 env: value.env,
2301 ignore_system_version: value.ignore_system_version,
2302 default_mode: value.default_mode,
2303 default_model: value.default_model,
2304 favorite_models: value.favorite_models,
2305 default_config_options: value.default_config_options,
2306 favorite_config_option_values: value.favorite_config_option_values,
2307 }
2308 }
2309}
2310
2311impl From<AgentServerCommand> for BuiltinAgentServerSettings {
2312 fn from(value: AgentServerCommand) -> Self {
2313 BuiltinAgentServerSettings {
2314 path: Some(value.path),
2315 args: Some(value.args),
2316 env: value.env,
2317 ..Default::default()
2318 }
2319 }
2320}
2321
2322#[derive(Clone, JsonSchema, Debug, PartialEq)]
2323pub enum CustomAgentServerSettings {
2324 Custom {
2325 command: AgentServerCommand,
2326 /// The default mode to use for this agent.
2327 ///
2328 /// Note: Not only all agents support modes.
2329 ///
2330 /// Default: None
2331 default_mode: Option<String>,
2332 /// The default model to use for this agent.
2333 ///
2334 /// This should be the model ID as reported by the agent.
2335 ///
2336 /// Default: None
2337 default_model: Option<String>,
2338 /// The favorite models for this agent.
2339 ///
2340 /// Default: []
2341 favorite_models: Vec<String>,
2342 /// Default values for session config options.
2343 ///
2344 /// This is a map from config option ID to value ID.
2345 ///
2346 /// Default: {}
2347 default_config_options: HashMap<String, String>,
2348 /// Favorited values for session config options.
2349 ///
2350 /// This is a map from config option ID to a list of favorited value IDs.
2351 ///
2352 /// Default: {}
2353 favorite_config_option_values: HashMap<String, Vec<String>>,
2354 },
2355 Extension {
2356 /// Additional environment variables to pass to the agent.
2357 ///
2358 /// Default: {}
2359 env: HashMap<String, String>,
2360 /// The default mode to use for this agent.
2361 ///
2362 /// Note: Not only all agents support modes.
2363 ///
2364 /// Default: None
2365 default_mode: Option<String>,
2366 /// The default model to use for this agent.
2367 ///
2368 /// This should be the model ID as reported by the agent.
2369 ///
2370 /// Default: None
2371 default_model: Option<String>,
2372 /// The favorite models for this agent.
2373 ///
2374 /// Default: []
2375 favorite_models: Vec<String>,
2376 /// Default values for session config options.
2377 ///
2378 /// This is a map from config option ID to value ID.
2379 ///
2380 /// Default: {}
2381 default_config_options: HashMap<String, String>,
2382 /// Favorited values for session config options.
2383 ///
2384 /// This is a map from config option ID to a list of favorited value IDs.
2385 ///
2386 /// Default: {}
2387 favorite_config_option_values: HashMap<String, Vec<String>>,
2388 },
2389 Registry {
2390 /// Additional environment variables to pass to the agent.
2391 ///
2392 /// Default: {}
2393 env: HashMap<String, String>,
2394 /// The default mode to use for this agent.
2395 ///
2396 /// Note: Not only all agents support modes.
2397 ///
2398 /// Default: None
2399 default_mode: Option<String>,
2400 /// The default model to use for this agent.
2401 ///
2402 /// This should be the model ID as reported by the agent.
2403 ///
2404 /// Default: None
2405 default_model: Option<String>,
2406 /// The favorite models for this agent.
2407 ///
2408 /// Default: []
2409 favorite_models: Vec<String>,
2410 /// Default values for session config options.
2411 ///
2412 /// This is a map from config option ID to value ID.
2413 ///
2414 /// Default: {}
2415 default_config_options: HashMap<String, String>,
2416 /// Favorited values for session config options.
2417 ///
2418 /// This is a map from config option ID to a list of favorited value IDs.
2419 ///
2420 /// Default: {}
2421 favorite_config_option_values: HashMap<String, Vec<String>>,
2422 },
2423}
2424
2425impl CustomAgentServerSettings {
2426 pub fn command(&self) -> Option<&AgentServerCommand> {
2427 match self {
2428 CustomAgentServerSettings::Custom { command, .. } => Some(command),
2429 CustomAgentServerSettings::Extension { .. }
2430 | CustomAgentServerSettings::Registry { .. } => None,
2431 }
2432 }
2433
2434 pub fn default_mode(&self) -> Option<&str> {
2435 match self {
2436 CustomAgentServerSettings::Custom { default_mode, .. }
2437 | CustomAgentServerSettings::Extension { default_mode, .. }
2438 | CustomAgentServerSettings::Registry { default_mode, .. } => default_mode.as_deref(),
2439 }
2440 }
2441
2442 pub fn default_model(&self) -> Option<&str> {
2443 match self {
2444 CustomAgentServerSettings::Custom { default_model, .. }
2445 | CustomAgentServerSettings::Extension { default_model, .. }
2446 | CustomAgentServerSettings::Registry { default_model, .. } => default_model.as_deref(),
2447 }
2448 }
2449
2450 pub fn favorite_models(&self) -> &[String] {
2451 match self {
2452 CustomAgentServerSettings::Custom {
2453 favorite_models, ..
2454 }
2455 | CustomAgentServerSettings::Extension {
2456 favorite_models, ..
2457 }
2458 | CustomAgentServerSettings::Registry {
2459 favorite_models, ..
2460 } => favorite_models,
2461 }
2462 }
2463
2464 pub fn default_config_option(&self, config_id: &str) -> Option<&str> {
2465 match self {
2466 CustomAgentServerSettings::Custom {
2467 default_config_options,
2468 ..
2469 }
2470 | CustomAgentServerSettings::Extension {
2471 default_config_options,
2472 ..
2473 }
2474 | CustomAgentServerSettings::Registry {
2475 default_config_options,
2476 ..
2477 } => default_config_options.get(config_id).map(|s| s.as_str()),
2478 }
2479 }
2480
2481 pub fn favorite_config_option_values(&self, config_id: &str) -> Option<&[String]> {
2482 match self {
2483 CustomAgentServerSettings::Custom {
2484 favorite_config_option_values,
2485 ..
2486 }
2487 | CustomAgentServerSettings::Extension {
2488 favorite_config_option_values,
2489 ..
2490 }
2491 | CustomAgentServerSettings::Registry {
2492 favorite_config_option_values,
2493 ..
2494 } => favorite_config_option_values
2495 .get(config_id)
2496 .map(|v| v.as_slice()),
2497 }
2498 }
2499}
2500
2501impl From<settings::CustomAgentServerSettings> for CustomAgentServerSettings {
2502 fn from(value: settings::CustomAgentServerSettings) -> Self {
2503 match value {
2504 settings::CustomAgentServerSettings::Custom {
2505 path,
2506 args,
2507 env,
2508 default_mode,
2509 default_model,
2510 favorite_models,
2511 default_config_options,
2512 favorite_config_option_values,
2513 } => CustomAgentServerSettings::Custom {
2514 command: AgentServerCommand {
2515 path: PathBuf::from(shellexpand::tilde(&path.to_string_lossy()).as_ref()),
2516 args,
2517 env: Some(env),
2518 },
2519 default_mode,
2520 default_model,
2521 favorite_models,
2522 default_config_options,
2523 favorite_config_option_values,
2524 },
2525 settings::CustomAgentServerSettings::Extension {
2526 env,
2527 default_mode,
2528 default_model,
2529 default_config_options,
2530 favorite_models,
2531 favorite_config_option_values,
2532 } => CustomAgentServerSettings::Extension {
2533 env,
2534 default_mode,
2535 default_model,
2536 default_config_options,
2537 favorite_models,
2538 favorite_config_option_values,
2539 },
2540 settings::CustomAgentServerSettings::Registry {
2541 env,
2542 default_mode,
2543 default_model,
2544 default_config_options,
2545 favorite_models,
2546 favorite_config_option_values,
2547 } => CustomAgentServerSettings::Registry {
2548 env,
2549 default_mode,
2550 default_model,
2551 default_config_options,
2552 favorite_models,
2553 favorite_config_option_values,
2554 },
2555 }
2556 }
2557}
2558
2559impl settings::Settings for AllAgentServersSettings {
2560 fn from_settings(content: &settings::SettingsContent) -> Self {
2561 let agent_settings = content.agent_servers.clone().unwrap();
2562 Self {
2563 gemini: agent_settings.gemini.map(Into::into),
2564 claude: agent_settings.claude.map(Into::into),
2565 codex: agent_settings.codex.map(Into::into),
2566 custom: agent_settings
2567 .custom
2568 .into_iter()
2569 .map(|(k, v)| (k, v.into()))
2570 .collect(),
2571 }
2572 }
2573}