acp: Allow running NPM agents from registry (#47291)

Ben Brandt created

Release Notes:

- N/A

Change summary

crates/agent_ui/src/agent_registry_ui.rs   |  41 ++-
crates/node_runtime/src/node_runtime.rs    | 252 ++++++++++++++++-------
crates/project/src/agent_registry_store.rs | 156 +++++++++++--
crates/project/src/agent_server_store.rs   | 154 ++++++++++++--
4 files changed, 447 insertions(+), 156 deletions(-)

Detailed changes

crates/agent_ui/src/agent_registry_ui.rs 🔗

@@ -133,10 +133,10 @@ impl AgentRegistryPage {
     fn reload_registry_agents(&mut self, cx: &mut Context<Self>) {
         self.registry_agents = self.registry_store.read(cx).agents().to_vec();
         self.registry_agents.sort_by(|left, right| {
-            left.name
+            left.name()
                 .as_ref()
-                .cmp(right.name.as_ref())
-                .then_with(|| left.id.as_ref().cmp(right.id.as_ref()))
+                .cmp(right.name().as_ref())
+                .then_with(|| left.id().as_ref().cmp(right.id().as_ref()))
         });
         self.filter_registry_agents(cx);
     }
@@ -189,19 +189,19 @@ impl AgentRegistryPage {
             .filter(|(_, agent)| {
                 // Filter out built-in agents since they already appear in the main
                 // agent configuration UI and don't need to be installed from the registry.
-                if BUILT_IN_REGISTRY_IDS.contains(&agent.id.as_ref()) {
+                if BUILT_IN_REGISTRY_IDS.contains(&agent.id().as_ref()) {
                     return false;
                 }
 
                 let matches_search = search.as_ref().is_none_or(|query| {
                     let query = query.as_str();
-                    agent.id.as_ref().to_lowercase().contains(query)
-                        || agent.name.as_ref().to_lowercase().contains(query)
-                        || agent.description.as_ref().to_lowercase().contains(query)
+                    agent.id().as_ref().to_lowercase().contains(query)
+                        || agent.name().as_ref().to_lowercase().contains(query)
+                        || agent.description().as_ref().to_lowercase().contains(query)
                 });
 
                 let install_status = installed_statuses
-                    .get(agent.id.as_ref())
+                    .get(agent.id().as_ref())
                     .copied()
                     .unwrap_or(RegistryInstallStatus::NotInstalled);
                 let matches_filter = match filter {
@@ -371,10 +371,10 @@ impl AgentRegistryPage {
         agent: &RegistryAgent,
         cx: &mut Context<Self>,
     ) -> AgentRegistryCard {
-        let install_status = self.install_status(agent.id.as_ref());
-        let supports_current_platform = agent.supports_current_platform;
+        let install_status = self.install_status(agent.id().as_ref());
+        let supports_current_platform = agent.supports_current_platform();
 
-        let icon = match agent.icon_path.as_ref() {
+        let icon = match agent.icon_path() {
             Some(icon_path) => Icon::from_external_svg(icon_path.clone()),
             None => Icon::new(IconName::Sparkle),
         }
@@ -384,10 +384,10 @@ impl AgentRegistryPage {
         let install_button =
             self.install_button(agent, install_status, supports_current_platform, cx);
 
-        let repository_button = agent.repository.as_ref().map(|repository| {
+        let repository_button = agent.repository().map(|repository| {
             let repository = repository.clone();
             IconButton::new(
-                SharedString::from(format!("agent-repo-{}", agent.id)),
+                SharedString::from(format!("agent-repo-{}", agent.id())),
                 IconName::Link,
             )
             .icon_size(IconSize::Small)
@@ -409,10 +409,11 @@ impl AgentRegistryPage {
                                     .gap_2()
                                     .items_end()
                                     .child(
-                                        Headline::new(agent.name.clone()).size(HeadlineSize::Small),
+                                        Headline::new(agent.name().clone())
+                                            .size(HeadlineSize::Small),
                                     )
                                     .child(
-                                        Headline::new(format!("v{}", agent.version))
+                                        Headline::new(format!("v{}", agent.version()))
                                             .size(HeadlineSize::XSmall),
                                     ),
                             ),
@@ -425,7 +426,7 @@ impl AgentRegistryPage {
                     .gap_2()
                     .justify_between()
                     .child(
-                        Label::new(agent.description.clone())
+                        Label::new(agent.description().clone())
                             .size(LabelSize::Small)
                             .color(Color::Default)
                             .truncate(),
@@ -437,7 +438,7 @@ impl AgentRegistryPage {
                     .gap_2()
                     .justify_between()
                     .child(
-                        Label::new(format!("ID: {}", agent.id))
+                        Label::new(format!("ID: {}", agent.id()))
                             .size(LabelSize::Small)
                             .color(Color::Muted)
                             .truncate(),
@@ -459,7 +460,7 @@ impl AgentRegistryPage {
         supports_current_platform: bool,
         cx: &mut Context<Self>,
     ) -> Button {
-        let button_id = SharedString::from(format!("install-agent-{}", agent.id));
+        let button_id = SharedString::from(format!("install-agent-{}", agent.id()));
 
         if !supports_current_platform {
             return Button::new(button_id, "Unavailable")
@@ -470,7 +471,7 @@ impl AgentRegistryPage {
         match install_status {
             RegistryInstallStatus::NotInstalled => {
                 let fs = <dyn Fs>::global(cx);
-                let agent_id = agent.id.to_string();
+                let agent_id = agent.id().to_string();
                 Button::new(button_id, "Install")
                     .style(ButtonStyle::Tinted(ui::TintColor::Accent))
                     .icon(IconName::Download)
@@ -496,7 +497,7 @@ impl AgentRegistryPage {
             }
             RegistryInstallStatus::InstalledRegistry => {
                 let fs = <dyn Fs>::global(cx);
-                let agent_id = agent.id.to_string();
+                let agent_id = agent.id().to_string();
                 Button::new(button_id, "Remove")
                     .style(ButtonStyle::OutlinedGhost)
                     .on_click(move |_, _, cx| {

crates/node_runtime/src/node_runtime.rs 🔗

@@ -8,6 +8,7 @@ use semver::Version;
 use serde::Deserialize;
 use smol::io::BufReader;
 use smol::{fs, lock::Mutex};
+use std::collections::HashMap;
 use std::fmt::Display;
 use std::{
     env::{self, consts},
@@ -30,6 +31,15 @@ pub struct NodeBinaryOptions {
     pub use_paths: Option<(PathBuf, PathBuf)>,
 }
 
+/// Use this when you need to launch npm as a long-lived process (for example, an agent server),
+/// so the invocation and environment stay consistent with the Node runtime's proxy and CA setup.
+#[derive(Clone, Debug)]
+pub struct NpmCommand {
+    pub path: PathBuf,
+    pub args: Vec<String>,
+    pub env: HashMap<String, String>,
+}
+
 pub enum VersionStrategy<'a> {
     /// Install if current version doesn't match pinned version
     Pin(&'a Version),
@@ -86,7 +96,7 @@ impl NodeRuntime {
                 Err(err) => {
                     return Box::new(UnavailableNodeRuntime {
                         error_message: err.to_string().into(),
-                    });
+                    }) as Box<dyn NodeRuntimeTrait>;
                 }
             }
         };
@@ -128,7 +138,7 @@ impl NodeRuntime {
                     log::info!("using Node.js found on PATH: {:?}", instance);
                     state.instance = Some(instance.boxed_clone());
                     state.last_options = Some(options);
-                    return Box::new(instance);
+                    return Box::new(instance) as Box<dyn NodeRuntimeTrait>;
                 }
                 Err(err) => Some(err),
             }
@@ -228,6 +238,14 @@ impl NodeRuntime {
             .await
     }
 
+    pub async fn npm_command(&self, subcommand: &str, args: &[&str]) -> Result<NpmCommand> {
+        let http = self.0.lock().await.http.clone();
+        self.instance()
+            .await
+            .npm_command(http.proxy(), subcommand, args)
+            .await
+    }
+
     pub async fn npm_package_latest_version(&self, name: &str) -> Result<Version> {
         let http = self.0.lock().await.http.clone();
         let output = self
@@ -352,6 +370,13 @@ trait NodeRuntimeTrait: Send + Sync {
         args: &[&str],
     ) -> Result<Output>;
 
+    async fn npm_command(
+        &self,
+        proxy: Option<&Url>,
+        subcommand: &str,
+        args: &[&str],
+    ) -> Result<NpmCommand>;
+
     async fn npm_package_installed_version(
         &self,
         local_package_directory: &Path,
@@ -528,40 +553,12 @@ impl NodeRuntimeTrait for ManagedNodeRuntime {
         subcommand: &str,
         args: &[&str],
     ) -> Result<Output> {
-        let attempt = || async move {
-            let node_binary = self.installation_path.join(Self::NODE_PATH);
-            let npm_file = self.installation_path.join(Self::NPM_PATH);
-            let env_path = path_with_node_binary_prepended(&node_binary).unwrap_or_default();
-
-            anyhow::ensure!(
-                smol::fs::metadata(&node_binary).await.is_ok(),
-                "missing node binary file"
-            );
-            anyhow::ensure!(
-                smol::fs::metadata(&npm_file).await.is_ok(),
-                "missing npm file"
-            );
-
-            let node_ca_certs = env::var(NODE_CA_CERTS_ENV_VAR).unwrap_or_else(|_| String::new());
-
-            let mut command = util::command::new_smol_command(node_binary);
-            command.env("PATH", env_path);
-            command.env(NODE_CA_CERTS_ENV_VAR, node_ca_certs);
-            command.arg(npm_file).arg(subcommand);
-            command.arg(format!(
-                "--cache={}",
-                self.installation_path.join("cache").display()
-            ));
-            command.args([
-                "--userconfig".into(),
-                self.installation_path.join("blank_user_npmrc"),
-            ]);
-            command.args([
-                "--globalconfig".into(),
-                self.installation_path.join("blank_global_npmrc"),
-            ]);
-            command.args(args);
-            configure_npm_command(&mut command, directory, proxy);
+        let attempt = || async {
+            let npm_command = self.npm_command(proxy, subcommand, args).await?;
+            let mut command = util::command::new_smol_command(npm_command.path);
+            command.args(npm_command.args);
+            command.envs(npm_command.env);
+            configure_npm_command(&mut command, directory);
             command.output().await.map_err(|e| anyhow!("{e}"))
         };
 
@@ -586,6 +583,43 @@ impl NodeRuntimeTrait for ManagedNodeRuntime {
 
         output.map_err(|e| anyhow!("{e}"))
     }
+
+    async fn npm_command(
+        &self,
+        proxy: Option<&Url>,
+        subcommand: &str,
+        args: &[&str],
+    ) -> Result<NpmCommand> {
+        let node_binary = self.installation_path.join(Self::NODE_PATH);
+        let npm_file = self.installation_path.join(Self::NPM_PATH);
+
+        anyhow::ensure!(
+            smol::fs::metadata(&node_binary).await.is_ok(),
+            "missing node binary file"
+        );
+        anyhow::ensure!(
+            smol::fs::metadata(&npm_file).await.is_ok(),
+            "missing npm file"
+        );
+
+        let command_args = build_npm_command_args(
+            Some(&npm_file),
+            &self.installation_path.join("cache"),
+            Some(&self.installation_path.join("blank_user_npmrc")),
+            Some(&self.installation_path.join("blank_global_npmrc")),
+            proxy,
+            subcommand,
+            args,
+        );
+        let command_env = npm_command_env(Some(&node_binary));
+
+        Ok(NpmCommand {
+            path: node_binary,
+            args: command_args,
+            env: command_env,
+        })
+    }
+
     async fn npm_package_installed_version(
         &self,
         local_package_directory: &Path,
@@ -688,19 +722,11 @@ impl NodeRuntimeTrait for SystemNodeRuntime {
         subcommand: &str,
         args: &[&str],
     ) -> anyhow::Result<Output> {
-        let node_ca_certs = env::var(NODE_CA_CERTS_ENV_VAR).unwrap_or_else(|_| String::new());
-        let mut command = util::command::new_smol_command(self.npm.clone());
-        let path = path_with_node_binary_prepended(&self.node).unwrap_or_default();
-        command
-            .env("PATH", path)
-            .env(NODE_CA_CERTS_ENV_VAR, node_ca_certs)
-            .arg(subcommand)
-            .arg(format!(
-                "--cache={}",
-                self.scratch_dir.join("cache").display()
-            ))
-            .args(args);
-        configure_npm_command(&mut command, directory, proxy);
+        let npm_command = self.npm_command(proxy, subcommand, args).await?;
+        let mut command = util::command::new_smol_command(npm_command.path);
+        command.args(npm_command.args);
+        command.envs(npm_command.env);
+        configure_npm_command(&mut command, directory);
         let output = command.output().await?;
         anyhow::ensure!(
             output.status.success(),
@@ -711,6 +737,30 @@ impl NodeRuntimeTrait for SystemNodeRuntime {
         Ok(output)
     }
 
+    async fn npm_command(
+        &self,
+        proxy: Option<&Url>,
+        subcommand: &str,
+        args: &[&str],
+    ) -> Result<NpmCommand> {
+        let command_args = build_npm_command_args(
+            None,
+            &self.scratch_dir.join("cache"),
+            None,
+            None,
+            proxy,
+            subcommand,
+            args,
+        );
+        let command_env = npm_command_env(Some(&self.node));
+
+        Ok(NpmCommand {
+            path: self.npm.clone(),
+            args: command_args,
+            env: command_env,
+        })
+    }
+
     async fn npm_package_installed_version(
         &self,
         local_package_directory: &Path,
@@ -773,6 +823,15 @@ impl NodeRuntimeTrait for UnavailableNodeRuntime {
         bail!("{}", self.error_message)
     }
 
+    async fn npm_command(
+        &self,
+        _proxy: Option<&Url>,
+        _subcommand: &str,
+        _args: &[&str],
+    ) -> Result<NpmCommand> {
+        bail!("{}", self.error_message)
+    }
+
     async fn npm_package_installed_version(
         &self,
         _local_package_directory: &Path,
@@ -782,59 +841,101 @@ impl NodeRuntimeTrait for UnavailableNodeRuntime {
     }
 }
 
-fn configure_npm_command(
-    command: &mut smol::process::Command,
-    directory: Option<&Path>,
-    proxy: Option<&Url>,
-) {
+fn configure_npm_command(command: &mut smol::process::Command, directory: Option<&Path>) {
     if let Some(directory) = directory {
         command.current_dir(directory);
         command.args(["--prefix".into(), directory.to_path_buf()]);
     }
+}
+
+fn proxy_argument(proxy: Option<&Url>) -> Option<String> {
+    let mut proxy = proxy.cloned()?;
+    // Map proxy settings from `http://localhost:10809` to `http://127.0.0.1:10809`
+    // NodeRuntime without environment information can not parse `localhost`
+    // correctly.
+    // TODO: map to `[::1]` if we are using ipv6
+    if matches!(proxy.host(), Some(Host::Domain(domain)) if domain.eq_ignore_ascii_case("localhost"))
+    {
+        // When localhost is a valid Host, so is `127.0.0.1`
+        let _ = proxy.set_ip_host(IpAddr::V4(Ipv4Addr::LOCALHOST));
+    }
 
-    if let Some(mut proxy) = proxy.cloned() {
-        // Map proxy settings from `http://localhost:10809` to `http://127.0.0.1:10809`
-        // NodeRuntime without environment information can not parse `localhost`
-        // correctly.
-        // TODO: map to `[::1]` if we are using ipv6
-        if matches!(proxy.host(), Some(Host::Domain(domain)) if domain.eq_ignore_ascii_case("localhost"))
-        {
-            // When localhost is a valid Host, so is `127.0.0.1`
-            let _ = proxy.set_ip_host(IpAddr::V4(Ipv4Addr::LOCALHOST));
-        }
+    Some(proxy.as_str().to_string())
+}
 
-        command.args(["--proxy", proxy.as_str()]);
+fn build_npm_command_args(
+    entrypoint: Option<&Path>,
+    cache_dir: &Path,
+    user_config: Option<&Path>,
+    global_config: Option<&Path>,
+    proxy: Option<&Url>,
+    subcommand: &str,
+    args: &[&str],
+) -> Vec<String> {
+    let mut command_args = Vec::new();
+    if let Some(entrypoint) = entrypoint {
+        command_args.push(entrypoint.to_string_lossy().into_owned());
+    }
+    command_args.push(subcommand.to_string());
+    command_args.push(format!("--cache={}", cache_dir.display()));
+    if let Some(user_config) = user_config {
+        command_args.push("--userconfig".into());
+        command_args.push(user_config.to_string_lossy().into_owned());
+    }
+    if let Some(global_config) = global_config {
+        command_args.push("--globalconfig".into());
+        command_args.push(global_config.to_string_lossy().into_owned());
+    }
+    if let Some(proxy_arg) = proxy_argument(proxy) {
+        command_args.push("--proxy".into());
+        command_args.push(proxy_arg);
+    }
+    command_args.extend(args.into_iter().map(|a| a.to_string()));
+    command_args
+}
+
+fn npm_command_env(node_binary: Option<&Path>) -> HashMap<String, String> {
+    let mut command_env = HashMap::new();
+    if let Some(node_binary) = node_binary {
+        let env_path = path_with_node_binary_prepended(node_binary).unwrap_or_default();
+        command_env.insert("PATH".into(), env_path.to_string_lossy().into_owned());
+    }
+
+    if let Ok(node_ca_certs) = env::var(NODE_CA_CERTS_ENV_VAR) {
+        if !node_ca_certs.is_empty() {
+            command_env.insert(NODE_CA_CERTS_ENV_VAR.to_string(), node_ca_certs);
+        }
     }
 
     #[cfg(windows)]
     {
-        // SYSTEMROOT is a critical environment variables for Windows.
         if let Some(val) = env::var("SYSTEMROOT")
             .context("Missing environment variable: SYSTEMROOT!")
             .log_err()
         {
-            command.env("SYSTEMROOT", val);
+            command_env.insert("SYSTEMROOT".into(), val);
         }
-        // Without ComSpec, the post-install will always fail.
         if let Some(val) = env::var("ComSpec")
             .context("Missing environment variable: ComSpec!")
             .log_err()
         {
-            command.env("ComSpec", val);
+            command_env.insert("ComSpec".into(), val);
         }
     }
+
+    command_env
 }
 
 #[cfg(test)]
 mod tests {
     use http_client::Url;
 
-    use super::configure_npm_command;
+    use super::proxy_argument;
 
     // Map localhost to 127.0.0.1
     // NodeRuntime without environment information can not parse `localhost` correctly.
     #[test]
-    fn test_configure_npm_command_map_localhost_proxy() {
+    fn test_proxy_argument_map_localhost_proxy() {
         const CASES: [(&str, &str); 4] = [
             // Map localhost to 127.0.0.1
             ("http://localhost:9090/", "http://127.0.0.1:9090/"),
@@ -851,15 +952,8 @@ mod tests {
         ];
 
         for (proxy, mapped_proxy) in CASES {
-            let mut dummy = smol::process::Command::new("");
             let proxy = Url::parse(proxy).unwrap();
-            configure_npm_command(&mut dummy, None, Some(&proxy));
-            let proxy = dummy
-                .get_args()
-                .skip_while(|&arg| arg != "--proxy")
-                .skip(1)
-                .next();
-            let proxy = proxy.expect("Proxy was not passed to Command correctly");
+            let proxy = proxy_argument(Some(&proxy)).expect("Proxy was not passed correctly");
             assert_eq!(
                 proxy, mapped_proxy,
                 "Incorrectly mapped localhost to 127.0.0.1"

crates/project/src/agent_registry_store.rs 🔗

@@ -15,17 +15,76 @@ const REGISTRY_URL: &str =
 const REGISTRY_REFRESH_INTERVAL: Duration = Duration::from_secs(60 * 60);
 
 #[derive(Clone, Debug)]
-pub struct RegistryAgent {
+pub struct RegistryAgentMetadata {
     pub id: SharedString,
     pub name: SharedString,
     pub description: SharedString,
     pub version: SharedString,
     pub repository: Option<SharedString>,
     pub icon_path: Option<SharedString>,
+}
+
+#[derive(Clone, Debug)]
+pub struct RegistryBinaryAgent {
+    pub metadata: RegistryAgentMetadata,
     pub targets: HashMap<String, RegistryTargetConfig>,
     pub supports_current_platform: bool,
 }
 
+#[derive(Clone, Debug)]
+pub struct RegistryNpxAgent {
+    pub metadata: RegistryAgentMetadata,
+    pub package: SharedString,
+    pub args: Vec<String>,
+    pub env: HashMap<String, String>,
+}
+
+#[derive(Clone, Debug)]
+pub enum RegistryAgent {
+    Binary(RegistryBinaryAgent),
+    Npx(RegistryNpxAgent),
+}
+
+impl RegistryAgent {
+    pub fn metadata(&self) -> &RegistryAgentMetadata {
+        match self {
+            RegistryAgent::Binary(agent) => &agent.metadata,
+            RegistryAgent::Npx(agent) => &agent.metadata,
+        }
+    }
+
+    pub fn id(&self) -> &SharedString {
+        &self.metadata().id
+    }
+
+    pub fn name(&self) -> &SharedString {
+        &self.metadata().name
+    }
+
+    pub fn description(&self) -> &SharedString {
+        &self.metadata().description
+    }
+
+    pub fn version(&self) -> &SharedString {
+        &self.metadata().version
+    }
+
+    pub fn repository(&self) -> Option<&SharedString> {
+        self.metadata().repository.as_ref()
+    }
+
+    pub fn icon_path(&self) -> Option<&SharedString> {
+        self.metadata().icon_path.as_ref()
+    }
+
+    pub fn supports_current_platform(&self) -> bool {
+        match self {
+            RegistryAgent::Binary(agent) => agent.supports_current_platform,
+            RegistryAgent::Npx(_) => true,
+        }
+    }
+}
+
 #[derive(Clone, Debug)]
 pub struct RegistryTargetConfig {
     pub archive: String,
@@ -81,7 +140,7 @@ impl AgentRegistryStore {
     }
 
     pub fn agent(&self, id: &str) -> Option<&RegistryAgent> {
-        self.agents.iter().find(|agent| agent.id == id)
+        self.agents.iter().find(|agent| agent.id().as_ref() == id)
     }
 
     pub fn is_fetching(&self) -> bool {
@@ -247,32 +306,6 @@ async fn build_registry_agents(
 
     let mut agents = Vec::new();
     for entry in index.agents {
-        let Some(binary) = entry.distribution.binary.as_ref() else {
-            continue;
-        };
-
-        if binary.is_empty() {
-            continue;
-        }
-
-        let mut targets = HashMap::default();
-        for (platform, target) in binary.iter() {
-            targets.insert(
-                platform.clone(),
-                RegistryTargetConfig {
-                    archive: target.archive.clone(),
-                    cmd: target.cmd.clone(),
-                    args: target.args.clone(),
-                    sha256: None,
-                    env: target.env.clone(),
-                },
-            );
-        }
-
-        let supports_current_platform = current_platform
-            .as_ref()
-            .is_some_and(|platform| targets.contains_key(*platform));
-
         let icon_path = resolve_icon_path(
             &entry,
             &icons_dir,
@@ -282,16 +315,66 @@ async fn build_registry_agents(
         )
         .await?;
 
-        agents.push(RegistryAgent {
+        let metadata = RegistryAgentMetadata {
             id: entry.id.into(),
             name: entry.name.into(),
             description: entry.description.into(),
             version: entry.version.into(),
             repository: entry.repository.map(Into::into),
             icon_path,
-            targets,
-            supports_current_platform,
+        };
+
+        let binary_agent = entry.distribution.binary.as_ref().and_then(|binary| {
+            if binary.is_empty() {
+                return None;
+            }
+
+            let mut targets = HashMap::default();
+            for (platform, target) in binary.iter() {
+                targets.insert(
+                    platform.clone(),
+                    RegistryTargetConfig {
+                        archive: target.archive.clone(),
+                        cmd: target.cmd.clone(),
+                        args: target.args.clone(),
+                        sha256: None,
+                        env: target.env.clone(),
+                    },
+                );
+            }
+
+            let supports_current_platform = current_platform
+                .as_ref()
+                .is_some_and(|platform| targets.contains_key(*platform));
+
+            Some(RegistryBinaryAgent {
+                metadata: metadata.clone(),
+                targets,
+                supports_current_platform,
+            })
         });
+
+        let npx_agent = entry.distribution.npx.as_ref().map(|npx| RegistryNpxAgent {
+            metadata: metadata.clone(),
+            package: npx.package.clone().into(),
+            args: npx.args.clone(),
+            env: npx.env.clone(),
+        });
+
+        let agent = match (binary_agent, npx_agent) {
+            (Some(binary_agent), Some(npx_agent)) => {
+                if binary_agent.supports_current_platform {
+                    RegistryAgent::Binary(binary_agent)
+                } else {
+                    RegistryAgent::Npx(npx_agent)
+                }
+            }
+            (Some(binary_agent), None) => RegistryAgent::Binary(binary_agent),
+            (None, Some(npx_agent)) => RegistryAgent::Npx(npx_agent),
+            (None, None) => continue,
+        };
+
+        agents.push(agent);
     }
 
     Ok(agents)
@@ -447,6 +530,8 @@ struct RegistryEntry {
 struct RegistryDistribution {
     #[serde(default)]
     binary: Option<HashMap<String, RegistryBinaryTarget>>,
+    #[serde(default)]
+    npx: Option<RegistryNpxDistribution>,
 }
 
 #[derive(Deserialize)]
@@ -458,3 +543,12 @@ struct RegistryBinaryTarget {
     #[serde(default)]
     env: HashMap<String, String>,
 }
+
+#[derive(Deserialize)]
+struct RegistryNpxDistribution {
+    package: String,
+    #[serde(default)]
+    args: Vec<String>,
+    #[serde(default)]
+    env: HashMap<String, String>,
+}

crates/project/src/agent_server_store.rs 🔗

@@ -30,7 +30,7 @@ use task::{Shell, SpawnInTerminal};
 use util::{ResultExt as _, debug_panic};
 
 use crate::ProjectEnvironment;
-use crate::agent_registry_store::{AgentRegistryStore, RegistryTargetConfig};
+use crate::agent_registry_store::{AgentRegistryStore, RegistryAgent, RegistryTargetConfig};
 
 #[derive(Deserialize, Serialize, Clone, PartialEq, Eq, JsonSchema)]
 pub struct AgentServerCommand {
@@ -718,7 +718,7 @@ impl AgentServerStore {
                     .agents()
                     .iter()
                     .cloned()
-                    .map(|agent| (agent.id.to_string(), agent))
+                    .map(|agent| (agent.id().to_string(), agent))
                     .collect::<HashMap<_, _>>()
             })
             .unwrap_or_default();
@@ -747,32 +747,57 @@ impl AgentServerStore {
                         }
                         continue;
                     };
-                    if !agent.supports_current_platform {
-                        log::warn!(
-                            "Registry agent '{}' has no compatible binary for this platform",
-                            name
-                        );
-                        continue;
-                    }
 
                     let agent_name = ExternalAgentServerName(name.clone().into());
-                    self.external_agents.insert(
-                        agent_name.clone(),
-                        ExternalAgentEntry::new(
-                            Box::new(LocalRegistryArchiveAgent {
-                                fs: fs.clone(),
-                                http_client: http_client.clone(),
-                                node_runtime: node_runtime.clone(),
-                                project_environment: project_environment.clone(),
-                                registry_id: Arc::from(name.as_str()),
-                                targets: agent.targets.clone(),
-                                env: env.clone(),
-                            }) as Box<dyn ExternalAgentServer>,
-                            ExternalAgentSource::Registry,
-                            agent.icon_path.clone(),
-                            Some(agent.name.clone()),
-                        ),
-                    );
+                    match agent {
+                        RegistryAgent::Binary(agent) => {
+                            if !agent.supports_current_platform {
+                                log::warn!(
+                                    "Registry agent '{}' has no compatible binary for this platform",
+                                    name
+                                );
+                                continue;
+                            }
+
+                            self.external_agents.insert(
+                                agent_name.clone(),
+                                ExternalAgentEntry::new(
+                                    Box::new(LocalRegistryArchiveAgent {
+                                        fs: fs.clone(),
+                                        http_client: http_client.clone(),
+                                        node_runtime: node_runtime.clone(),
+                                        project_environment: project_environment.clone(),
+                                        registry_id: Arc::from(name.as_str()),
+                                        targets: agent.targets.clone(),
+                                        env: env.clone(),
+                                    })
+                                        as Box<dyn ExternalAgentServer>,
+                                    ExternalAgentSource::Registry,
+                                    agent.metadata.icon_path.clone(),
+                                    Some(agent.metadata.name.clone()),
+                                ),
+                            );
+                        }
+                        RegistryAgent::Npx(agent) => {
+                            self.external_agents.insert(
+                                agent_name.clone(),
+                                ExternalAgentEntry::new(
+                                    Box::new(LocalRegistryNpxAgent {
+                                        node_runtime: node_runtime.clone(),
+                                        project_environment: project_environment.clone(),
+                                        package: agent.package.clone(),
+                                        args: agent.args.clone(),
+                                        distribution_env: agent.env.clone(),
+                                        settings_env: env.clone(),
+                                    })
+                                        as Box<dyn ExternalAgentServer>,
+                                    ExternalAgentSource::Registry,
+                                    agent.metadata.icon_path.clone(),
+                                    Some(agent.metadata.name.clone()),
+                                ),
+                            );
+                        }
+                    }
                 }
                 CustomAgentServerSettings::Extension { .. } => {}
             }
@@ -2310,6 +2335,83 @@ impl ExternalAgentServer for LocalRegistryArchiveAgent {
     }
 }
 
+struct LocalRegistryNpxAgent {
+    node_runtime: NodeRuntime,
+    project_environment: Entity<ProjectEnvironment>,
+    package: SharedString,
+    args: Vec<String>,
+    distribution_env: HashMap<String, String>,
+    settings_env: HashMap<String, String>,
+}
+
+impl ExternalAgentServer for LocalRegistryNpxAgent {
+    fn get_command(
+        &mut self,
+        root_dir: Option<&str>,
+        extra_env: HashMap<String, String>,
+        _status_tx: Option<watch::Sender<SharedString>>,
+        _new_version_available_tx: Option<watch::Sender<Option<String>>>,
+        cx: &mut AsyncApp,
+    ) -> Task<Result<(AgentServerCommand, String, Option<task::SpawnInTerminal>)>> {
+        let node_runtime = self.node_runtime.clone();
+        let project_environment = self.project_environment.downgrade();
+        let package = self.package.clone();
+        let args = self.args.clone();
+        let distribution_env = self.distribution_env.clone();
+        let settings_env = self.settings_env.clone();
+
+        let env_root_dir: Arc<Path> = root_dir
+            .map(|root_dir| Path::new(root_dir))
+            .unwrap_or(paths::home_dir())
+            .into();
+
+        cx.spawn(async move |cx| {
+            let mut env = project_environment
+                .update(cx, |project_environment, cx| {
+                    project_environment.local_directory_environment(
+                        &Shell::System,
+                        env_root_dir.clone(),
+                        cx,
+                    )
+                })?
+                .await
+                .unwrap_or_default();
+
+            let mut exec_args = Vec::new();
+            exec_args.push("--yes".to_string());
+            exec_args.push(package.to_string());
+            if !args.is_empty() {
+                exec_args.push("--".to_string());
+                exec_args.extend(args);
+            }
+
+            let npm_command = node_runtime
+                .npm_command(
+                    "exec",
+                    &exec_args.iter().map(|a| a.as_str()).collect::<Vec<_>>(),
+                )
+                .await?;
+
+            env.extend(npm_command.env);
+            env.extend(distribution_env);
+            env.extend(extra_env);
+            env.extend(settings_env);
+
+            let command = AgentServerCommand {
+                path: npm_command.path,
+                args: npm_command.args,
+                env: Some(env),
+            };
+
+            Ok((command, env_root_dir.to_string_lossy().into_owned(), None))
+        })
+    }
+
+    fn as_any_mut(&mut self) -> &mut dyn Any {
+        self
+    }
+}
+
 struct LocalCustomAgent {
     project_environment: Entity<ProjectEnvironment>,
     command: AgentServerCommand,