diff --git a/hermes_cli/config.py b/hermes_cli/config.py index 7a932d9e43..bdde858d34 100644 --- a/hermes_cli/config.py +++ b/hermes_cli/config.py @@ -1092,6 +1092,13 @@ def save_anthropic_oauth_token(value: str, save_fn=None): writer("ANTHROPIC_API_KEY", "") +def use_anthropic_claude_code_credentials(save_fn=None): + """Use Claude Code's own credential files instead of persisting env tokens.""" + writer = save_fn or save_env_value + writer("ANTHROPIC_TOKEN", "") + writer("ANTHROPIC_API_KEY", "") + + def save_anthropic_api_key(value: str, save_fn=None): """Persist an Anthropic API key and clear the OAuth/setup-token slot.""" writer = save_fn or save_env_value diff --git a/hermes_cli/main.py b/hermes_cli/main.py index 3d910907d7..8bae440b57 100644 --- a/hermes_cli/main.py +++ b/hermes_cli/main.py @@ -1586,8 +1586,30 @@ def _model_flow_api_key_provider(config, provider_id, current_model=""): def _run_anthropic_oauth_flow(save_env_value): """Run the Claude OAuth setup-token flow. Returns True if credentials were saved.""" - from agent.anthropic_adapter import run_oauth_setup_token - from hermes_cli.config import save_anthropic_oauth_token + from agent.anthropic_adapter import ( + run_oauth_setup_token, + read_claude_code_credentials, + is_claude_code_token_valid, + ) + from hermes_cli.config import ( + save_anthropic_oauth_token, + use_anthropic_claude_code_credentials, + ) + + def _activate_claude_code_credentials_if_available() -> bool: + try: + creds = read_claude_code_credentials() + except Exception: + creds = None + if creds and ( + is_claude_code_token_valid(creds) + or bool(creds.get("refreshToken")) + ): + use_anthropic_claude_code_credentials(save_fn=save_env_value) + print(" ✓ Claude Code credentials linked.") + print(" Hermes will use Claude's credential store directly instead of copying a setup-token into ~/.hermes/.env.") + return True + return False try: print() @@ -1596,6 +1618,8 @@ def _run_anthropic_oauth_flow(save_env_value): print() token = run_oauth_setup_token() if token: + if _activate_claude_code_credentials_if_available(): + return True save_anthropic_oauth_token(token, save_fn=save_env_value) print(" ✓ OAuth credentials saved.") return True diff --git a/run_agent.py b/run_agent.py index 002ed05535..419b56929e 100644 --- a/run_agent.py +++ b/run_agent.py @@ -2645,6 +2645,11 @@ class AIAgent: self._anthropic_api_key = new_token return True + def _anthropic_messages_create(self, api_kwargs: dict): + if self.api_mode == "anthropic_messages": + self._try_refresh_anthropic_client_credentials() + return self._anthropic_client.messages.create(**api_kwargs) + def _interruptible_api_call(self, api_kwargs: dict): """ Run the API call in a background thread so the main conversation loop @@ -2661,7 +2666,7 @@ class AIAgent: if self.api_mode == "codex_responses": result["response"] = self._run_codex_stream(api_kwargs) elif self.api_mode == "anthropic_messages": - result["response"] = self._anthropic_client.messages.create(**api_kwargs) + result["response"] = self._anthropic_messages_create(api_kwargs) else: result["response"] = self.client.chat.completions.create(**api_kwargs) except Exception as e: @@ -3299,7 +3304,7 @@ class AIAgent: tools=[memory_tool_def], max_tokens=5120, reasoning_config=None, ) - response = self._anthropic_client.messages.create(**ant_kwargs) + response = self._anthropic_messages_create(ant_kwargs) elif not _aux_available: api_kwargs = { "model": self.model, @@ -4050,7 +4055,7 @@ class AIAgent: from agent.anthropic_adapter import build_anthropic_kwargs as _bak, normalize_anthropic_response as _nar _ant_kw = _bak(model=self.model, messages=api_messages, tools=None, max_tokens=self.max_tokens, reasoning_config=self.reasoning_config) - summary_response = self._anthropic_client.messages.create(**_ant_kw) + summary_response = self._anthropic_messages_create(_ant_kw) _msg, _ = _nar(summary_response) final_response = (_msg.content or "").strip() else: @@ -4080,7 +4085,7 @@ class AIAgent: from agent.anthropic_adapter import build_anthropic_kwargs as _bak2, normalize_anthropic_response as _nar2 _ant_kw2 = _bak2(model=self.model, messages=api_messages, tools=None, max_tokens=self.max_tokens, reasoning_config=self.reasoning_config) - retry_response = self._anthropic_client.messages.create(**_ant_kw2) + retry_response = self._anthropic_messages_create(_ant_kw2) _retry_msg, _ = _nar2(retry_response) final_response = (_retry_msg.content or "").strip() else: diff --git a/tests/test_anthropic_oauth_flow.py b/tests/test_anthropic_oauth_flow.py new file mode 100644 index 0000000000..3b52831aa3 --- /dev/null +++ b/tests/test_anthropic_oauth_flow.py @@ -0,0 +1,51 @@ +"""Tests for Anthropic OAuth setup flow behavior.""" + +from hermes_cli.config import load_env, save_env_value + + +def test_run_anthropic_oauth_flow_prefers_claude_code_credentials(tmp_path, monkeypatch, capsys): + monkeypatch.setenv("HERMES_HOME", str(tmp_path)) + monkeypatch.setattr( + "agent.anthropic_adapter.run_oauth_setup_token", + lambda: "sk-ant-oat01-from-claude-setup", + ) + monkeypatch.setattr( + "agent.anthropic_adapter.read_claude_code_credentials", + lambda: { + "accessToken": "cc-access-token", + "refreshToken": "cc-refresh-token", + "expiresAt": 9999999999999, + }, + ) + monkeypatch.setattr( + "agent.anthropic_adapter.is_claude_code_token_valid", + lambda creds: True, + ) + + from hermes_cli.main import _run_anthropic_oauth_flow + + save_env_value("ANTHROPIC_TOKEN", "stale-env-token") + assert _run_anthropic_oauth_flow(save_env_value) is True + + env_vars = load_env() + assert env_vars["ANTHROPIC_TOKEN"] == "" + assert env_vars["ANTHROPIC_API_KEY"] == "" + output = capsys.readouterr().out + assert "Claude Code credentials linked" in output + + +def test_run_anthropic_oauth_flow_manual_token_still_persists(tmp_path, monkeypatch, capsys): + monkeypatch.setenv("HERMES_HOME", str(tmp_path)) + monkeypatch.setattr("agent.anthropic_adapter.run_oauth_setup_token", lambda: None) + monkeypatch.setattr("agent.anthropic_adapter.read_claude_code_credentials", lambda: None) + monkeypatch.setattr("agent.anthropic_adapter.is_claude_code_token_valid", lambda creds: False) + monkeypatch.setattr("builtins.input", lambda _prompt="": "sk-ant-oat01-manual-token") + + from hermes_cli.main import _run_anthropic_oauth_flow + + assert _run_anthropic_oauth_flow(save_env_value) is True + + env_vars = load_env() + assert env_vars["ANTHROPIC_TOKEN"] == "sk-ant-oat01-manual-token" + output = capsys.readouterr().out + assert "Setup-token saved" in output diff --git a/tests/test_anthropic_provider_persistence.py b/tests/test_anthropic_provider_persistence.py index fd55d21b7a..4c2c472808 100644 --- a/tests/test_anthropic_provider_persistence.py +++ b/tests/test_anthropic_provider_persistence.py @@ -17,6 +17,21 @@ def test_save_anthropic_oauth_token_uses_token_slot_and_clears_api_key(tmp_path, assert env_vars["ANTHROPIC_API_KEY"] == "" +def test_use_anthropic_claude_code_credentials_clears_env_slots(tmp_path, monkeypatch): + home = tmp_path / "hermes" + home.mkdir() + monkeypatch.setenv("HERMES_HOME", str(home)) + + from hermes_cli.config import save_anthropic_oauth_token, use_anthropic_claude_code_credentials + + save_anthropic_oauth_token("sk-ant-oat01-token") + use_anthropic_claude_code_credentials() + + env_vars = load_env() + assert env_vars["ANTHROPIC_TOKEN"] == "" + assert env_vars["ANTHROPIC_API_KEY"] == "" + + def test_save_anthropic_api_key_uses_api_key_slot_and_clears_token(tmp_path, monkeypatch): home = tmp_path / "hermes" home.mkdir() @@ -24,8 +39,8 @@ def test_save_anthropic_api_key_uses_api_key_slot_and_clears_token(tmp_path, mon from hermes_cli.config import save_anthropic_api_key - save_anthropic_api_key("sk-ant-api03-test-key") + save_anthropic_api_key("sk-ant-api03-key") env_vars = load_env() - assert env_vars["ANTHROPIC_API_KEY"] == "sk-ant-api03-test-key" + assert env_vars["ANTHROPIC_API_KEY"] == "sk-ant-api03-key" assert env_vars["ANTHROPIC_TOKEN"] == "" diff --git a/tests/test_run_agent.py b/tests/test_run_agent.py index 44a315cefb..c3673eb1e8 100644 --- a/tests/test_run_agent.py +++ b/tests/test_run_agent.py @@ -2145,6 +2145,31 @@ class TestAnthropicCredentialRefresh: old_client.close.assert_not_called() rebuild.assert_not_called() + def test_anthropic_messages_create_preflights_refresh(self): + with ( + patch("run_agent.get_tool_definitions", return_value=_make_tool_defs("web_search")), + patch("run_agent.check_toolset_requirements", return_value={}), + patch("agent.anthropic_adapter.build_anthropic_client", return_value=MagicMock()), + ): + agent = AIAgent( + api_key="sk-ant-oat01-current-token", + api_mode="anthropic_messages", + quiet_mode=True, + skip_context_files=True, + skip_memory=True, + ) + + response = SimpleNamespace(content=[]) + agent._anthropic_client = MagicMock() + agent._anthropic_client.messages.create.return_value = response + + with patch.object(agent, "_try_refresh_anthropic_client_credentials", return_value=True) as refresh: + result = agent._anthropic_messages_create({"model": "claude-sonnet-4-20250514"}) + + refresh.assert_called_once_with() + agent._anthropic_client.messages.create.assert_called_once_with(model="claude-sonnet-4-20250514") + assert result is response + # =================================================================== # _streaming_api_call tests