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