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    App, AppContext as _, AsyncApp, Context, Entity, EventEmitter, SharedString, Subscription, Task,
  16};
  17use node_runtime::NodeRuntime;
  18use remote::RemoteClient;
  19use rpc::{
  20    AnyProtoClient, TypedEnvelope,
  21    proto::{self, ToProto},
  22};
  23use schemars::JsonSchema;
  24use serde::{Deserialize, Serialize};
  25use settings::{SettingsKey, SettingsSources, SettingsStore, SettingsUi};
  26use util::{ResultExt as _, debug_panic};
  27
  28use crate::ProjectEnvironment;
  29
  30#[derive(Deserialize, Serialize, Clone, PartialEq, Eq, JsonSchema)]
  31pub struct AgentServerCommand {
  32    #[serde(rename = "command")]
  33    pub path: PathBuf,
  34    #[serde(default)]
  35    pub args: Vec<String>,
  36    pub env: Option<HashMap<String, String>>,
  37}
  38
  39impl std::fmt::Debug for AgentServerCommand {
  40    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
  41        let filtered_env = self.env.as_ref().map(|env| {
  42            env.iter()
  43                .map(|(k, v)| {
  44                    (
  45                        k,
  46                        if util::redact::should_redact(k) {
  47                            "[REDACTED]"
  48                        } else {
  49                            v
  50                        },
  51                    )
  52                })
  53                .collect::<Vec<_>>()
  54        });
  55
  56        f.debug_struct("AgentServerCommand")
  57            .field("path", &self.path)
  58            .field("args", &self.args)
  59            .field("env", &filtered_env)
  60            .finish()
  61    }
  62}
  63
  64#[derive(Clone, Debug, PartialEq, Eq, Hash)]
  65pub struct ExternalAgentServerName(pub SharedString);
  66
  67impl std::fmt::Display for ExternalAgentServerName {
  68    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
  69        write!(f, "{}", self.0)
  70    }
  71}
  72
  73impl From<&'static str> for ExternalAgentServerName {
  74    fn from(value: &'static str) -> Self {
  75        ExternalAgentServerName(value.into())
  76    }
  77}
  78
  79impl From<ExternalAgentServerName> for SharedString {
  80    fn from(value: ExternalAgentServerName) -> Self {
  81        value.0
  82    }
  83}
  84
  85impl Borrow<str> for ExternalAgentServerName {
  86    fn borrow(&self) -> &str {
  87        &self.0
  88    }
  89}
  90
  91pub trait ExternalAgentServer {
  92    fn get_command(
  93        &mut self,
  94        root_dir: Option<&str>,
  95        extra_env: HashMap<String, String>,
  96        status_tx: Option<watch::Sender<SharedString>>,
  97        new_version_available_tx: Option<watch::Sender<Option<String>>>,
  98        cx: &mut AsyncApp,
  99    ) -> Task<Result<(AgentServerCommand, String, Option<task::SpawnInTerminal>)>>;
 100
 101    fn as_any_mut(&mut self) -> &mut dyn Any;
 102}
 103
 104impl dyn ExternalAgentServer {
 105    fn downcast_mut<T: ExternalAgentServer + 'static>(&mut self) -> Option<&mut T> {
 106        self.as_any_mut().downcast_mut()
 107    }
 108}
 109
 110enum AgentServerStoreState {
 111    Local {
 112        node_runtime: NodeRuntime,
 113        fs: Arc<dyn Fs>,
 114        project_environment: Entity<ProjectEnvironment>,
 115        downstream_client: Option<(u64, AnyProtoClient)>,
 116        settings: Option<AllAgentServersSettings>,
 117        _subscriptions: [Subscription; 1],
 118    },
 119    Remote {
 120        project_id: u64,
 121        upstream_client: Entity<RemoteClient>,
 122    },
 123    Collab,
 124}
 125
 126pub struct AgentServerStore {
 127    state: AgentServerStoreState,
 128    external_agents: HashMap<ExternalAgentServerName, Box<dyn ExternalAgentServer>>,
 129}
 130
 131pub struct AgentServersUpdated;
 132
 133impl EventEmitter<AgentServersUpdated> for AgentServerStore {}
 134
 135impl AgentServerStore {
 136    pub fn init_remote(session: &AnyProtoClient) {
 137        session.add_entity_message_handler(Self::handle_external_agents_updated);
 138        session.add_entity_message_handler(Self::handle_loading_status_updated);
 139        session.add_entity_message_handler(Self::handle_new_version_available);
 140    }
 141
 142    pub fn init_headless(session: &AnyProtoClient) {
 143        session.add_entity_request_handler(Self::handle_get_agent_server_command);
 144    }
 145
 146    fn agent_servers_settings_changed(&mut self, cx: &mut Context<Self>) {
 147        let AgentServerStoreState::Local {
 148            node_runtime,
 149            fs,
 150            project_environment,
 151            downstream_client,
 152            settings: old_settings,
 153            ..
 154        } = &mut self.state
 155        else {
 156            debug_panic!(
 157                "should not be subscribed to agent server settings changes in non-local project"
 158            );
 159            return;
 160        };
 161
 162        let new_settings = cx
 163            .global::<SettingsStore>()
 164            .get::<AllAgentServersSettings>(None)
 165            .clone();
 166        if Some(&new_settings) == old_settings.as_ref() {
 167            return;
 168        }
 169
 170        self.external_agents.clear();
 171        self.external_agents.insert(
 172            GEMINI_NAME.into(),
 173            Box::new(LocalGemini {
 174                fs: fs.clone(),
 175                node_runtime: node_runtime.clone(),
 176                project_environment: project_environment.clone(),
 177                custom_command: new_settings
 178                    .gemini
 179                    .clone()
 180                    .and_then(|settings| settings.custom_command()),
 181                ignore_system_version: new_settings
 182                    .gemini
 183                    .as_ref()
 184                    .and_then(|settings| settings.ignore_system_version)
 185                    .unwrap_or(true),
 186            }),
 187        );
 188        self.external_agents.insert(
 189            CLAUDE_CODE_NAME.into(),
 190            Box::new(LocalClaudeCode {
 191                fs: fs.clone(),
 192                node_runtime: node_runtime.clone(),
 193                project_environment: project_environment.clone(),
 194                custom_command: new_settings
 195                    .claude
 196                    .clone()
 197                    .and_then(|settings| settings.custom_command()),
 198            }),
 199        );
 200        self.external_agents
 201            .extend(new_settings.custom.iter().map(|(name, settings)| {
 202                (
 203                    ExternalAgentServerName(name.clone()),
 204                    Box::new(LocalCustomAgent {
 205                        command: settings.command.clone(),
 206                        project_environment: project_environment.clone(),
 207                    }) as Box<dyn ExternalAgentServer>,
 208                )
 209            }));
 210
 211        *old_settings = Some(new_settings.clone());
 212
 213        if let Some((project_id, downstream_client)) = downstream_client {
 214            downstream_client
 215                .send(proto::ExternalAgentsUpdated {
 216                    project_id: *project_id,
 217                    names: self
 218                        .external_agents
 219                        .keys()
 220                        .map(|name| name.to_string())
 221                        .collect(),
 222                })
 223                .log_err();
 224        }
 225        cx.emit(AgentServersUpdated);
 226    }
 227
 228    pub fn local(
 229        node_runtime: NodeRuntime,
 230        fs: Arc<dyn Fs>,
 231        project_environment: Entity<ProjectEnvironment>,
 232        cx: &mut Context<Self>,
 233    ) -> Self {
 234        let subscription = cx.observe_global::<SettingsStore>(|this, cx| {
 235            this.agent_servers_settings_changed(cx);
 236        });
 237        let this = Self {
 238            state: AgentServerStoreState::Local {
 239                node_runtime,
 240                fs,
 241                project_environment,
 242                downstream_client: None,
 243                settings: None,
 244                _subscriptions: [subscription],
 245            },
 246            external_agents: Default::default(),
 247        };
 248        cx.spawn(async move |this, cx| {
 249            cx.background_executor().timer(Duration::from_secs(1)).await;
 250            this.update(cx, |this, cx| {
 251                this.agent_servers_settings_changed(cx);
 252            })
 253            .ok();
 254        })
 255        .detach();
 256        this
 257    }
 258
 259    pub(crate) fn remote(
 260        project_id: u64,
 261        upstream_client: Entity<RemoteClient>,
 262        _cx: &mut Context<Self>,
 263    ) -> Self {
 264        // Set up the builtin agents here so they're immediately available in
 265        // remote projects--we know that the HeadlessProject on the other end
 266        // will have them.
 267        let external_agents = [
 268            (
 269                GEMINI_NAME.into(),
 270                Box::new(RemoteExternalAgentServer {
 271                    project_id,
 272                    upstream_client: upstream_client.clone(),
 273                    name: GEMINI_NAME.into(),
 274                    status_tx: None,
 275                    new_version_available_tx: None,
 276                }) as Box<dyn ExternalAgentServer>,
 277            ),
 278            (
 279                CLAUDE_CODE_NAME.into(),
 280                Box::new(RemoteExternalAgentServer {
 281                    project_id,
 282                    upstream_client: upstream_client.clone(),
 283                    name: CLAUDE_CODE_NAME.into(),
 284                    status_tx: None,
 285                    new_version_available_tx: None,
 286                }) as Box<dyn ExternalAgentServer>,
 287            ),
 288        ]
 289        .into_iter()
 290        .collect();
 291
 292        Self {
 293            state: AgentServerStoreState::Remote {
 294                project_id,
 295                upstream_client,
 296            },
 297            external_agents,
 298        }
 299    }
 300
 301    pub(crate) fn collab(_cx: &mut Context<Self>) -> Self {
 302        Self {
 303            state: AgentServerStoreState::Collab,
 304            external_agents: Default::default(),
 305        }
 306    }
 307
 308    pub fn shared(&mut self, project_id: u64, client: AnyProtoClient) {
 309        match &mut self.state {
 310            AgentServerStoreState::Local {
 311                downstream_client, ..
 312            } => {
 313                client
 314                    .send(proto::ExternalAgentsUpdated {
 315                        project_id,
 316                        names: self
 317                            .external_agents
 318                            .keys()
 319                            .map(|name| name.to_string())
 320                            .collect(),
 321                    })
 322                    .log_err();
 323                *downstream_client = Some((project_id, client));
 324            }
 325            AgentServerStoreState::Remote { .. } => {
 326                debug_panic!(
 327                    "external agents over collab not implemented, remote project should not be shared"
 328                );
 329            }
 330            AgentServerStoreState::Collab => {
 331                debug_panic!("external agents over collab not implemented, should not be shared");
 332            }
 333        }
 334    }
 335
 336    pub fn get_external_agent(
 337        &mut self,
 338        name: &ExternalAgentServerName,
 339    ) -> Option<&mut (dyn ExternalAgentServer + 'static)> {
 340        self.external_agents
 341            .get_mut(name)
 342            .map(|agent| agent.as_mut())
 343    }
 344
 345    pub fn external_agents(&self) -> impl Iterator<Item = &ExternalAgentServerName> {
 346        self.external_agents.keys()
 347    }
 348
 349    async fn handle_get_agent_server_command(
 350        this: Entity<Self>,
 351        envelope: TypedEnvelope<proto::GetAgentServerCommand>,
 352        mut cx: AsyncApp,
 353    ) -> Result<proto::AgentServerCommand> {
 354        let (command, root_dir, login) = this
 355            .update(&mut cx, |this, cx| {
 356                let AgentServerStoreState::Local {
 357                    downstream_client, ..
 358                } = &this.state
 359                else {
 360                    debug_panic!("should not receive GetAgentServerCommand in a non-local project");
 361                    bail!("unexpected GetAgentServerCommand request in a non-local project");
 362                };
 363                let agent = this
 364                    .external_agents
 365                    .get_mut(&*envelope.payload.name)
 366                    .with_context(|| format!("agent `{}` not found", envelope.payload.name))?;
 367                let (status_tx, new_version_available_tx) = downstream_client
 368                    .clone()
 369                    .map(|(project_id, downstream_client)| {
 370                        let (status_tx, mut status_rx) = watch::channel(SharedString::from(""));
 371                        let (new_version_available_tx, mut new_version_available_rx) =
 372                            watch::channel(None);
 373                        cx.spawn({
 374                            let downstream_client = downstream_client.clone();
 375                            let name = envelope.payload.name.clone();
 376                            async move |_, _| {
 377                                while let Some(status) = status_rx.recv().await.ok() {
 378                                    downstream_client.send(
 379                                        proto::ExternalAgentLoadingStatusUpdated {
 380                                            project_id,
 381                                            name: name.clone(),
 382                                            status: status.to_string(),
 383                                        },
 384                                    )?;
 385                                }
 386                                anyhow::Ok(())
 387                            }
 388                        })
 389                        .detach_and_log_err(cx);
 390                        cx.spawn({
 391                            let name = envelope.payload.name.clone();
 392                            async move |_, _| {
 393                                if let Some(version) =
 394                                    new_version_available_rx.recv().await.ok().flatten()
 395                                {
 396                                    downstream_client.send(
 397                                        proto::NewExternalAgentVersionAvailable {
 398                                            project_id,
 399                                            name: name.clone(),
 400                                            version,
 401                                        },
 402                                    )?;
 403                                }
 404                                anyhow::Ok(())
 405                            }
 406                        })
 407                        .detach_and_log_err(cx);
 408                        (status_tx, new_version_available_tx)
 409                    })
 410                    .unzip();
 411                anyhow::Ok(agent.get_command(
 412                    envelope.payload.root_dir.as_deref(),
 413                    HashMap::default(),
 414                    status_tx,
 415                    new_version_available_tx,
 416                    &mut cx.to_async(),
 417                ))
 418            })??
 419            .await?;
 420        Ok(proto::AgentServerCommand {
 421            path: command.path.to_string_lossy().to_string(),
 422            args: command.args,
 423            env: command
 424                .env
 425                .map(|env| env.into_iter().collect())
 426                .unwrap_or_default(),
 427            root_dir: root_dir,
 428            login: login.map(|login| login.to_proto()),
 429        })
 430    }
 431
 432    async fn handle_external_agents_updated(
 433        this: Entity<Self>,
 434        envelope: TypedEnvelope<proto::ExternalAgentsUpdated>,
 435        mut cx: AsyncApp,
 436    ) -> Result<()> {
 437        this.update(&mut cx, |this, cx| {
 438            let AgentServerStoreState::Remote {
 439                project_id,
 440                upstream_client,
 441            } = &this.state
 442            else {
 443                debug_panic!(
 444                    "handle_external_agents_updated should not be called for a non-remote project"
 445                );
 446                bail!("unexpected ExternalAgentsUpdated message")
 447            };
 448
 449            let mut status_txs = this
 450                .external_agents
 451                .iter_mut()
 452                .filter_map(|(name, agent)| {
 453                    Some((
 454                        name.clone(),
 455                        agent
 456                            .downcast_mut::<RemoteExternalAgentServer>()?
 457                            .status_tx
 458                            .take(),
 459                    ))
 460                })
 461                .collect::<HashMap<_, _>>();
 462            let mut new_version_available_txs = this
 463                .external_agents
 464                .iter_mut()
 465                .filter_map(|(name, agent)| {
 466                    Some((
 467                        name.clone(),
 468                        agent
 469                            .downcast_mut::<RemoteExternalAgentServer>()?
 470                            .new_version_available_tx
 471                            .take(),
 472                    ))
 473                })
 474                .collect::<HashMap<_, _>>();
 475
 476            this.external_agents = envelope
 477                .payload
 478                .names
 479                .into_iter()
 480                .map(|name| {
 481                    let agent = RemoteExternalAgentServer {
 482                        project_id: *project_id,
 483                        upstream_client: upstream_client.clone(),
 484                        name: ExternalAgentServerName(name.clone().into()),
 485                        status_tx: status_txs.remove(&*name).flatten(),
 486                        new_version_available_tx: new_version_available_txs
 487                            .remove(&*name)
 488                            .flatten(),
 489                    };
 490                    (
 491                        ExternalAgentServerName(name.into()),
 492                        Box::new(agent) as Box<dyn ExternalAgentServer>,
 493                    )
 494                })
 495                .collect();
 496            cx.emit(AgentServersUpdated);
 497            Ok(())
 498        })?
 499    }
 500
 501    async fn handle_loading_status_updated(
 502        this: Entity<Self>,
 503        envelope: TypedEnvelope<proto::ExternalAgentLoadingStatusUpdated>,
 504        mut cx: AsyncApp,
 505    ) -> Result<()> {
 506        this.update(&mut cx, |this, _| {
 507            if let Some(agent) = this.external_agents.get_mut(&*envelope.payload.name)
 508                && let Some(agent) = agent.downcast_mut::<RemoteExternalAgentServer>()
 509                && let Some(status_tx) = &mut agent.status_tx
 510            {
 511                status_tx.send(envelope.payload.status.into()).ok();
 512            }
 513        })
 514    }
 515
 516    async fn handle_new_version_available(
 517        this: Entity<Self>,
 518        envelope: TypedEnvelope<proto::NewExternalAgentVersionAvailable>,
 519        mut cx: AsyncApp,
 520    ) -> Result<()> {
 521        this.update(&mut cx, |this, _| {
 522            if let Some(agent) = this.external_agents.get_mut(&*envelope.payload.name)
 523                && let Some(agent) = agent.downcast_mut::<RemoteExternalAgentServer>()
 524                && let Some(new_version_available_tx) = &mut agent.new_version_available_tx
 525            {
 526                new_version_available_tx
 527                    .send(Some(envelope.payload.version))
 528                    .ok();
 529            }
 530        })
 531    }
 532}
 533
 534fn get_or_npm_install_builtin_agent(
 535    binary_name: SharedString,
 536    package_name: SharedString,
 537    entrypoint_path: PathBuf,
 538    minimum_version: Option<semver::Version>,
 539    status_tx: Option<watch::Sender<SharedString>>,
 540    new_version_available: Option<watch::Sender<Option<String>>>,
 541    fs: Arc<dyn Fs>,
 542    node_runtime: NodeRuntime,
 543    cx: &mut AsyncApp,
 544) -> Task<std::result::Result<AgentServerCommand, anyhow::Error>> {
 545    cx.spawn(async move |cx| {
 546        let node_path = node_runtime.binary_path().await?;
 547        let dir = paths::data_dir()
 548            .join("external_agents")
 549            .join(binary_name.as_str());
 550        fs.create_dir(&dir).await?;
 551
 552        let mut stream = fs.read_dir(&dir).await?;
 553        let mut versions = Vec::new();
 554        let mut to_delete = Vec::new();
 555        while let Some(entry) = stream.next().await {
 556            let Ok(entry) = entry else { continue };
 557            let Some(file_name) = entry.file_name() else {
 558                continue;
 559            };
 560
 561            if let Some(name) = file_name.to_str()
 562                && let Some(version) = semver::Version::from_str(name).ok()
 563                && fs
 564                    .is_file(&dir.join(file_name).join(&entrypoint_path))
 565                    .await
 566            {
 567                versions.push((version, file_name.to_owned()));
 568            } else {
 569                to_delete.push(file_name.to_owned())
 570            }
 571        }
 572
 573        versions.sort();
 574        let newest_version = if let Some((version, file_name)) = versions.last().cloned()
 575            && minimum_version.is_none_or(|minimum_version| version >= minimum_version)
 576        {
 577            versions.pop();
 578            Some(file_name)
 579        } else {
 580            None
 581        };
 582        log::debug!("existing version of {package_name}: {newest_version:?}");
 583        to_delete.extend(versions.into_iter().map(|(_, file_name)| file_name));
 584
 585        cx.background_spawn({
 586            let fs = fs.clone();
 587            let dir = dir.clone();
 588            async move {
 589                for file_name in to_delete {
 590                    fs.remove_dir(
 591                        &dir.join(file_name),
 592                        RemoveOptions {
 593                            recursive: true,
 594                            ignore_if_not_exists: false,
 595                        },
 596                    )
 597                    .await
 598                    .ok();
 599                }
 600            }
 601        })
 602        .detach();
 603
 604        let version = if let Some(file_name) = newest_version {
 605            cx.background_spawn({
 606                let file_name = file_name.clone();
 607                let dir = dir.clone();
 608                let fs = fs.clone();
 609                async move {
 610                    let latest_version =
 611                        node_runtime.npm_package_latest_version(&package_name).await;
 612                    if let Ok(latest_version) = latest_version
 613                        && &latest_version != &file_name.to_string_lossy()
 614                    {
 615                        download_latest_version(
 616                            fs,
 617                            dir.clone(),
 618                            node_runtime,
 619                            package_name.clone(),
 620                        )
 621                        .await
 622                        .log_err();
 623                        if let Some(mut new_version_available) = new_version_available {
 624                            new_version_available.send(Some(latest_version)).ok();
 625                        }
 626                    }
 627                }
 628            })
 629            .detach();
 630            file_name
 631        } else {
 632            if let Some(mut status_tx) = status_tx {
 633                status_tx.send("Installing…".into()).ok();
 634            }
 635            let dir = dir.clone();
 636            cx.background_spawn(download_latest_version(
 637                fs.clone(),
 638                dir.clone(),
 639                node_runtime,
 640                package_name.clone(),
 641            ))
 642            .await?
 643            .into()
 644        };
 645
 646        let agent_server_path = dir.join(version).join(entrypoint_path);
 647        let agent_server_path_exists = fs.is_file(&agent_server_path).await;
 648        anyhow::ensure!(
 649            agent_server_path_exists,
 650            "Missing entrypoint path {} after installation",
 651            agent_server_path.to_string_lossy()
 652        );
 653
 654        anyhow::Ok(AgentServerCommand {
 655            path: node_path,
 656            args: vec![agent_server_path.to_string_lossy().to_string()],
 657            env: None,
 658        })
 659    })
 660}
 661
 662fn find_bin_in_path(
 663    bin_name: SharedString,
 664    root_dir: PathBuf,
 665    env: HashMap<String, String>,
 666    cx: &mut AsyncApp,
 667) -> Task<Option<PathBuf>> {
 668    cx.background_executor().spawn(async move {
 669        let which_result = if cfg!(windows) {
 670            which::which(bin_name.as_str())
 671        } else {
 672            let shell_path = env.get("PATH").cloned();
 673            which::which_in(bin_name.as_str(), shell_path.as_ref(), &root_dir)
 674        };
 675
 676        if let Err(which::Error::CannotFindBinaryPath) = which_result {
 677            return None;
 678        }
 679
 680        which_result.log_err()
 681    })
 682}
 683
 684async fn download_latest_version(
 685    fs: Arc<dyn Fs>,
 686    dir: PathBuf,
 687    node_runtime: NodeRuntime,
 688    package_name: SharedString,
 689) -> Result<String> {
 690    log::debug!("downloading latest version of {package_name}");
 691
 692    let tmp_dir = tempfile::tempdir_in(&dir)?;
 693
 694    node_runtime
 695        .npm_install_packages(tmp_dir.path(), &[(&package_name, "latest")])
 696        .await?;
 697
 698    let version = node_runtime
 699        .npm_package_installed_version(tmp_dir.path(), &package_name)
 700        .await?
 701        .context("expected package to be installed")?;
 702
 703    fs.rename(
 704        &tmp_dir.keep(),
 705        &dir.join(&version),
 706        RenameOptions {
 707            ignore_if_exists: true,
 708            overwrite: false,
 709        },
 710    )
 711    .await?;
 712
 713    anyhow::Ok(version)
 714}
 715
 716struct RemoteExternalAgentServer {
 717    project_id: u64,
 718    upstream_client: Entity<RemoteClient>,
 719    name: ExternalAgentServerName,
 720    status_tx: Option<watch::Sender<SharedString>>,
 721    new_version_available_tx: Option<watch::Sender<Option<String>>>,
 722}
 723
 724// new method: status_updated
 725// does nothing in the all-local case
 726// for RemoteExternalAgentServer, sends on the stored tx
 727// etc.
 728
 729impl ExternalAgentServer for RemoteExternalAgentServer {
 730    fn get_command(
 731        &mut self,
 732        root_dir: Option<&str>,
 733        extra_env: HashMap<String, String>,
 734        status_tx: Option<watch::Sender<SharedString>>,
 735        new_version_available_tx: Option<watch::Sender<Option<String>>>,
 736        cx: &mut AsyncApp,
 737    ) -> Task<Result<(AgentServerCommand, String, Option<task::SpawnInTerminal>)>> {
 738        let project_id = self.project_id;
 739        let name = self.name.to_string();
 740        let upstream_client = self.upstream_client.downgrade();
 741        let root_dir = root_dir.map(|root_dir| root_dir.to_owned());
 742        self.status_tx = status_tx;
 743        self.new_version_available_tx = new_version_available_tx;
 744        cx.spawn(async move |cx| {
 745            let mut response = upstream_client
 746                .update(cx, |upstream_client, _| {
 747                    upstream_client
 748                        .proto_client()
 749                        .request(proto::GetAgentServerCommand {
 750                            project_id,
 751                            name,
 752                            root_dir: root_dir.clone(),
 753                        })
 754                })?
 755                .await?;
 756            let root_dir = response.root_dir;
 757            response.env.extend(extra_env);
 758            let command = upstream_client.update(cx, |client, _| {
 759                client.build_command(
 760                    Some(response.path),
 761                    &response.args,
 762                    &response.env.into_iter().collect(),
 763                    Some(root_dir.clone()),
 764                    None,
 765                )
 766            })??;
 767            Ok((
 768                AgentServerCommand {
 769                    path: command.program.into(),
 770                    args: command.args,
 771                    env: Some(command.env),
 772                },
 773                root_dir,
 774                response
 775                    .login
 776                    .map(|login| task::SpawnInTerminal::from_proto(login)),
 777            ))
 778        })
 779    }
 780
 781    fn as_any_mut(&mut self) -> &mut dyn Any {
 782        self
 783    }
 784}
 785
 786struct LocalGemini {
 787    fs: Arc<dyn Fs>,
 788    node_runtime: NodeRuntime,
 789    project_environment: Entity<ProjectEnvironment>,
 790    custom_command: Option<AgentServerCommand>,
 791    ignore_system_version: bool,
 792}
 793
 794impl ExternalAgentServer for LocalGemini {
 795    fn get_command(
 796        &mut self,
 797        root_dir: Option<&str>,
 798        extra_env: HashMap<String, String>,
 799        status_tx: Option<watch::Sender<SharedString>>,
 800        new_version_available_tx: Option<watch::Sender<Option<String>>>,
 801        cx: &mut AsyncApp,
 802    ) -> Task<Result<(AgentServerCommand, String, Option<task::SpawnInTerminal>)>> {
 803        let fs = self.fs.clone();
 804        let node_runtime = self.node_runtime.clone();
 805        let project_environment = self.project_environment.downgrade();
 806        let custom_command = self.custom_command.clone();
 807        let ignore_system_version = self.ignore_system_version;
 808        let root_dir: Arc<Path> = root_dir
 809            .map(|root_dir| Path::new(root_dir))
 810            .unwrap_or(paths::home_dir())
 811            .into();
 812
 813        cx.spawn(async move |cx| {
 814            let mut env = project_environment
 815                .update(cx, |project_environment, cx| {
 816                    project_environment.get_directory_environment(root_dir.clone(), cx)
 817                })?
 818                .await
 819                .unwrap_or_default();
 820
 821            let mut command = if let Some(mut custom_command) = custom_command {
 822                env.extend(custom_command.env.unwrap_or_default());
 823                custom_command.env = Some(env);
 824                custom_command
 825            } else if !ignore_system_version
 826                && let Some(bin) =
 827                    find_bin_in_path("gemini".into(), root_dir.to_path_buf(), env.clone(), cx).await
 828            {
 829                AgentServerCommand {
 830                    path: bin,
 831                    args: Vec::new(),
 832                    env: Some(env),
 833                }
 834            } else {
 835                let mut command = get_or_npm_install_builtin_agent(
 836                    GEMINI_NAME.into(),
 837                    "@google/gemini-cli".into(),
 838                    "node_modules/@google/gemini-cli/dist/index.js".into(),
 839                    Some("0.2.1".parse().unwrap()),
 840                    status_tx,
 841                    new_version_available_tx,
 842                    fs,
 843                    node_runtime,
 844                    cx,
 845                )
 846                .await?;
 847                command.env = Some(env);
 848                command
 849            };
 850
 851            // Gemini CLI doesn't seem to have a dedicated invocation for logging in--we just run it normally without any arguments.
 852            let login = task::SpawnInTerminal {
 853                command: Some(command.path.clone().to_proto()),
 854                args: command.args.clone(),
 855                env: command.env.clone().unwrap_or_default(),
 856                label: "gemini /auth".into(),
 857                ..Default::default()
 858            };
 859
 860            command.env.get_or_insert_default().extend(extra_env);
 861            command.args.push("--experimental-acp".into());
 862            Ok((command, root_dir.to_proto(), Some(login)))
 863        })
 864    }
 865
 866    fn as_any_mut(&mut self) -> &mut dyn Any {
 867        self
 868    }
 869}
 870
 871struct LocalClaudeCode {
 872    fs: Arc<dyn Fs>,
 873    node_runtime: NodeRuntime,
 874    project_environment: Entity<ProjectEnvironment>,
 875    custom_command: Option<AgentServerCommand>,
 876}
 877
 878impl ExternalAgentServer for LocalClaudeCode {
 879    fn get_command(
 880        &mut self,
 881        root_dir: Option<&str>,
 882        extra_env: HashMap<String, String>,
 883        status_tx: Option<watch::Sender<SharedString>>,
 884        new_version_available_tx: Option<watch::Sender<Option<String>>>,
 885        cx: &mut AsyncApp,
 886    ) -> Task<Result<(AgentServerCommand, String, Option<task::SpawnInTerminal>)>> {
 887        let fs = self.fs.clone();
 888        let node_runtime = self.node_runtime.clone();
 889        let project_environment = self.project_environment.downgrade();
 890        let custom_command = self.custom_command.clone();
 891        let root_dir: Arc<Path> = root_dir
 892            .map(|root_dir| Path::new(root_dir))
 893            .unwrap_or(paths::home_dir())
 894            .into();
 895
 896        cx.spawn(async move |cx| {
 897            let mut env = project_environment
 898                .update(cx, |project_environment, cx| {
 899                    project_environment.get_directory_environment(root_dir.clone(), cx)
 900                })?
 901                .await
 902                .unwrap_or_default();
 903            env.insert("ANTHROPIC_API_KEY".into(), "".into());
 904
 905            let (mut command, login) = if let Some(mut custom_command) = custom_command {
 906                env.extend(custom_command.env.unwrap_or_default());
 907                custom_command.env = Some(env);
 908                (custom_command, None)
 909            } else {
 910                let mut command = get_or_npm_install_builtin_agent(
 911                    "claude-code-acp".into(),
 912                    "@zed-industries/claude-code-acp".into(),
 913                    "node_modules/@zed-industries/claude-code-acp/dist/index.js".into(),
 914                    Some("0.2.5".parse().unwrap()),
 915                    status_tx,
 916                    new_version_available_tx,
 917                    fs,
 918                    node_runtime,
 919                    cx,
 920                )
 921                .await?;
 922                command.env = Some(env);
 923                let login = command
 924                    .args
 925                    .first()
 926                    .and_then(|path| {
 927                        path.strip_suffix("/@zed-industries/claude-code-acp/dist/index.js")
 928                    })
 929                    .map(|path_prefix| task::SpawnInTerminal {
 930                        command: Some(command.path.clone().to_proto()),
 931                        args: vec![
 932                            Path::new(path_prefix)
 933                                .join("@anthropic-ai/claude-code/cli.js")
 934                                .to_string_lossy()
 935                                .to_string(),
 936                            "/login".into(),
 937                        ],
 938                        env: command.env.clone().unwrap_or_default(),
 939                        label: "claude /login".into(),
 940                        ..Default::default()
 941                    });
 942                (command, login)
 943            };
 944
 945            command.env.get_or_insert_default().extend(extra_env);
 946            Ok((command, root_dir.to_proto(), login))
 947        })
 948    }
 949
 950    fn as_any_mut(&mut self) -> &mut dyn Any {
 951        self
 952    }
 953}
 954
 955struct LocalCustomAgent {
 956    project_environment: Entity<ProjectEnvironment>,
 957    command: AgentServerCommand,
 958}
 959
 960impl ExternalAgentServer for LocalCustomAgent {
 961    fn get_command(
 962        &mut self,
 963        root_dir: Option<&str>,
 964        extra_env: HashMap<String, String>,
 965        _status_tx: Option<watch::Sender<SharedString>>,
 966        _new_version_available_tx: Option<watch::Sender<Option<String>>>,
 967        cx: &mut AsyncApp,
 968    ) -> Task<Result<(AgentServerCommand, String, Option<task::SpawnInTerminal>)>> {
 969        let mut command = self.command.clone();
 970        let root_dir: Arc<Path> = root_dir
 971            .map(|root_dir| Path::new(root_dir))
 972            .unwrap_or(paths::home_dir())
 973            .into();
 974        let project_environment = self.project_environment.downgrade();
 975        cx.spawn(async move |cx| {
 976            let mut env = project_environment
 977                .update(cx, |project_environment, cx| {
 978                    project_environment.get_directory_environment(root_dir.clone(), cx)
 979                })?
 980                .await
 981                .unwrap_or_default();
 982            env.extend(command.env.unwrap_or_default());
 983            env.extend(extra_env);
 984            command.env = Some(env);
 985            Ok((command, root_dir.to_proto(), None))
 986        })
 987    }
 988
 989    fn as_any_mut(&mut self) -> &mut dyn Any {
 990        self
 991    }
 992}
 993
 994pub const GEMINI_NAME: &'static str = "gemini";
 995pub const CLAUDE_CODE_NAME: &'static str = "claude";
 996
 997#[derive(
 998    Default, Deserialize, Serialize, Clone, JsonSchema, Debug, SettingsUi, SettingsKey, PartialEq,
 999)]
