fix(auth): find the Claude CLI outside PATH in external-process and setup-token checks

_external_process_spec asked PATH only (shutil.which) for a provider's
process_command, so a backend started by a LaunchAgent or Hermes Desktop
(bare PATH) reported 'claude' missing even when it lived in
~/.local/bin, ~/.claude/local, ... -- the prefixes agent/anthropic_adapter
already probes for version detection. run_oauth_setup_token had the same
PATH-only lookup.

Add anthropic_adapter.find_claude_code_cli(), which resolves a bare
claude/claude-code name via hermes_platform's locate_command over the
adapter's existing _CLAUDE_CODE_PREFIXES, and use it on a PATH miss in
_external_process_spec and in run_oauth_setup_token. PATH hits and every
other command are unchanged.
This commit is contained in:
Teknium
2026-10-04 17:41:41 -07:00
parent 0b088a78a6
commit fc4c176eec
6 changed files with 94 additions and 10 deletions

View File

@@ -265,6 +265,19 @@ def _claude_code_candidates() -> List[str]:
return list(seen)
def find_claude_code_cli(command: str) -> Optional[str]:
"""Path of a bare Claude Code command name on PATH, else in an install prefix; None for any other command.
Core's own presence checks (external-process providers, ``claude setup-token``) ask this so they
agree with version detection about whether the CLI is installed under a GUI/service PATH."""
if command not in _CLAUDE_CODE_NAMES:
return None
from hermes_platform.resolver import locate_command
found = locate_command(command, known_dirs=_CLAUDE_CODE_PREFIXES).command
return found[0] if found else None
def _detect_claude_code_version() -> str:
"""Installed Claude Code version (``claude --version``), else the static fallback."""
for cmd in _claude_code_candidates():

View File

@@ -711,8 +711,8 @@ def resolve_anthropic_token(*, model: Optional[str] = None) -> Optional[str]:
def run_oauth_setup_token() -> Optional[str]:
"""Run 'claude setup-token' interactively; the resulting token or None. FileNotFoundError if no 'claude' CLI."""
import shutil
claude_path = shutil.which("claude")
from agent.anthropic_adapter import find_claude_code_cli # late: the adapter imports this module
claude_path = find_claude_code_cli("claude")
if not claude_path:
raise FileNotFoundError("The 'claude' CLI is not installed. Install it with: npm install -g @anthropic-ai/claude-code")
# Interactive: stdio inherited so the user can complete the OAuth prompt. noqa: subprocess-stdin

View File

@@ -2154,7 +2154,13 @@ def _external_process_spec(
or str(getattr(profile, "process_command", "") or ""))
raw_args = os.getenv(args_env_var, "").strip() if args_env_var else ""
args = shlex.split(raw_args) if raw_args else list(getattr(profile, "process_args", ()) or [])
return command, args, base_url, shutil.which(command) if command else None, command_env_vars
resolved = shutil.which(command) if command else None
if command and not resolved:
# A GUI/service launch (LaunchAgent, Desktop backend) has a bare PATH: probe Claude Code's
# install prefixes as the Anthropic adapter does. Any other command stays PATH-only.
from agent.anthropic_adapter import find_claude_code_cli
resolved = find_claude_code_cli(command)
return command, args, base_url, resolved, command_env_vars
def get_external_process_provider_status(provider_id: str) -> Dict[str, Any]:

View File

