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