1000#[settings_key(key = "agent_servers")]
1001pub struct AllAgentServersSettings {
1002    pub gemini: Option<BuiltinAgentServerSettings>,
1003    pub claude: Option<BuiltinAgentServerSettings>,
1004
1005    /// Custom agent servers configured by the user
1006    #[serde(flatten)]
1007    pub custom: HashMap<SharedString, CustomAgentServerSettings>,
1008}
1009
1010#[derive(Default, Deserialize, Serialize, Clone, JsonSchema, Debug, PartialEq)]
1011pub struct BuiltinAgentServerSettings {
1012    /// Absolute path to a binary to be used when launching this agent.
1013    ///
1014    /// This can be used to run a specific binary without automatic downloads or searching `$PATH`.
1015    #[serde(rename = "command")]
1016    pub path: Option<PathBuf>,
1017    /// If a binary is specified in `command`, it will be passed these arguments.
1018    pub args: Option<Vec<String>>,
1019    /// If a binary is specified in `command`, it will be passed these environment variables.
1020    pub env: Option<HashMap<String, String>>,
1021    /// Whether to skip searching `$PATH` for an agent server binary when
1022    /// launching this agent.
1023    ///
1024    /// This has no effect if a `command` is specified. Otherwise, when this is
1025    /// `false`, Zed will search `$PATH` for an agent server binary and, if one
1026    /// is found, use it for threads with this agent. If no agent binary is
1027    /// found on `$PATH`, Zed will automatically install and use its own binary.
1028    /// When this is `true`, Zed will not search `$PATH`, and will always use
1029    /// its own binary.
1030    ///
1031    /// Default: true
1032    pub ignore_system_version: Option<bool>,
1033    /// The default mode to use for this agent.
1034    ///
1035    /// Note: Not only all agents support modes.
1036    ///
1037    /// Default: None
1038    pub default_mode: Option<String>,
1039}
1040
1041impl BuiltinAgentServerSettings {
1042    pub(crate) fn custom_command(self) -> Option<AgentServerCommand> {
1043        self.path.map(|path| AgentServerCommand {
1044            path,
1045            args: self.args.unwrap_or_default(),
1046            env: self.env,
1047        })
1048    }
1049}
1050
1051impl From<AgentServerCommand> for BuiltinAgentServerSettings {
1052    fn from(value: AgentServerCommand) -> Self {
1053        BuiltinAgentServerSettings {
1054            path: Some(value.path),
1055            args: Some(value.args),
1056            env: value.env,
1057            ..Default::default()
1058        }
1059    }
1060}
1061
1062#[derive(Deserialize, Serialize, Clone, JsonSchema, Debug, PartialEq)]
1063pub struct CustomAgentServerSettings {
1064    #[serde(flatten)]
1065    pub command: AgentServerCommand,
1066    /// The default mode to use for this agent.
1067    ///
1068    /// Note: Not only all agents support modes.
1069    ///
1070    /// Default: None
1071    pub default_mode: Option<String>,
1072}
1073
1074impl settings::Settings for AllAgentServersSettings {
1075    type FileContent = Self;
1076
1077    fn load(sources: SettingsSources<Self::FileContent>, _: &mut App) -> Result<Self> {
1078        let mut settings = AllAgentServersSettings::default();
1079
1080        for AllAgentServersSettings {
1081            gemini,
1082            claude,
1083            custom,
1084        } in sources.defaults_and_customizations()
1085        {
1086            if gemini.is_some() {
1087                settings.gemini = gemini.clone();
1088            }
1089            if claude.is_some() {
1090                settings.claude = claude.clone();
1091            }
1092
1093            // Merge custom agents
1094            for (name, config) in custom {
1095                // Skip built-in agent names to avoid conflicts
1096                if name != GEMINI_NAME && name != CLAUDE_CODE_NAME {
1097                    settings.custom.insert(name.clone(), config.clone());
1098                }
1099            }
1100        }
1101
1102        Ok(settings)
1103    }
1104
1105    fn import_from_vscode(_vscode: &settings::VsCodeSettings, _current: &mut Self::FileContent) {}
1106}