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