acp: Use the custom claude installation to perform login (#37169)

Antonio Scandurra , Bennet Bo Fenner , Agus Zubiaga , Nathan Sobo , Cole Miller , and morgankrey created

Release Notes:

- N/A

---------

Co-authored-by: Bennet Bo Fenner <bennetbo@gmx.de>
Co-authored-by: Agus Zubiaga <agus@zed.dev>
Co-authored-by: Nathan Sobo <nathan@zed.dev>
Co-authored-by: Cole Miller <cole@zed.dev>
Co-authored-by: morgankrey <morgan@zed.dev>

Change summary

Cargo.lock                                |  1 
crates/agent_servers/src/agent_servers.rs | 10 ++-
crates/agent_servers/src/claude.rs        | 40 ++++++++++++++
crates/agent_servers/src/e2e_tests.rs     |  2 
crates/agent_ui/Cargo.toml                |  1 
crates/agent_ui/src/acp/message_editor.rs |  2 
crates/agent_ui/src/acp/thread_view.rs    | 69 ++++++++++++++++--------
7 files changed, 94 insertions(+), 31 deletions(-)

Detailed changes

Cargo.lock 🔗

@@ -414,6 +414,7 @@ dependencies = [
  "serde_json",
  "serde_json_lenient",
  "settings",
+ "shlex",
  "smol",
  "streaming_diff",
  "task",

crates/agent_servers/src/agent_servers.rs 🔗

@@ -44,11 +44,11 @@ pub fn init(cx: &mut App) {
 
 pub struct AgentServerDelegate {
     project: Entity<Project>,
-    status_tx: watch::Sender<SharedString>,
+    status_tx: Option<watch::Sender<SharedString>>,
 }
 
 impl AgentServerDelegate {
-    pub fn new(project: Entity<Project>, status_tx: watch::Sender<SharedString>) -> Self {
+    pub fn new(project: Entity<Project>, status_tx: Option<watch::Sender<SharedString>>) -> Self {
         Self { project, status_tx }
     }
 
@@ -72,7 +72,7 @@ impl AgentServerDelegate {
                 "External agents are not yet available in remote projects."
             )));
         };
-        let mut status_tx = self.status_tx;
+        let status_tx = self.status_tx;
 
         cx.spawn(async move |cx| {
             if !ignore_system_version {
@@ -165,7 +165,9 @@ impl AgentServerDelegate {
                     .detach();
                     file_name
                 } else {
-                    status_tx.send("Installing…".into()).ok();
+                    if let Some(mut status_tx) = status_tx {
+                        status_tx.send("Installing…".into()).ok();
+                    }
                     let dir = dir.clone();
                     cx.background_spawn(Self::download_latest_version(
                         fs,

crates/agent_servers/src/claude.rs 🔗

@@ -1,8 +1,8 @@
 use language_models::provider::anthropic::AnthropicLanguageModelProvider;
 use settings::SettingsStore;
-use std::any::Any;
 use std::path::Path;
 use std::rc::Rc;
+use std::{any::Any, path::PathBuf};
 
 use anyhow::Result;
 use gpui::{App, AppContext as _, SharedString, Task};
@@ -13,9 +13,47 @@ use acp_thread::AgentConnection;
 #[derive(Clone)]
 pub struct ClaudeCode;
 
+pub struct ClaudeCodeLoginCommand {
+    pub path: PathBuf,
+    pub arguments: Vec<String>,
+}
+
 impl ClaudeCode {
     const BINARY_NAME: &'static str = "claude-code-acp";
     const PACKAGE_NAME: &'static str = "@zed-industries/claude-code-acp";
+
+    pub fn login_command(
+        delegate: AgentServerDelegate,
+        cx: &mut App,
+    ) -> Task<Result<ClaudeCodeLoginCommand>> {
+        let settings = cx.read_global(|settings: &SettingsStore, _| {
+            settings.get::<AllAgentServersSettings>(None).claude.clone()
+        });
+
+        cx.spawn(async move |cx| {
+            let mut command = if let Some(settings) = settings {
+                settings.command
+            } else {
+                cx.update(|cx| {
+                    delegate.get_or_npm_install_builtin_agent(
+                        Self::BINARY_NAME.into(),
+                        Self::PACKAGE_NAME.into(),
+                        "node_modules/@anthropic-ai/claude-code/cli.js".into(),
+                        true,
+                        None,
+                        cx,
+                    )
+                })?
+                .await?
+            };
+            command.args.push("/login".into());
+
+            Ok(ClaudeCodeLoginCommand {
+                path: command.path,
+                arguments: command.args,
+            })
+        })
+    }
 }
 
 impl AgentServer for ClaudeCode {

crates/agent_servers/src/e2e_tests.rs 🔗

@@ -498,7 +498,7 @@ pub async fn new_test_thread(
     current_dir: impl AsRef<Path>,
     cx: &mut TestAppContext,
 ) -> Entity<AcpThread> {
-    let delegate = AgentServerDelegate::new(project.clone(), watch::channel("".into()).0);
+    let delegate = AgentServerDelegate::new(project.clone(), None);
 
     let connection = cx
         .update(|cx| server.connect(current_dir.as_ref(), delegate, cx))

crates/agent_ui/Cargo.toml 🔗

@@ -80,6 +80,7 @@ serde.workspace = true
 serde_json.workspace = true
 serde_json_lenient.workspace = true
 settings.workspace = true
+shlex.workspace = true
 smol.workspace = true
 streaming_diff.workspace = true
 task.workspace = true

crates/agent_ui/src/acp/message_editor.rs 🔗

@@ -645,7 +645,7 @@ impl MessageEditor {
             self.project.read(cx).fs().clone(),
             self.history_store.clone(),
         ));
-        let delegate = AgentServerDelegate::new(self.project.clone(), watch::channel("".into()).0);
+        let delegate = AgentServerDelegate::new(self.project.clone(), None);
         let connection = server.connect(Path::new(""), delegate, cx);
         cx.spawn(async move |_, cx| {
             let agent = connection.await?;

crates/agent_ui/src/acp/thread_view.rs 🔗

@@ -9,7 +9,7 @@ use agent_client_protocol::{self as acp, PromptCapabilities};
 use agent_servers::{AgentServer, AgentServerDelegate, ClaudeCode};
 use agent_settings::{AgentProfileId, AgentSettings, CompletionMode, NotifyWhenAgentWaiting};
 use agent2::{DbThreadMetadata, HistoryEntry, HistoryEntryId, HistoryStore};
-use anyhow::{Result, anyhow, bail};
+use anyhow::{Context as _, Result, anyhow, bail};
 use audio::{Audio, Sound};
 use buffer_diff::BufferDiff;
 use client::zed_urls;
@@ -423,7 +423,7 @@ impl AcpThreadView {
             .map(|worktree| worktree.read(cx).abs_path())
             .unwrap_or_else(|| paths::home_dir().as_path().into());
         let (tx, mut rx) = watch::channel("Loading…".into());
-        let delegate = AgentServerDelegate::new(project.clone(), tx);
+        let delegate = AgentServerDelegate::new(project.clone(), Some(tx));
 
         let connect_task = agent.connect(&root_dir, delegate, cx);
         let load_task = cx.spawn_in(window, async move |this, cx| {
@@ -1386,31 +1386,52 @@ impl AcpThreadView {
         let Some(terminal_panel) = workspace.read(cx).panel::<TerminalPanel>(cx) else {
             return Task::ready(Ok(()));
         };
-        let project = workspace.read(cx).project().read(cx);
+        let project_entity = workspace.read(cx).project();
+        let project = project_entity.read(cx);
         let cwd = project.first_project_directory(cx);
         let shell = project.terminal_settings(&cwd, cx).shell.clone();
 
-        let terminal = terminal_panel.update(cx, |terminal_panel, cx| {
-            terminal_panel.spawn_task(
-                &SpawnInTerminal {
-                    id: task::TaskId("claude-login".into()),
-                    full_label: "claude /login".to_owned(),
-                    label: "claude /login".to_owned(),
-                    command: Some("claude".to_owned()),
-                    args: vec!["/login".to_owned()],
-                    command_label: "claude /login".to_owned(),
-                    cwd,
-                    use_new_terminal: true,
-                    allow_concurrent_runs: true,
-                    hide: task::HideStrategy::Always,
-                    shell,
-                    ..Default::default()
-                },
-                window,
-                cx,
-            )
-        });
-        cx.spawn(async move |cx| {
+        let delegate = AgentServerDelegate::new(project_entity.clone(), None);
+        let command = ClaudeCode::login_command(delegate, cx);
+
+        window.spawn(cx, async move |cx| {
+            let login_command = command.await?;
+            let command = login_command
+                .path
+                .to_str()
+                .with_context(|| format!("invalid login command: {:?}", login_command.path))?;
+            let command = shlex::try_quote(command)?;
+            let args = login_command
+                .arguments
+                .iter()
+                .map(|arg| {
+                    Ok(shlex::try_quote(arg)
+                        .context("Failed to quote argument")?
+                        .to_string())
+                })
+                .collect::<Result<Vec<_>>>()?;
+
+            let terminal = terminal_panel.update_in(cx, |terminal_panel, window, cx| {
+                terminal_panel.spawn_task(
+                    &SpawnInTerminal {
+                        id: task::TaskId("claude-login".into()),
+                        full_label: "claude /login".to_owned(),
+                        label: "claude /login".to_owned(),
+                        command: Some(command.into()),
+                        args,
+                        command_label: "claude /login".to_owned(),
+                        cwd,
+                        use_new_terminal: true,
+                        allow_concurrent_runs: true,
+                        hide: task::HideStrategy::Always,
+                        shell,
+                        ..Default::default()
+                    },
+                    window,
+                    cx,
+                )
+            })?;
+
             let terminal = terminal.await?;
             let mut exit_status = terminal
                 .read_with(cx, |terminal, cx| terminal.wait_for_completed_task(cx))?