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