@@ -550,7 +550,7 @@ class TestRunOauthSetupToken:
def test_returns_token_from_credential_files(self, monkeypatch, tmp_path):
"""After subprocess completes, reads credentials from Claude Code files."""
monkeypatch.setattr("shutil.which", lambda _: "/usr/bin/claude")
monkeypatch.setattr("agent.anthropic_adapter.find_claude_code_cli", lambda _: "/usr/bin/claude")
monkeypatch.delenv("CLAUDE_CODE_OAUTH_TOKEN", raising=False)
monkeypatch.delenv("ANTHROPIC_TOKEN", raising=False)
@@ -580,7 +580,7 @@ class TestRunOauthSetupToken:
def test_returns_none_when_no_creds_found(self, monkeypatch, tmp_path):
"""Returns None when subprocess completes but no credentials are found."""
monkeypatch.setattr("shutil.which", lambda _: "/usr/bin/claude")
monkeypatch.setattr("agent.anthropic_adapter.find_claude_code_cli", lambda _: "/usr/bin/claude")
monkeypatch.delenv("CLAUDE_CODE_OAUTH_TOKEN", raising=False)
monkeypatch.delenv("ANTHROPIC_TOKEN", raising=False)
monkeypatch.setattr("agent.anthropic_credentials.Path.home", lambda: tmp_path)

View File

@@ -9,11 +9,6 @@
"symbol": "_claude_code_candidates",
"kind": "bare_which"
},
{
"path": "agent/anthropic_credentials.py",
"symbol": "run_oauth_setup_token",
"kind": "bare_which"
},
{
"path": "agent/lsp/cli.py",
"symbol": "_backend_warnings",

View File

@@ -0,0 +1,70 @@
"""A Claude Code CLI outside PATH is found by every core check, not only by the Anthropic adapter.
A backend started by a macOS LaunchAgent or spawned by Hermes Desktop inherits a bare PATH that
carries none of Claude Code's install prefixes (``~/.local/bin``, ``~/.claude/local``, ...).
``agent.anthropic_adapter`` probes those prefixes after PATH; ``_external_process_spec`` and
``run_oauth_setup_token`` asked PATH only, so a provider whose ``process_command`` is ``claude``
was reported missing ("Could not find the ... CLI command 'claude'") on a machine where the
adapter could run it. Reported against hermes-plugin-claude-subscription-directsdk#32.
"""
from __future__ import annotations
import subprocess
import pytest
from providers import register_provider
from providers.base import ProviderProfile
register_provider(
ProviderProfile(
name="claude-cli-acp-test",
display_name="Claude CLI (test)",
base_url="process://claude-cli-acp-test",
auth_type="external_process",
process_command="claude",
)
)
def _install(directory) -> str:
directory.mkdir(parents=True, exist_ok=True)
exe = directory / "claude"
exe.write_text("#!/bin/sh\nexit 0\n")
exe.chmod(0o755)
return str(exe)
@pytest.fixture
def service_launch(tmp_path, monkeypatch):
"""The user's home holds ``~/.local/bin/claude``; the process PATH carries no install prefix."""
monkeypatch.setenv("HOME", str(tmp_path))
monkeypatch.setenv("PATH", str(tmp_path / "empty-path"))
return _install(tmp_path / ".local" / "bin")
@pytest.mark.platforms("posix") # HOME-relative prefixes and the exec bit are the POSIX layout
def test_external_process_provider_finds_claude_in_an_install_prefix(service_launch, tmp_path, monkeypatch):
from hermes_cli.auth import get_external_process_provider_status, resolve_external_process_provider_credentials
assert resolve_external_process_provider_credentials("claude-cli-acp-test")["command"] == service_launch
status = get_external_process_provider_status("claude-cli-acp-test")
assert (status["configured"], status["resolved_command"]) == (True, service_launch)
# A PATH hit is still what runs: the prefix probe only answers a PATH miss.
on_path = _install(tmp_path / "path-bin")
monkeypatch.setenv("PATH", str(tmp_path / "path-bin"))
assert resolve_external_process_provider_credentials("claude-cli-acp-test")["command"] == on_path
@pytest.mark.platforms("posix")
def test_setup_token_runs_claude_from_an_install_prefix(service_launch, monkeypatch):
import agent.anthropic_credentials as creds
ran: list[list[str]] = []
monkeypatch.setattr(creds.subprocess, "run", lambda argv, *a, **k: ran.append(argv) or subprocess.CompletedProcess(argv, 0))
monkeypatch.setattr(creds, "read_claude_code_credentials", lambda: None)
creds.run_oauth_setup_token()
assert ran == [[service_launch, "setup-token"]]