agent_server_store.rs

   1use std::{
   2    any::Any,
   3    borrow::Borrow,
   4    path::{Path, PathBuf},
   5    str::FromStr as _,
   6    sync::Arc,
   7    time::Duration,
   8};
   9
  10use anyhow::{Context as _, Result, bail};
  11use collections::HashMap;
  12use fs::{Fs, RemoveOptions, RenameOptions};
  13use futures::StreamExt as _;
  14use gpui::{
  15    AppContext as _, AsyncApp, Context, Entity, EventEmitter, SharedString, Subscription, Task,
  16};
  17use http_client::{HttpClient, github::AssetKind};
  18use node_runtime::NodeRuntime;
  19use remote::RemoteClient;
  20use rpc::{AnyProtoClient, TypedEnvelope, proto};
  21use schemars::JsonSchema;
  22use serde::{Deserialize, Serialize};
  23use settings::SettingsStore;
  24use task::Shell;
  25use util::{ResultExt as _, debug_panic};
  26
  27use crate::ProjectEnvironment;
  28
  29#[derive(Deserialize, Serialize, Clone, PartialEq, Eq, JsonSchema)]
  30pub struct AgentServerCommand {
  31    #[serde(rename = "command")]
  32    pub path: PathBuf,
  33    #[serde(default)]
  34    pub args: Vec<String>,
  35    pub env: Option<HashMap<String, String>>,
  36}
  37
  38impl std::fmt::Debug for AgentServerCommand {
  39    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
  40        let filtered_env = self.env.as_ref().map(|env| {
  41            env.iter()
  42                .map(|(k, v)| {
  43                    (
  44                        k,
  45                        if util::redact::should_redact(k) {
  46                            "[REDACTED]"
  47                        } else {
  48                            v
  49                        },
  50                    )
  51                })
  52                .collect::<Vec<_>>()
  53        });
  54
  55        f.debug_struct("AgentServerCommand")
  56            .field("path", &self.path)
  57            .field("args", &self.args)
  58            .field("env", &filtered_env)
  59            .finish()
  60    }
  61}
  62
  63#[derive(Clone, Debug, PartialEq, Eq, Hash)]
  64pub struct ExternalAgentServerName(pub SharedString);
  65
  66impl std::fmt::Display for ExternalAgentServerName {
  67    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
  68        write!(f, "{}", self.0)
  69    }
  70}
  71
  72impl From<&'static str> for ExternalAgentServerName {
  73    fn from(value: &'static str) -> Self {
  74        ExternalAgentServerName(value.into())
  75    }
  76}
  77
  78impl From<ExternalAgentServerName> for SharedString {
  79    fn from(value: ExternalAgentServerName) -> Self {
  80        value.0
  81    }
  82}
  83
  84impl Borrow<str> for ExternalAgentServerName {
  85    fn borrow(&self) -> &str {
  86        &self.0
  87    }
  88}
  89
  90pub trait ExternalAgentServer {
  91    fn get_command(
  92        &mut self,
  93        root_dir: Option<&str>,
  94        extra_env: HashMap<String, String>,
  95        status_tx: Option<watch::Sender<SharedString>>,
  96        new_version_available_tx: Option<watch::Sender<Option<String>>>,
  97        cx: &mut AsyncApp,
  98    ) -> Task<
  99        Result<(
 100            AgentServerCommand,
 101            String,
 102            HashMap<String, task::SpawnInTerminal>,
 103        )>,
 104    >;
 105
 106    fn as_any_mut(&mut self) -> &mut dyn Any;
 107}
 108
 109impl dyn ExternalAgentServer {
 110    fn downcast_mut<T: ExternalAgentServer + 'static>(&mut self) -> Option<&mut T> {
 111        self.as_any_mut().downcast_mut()
 112    }
 113}
 114
 115enum AgentServerStoreState {
 116    Local {
 117        node_runtime: NodeRuntime,
 118        fs: Arc<dyn Fs>,
 119        project_environment: Entity<ProjectEnvironment>,
 120        downstream_client: Option<(u64, AnyProtoClient)>,
 121        settings: Option<AllAgentServersSettings>,
 122        http_client: Arc<dyn HttpClient>,
 123        _subscriptions: [Subscription; 1],
 124    },
 125    Remote {
 126        project_id: u64,
 127        upstream_client: Entity<RemoteClient>,
 128    },
 129    Collab,
 130}
 131
 132pub struct AgentServerStore {
 133    state: AgentServerStoreState,
 134    external_agents: HashMap<ExternalAgentServerName, Box<dyn ExternalAgentServer>>,
 135}
 136
 137pub struct AgentServersUpdated;
 138
 139impl EventEmitter<AgentServersUpdated> for AgentServerStore {}
 140
 141impl AgentServerStore {
 142    pub fn init_remote(session: &AnyProtoClient) {
 143        session.add_entity_message_handler(Self::handle_external_agents_updated);
 144        session.add_entity_message_handler(Self::handle_loading_status_updated);
 145        session.add_entity_message_handler(Self::handle_new_version_available);
 146    }
 147
 148    pub fn init_headless(session: &AnyProtoClient) {
 149        session.add_entity_request_handler(Self::handle_get_agent_server_command);
 150    }
 151
 152    fn agent_servers_settings_changed(&mut self, cx: &mut Context<Self>) {
 153        let AgentServerStoreState::Local {
 154            settings: old_settings,
 155            ..
 156        } = &mut self.state
 157        else {
 158            debug_panic!(
 159                "should not be subscribed to agent server settings changes in non-local project"
 160            );
 161            return;
 162        };
 163
 164        let new_settings = cx
 165            .global::<SettingsStore>()
 166            .get::<AllAgentServersSettings>(None)
 167            .clone();
 168        if Some(&new_settings) == old_settings.as_ref() {
 169            return;
 170        }
 171
 172        self.reregister_agents(cx);
 173    }
 174
 175    fn reregister_agents(&mut self, cx: &mut Context<Self>) {
 176        let AgentServerStoreState::Local {
 177            node_runtime,
 178            fs,
 179            project_environment,
 180            downstream_client,
 181            settings: old_settings,
 182            http_client,
 183            ..
 184        } = &mut self.state
 185        else {
 186            debug_panic!("Non-local projects should never attempt to reregister. This is a bug!");
 187
 188            return;
 189        };
 190
 191        let new_settings = cx
 192            .global::<SettingsStore>()
 193            .get::<AllAgentServersSettings>(None)
 194            .clone();
 195
 196        self.external_agents.clear();
 197        self.external_agents.insert(
 198            GEMINI_NAME.into(),
 199            Box::new(LocalGemini {
 200                fs: fs.clone(),
 201                node_runtime: node_runtime.clone(),
 202                project_environment: project_environment.clone(),
 203                custom_command: new_settings
 204                    .gemini
 205                    .clone()
 206                    .and_then(|settings| settings.custom_command()),
 207                ignore_system_version: new_settings
 208                    .gemini
 209                    .as_ref()
 210                    .and_then(|settings| settings.ignore_system_version)
 211                    .unwrap_or(true),
 212            }),
 213        );
 214        self.external_agents.insert(
 215            CODEX_NAME.into(),
 216            Box::new(LocalCodex {
 217                fs: fs.clone(),
 218                project_environment: project_environment.clone(),
 219                custom_command: new_settings
 220                    .codex
 221                    .clone()
 222                    .and_then(|settings| settings.custom_command()),
 223                http_client: http_client.clone(),
 224                is_remote: downstream_client.is_some(),
 225            }),
 226        );
 227        self.external_agents.insert(
 228            CLAUDE_CODE_NAME.into(),
 229            Box::new(LocalClaudeCode {
 230                fs: fs.clone(),
 231                node_runtime: node_runtime.clone(),
 232                project_environment: project_environment.clone(),
 233                custom_command: new_settings
 234                    .claude
 235                    .clone()
 236                    .and_then(|settings| settings.custom_command()),
 237            }),
 238        );
 239        self.external_agents
 240            .extend(new_settings.custom.iter().map(|(name, settings)| {
 241                (
 242                    ExternalAgentServerName(name.clone()),
 243                    Box::new(LocalCustomAgent {
 244                        command: settings.command.clone(),
 245                        project_environment: project_environment.clone(),
 246                    }) as Box<dyn ExternalAgentServer>,
 247                )
 248            }));
 249
 250        *old_settings = Some(new_settings.clone());
 251
 252        if let Some((project_id, downstream_client)) = downstream_client {
 253            downstream_client
 254                .send(proto::ExternalAgentsUpdated {
 255                    project_id: *project_id,
 256                    names: self
 257                        .external_agents
 258                        .keys()
 259                        .map(|name| name.to_string())
 260                        .collect(),
 261                })
 262                .log_err();
 263        }
 264        cx.emit(AgentServersUpdated);
 265    }
 266
 267    pub fn local(
 268        node_runtime: NodeRuntime,
 269        fs: Arc<dyn Fs>,
 270        project_environment: Entity<ProjectEnvironment>,
 271        http_client: Arc<dyn HttpClient>,
 272        cx: &mut Context<Self>,
 273    ) -> Self {
 274        let subscription = cx.observe_global::<SettingsStore>(|this, cx| {
 275            this.agent_servers_settings_changed(cx);
 276        });
 277        let mut this = Self {
 278            state: AgentServerStoreState::Local {
 279                node_runtime,
 280                fs,
 281                project_environment,
 282                http_client,
 283                downstream_client: None,
 284                settings: None,
 285                _subscriptions: [subscription],
 286            },
 287            external_agents: Default::default(),
 288        };
 289        this.agent_servers_settings_changed(cx);
 290        this
 291    }
 292
 293    pub(crate) fn remote(project_id: u64, upstream_client: Entity<RemoteClient>) -> Self {
 294        // Set up the builtin agents here so they're immediately available in
 295        // remote projects--we know that the HeadlessProject on the other end
 296        // will have them.
 297        let external_agents = [
 298            (
 299                CLAUDE_CODE_NAME.into(),
 300                Box::new(RemoteExternalAgentServer {
 301                    project_id,
 302                    upstream_client: upstream_client.clone(),
 303                    name: CLAUDE_CODE_NAME.into(),
 304                    status_tx: None,
 305                    new_version_available_tx: None,
 306                }) as Box<dyn ExternalAgentServer>,
 307            ),
 308            (
 309                CODEX_NAME.into(),
 310                Box::new(RemoteExternalAgentServer {
 311                    project_id,
 312                    upstream_client: upstream_client.clone(),
 313                    name: CODEX_NAME.into(),
 314                    status_tx: None,
 315                    new_version_available_tx: None,
 316                }) as Box<dyn ExternalAgentServer>,
 317            ),
 318            (
 319                GEMINI_NAME.into(),
 320                Box::new(RemoteExternalAgentServer {
 321                    project_id,
 322                    upstream_client: upstream_client.clone(),
 323                    name: GEMINI_NAME.into(),
 324                    status_tx: None,
 325                    new_version_available_tx: None,
 326                }) as Box<dyn ExternalAgentServer>,
 327            ),
 328        ]
 329        .into_iter()
 330        .collect();
 331
 332        Self {
 333            state: AgentServerStoreState::Remote {
 334                project_id,
 335                upstream_client,
 336            },
 337            external_agents,
 338        }
 339    }
 340
 341    pub(crate) fn collab(_cx: &mut Context<Self>) -> Self {
 342        Self {
 343            state: AgentServerStoreState::Collab,
 344            external_agents: Default::default(),
 345        }
 346    }
 347
 348    pub fn shared(&mut self, project_id: u64, client: AnyProtoClient, cx: &mut Context<Self>) {
 349        match &mut self.state {
 350            AgentServerStoreState::Local {
 351                downstream_client, ..
 352            } => {
 353                *downstream_client = Some((project_id, client.clone()));
 354                // Send the current list of external agents downstream, but only after a delay,
 355                // to avoid having the message arrive before the downstream project's agent server store
 356                // sets up its handlers.
 357                cx.spawn(async move |this, cx| {
 358                    cx.background_executor().timer(Duration::from_secs(1)).await;
 359                    let names = this.update(cx, |this, _| {
 360                        this.external_agents
 361                            .keys()
 362                            .map(|name| name.to_string())
 363                            .collect()
 364                    })?;
 365                    client
 366                        .send(proto::ExternalAgentsUpdated { project_id, names })
 367                        .log_err();
 368                    anyhow::Ok(())
 369                })
 370                .detach();
 371            }
 372            AgentServerStoreState::Remote { .. } => {
 373                debug_panic!(
 374                    "external agents over collab not implemented, remote project should not be shared"
 375                );
 376            }
 377            AgentServerStoreState::Collab => {
 378                debug_panic!("external agents over collab not implemented, should not be shared");
 379            }
 380        }
 381    }
 382
 383    pub fn get_external_agent(
 384        &mut self,
 385        name: &ExternalAgentServerName,
 386    ) -> Option<&mut (dyn ExternalAgentServer + 'static)> {
 387        self.external_agents
 388            .get_mut(name)
 389            .map(|agent| agent.as_mut())
 390    }
 391
 392    pub fn external_agents(&self) -> impl Iterator<Item = &ExternalAgentServerName> {
 393        self.external_agents.keys()
 394    }
 395
 396    async fn handle_get_agent_server_command(
 397        this: Entity<Self>,
 398        envelope: TypedEnvelope<proto::GetAgentServerCommand>,
 399        mut cx: AsyncApp,
 400    ) -> Result<proto::AgentServerCommand> {
 401        let (command, root_dir, _login) = this
 402            .update(&mut cx, |this, cx| {
 403                let AgentServerStoreState::Local {
 404                    downstream_client, ..
 405                } = &this.state
 406                else {
 407                    debug_panic!("should not receive GetAgentServerCommand in a non-local project");
 408                    bail!("unexpected GetAgentServerCommand request in a non-local project");
 409                };
 410                let agent = this
 411                    .external_agents
 412                    .get_mut(&*envelope.payload.name)
 413                    .with_context(|| format!("agent `{}` not found", envelope.payload.name))?;
 414                let (status_tx, new_version_available_tx) = downstream_client
 415                    .clone()
 416                    .map(|(project_id, downstream_client)| {
 417                        let (status_tx, mut status_rx) = watch::channel(SharedString::from(""));
 418                        let (new_version_available_tx, mut new_version_available_rx) =
 419                            watch::channel(None);
 420                        cx.spawn({
 421                            let downstream_client = downstream_client.clone();
 422                            let name = envelope.payload.name.clone();
 423                            async move |_, _| {
 424                                while let Some(status) = status_rx.recv().await.ok() {
 425                                    downstream_client.send(
 426                                        proto::ExternalAgentLoadingStatusUpdated {
 427                                            project_id,
 428                                            name: name.clone(),
 429                                            status: status.to_string(),
 430                                        },
 431                                    )?;
 432                                }
 433                                anyhow::Ok(())
 434                            }
 435                        })
 436                        .detach_and_log_err(cx);
 437                        cx.spawn({
 438                            let name = envelope.payload.name.clone();
 439                            async move |_, _| {
 440                                if let Some(version) =
 441                                    new_version_available_rx.recv().await.ok().flatten()
 442                                {
 443                                    downstream_client.send(
 444                                        proto::NewExternalAgentVersionAvailable {
 445                                            project_id,
 446                                            name: name.clone(),
 447                                            version,
 448                                        },
 449                                    )?;
 450                                }
 451                                anyhow::Ok(())
 452                            }
 453                        })
 454                        .detach_and_log_err(cx);
 455                        (status_tx, new_version_available_tx)
 456                    })
 457                    .unzip();
 458                anyhow::Ok(agent.get_command(
 459                    envelope.payload.root_dir.as_deref(),
 460                    HashMap::default(),
 461                    status_tx,
 462                    new_version_available_tx,
 463                    &mut cx.to_async(),
 464                ))
 465            })??
 466            .await?;
 467        Ok(proto::AgentServerCommand {
 468            path: command.path.to_string_lossy().into_owned(),
 469            args: command.args,
 470            env: command
 471                .env
 472                .map(|env| env.into_iter().collect())
 473                .unwrap_or_default(),
 474            root_dir: root_dir,
 475            login: None,
 476        })
 477    }
 478
 479    async fn handle_external_agents_updated(
 480        this: Entity<Self>,
 481        envelope: TypedEnvelope<proto::ExternalAgentsUpdated>,
 482        mut cx: AsyncApp,
 483    ) -> Result<()> {
 484        this.update(&mut cx, |this, cx| {
 485            let AgentServerStoreState::Remote {
 486                project_id,
 487                upstream_client,
 488            } = &this.state
 489            else {
 490                debug_panic!(
 491                    "handle_external_agents_updated should not be called for a non-remote project"
 492                );
 493                bail!("unexpected ExternalAgentsUpdated message")
 494            };
 495
 496            let mut status_txs = this
 497                .external_agents
 498                .iter_mut()
 499                .filter_map(|(name, agent)| {
 500                    Some((
 501                        name.clone(),
 502                        agent
 503                            .downcast_mut::<RemoteExternalAgentServer>()?
 504                            .status_tx
 505                            .take(),
 506                    ))
 507                })
 508                .collect::<HashMap<_, _>>();
 509            let mut new_version_available_txs = this
 510                .external_agents
 511                .iter_mut()
 512                .filter_map(|(name, agent)| {
 513                    Some((
 514                        name.clone(),
 515                        agent
 516                            .downcast_mut::<RemoteExternalAgentServer>()?
 517                            .new_version_available_tx
 518                            .take(),
 519                    ))
 520                })
 521                .collect::<HashMap<_, _>>();
 522
 523            this.external_agents = envelope
 524                .payload
 525                .names
 526                .into_iter()
 527                .map(|name| {
 528                    let agent = RemoteExternalAgentServer {
 529                        project_id: *project_id,
 530                        upstream_client: upstream_client.clone(),
 531                        name: ExternalAgentServerName(name.clone().into()),
 532                        status_tx: status_txs.remove(&*name).flatten(),
 533                        new_version_available_tx: new_version_available_txs
 534                            .remove(&*name)
 535                            .flatten(),
 536                    };
 537                    (
 538                        ExternalAgentServerName(name.into()),
 539                        Box::new(agent) as Box<dyn ExternalAgentServer>,
 540                    )
 541                })
 542                .collect();
 543            cx.emit(AgentServersUpdated);
 544            Ok(())
 545        })?
 546    }
 547
 548    async fn handle_loading_status_updated(
 549        this: Entity<Self>,
 550        envelope: TypedEnvelope<proto::ExternalAgentLoadingStatusUpdated>,
 551        mut cx: AsyncApp,
 552    ) -> Result<()> {
 553        this.update(&mut cx, |this, _| {
 554            if let Some(agent) = this.external_agents.get_mut(&*envelope.payload.name)
 555                && let Some(agent) = agent.downcast_mut::<RemoteExternalAgentServer>()
 556                && let Some(status_tx) = &mut agent.status_tx
 557            {
 558                status_tx.send(envelope.payload.status.into()).ok();
 559            }
 560        })
 561    }
 562
 563    async fn handle_new_version_available(
 564        this: Entity<Self>,
 565        envelope: TypedEnvelope<proto::NewExternalAgentVersionAvailable>,
 566        mut cx: AsyncApp,
 567    ) -> Result<()> {
 568        this.update(&mut cx, |this, _| {
 569            if let Some(agent) = this.external_agents.get_mut(&*envelope.payload.name)
 570                && let Some(agent) = agent.downcast_mut::<RemoteExternalAgentServer>()
 571                && let Some(new_version_available_tx) = &mut agent.new_version_available_tx
 572            {
 573                new_version_available_tx
 574                    .send(Some(envelope.payload.version))
 575                    .ok();
 576            }
 577        })
 578    }
 579}
 580
 581fn get_or_npm_install_builtin_agent(
 582    binary_name: SharedString,
 583    package_name: SharedString,
 584    entrypoint_path: PathBuf,
 585    minimum_version: Option<semver::Version>,
 586    status_tx: Option<watch::Sender<SharedString>>,
 587    new_version_available: Option<watch::Sender<Option<String>>>,
 588    fs: Arc<dyn Fs>,
 589    node_runtime: NodeRuntime,
 590    cx: &mut AsyncApp,
 591) -> Task<std::result::Result<AgentServerCommand, anyhow::Error>> {
 592    cx.spawn(async move |cx| {
 593        let node_path = node_runtime.binary_path().await?;
 594        let dir = paths::data_dir()
 595            .join("external_agents")
 596            .join(binary_name.as_str());
 597        fs.create_dir(&dir).await?;
 598
 599        let mut stream = fs.read_dir(&dir).await?;
 600        let mut versions = Vec::new();
 601        let mut to_delete = Vec::new();
 602        while let Some(entry) = stream.next().await {
 603            let Ok(entry) = entry else { continue };
 604            let Some(file_name) = entry.file_name() else {
 605                continue;
 606            };
 607
 608            if let Some(name) = file_name.to_str()
 609                && let Some(version) = semver::Version::from_str(name).ok()
 610                && fs
 611                    .is_file(&dir.join(file_name).join(&entrypoint_path))
 612                    .await
 613            {
 614                versions.push((version, file_name.to_owned()));
 615            } else {
 616                to_delete.push(file_name.to_owned())
 617            }
 618        }
 619
 620        versions.sort();
 621        let newest_version = if let Some((version, file_name)) = versions.last().cloned()
 622            && minimum_version.is_none_or(|minimum_version| version >= minimum_version)
 623        {
 624            versions.pop();
 625            Some(file_name)
 626        } else {
 627            None
 628        };
 629        log::debug!("existing version of {package_name}: {newest_version:?}");
 630        to_delete.extend(versions.into_iter().map(|(_, file_name)| file_name));
 631
 632        cx.background_spawn({
 633            let fs = fs.clone();
 634            let dir = dir.clone();
 635            async move {
 636                for file_name in to_delete {
 637                    fs.remove_dir(
 638                        &dir.join(file_name),
 639                        RemoveOptions {
 640                            recursive: true,
 641                            ignore_if_not_exists: false,
 642                        },
 643                    )
 644                    .await
 645                    .ok();
 646                }
 647            }
 648        })
 649        .detach();
 650
 651        let version = if let Some(file_name) = newest_version {
 652            cx.background_spawn({
 653                let file_name = file_name.clone();
 654                let dir = dir.clone();
 655                let fs = fs.clone();
 656                async move {
 657                    let latest_version = node_runtime
 658                        .npm_package_latest_version(&package_name)
 659                        .await
 660                        .ok();
 661                    if let Some(latest_version) = latest_version
 662                        && &latest_version != &file_name.to_string_lossy()
 663                    {
 664                        let download_result = download_latest_version(
 665                            fs,
 666                            dir.clone(),
 667                            node_runtime,
 668                            package_name.clone(),
 669                        )
 670                        .await
 671                        .log_err();
 672                        if let Some(mut new_version_available) = new_version_available
 673                            && download_result.is_some()
 674                        {
 675                            new_version_available.send(Some(latest_version)).ok();
 676                        }
 677                    }
 678                }
 679            })
 680            .detach();
 681            file_name
 682        } else {
 683            if let Some(mut status_tx) = status_tx {
 684                status_tx.send("Installing…".into()).ok();
 685            }
 686            let dir = dir.clone();
 687            cx.background_spawn(download_latest_version(
 688                fs.clone(),
 689                dir.clone(),
 690                node_runtime,
 691                package_name.clone(),
 692            ))
 693            .await?
 694            .into()
 695        };
 696
 697        let agent_server_path = dir.join(version).join(entrypoint_path);
 698        let agent_server_path_exists = fs.is_file(&agent_server_path).await;
 699        anyhow::ensure!(
 700            agent_server_path_exists,
 701            "Missing entrypoint path {} after installation",
 702            agent_server_path.to_string_lossy()
 703        );
 704
 705        anyhow::Ok(AgentServerCommand {
 706            path: node_path,
 707            args: vec![agent_server_path.to_string_lossy().into_owned()],
 708            env: None,
 709        })
 710    })
 711}
 712
 713fn find_bin_in_path(
 714    bin_name: SharedString,
 715    root_dir: PathBuf,
 716    env: HashMap<String, String>,
 717    cx: &mut AsyncApp,
 718) -> Task<Option<PathBuf>> {
 719    cx.background_executor().spawn(async move {
 720        let which_result = if cfg!(windows) {
 721            which::which(bin_name.as_str())
 722        } else {
 723            let shell_path = env.get("PATH").cloned();
 724            which::which_in(bin_name.as_str(), shell_path.as_ref(), &root_dir)
 725        };
 726
 727        if let Err(which::Error::CannotFindBinaryPath) = which_result {
 728            return None;
 729        }
 730
 731        which_result.log_err()
 732    })
 733}
 734
 735async fn download_latest_version(
 736    fs: Arc<dyn Fs>,
 737    dir: PathBuf,
 738    node_runtime: NodeRuntime,
 739    package_name: SharedString,
 740) -> Result<String> {
 741    log::debug!("downloading latest version of {package_name}");
 742
 743    let tmp_dir = tempfile::tempdir_in(&dir)?;
 744
 745    node_runtime
 746        .npm_install_packages(tmp_dir.path(), &[(&package_name, "latest")])
 747        .await?;
 748
 749    let version = node_runtime
 750        .npm_package_installed_version(tmp_dir.path(), &package_name)
 751        .await?
 752        .context("expected package to be installed")?;
 753
 754    fs.rename(
 755        &tmp_dir.keep(),
 756        &dir.join(&version),
 757        RenameOptions {
 758            ignore_if_exists: true,
 759            overwrite: true,
 760        },
 761    )
 762    .await?;
 763
 764    anyhow::Ok(version)
 765}
 766
 767struct RemoteExternalAgentServer {
 768    project_id: u64,
 769    upstream_client: Entity<RemoteClient>,
 770    name: ExternalAgentServerName,
 771    status_tx: Option<watch::Sender<SharedString>>,
 772    new_version_available_tx: Option<watch::Sender<Option<String>>>,
 773}
 774
 775impl ExternalAgentServer for RemoteExternalAgentServer {
 776    fn get_command(
 777        &mut self,
 778        root_dir: Option<&str>,
 779        extra_env: HashMap<String, String>,
 780        status_tx: Option<watch::Sender<SharedString>>,
 781        new_version_available_tx: Option<watch::Sender<Option<String>>>,
 782        cx: &mut AsyncApp,
 783    ) -> Task<
 784        Result<(
 785            AgentServerCommand,
 786            String,
 787            HashMap<String, task::SpawnInTerminal>,
 788        )>,
 789    > {
 790        let project_id = self.project_id;
 791        let name = self.name.to_string();
 792        let upstream_client = self.upstream_client.downgrade();
 793        let root_dir = root_dir.map(|root_dir| root_dir.to_owned());
 794        self.status_tx = status_tx;
 795        self.new_version_available_tx = new_version_available_tx;
 796        cx.spawn(async move |cx| {
 797            let mut response = upstream_client
 798                .update(cx, |upstream_client, _| {
 799                    upstream_client
 800                        .proto_client()
 801                        .request(proto::GetAgentServerCommand {
 802                            project_id,
 803                            name,
 804                            root_dir: root_dir.clone(),
 805                        })
 806                })?
 807                .await?;
 808            let root_dir = response.root_dir;
 809            response.env.extend(extra_env);
 810            let command = upstream_client.update(cx, |client, _| {
 811                client.build_command(
 812                    Some(response.path),
 813                    &response.args,
 814                    &response.env.into_iter().collect(),
 815                    Some(root_dir.clone()),
 816                    None,
 817                )
 818            })??;
 819            Ok((
 820                AgentServerCommand {
 821                    path: command.program.into(),
 822                    args: command.args,
 823                    env: Some(command.env),
 824                },
 825                root_dir,
 826                HashMap::default(),
 827            ))
 828        })
 829    }
 830
 831    fn as_any_mut(&mut self) -> &mut dyn Any {
 832        self
 833    }
 834}
 835
 836struct LocalGemini {
 837    fs: Arc<dyn Fs>,
 838    node_runtime: NodeRuntime,
 839    project_environment: Entity<ProjectEnvironment>,
 840    custom_command: Option<AgentServerCommand>,
 841    ignore_system_version: bool,
 842}
 843
 844impl ExternalAgentServer for LocalGemini {
 845    fn get_command(
 846        &mut self,
 847        root_dir: Option<&str>,
 848        extra_env: HashMap<String, String>,
 849        status_tx: Option<watch::Sender<SharedString>>,
 850        new_version_available_tx: Option<watch::Sender<Option<String>>>,
 851        cx: &mut AsyncApp,
 852    ) -> Task<
 853        Result<(
 854            AgentServerCommand,
 855            String,
 856            HashMap<String, task::SpawnInTerminal>,
 857        )>,
 858    > {
 859        let fs = self.fs.clone();
 860        let node_runtime = self.node_runtime.clone();
 861        let project_environment = self.project_environment.downgrade();
 862        let custom_command = self.custom_command.clone();
 863        let ignore_system_version = self.ignore_system_version;
 864        let root_dir: Arc<Path> = root_dir
 865            .map(|root_dir| Path::new(root_dir))
 866            .unwrap_or(paths::home_dir())
 867            .into();
 868
 869        cx.spawn(async move |cx| {
 870            let mut env = project_environment
 871                .update(cx, |project_environment, cx| {
 872                    project_environment.get_local_directory_environment(
 873                        &Shell::System,
 874                        root_dir.clone(),
 875                        cx,
 876                    )
 877                })?
 878                .await
 879                .unwrap_or_default();
 880
 881            let mut command = if let Some(mut custom_command) = custom_command {
 882                env.extend(custom_command.env.unwrap_or_default());
 883                custom_command.env = Some(env);
 884                custom_command
 885            } else if !ignore_system_version
 886                && let Some(bin) =
 887                    find_bin_in_path("gemini".into(), root_dir.to_path_buf(), env.clone(), cx).await
 888            {
 889                AgentServerCommand {
 890                    path: bin,
 891                    args: Vec::new(),
 892                    env: Some(env),
 893                }
 894            } else {
 895                let mut command = get_or_npm_install_builtin_agent(
 896                    GEMINI_NAME.into(),
 897                    "@google/gemini-cli".into(),
 898                    "node_modules/@google/gemini-cli/dist/index.js".into(),
 899                    if cfg!(windows) {
 900                        // v0.8.x on Windows has a bug that causes the initialize request to hang forever
 901                        Some("0.9.0".parse().unwrap())
 902                    } else {
 903                        Some("0.2.1".parse().unwrap())
 904                    },
 905                    status_tx,
 906                    new_version_available_tx,
 907                    fs,
 908                    node_runtime,
 909                    cx,
 910                )
 911                .await?;
 912                command.env = Some(env);
 913                command
 914            };
 915
 916            // Gemini CLI doesn't seem to have a dedicated invocation for logging in--we just run it normally without any arguments.
 917            let login = task::SpawnInTerminal {
 918                command: Some(command.path.to_string_lossy().into_owned()),
 919                args: command.args.clone(),
 920                env: command.env.clone().unwrap_or_default(),
 921                label: "gemini /auth".into(),
 922                ..Default::default()
 923            };
 924
 925            let mut auth_commands = HashMap::default();
 926            auth_commands.insert("spawn-gemini-cli".to_string(), login);
 927
 928            command.env.get_or_insert_default().extend(extra_env);
 929            command.args.push("--experimental-acp".into());
 930            Ok((
 931                command,
 932                root_dir.to_string_lossy().into_owned(),
 933                auth_commands,
 934            ))
 935        })
 936    }
 937
 938    fn as_any_mut(&mut self) -> &mut dyn Any {
 939        self
 940    }
 941}
 942
 943struct LocalClaudeCode {
 944    fs: Arc<dyn Fs>,
 945    node_runtime: NodeRuntime,
 946    project_environment: Entity<ProjectEnvironment>,
 947    custom_command: Option<AgentServerCommand>,
 948}
 949
 950impl ExternalAgentServer for LocalClaudeCode {
 951    fn get_command(
 952        &mut self,
 953        root_dir: Option<&str>,
 954        extra_env: HashMap<String, String>,
 955        status_tx: Option<watch::Sender<SharedString>>,
 956        new_version_available_tx: Option<watch::Sender<Option<String>>>,
 957        cx: &mut AsyncApp,
 958    ) -> Task<
 959        Result<(
 960            AgentServerCommand,
 961            String,
 962            HashMap<String, task::SpawnInTerminal>,
 963        )>,
 964    > {
 965        let fs = self.fs.clone();
 966        let node_runtime = self.node_runtime.clone();
 967        let project_environment = self.project_environment.downgrade();
 968        let custom_command = self.custom_command.clone();
 969        let root_dir: Arc<Path> = root_dir
 970            .map(|root_dir| Path::new(root_dir))
 971            .unwrap_or(paths::home_dir())
 972            .into();
 973
 974        cx.spawn(async move |cx| {
 975            let mut env = project_environment
 976                .update(cx, |project_environment, cx| {
 977                    project_environment.get_local_directory_environment(
 978                        &Shell::System,
 979                        root_dir.clone(),
 980                        cx,
 981                    )
 982                })?
 983                .await
 984                .unwrap_or_default();
 985            env.insert("ANTHROPIC_API_KEY".into(), "".into());
 986
 987            let (mut command, login) = if let Some(mut custom_command) = custom_command {
 988                env.extend(custom_command.env.unwrap_or_default());
 989                custom_command.env = Some(env);
 990                (custom_command, None)
 991            } else {
 992                let mut command = get_or_npm_install_builtin_agent(
 993                    "claude-code-acp".into(),
 994                    "@zed-industries/claude-code-acp".into(),
 995                    "node_modules/@zed-industries/claude-code-acp/dist/index.js".into(),
 996                    Some("0.5.2".parse().unwrap()),
 997                    status_tx,
 998                    new_version_available_tx,
 999                    fs,
1000                    node_runtime,
1001                    cx,
1002                )
1003                .await?;
1004                command.env = Some(env);
1005                let login = command
1006                    .args
1007                    .first()
1008                    .and_then(|path| {
1009                        path.strip_suffix("/@zed-industries/claude-code-acp/dist/index.js")
1010                    })
1011                    .map(|path_prefix| task::SpawnInTerminal {
1012                        command: Some(command.path.to_string_lossy().into_owned()),
1013                        args: vec![
1014                            Path::new(path_prefix)
1015                                .join("@anthropic-ai/claude-agent-sdk/cli.js")
1016                                .to_string_lossy()
1017                                .to_string(),
1018                            "/login".into(),
1019                        ],
1020                        env: command.env.clone().unwrap_or_default(),
1021                        label: "claude /login".into(),
1022                        ..Default::default()
1023                    });
1024                (command, login)
1025            };
1026
1027            let mut auth_commands = HashMap::default();
1028            if let Some(login) = login {
1029                auth_commands.insert("claude-login".to_string(), login);
1030            }
1031
1032            command.env.get_or_insert_default().extend(extra_env);
1033            Ok((
1034                command,
1035                root_dir.to_string_lossy().into_owned(),
1036                auth_commands,
1037            ))
1038        })
1039    }
1040
1041    fn as_any_mut(&mut self) -> &mut dyn Any {
1042        self
1043    }
1044}
1045
1046struct LocalCodex {
1047    fs: Arc<dyn Fs>,
1048    project_environment: Entity<ProjectEnvironment>,
1049    http_client: Arc<dyn HttpClient>,
1050    custom_command: Option<AgentServerCommand>,
1051    is_remote: bool,
1052}
1053
1054impl ExternalAgentServer for LocalCodex {
1055    fn get_command(
1056        &mut self,
1057        root_dir: Option<&str>,
1058        extra_env: HashMap<String, String>,
1059        _status_tx: Option<watch::Sender<SharedString>>,
1060        _new_version_available_tx: Option<watch::Sender<Option<String>>>,
1061        cx: &mut AsyncApp,
1062    ) -> Task<
1063        Result<(
1064            AgentServerCommand,
1065            String,
1066            HashMap<String, task::SpawnInTerminal>,
1067        )>,
1068    > {
1069        let fs = self.fs.clone();
1070        let project_environment = self.project_environment.downgrade();
1071        let http = self.http_client.clone();
1072        let custom_command = self.custom_command.clone();
1073        let root_dir: Arc<Path> = root_dir
1074            .map(|root_dir| Path::new(root_dir))
1075            .unwrap_or(paths::home_dir())
1076            .into();
1077        let is_remote = self.is_remote;
1078
1079        cx.spawn(async move |cx| {
1080            let mut env = project_environment
1081                .update(cx, |project_environment, cx| {
1082                    project_environment.get_local_directory_environment(
1083                        &Shell::System,
1084                        root_dir.clone(),
1085                        cx,
1086                    )
1087                })?
1088                .await
1089                .unwrap_or_default();
1090            if is_remote {
1091                env.insert("NO_BROWSER".to_owned(), "1".to_owned());
1092            }
1093
1094            let mut command = if let Some(mut custom_command) = custom_command {
1095                env.extend(custom_command.env.unwrap_or_default());
1096                custom_command.env = Some(env);
1097                custom_command
1098            } else {
1099                let dir = paths::data_dir().join("external_agents").join(CODEX_NAME);
1100                fs.create_dir(&dir).await?;
1101
1102                // Find or install the latest Codex release (no update checks for now).
1103                let release = ::http_client::github::latest_github_release(
1104                    CODEX_ACP_REPO,
1105                    true,
1106                    false,
1107                    http.clone(),
1108                )
1109                .await
1110                .context("fetching Codex latest release")?;
1111
1112                let version_dir = dir.join(&release.tag_name);
1113                if !fs.is_dir(&version_dir).await {
1114                    let tag = release.tag_name.clone();
1115                    let version_number = tag.trim_start_matches('v');
1116                    let asset_name = asset_name(version_number)
1117                        .context("codex acp is not supported for this architecture")?;
1118                    let asset = release
1119                        .assets
1120                        .into_iter()
1121                        .find(|asset| asset.name == asset_name)
1122                        .with_context(|| format!("no asset found matching `{asset_name:?}`"))?;
1123                    ::http_client::github_download::download_server_binary(
1124                        &*http,
1125                        &asset.browser_download_url,
1126                        asset.digest.as_deref(),
1127                        &version_dir,
1128                        if cfg!(target_os = "windows") && cfg!(target_arch = "x86_64") {
1129                            AssetKind::Zip
1130                        } else {
1131                            AssetKind::TarGz
1132                        },
1133                    )
1134                    .await?;
1135                }
1136
1137                let bin_name = if cfg!(windows) {
1138                    "codex-acp.exe"
1139                } else {
1140                    "codex-acp"
1141                };
1142                let bin_path = version_dir.join(bin_name);
1143                anyhow::ensure!(
1144                    fs.is_file(&bin_path).await,
1145                    "Missing Codex binary at {} after installation",
1146                    bin_path.to_string_lossy()
1147                );
1148
1149                let mut cmd = AgentServerCommand {
1150                    path: bin_path,
1151                    args: Vec::new(),
1152                    env: None,
1153                };
1154                cmd.env = Some(env);
1155                cmd
1156            };
1157
1158            command.env.get_or_insert_default().extend(extra_env);
1159            Ok((
1160                command,
1161                root_dir.to_string_lossy().into_owned(),
1162                HashMap::default(),
1163            ))
1164        })
1165    }
1166
1167    fn as_any_mut(&mut self) -> &mut dyn Any {
1168        self
1169    }
1170}
1171
1172pub const CODEX_ACP_REPO: &str = "zed-industries/codex-acp";
1173
1174/// Assemble Codex release URL for the current OS/arch and the given version number.
1175/// Returns None if the current target is unsupported.
1176/// Example output:
1177/// https://github.com/zed-industries/codex-acp/releases/download/v{version}/codex-acp-{version}-{arch}-{platform}.{ext}
1178fn asset_name(version: &str) -> Option<String> {
1179    let arch = if cfg!(target_arch = "x86_64") {
1180        "x86_64"
1181    } else if cfg!(target_arch = "aarch64") {
1182        "aarch64"
1183    } else {
1184        return None;
1185    };
1186
1187    let platform = if cfg!(target_os = "macos") {
1188        "apple-darwin"
1189    } else if cfg!(target_os = "windows") {
1190        "pc-windows-msvc"
1191    } else if cfg!(target_os = "linux") {
1192        "unknown-linux-gnu"
1193    } else {
1194        return None;
1195    };
1196
1197    // Only Windows x86_64 uses .zip in release assets
1198    let ext = if cfg!(target_os = "windows") && cfg!(target_arch = "x86_64") {
1199        "zip"
1200    } else {
1201        "tar.gz"
1202    };
1203
1204    Some(format!("codex-acp-{version}-{arch}-{platform}.{ext}"))
1205}
1206
1207struct LocalCustomAgent {
1208    project_environment: Entity<ProjectEnvironment>,
1209    command: AgentServerCommand,
1210}
1211
1212impl ExternalAgentServer for LocalCustomAgent {
1213    fn get_command(
1214        &mut self,
1215        root_dir: Option<&str>,
1216        extra_env: HashMap<String, String>,
1217        _status_tx: Option<watch::Sender<SharedString>>,
1218        _new_version_available_tx: Option<watch::Sender<Option<String>>>,
1219        cx: &mut AsyncApp,
1220    ) -> Task<
1221        Result<(
1222            AgentServerCommand,
1223            String,
1224            HashMap<String, task::SpawnInTerminal>,
1225        )>,
1226    > {
1227        let mut command = self.command.clone();
1228        let root_dir: Arc<Path> = root_dir
1229            .map(|root_dir| Path::new(root_dir))
1230            .unwrap_or(paths::home_dir())
1231            .into();
1232        let project_environment = self.project_environment.downgrade();
1233        cx.spawn(async move |cx| {
1234            let mut env = project_environment
1235                .update(cx, |project_environment, cx| {
1236                    project_environment.get_local_directory_environment(
1237                        &Shell::System,
1238                        root_dir.clone(),
1239                        cx,
1240                    )
1241                })?
1242                .await
1243                .unwrap_or_default();
1244            env.extend(command.env.unwrap_or_default());
1245            env.extend(extra_env);
1246            command.env = Some(env);
1247            Ok((
1248                command,
1249                root_dir.to_string_lossy().into_owned(),
1250                HashMap::default(),
1251            ))
1252        })
1253    }
1254
1255    fn as_any_mut(&mut self) -> &mut dyn Any {
1256        self
1257    }
1258}
1259
1260#[cfg(test)]
1261mod tests {
1262    #[test]
1263    fn assembles_codex_release_url_for_current_target() {
1264        let version_number = "0.1.0";
1265
1266        // This test fails the build if we are building a version of Zed
1267        // which does not have a known build of codex-acp, to prevent us
1268        // from accidentally doing a release on a new target without
1269        // realizing that codex-acp support will not work on that target!
1270        //
1271        // Additionally, it verifies that our logic for assembling URLs
1272        // correctly resolves to a known-good URL on each of our targets.
1273        let allowed = [
1274            "codex-acp-0.1.0-aarch64-apple-darwin.tar.gz",
1275            "codex-acp-0.1.0-aarch64-pc-windows-msvc.tar.gz",
1276            "codex-acp-0.1.0-aarch64-unknown-linux-gnu.tar.gz",
1277            "codex-acp-0.1.0-x86_64-apple-darwin.tar.gz",
1278            "codex-acp-0.1.0-x86_64-pc-windows-msvc.zip",
1279            "codex-acp-0.1.0-x86_64-unknown-linux-gnu.tar.gz",
1280        ];
1281
1282        if let Some(url) = super::asset_name(version_number) {
1283            assert!(
1284                allowed.contains(&url.as_str()),
1285                "Assembled asset name {} not in allowed list",
1286                url
1287            );
1288        } else {
1289            panic!(
1290                "This target does not have a known codex-acp release! We should fix this by building a release of codex-acp for this target, as otherwise codex-acp will not be usable with this Zed build."
1291            );
1292        }
1293    }
1294}
1295
1296pub const GEMINI_NAME: &'static str = "gemini";
1297pub const CLAUDE_CODE_NAME: &'static str = "claude";
1298pub const CODEX_NAME: &'static str = "codex";
1299
1300#[derive(Default, Clone, JsonSchema, Debug, PartialEq)]
1301pub struct AllAgentServersSettings {
1302    pub gemini: Option<BuiltinAgentServerSettings>,
1303    pub claude: Option<BuiltinAgentServerSettings>,
1304    pub codex: Option<BuiltinAgentServerSettings>,
1305    pub custom: HashMap<SharedString, CustomAgentServerSettings>,
1306}
1307#[derive(Default, Clone, JsonSchema, Debug, PartialEq)]
1308pub struct BuiltinAgentServerSettings {
1309    pub path: Option<PathBuf>,
1310    pub args: Option<Vec<String>>,
1311    pub env: Option<HashMap<String, String>>,
1312    pub ignore_system_version: Option<bool>,
1313    pub default_mode: Option<String>,
1314}
1315
1316impl BuiltinAgentServerSettings {
1317    pub(crate) fn custom_command(self) -> Option<AgentServerCommand> {
1318        self.path.map(|path| AgentServerCommand {
1319            path,
1320            args: self.args.unwrap_or_default(),
1321            env: self.env,
1322        })
1323    }
1324}
1325
1326impl From<settings::BuiltinAgentServerSettings> for BuiltinAgentServerSettings {
1327    fn from(value: settings::BuiltinAgentServerSettings) -> Self {
1328        BuiltinAgentServerSettings {
1329            path: value.path,
1330            args: value.args,
1331            env: value.env,
1332            ignore_system_version: value.ignore_system_version,
1333            default_mode: value.default_mode,
1334        }
1335    }
1336}
1337
1338impl From<AgentServerCommand> for BuiltinAgentServerSettings {
1339    fn from(value: AgentServerCommand) -> Self {
1340        BuiltinAgentServerSettings {
1341            path: Some(value.path),
1342            args: Some(value.args),
1343            env: value.env,
1344            ..Default::default()
1345        }
1346    }
1347}
1348
1349#[derive(Clone, JsonSchema, Debug, PartialEq)]
1350pub struct CustomAgentServerSettings {
1351    pub command: AgentServerCommand,
1352    /// The default mode to use for this agent.
1353    ///
1354    /// Note: Not only all agents support modes.
1355    ///
1356    /// Default: None
1357    pub default_mode: Option<String>,
1358}
1359
1360impl From<settings::CustomAgentServerSettings> for CustomAgentServerSettings {
1361    fn from(value: settings::CustomAgentServerSettings) -> Self {
1362        CustomAgentServerSettings {
1363            command: AgentServerCommand {
1364                path: value.path,
1365                args: value.args,
1366                env: value.env,
1367            },
1368            default_mode: value.default_mode,
1369        }
1370    }
1371}
1372
1373impl settings::Settings for AllAgentServersSettings {
1374    fn from_settings(content: &settings::SettingsContent) -> Self {
1375        let agent_settings = content.agent_servers.clone().unwrap();
1376        Self {
1377            gemini: agent_settings.gemini.map(Into::into),
1378            claude: agent_settings.claude.map(Into::into),
1379            codex: agent_settings.codex.map(Into::into),
1380            custom: agent_settings
1381                .custom
1382                .into_iter()
1383                .map(|(k, v)| (k, v.into()))
1384                .collect(),
1385        }
1386    }
1387}