agent_server_store.rs

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