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