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    channel: &'static str,
 581    status_tx: Option<watch::Sender<SharedString>>,
 582    new_version_available: Option<watch::Sender<Option<String>>>,
 583    fs: Arc<dyn Fs>,
 584    node_runtime: NodeRuntime,
 585    cx: &mut AsyncApp,
 586) -> Task<std::result::Result<AgentServerCommand, anyhow::Error>> {
 587    cx.spawn(async move |cx| {
 588        let node_path = node_runtime.binary_path().await?;
 589        let dir = paths::data_dir()
 590            .join("external_agents")
 591            .join(binary_name.as_str());
 592        fs.create_dir(&dir).await?;
 593
 594        let mut stream = fs.read_dir(&dir).await?;
 595        let mut versions = Vec::new();
 596        let mut to_delete = Vec::new();
 597        while let Some(entry) = stream.next().await {
 598            let Ok(entry) = entry else { continue };
 599            let Some(file_name) = entry.file_name() else {
 600                continue;
 601            };
 602
 603            if let Some(name) = file_name.to_str()
 604                && let Some(version) = semver::Version::from_str(name).ok()
 605                && fs
 606                    .is_file(&dir.join(file_name).join(&entrypoint_path))
 607                    .await
 608            {
 609                versions.push((version, file_name.to_owned()));
 610            } else {
 611                to_delete.push(file_name.to_owned())
 612            }
 613        }
 614
 615        versions.sort();
 616        let newest_version = if let Some((version, file_name)) = versions.last().cloned()
 617            && minimum_version.is_none_or(|minimum_version| version >= minimum_version)
 618        {
 619            versions.pop();
 620            Some(file_name)
 621        } else {
 622            None
 623        };
 624        log::debug!("existing version of {package_name}: {newest_version:?}");
 625        to_delete.extend(versions.into_iter().map(|(_, file_name)| file_name));
 626
 627        cx.background_spawn({
 628            let fs = fs.clone();
 629            let dir = dir.clone();
 630            async move {
 631                for file_name in to_delete {
 632                    fs.remove_dir(
 633                        &dir.join(file_name),
 634                        RemoveOptions {
 635                            recursive: true,
 636                            ignore_if_not_exists: false,
 637                        },
 638                    )
 639                    .await
 640                    .ok();
 641                }
 642            }
 643        })
 644        .detach();
 645
 646        let version = if let Some(file_name) = newest_version {
 647            cx.background_spawn({
 648                let file_name = file_name.clone();
 649                let dir = dir.clone();
 650                let fs = fs.clone();
 651                async move {
 652                    // TODO remove the filter
 653                    let latest_version = node_runtime
 654                        .npm_package_latest_version(&package_name)
 655                        .await
 656                        .ok()
 657                        .filter(|_| channel == "latest");
 658                    if let Some(latest_version) = latest_version
 659                        && &latest_version != &file_name.to_string_lossy()
 660                    {
 661                        let download_result = download_latest_version(
 662                            fs,
 663                            dir.clone(),
 664                            node_runtime,
 665                            package_name.clone(),
 666                            channel,
 667                        )
 668                        .await
 669                        .log_err();
 670                        if let Some(mut new_version_available) = new_version_available
 671                            && download_result.is_some()
 672                        {
 673                            new_version_available.send(Some(latest_version)).ok();
 674                        }
 675                    }
 676                }
 677            })
 678            .detach();
 679            file_name
 680        } else {
 681            if let Some(mut status_tx) = status_tx {
 682                status_tx.send("Installing…".into()).ok();
 683            }
 684            let dir = dir.clone();
 685            cx.background_spawn(download_latest_version(
 686                fs.clone(),
 687                dir.clone(),
 688                node_runtime,
 689                package_name.clone(),
 690                channel,
 691            ))
 692            .await?
 693            .into()
 694        };
 695
 696        let agent_server_path = dir.join(version).join(entrypoint_path);
 697        let agent_server_path_exists = fs.is_file(&agent_server_path).await;
 698        anyhow::ensure!(
 699            agent_server_path_exists,
 700            "Missing entrypoint path {} after installation",
 701            agent_server_path.to_string_lossy()
 702        );
 703
 704        anyhow::Ok(AgentServerCommand {
 705            path: node_path,
 706            args: vec![agent_server_path.to_string_lossy().into_owned()],
 707            env: None,
 708        })
 709    })
 710}
 711
 712fn find_bin_in_path(
 713    bin_name: SharedString,
 714    root_dir: PathBuf,
 715    env: HashMap<String, String>,
 716    cx: &mut AsyncApp,
 717) -> Task<Option<PathBuf>> {
 718    cx.background_executor().spawn(async move {
 719        let which_result = if cfg!(windows) {
 720            which::which(bin_name.as_str())
 721        } else {
 722            let shell_path = env.get("PATH").cloned();
 723            which::which_in(bin_name.as_str(), shell_path.as_ref(), &root_dir)
 724        };
 725
 726        if let Err(which::Error::CannotFindBinaryPath) = which_result {
 727            return None;
 728        }
 729
 730        which_result.log_err()
 731    })
 732}
 733
 734async fn download_latest_version(
 735    fs: Arc<dyn Fs>,
 736    dir: PathBuf,
 737    node_runtime: NodeRuntime,
 738    package_name: SharedString,
 739    channel: &'static str,
 740) -> Result<String> {
 741    log::debug!("downloading latest version of {package_name}");
 742
 743    let tmp_dir = tempfile::tempdir_in(&dir)?;
 744
 745    node_runtime
 746        .npm_install_packages(tmp_dir.path(), &[(&package_name, channel)])
 747        .await?;
 748
 749    let version = node_runtime
 750        .npm_package_installed_version(tmp_dir.path(), &package_name)
 751        .await?
 752        .context("expected package to be installed")?;
 753
 754    fs.rename(
 755        &tmp_dir.keep(),
 756        &dir.join(&version),
 757        RenameOptions {
 758            ignore_if_exists: true,
 759            overwrite: true,
 760        },
 761    )
 762    .await?;
 763
 764    anyhow::Ok(version)
 765}
 766
 767struct RemoteExternalAgentServer {
 768    project_id: u64,
 769    upstream_client: Entity<RemoteClient>,
 770    name: ExternalAgentServerName,
 771    status_tx: Option<watch::Sender<SharedString>>,
 772    new_version_available_tx: Option<watch::Sender<Option<String>>>,
 773}
 774
 775impl ExternalAgentServer for RemoteExternalAgentServer {
 776    fn get_command(
 777        &mut self,
 778        root_dir: Option<&str>,
 779        extra_env: HashMap<String, String>,
 780        status_tx: Option<watch::Sender<SharedString>>,
 781        new_version_available_tx: Option<watch::Sender<Option<String>>>,
 782        cx: &mut AsyncApp,
 783    ) -> Task<Result<(AgentServerCommand, String, Option<task::SpawnInTerminal>)>> {
 784        let project_id = self.project_id;
 785        let name = self.name.to_string();
 786        let upstream_client = self.upstream_client.downgrade();
 787        let root_dir = root_dir.map(|root_dir| root_dir.to_owned());
 788        self.status_tx = status_tx;
 789        self.new_version_available_tx = new_version_available_tx;
 790        cx.spawn(async move |cx| {
 791            let mut response = upstream_client
 792                .update(cx, |upstream_client, _| {
 793                    upstream_client
 794                        .proto_client()
 795                        .request(proto::GetAgentServerCommand {
 796                            project_id,
 797                            name,
 798                            root_dir: root_dir.clone(),
 799                        })
 800                })?
 801                .await?;
 802            let root_dir = response.root_dir;
 803            response.env.extend(extra_env);
 804            let command = upstream_client.update(cx, |client, _| {
 805                client.build_command(
 806                    Some(response.path),
 807                    &response.args,
 808                    &response.env.into_iter().collect(),
 809                    Some(root_dir.clone()),
 810                    None,
 811                )
 812            })??;
 813            Ok((
 814                AgentServerCommand {
 815                    path: command.program.into(),
 816                    args: command.args,
 817                    env: Some(command.env),
 818                },
 819                root_dir,
 820                response
 821                    .login
 822                    .map(|login| task::SpawnInTerminal::from_proto(login)),
 823            ))
 824        })
 825    }
 826
 827    fn as_any_mut(&mut self) -> &mut dyn Any {
 828        self
 829    }
 830}
 831
 832struct LocalGemini {
 833    fs: Arc<dyn Fs>,
 834    node_runtime: NodeRuntime,
 835    project_environment: Entity<ProjectEnvironment>,
 836    custom_command: Option<AgentServerCommand>,
 837    ignore_system_version: bool,
 838}
 839
 840impl ExternalAgentServer for LocalGemini {
 841    fn get_command(
 842        &mut self,
 843        root_dir: Option<&str>,
 844        extra_env: HashMap<String, String>,
 845        status_tx: Option<watch::Sender<SharedString>>,
 846        new_version_available_tx: Option<watch::Sender<Option<String>>>,
 847        cx: &mut AsyncApp,
 848    ) -> Task<Result<(AgentServerCommand, String, Option<task::SpawnInTerminal>)>> {
 849        let fs = self.fs.clone();
 850        let node_runtime = self.node_runtime.clone();
 851        let project_environment = self.project_environment.downgrade();
 852        let custom_command = self.custom_command.clone();
 853        let ignore_system_version = self.ignore_system_version;
 854        let root_dir: Arc<Path> = root_dir
 855            .map(|root_dir| Path::new(root_dir))
 856            .unwrap_or(paths::home_dir())
 857            .into();
 858
 859        cx.spawn(async move |cx| {
 860            let mut env = project_environment
 861                .update(cx, |project_environment, cx| {
 862                    project_environment.get_local_directory_environment(
 863                        &Shell::System,
 864                        root_dir.clone(),
 865                        cx,
 866                    )
 867                })?
 868                .await
 869                .unwrap_or_default();
 870
 871            let mut command = if let Some(mut custom_command) = custom_command {
 872                env.extend(custom_command.env.unwrap_or_default());
 873                custom_command.env = Some(env);
 874                custom_command
 875            } else if !ignore_system_version
 876                && let Some(bin) =
 877                    find_bin_in_path("gemini".into(), root_dir.to_path_buf(), env.clone(), cx).await
 878            {
 879                AgentServerCommand {
 880                    path: bin,
 881                    args: Vec::new(),
 882                    env: Some(env),
 883                }
 884            } else {
 885                let mut command = get_or_npm_install_builtin_agent(
 886                    GEMINI_NAME.into(),
 887                    "@google/gemini-cli".into(),
 888                    "node_modules/@google/gemini-cli/dist/index.js".into(),
 889                    // TODO remove these windows-specific workarounds once v0.9.0 stable is released
 890                    if cfg!(windows) {
 891                        Some("0.9.0-preview.4".parse().unwrap())
 892                    } else {
 893                        Some("0.2.1".parse().unwrap())
 894                    },
 895                    if cfg!(windows) {
 896                        "0.9.0-preview.4"
 897                    } else {
 898                        "latest"
 899                    },
 900                    status_tx,
 901                    new_version_available_tx,
 902                    fs,
 903                    node_runtime,
 904                    cx,
 905                )
 906                .await?;
 907                command.env = Some(env);
 908                command
 909            };
 910
 911            // Gemini CLI doesn't seem to have a dedicated invocation for logging in--we just run it normally without any arguments.
 912            let login = task::SpawnInTerminal {
 913                command: Some(command.path.to_string_lossy().into_owned()),
 914                args: command.args.clone(),
 915                env: command.env.clone().unwrap_or_default(),
 916                label: "gemini /auth".into(),
 917                ..Default::default()
 918            };
 919
 920            command.env.get_or_insert_default().extend(extra_env);
 921            command.args.push("--experimental-acp".into());
 922            Ok((
 923                command,
 924                root_dir.to_string_lossy().into_owned(),
 925                Some(login),
 926            ))
 927        })
 928    }
 929
 930    fn as_any_mut(&mut self) -> &mut dyn Any {
 931        self
 932    }
 933}
 934
 935struct LocalClaudeCode {
 936    fs: Arc<dyn Fs>,
 937    node_runtime: NodeRuntime,
 938    project_environment: Entity<ProjectEnvironment>,
 939    custom_command: Option<AgentServerCommand>,
 940}
 941
 942impl ExternalAgentServer for LocalClaudeCode {
 943    fn get_command(
 944        &mut self,
 945        root_dir: Option<&str>,
 946        extra_env: HashMap<String, String>,
 947        status_tx: Option<watch::Sender<SharedString>>,
 948        new_version_available_tx: Option<watch::Sender<Option<String>>>,
 949        cx: &mut AsyncApp,
 950    ) -> Task<Result<(AgentServerCommand, String, Option<task::SpawnInTerminal>)>> {
 951        let fs = self.fs.clone();
 952        let node_runtime = self.node_runtime.clone();
 953        let project_environment = self.project_environment.downgrade();
 954        let custom_command = self.custom_command.clone();
 955        let root_dir: Arc<Path> = root_dir
 956            .map(|root_dir| Path::new(root_dir))
 957            .unwrap_or(paths::home_dir())
 958            .into();
 959
 960        cx.spawn(async move |cx| {
 961            let mut env = project_environment
 962                .update(cx, |project_environment, cx| {
 963                    project_environment.get_local_directory_environment(
 964                        &Shell::System,
 965                        root_dir.clone(),
 966                        cx,
 967                    )
 968                })?
 969                .await
 970                .unwrap_or_default();
 971            env.insert("ANTHROPIC_API_KEY".into(), "".into());
 972
 973            let (mut command, login) = if let Some(mut custom_command) = custom_command {
 974                env.extend(custom_command.env.unwrap_or_default());
 975                custom_command.env = Some(env);
 976                (custom_command, None)
 977            } else {
 978                let mut command = get_or_npm_install_builtin_agent(
 979                    "claude-code-acp".into(),
 980                    "@zed-industries/claude-code-acp".into(),
 981                    "node_modules/@zed-industries/claude-code-acp/dist/index.js".into(),
 982                    Some("0.5.2".parse().unwrap()),
 983                    "latest",
 984                    status_tx,
 985                    new_version_available_tx,
 986                    fs,
 987                    node_runtime,
 988                    cx,
 989                )
 990                .await?;
 991                command.env = Some(env);
 992                let login = command
 993                    .args
 994                    .first()
 995                    .and_then(|path| {
 996                        path.strip_suffix("/@zed-industries/claude-code-acp/dist/index.js")
 997                    })
 998                    .map(|path_prefix| task::SpawnInTerminal {
 999                        command: Some(command.path.to_string_lossy().into_owned()),
1000                        args: vec![
1001                            Path::new(path_prefix)
1002                                .join("@anthropic-ai/claude-agent-sdk/cli.js")
1003                                .to_string_lossy()
1004                                .to_string(),
1005                            "/login".into(),
1006                        ],
1007                        env: command.env.clone().unwrap_or_default(),
1008                        label: "claude /login".into(),
1009                        ..Default::default()
1010                    });
1011                (command, login)
1012            };
1013
1014            command.env.get_or_insert_default().extend(extra_env);
1015            Ok((command, root_dir.to_string_lossy().into_owned(), login))
1016        })
1017    }
1018
1019    fn as_any_mut(&mut self) -> &mut dyn Any {
1020        self
1021    }
1022}
1023
1024struct LocalCodex {
1025    fs: Arc<dyn Fs>,
1026    project_environment: Entity<ProjectEnvironment>,
1027    http_client: Arc<dyn HttpClient>,
1028    custom_command: Option<AgentServerCommand>,
1029    is_remote: bool,
1030}
1031
1032impl ExternalAgentServer for LocalCodex {
1033    fn get_command(
1034        &mut self,
1035        root_dir: Option<&str>,
1036        extra_env: HashMap<String, String>,
1037        _status_tx: Option<watch::Sender<SharedString>>,
1038        _new_version_available_tx: Option<watch::Sender<Option<String>>>,
1039        cx: &mut AsyncApp,
1040    ) -> Task<Result<(AgentServerCommand, String, Option<task::SpawnInTerminal>)>> {
1041        let fs = self.fs.clone();
1042        let project_environment = self.project_environment.downgrade();
1043        let http = self.http_client.clone();
1044        let custom_command = self.custom_command.clone();
1045        let root_dir: Arc<Path> = root_dir
1046            .map(|root_dir| Path::new(root_dir))
1047            .unwrap_or(paths::home_dir())
1048            .into();
1049        let is_remote = self.is_remote;
1050
1051        cx.spawn(async move |cx| {
1052            let mut env = project_environment
1053                .update(cx, |project_environment, cx| {
1054                    project_environment.get_local_directory_environment(
1055                        &Shell::System,
1056                        root_dir.clone(),
1057                        cx,
1058                    )
1059                })?
1060                .await
1061                .unwrap_or_default();
1062            if is_remote {
1063                env.insert("NO_BROWSER".to_owned(), "1".to_owned());
1064            }
1065
1066            let mut command = if let Some(mut custom_command) = custom_command {
1067                env.extend(custom_command.env.unwrap_or_default());
1068                custom_command.env = Some(env);
1069                custom_command
1070            } else {
1071                let dir = paths::data_dir().join("external_agents").join(CODEX_NAME);
1072                fs.create_dir(&dir).await?;
1073
1074                // Find or install the latest Codex release (no update checks for now).
1075                let release = ::http_client::github::latest_github_release(
1076                    CODEX_ACP_REPO,
1077                    true,
1078                    false,
1079                    http.clone(),
1080                )
1081                .await
1082                .context("fetching Codex latest release")?;
1083
1084                let version_dir = dir.join(&release.tag_name);
1085                if !fs.is_dir(&version_dir).await {
1086                    let tag = release.tag_name.clone();
1087                    let version_number = tag.trim_start_matches('v');
1088                    let asset_name = asset_name(version_number)
1089                        .context("codex acp is not supported for this architecture")?;
1090                    let asset = release
1091                        .assets
1092                        .into_iter()
1093                        .find(|asset| asset.name == asset_name)
1094                        .with_context(|| format!("no asset found matching `{asset_name:?}`"))?;
1095                    ::http_client::github_download::download_server_binary(
1096                        &*http,
1097                        &asset.browser_download_url,
1098                        asset.digest.as_deref(),
1099                        &version_dir,
1100                        if cfg!(target_os = "windows") && cfg!(target_arch = "x86_64") {
1101                            AssetKind::Zip
1102                        } else {
1103                            AssetKind::TarGz
1104                        },
1105                    )
1106                    .await?;
1107                }
1108
1109                let bin_name = if cfg!(windows) {
1110                    "codex-acp.exe"
1111                } else {
1112                    "codex-acp"
1113                };
1114                let bin_path = version_dir.join(bin_name);
1115                anyhow::ensure!(
1116                    fs.is_file(&bin_path).await,
1117                    "Missing Codex binary at {} after installation",
1118                    bin_path.to_string_lossy()
1119                );
1120
1121                let mut cmd = AgentServerCommand {
1122                    path: bin_path,
1123                    args: Vec::new(),
1124                    env: None,
1125                };
1126                cmd.env = Some(env);
1127                cmd
1128            };
1129
1130            command.env.get_or_insert_default().extend(extra_env);
1131            Ok((command, root_dir.to_string_lossy().into_owned(), None))
1132        })
1133    }
1134
1135    fn as_any_mut(&mut self) -> &mut dyn Any {
1136        self
1137    }
1138}
1139
1140pub const CODEX_ACP_REPO: &str = "zed-industries/codex-acp";
1141
1142/// Assemble Codex release URL for the current OS/arch and the given version number.
1143/// Returns None if the current target is unsupported.
1144/// Example output:
1145/// https://github.com/zed-industries/codex-acp/releases/download/v{version}/codex-acp-{version}-{arch}-{platform}.{ext}
1146fn asset_name(version: &str) -> Option<String> {
1147    let arch = if cfg!(target_arch = "x86_64") {
1148        "x86_64"
1149    } else if cfg!(target_arch = "aarch64") {
1150        "aarch64"
1151    } else {
1152        return None;
1153    };
1154
1155    let platform = if cfg!(target_os = "macos") {
1156        "apple-darwin"
1157    } else if cfg!(target_os = "windows") {
1158        "pc-windows-msvc"
1159    } else if cfg!(target_os = "linux") {
1160        "unknown-linux-gnu"
1161    } else {
1162        return None;
1163    };
1164
1165    // Only Windows x86_64 uses .zip in release assets
1166    let ext = if cfg!(target_os = "windows") && cfg!(target_arch = "x86_64") {
1167        "zip"
1168    } else {
1169        "tar.gz"
1170    };
1171
1172    Some(format!("codex-acp-{version}-{arch}-{platform}.{ext}"))
1173}
1174
1175struct LocalCustomAgent {
1176    project_environment: Entity<ProjectEnvironment>,
1177    command: AgentServerCommand,
1178}
1179
1180impl ExternalAgentServer for LocalCustomAgent {
1181    fn get_command(
1182        &mut self,
1183        root_dir: Option<&str>,
1184        extra_env: HashMap<String, String>,
1185        _status_tx: Option<watch::Sender<SharedString>>,
1186        _new_version_available_tx: Option<watch::Sender<Option<String>>>,
1187        cx: &mut AsyncApp,
1188    ) -> Task<Result<(AgentServerCommand, String, Option<task::SpawnInTerminal>)>> {
1189        let mut command = self.command.clone();
1190        let root_dir: Arc<Path> = root_dir
1191            .map(|root_dir| Path::new(root_dir))
1192            .unwrap_or(paths::home_dir())
1193            .into();
1194        let project_environment = self.project_environment.downgrade();
1195        cx.spawn(async move |cx| {
1196            let mut env = project_environment
1197                .update(cx, |project_environment, cx| {
1198                    project_environment.get_local_directory_environment(
1199                        &Shell::System,
1200                        root_dir.clone(),
1201                        cx,
1202                    )
1203                })?
1204                .await
1205                .unwrap_or_default();
1206            env.extend(command.env.unwrap_or_default());
1207            env.extend(extra_env);
1208            command.env = Some(env);
1209            Ok((command, root_dir.to_string_lossy().into_owned(), None))
1210        })
1211    }
1212
1213    fn as_any_mut(&mut self) -> &mut dyn Any {
1214        self
1215    }
1216}
1217
1218#[cfg(test)]
1219mod tests {
1220    #[test]
1221    fn assembles_codex_release_url_for_current_target() {
1222        let version_number = "0.1.0";
1223
1224        // This test fails the build if we are building a version of Zed
1225        // which does not have a known build of codex-acp, to prevent us
1226        // from accidentally doing a release on a new target without
1227        // realizing that codex-acp support will not work on that target!
1228        //
1229        // Additionally, it verifies that our logic for assembling URLs
1230        // correctly resolves to a known-good URL on each of our targets.
1231        let allowed = [
1232            "codex-acp-0.1.0-aarch64-apple-darwin.tar.gz",
1233            "codex-acp-0.1.0-aarch64-pc-windows-msvc.tar.gz",
1234            "codex-acp-0.1.0-aarch64-unknown-linux-gnu.tar.gz",
1235            "codex-acp-0.1.0-x86_64-apple-darwin.tar.gz",
1236            "codex-acp-0.1.0-x86_64-pc-windows-msvc.zip",
1237            "codex-acp-0.1.0-x86_64-unknown-linux-gnu.tar.gz",
1238        ];
1239
1240        if let Some(url) = super::asset_name(version_number) {
1241            assert!(
1242                allowed.contains(&url.as_str()),
1243                "Assembled asset name {} not in allowed list",
1244                url
1245            );
1246        } else {
1247            panic!(
1248                "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."
1249            );
1250        }
1251    }
1252}
1253
1254pub const GEMINI_NAME: &'static str = "gemini";
1255pub const CLAUDE_CODE_NAME: &'static str = "claude";
1256pub const CODEX_NAME: &'static str = "codex";
1257
1258#[derive(Default, Clone, JsonSchema, Debug, PartialEq)]
1259pub struct AllAgentServersSettings {
1260    pub gemini: Option<BuiltinAgentServerSettings>,
1261    pub claude: Option<BuiltinAgentServerSettings>,
1262    pub codex: Option<BuiltinAgentServerSettings>,
1263    pub custom: HashMap<SharedString, CustomAgentServerSettings>,
1264}
1265#[derive(Default, Clone, JsonSchema, Debug, PartialEq)]
1266pub struct BuiltinAgentServerSettings {
1267    pub path: Option<PathBuf>,
1268    pub args: Option<Vec<String>>,
1269    pub env: Option<HashMap<String, String>>,
1270    pub ignore_system_version: Option<bool>,
1271    pub default_mode: Option<String>,
1272}
1273
1274impl BuiltinAgentServerSettings {
1275    pub(crate) fn custom_command(self) -> Option<AgentServerCommand> {
1276        self.path.map(|path| AgentServerCommand {
1277            path,
1278            args: self.args.unwrap_or_default(),
1279            env: self.env,
1280        })
1281    }
1282}
1283
1284impl From<settings::BuiltinAgentServerSettings> for BuiltinAgentServerSettings {
1285    fn from(value: settings::BuiltinAgentServerSettings) -> Self {
1286        BuiltinAgentServerSettings {
1287            path: value.path,
1288            args: value.args,
1289            env: value.env,
1290            ignore_system_version: value.ignore_system_version,
1291            default_mode: value.default_mode,
1292        }
1293    }
1294}
1295
1296impl From<AgentServerCommand> for BuiltinAgentServerSettings {
1297    fn from(value: AgentServerCommand) -> Self {
1298        BuiltinAgentServerSettings {
1299            path: Some(value.path),
1300            args: Some(value.args),
1301            env: value.env,
1302            ..Default::default()
1303        }
1304    }
1305}
1306
1307#[derive(Clone, JsonSchema, Debug, PartialEq)]
1308pub struct CustomAgentServerSettings {
1309    pub command: AgentServerCommand,
1310    /// The default mode to use for this agent.
1311    ///
1312    /// Note: Not only all agents support modes.
1313    ///
1314    /// Default: None
1315    pub default_mode: Option<String>,
1316}
1317
1318impl From<settings::CustomAgentServerSettings> for CustomAgentServerSettings {
1319    fn from(value: settings::CustomAgentServerSettings) -> Self {
1320        CustomAgentServerSettings {
1321            command: AgentServerCommand {
1322                path: value.path,
1323                args: value.args,
1324                env: value.env,
1325            },
1326            default_mode: value.default_mode,
1327        }
1328    }
1329}
1330
1331impl settings::Settings for AllAgentServersSettings {
1332    fn from_settings(content: &settings::SettingsContent) -> Self {
1333        let agent_settings = content.agent_servers.clone().unwrap();
1334        Self {
1335            gemini: agent_settings.gemini.map(Into::into),
1336            claude: agent_settings.claude.map(Into::into),
1337            codex: agent_settings.codex.map(Into::into),
1338            custom: agent_settings
1339                .custom
1340                .into_iter()
1341                .map(|(k, v)| (k, v.into()))
1342                .collect(),
1343        }
1344    }
1345}