agent_server_store.rs

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