fix(backend): 改回全局 ~/.claude/settings.json + 补充测试
- claude_settings: 目标文件改为全局 ~/.claude/settings.json,与 cc-switch 行为一致 - claude_settings: 切回 claude 时保留用户已有的 ANTHROPIC_API_KEY - backend: 修正 save_project_settings -> save_settings,提示文本同步 - test_backends: 清理未用 import - test_entrypoints: 新增 backend 命令注册测试 - 新增 test_claude_settings / test_secrets / test_backend_command(共 36 tests)
This commit is contained in:
+27
-30
@@ -1,13 +1,8 @@
|
|||||||
"""Read/write project-level Claude Code settings.json for backend switching.
|
"""Read/write global Claude Code settings.json for backend switching.
|
||||||
|
|
||||||
Claude Code configuration precedence is:
|
This module targets the user-level file at ``~/.claude/settings.json``,
|
||||||
|
mirroring the behavior of ``cc-switch``: the selected backend applies
|
||||||
local settings > project settings > user settings
|
globally to all Claude Code sessions.
|
||||||
|
|
||||||
This module targets the project-level file at
|
|
||||||
``workspace/.agents/settings.json`` (symlinked from ``workspace/.claude/settings.json``),
|
|
||||||
so the selected backend only affects the current workspace without polluting the
|
|
||||||
user's global ``~/.claude/settings.json``.
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
@@ -23,12 +18,11 @@ from myagents.backends import (
|
|||||||
build_provider_env,
|
build_provider_env,
|
||||||
detect_provider,
|
detect_provider,
|
||||||
)
|
)
|
||||||
from myagents.project_root import get_workspace_root
|
|
||||||
|
|
||||||
|
|
||||||
def _settings_path() -> Path:
|
def _settings_path() -> Path:
|
||||||
"""Return the project-level Claude Code settings path."""
|
"""Return the global Claude Code settings path."""
|
||||||
return get_workspace_root() / ".agents" / "settings.json"
|
return Path.home() / ".claude" / "settings.json"
|
||||||
|
|
||||||
|
|
||||||
def _load_json(path: Path) -> dict[str, Any]:
|
def _load_json(path: Path) -> dict[str, Any]:
|
||||||
@@ -45,25 +39,25 @@ def _save_json(path: Path, data: dict[str, Any]) -> None:
|
|||||||
path.write_text(json.dumps(data, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
|
path.write_text(json.dumps(data, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
|
||||||
|
|
||||||
|
|
||||||
def load_project_settings() -> dict[str, Any]:
|
def load_settings() -> dict[str, Any]:
|
||||||
"""Load the project-level Claude Code settings dict."""
|
"""Load the global Claude Code settings dict."""
|
||||||
return _load_json(_settings_path())
|
return _load_json(_settings_path())
|
||||||
|
|
||||||
|
|
||||||
def save_project_settings(data: dict[str, Any]) -> None:
|
def save_settings(data: dict[str, Any]) -> None:
|
||||||
"""Persist the project-level Claude Code settings dict."""
|
"""Persist the global Claude Code settings dict."""
|
||||||
_save_json(_settings_path(), data)
|
_save_json(_settings_path(), data)
|
||||||
|
|
||||||
|
|
||||||
def get_project_env() -> dict[str, str]:
|
def get_env() -> dict[str, str]:
|
||||||
"""Return the current ``env`` block from project settings."""
|
"""Return the current ``env`` block from global settings."""
|
||||||
env = load_project_settings().get("env", {})
|
env = load_settings().get("env", {})
|
||||||
return {k: v for k, v in env.items() if isinstance(v, str)}
|
return {k: v for k, v in env.items() if isinstance(v, str)}
|
||||||
|
|
||||||
|
|
||||||
def get_active_provider() -> Provider | None:
|
def get_active_provider() -> Provider | None:
|
||||||
"""Detect the active provider from project-level env."""
|
"""Detect the active provider from global env."""
|
||||||
return detect_provider(get_project_env())
|
return detect_provider(get_env())
|
||||||
|
|
||||||
|
|
||||||
def apply_provider(
|
def apply_provider(
|
||||||
@@ -72,24 +66,27 @@ def apply_provider(
|
|||||||
model: str | None = None,
|
model: str | None = None,
|
||||||
base_url: str | None = None,
|
base_url: str | None = None,
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
"""Return updated project settings with the given provider applied.
|
"""Return updated global settings with the given provider applied.
|
||||||
|
|
||||||
This does not write to disk; callers should pass the result to
|
This does not write to disk; callers should pass the result to
|
||||||
``save_project_settings``.
|
``save_settings``.
|
||||||
"""
|
"""
|
||||||
settings = load_project_settings()
|
settings = load_settings()
|
||||||
env: dict[str, str] = {
|
env: dict[str, str] = {
|
||||||
k: v for k, v in settings.get("env", {}).items() if isinstance(v, str)
|
k: v for k, v in settings.get("env", {}).items() if isinstance(v, str)
|
||||||
}
|
}
|
||||||
|
|
||||||
# Remove stale managed env vars first.
|
# Remove stale managed env vars first, but preserve a user-managed
|
||||||
|
# ANTHROPIC_API_KEY when switching back to official Claude.
|
||||||
|
preserved_api_key = env.get("ANTHROPIC_API_KEY")
|
||||||
for var in MANAGED_ENV_VARS:
|
for var in MANAGED_ENV_VARS:
|
||||||
env.pop(var, None)
|
env.pop(var, None)
|
||||||
|
|
||||||
if provider.id == "claude":
|
if provider.id == "claude":
|
||||||
# Official Claude: no third-party env vars needed. Any ANTHROPIC_API_KEY
|
# Official Claude: no third-party env vars needed. Restore a user-managed
|
||||||
# the user manages separately is left untouched.
|
# ANTHROPIC_API_KEY if present; otherwise leave it cleared.
|
||||||
pass
|
if preserved_api_key:
|
||||||
|
env["ANTHROPIC_API_KEY"] = preserved_api_key
|
||||||
else:
|
else:
|
||||||
if not key:
|
if not key:
|
||||||
raise ValueError(f"API key required for provider '{provider.id}'")
|
raise ValueError(f"API key required for provider '{provider.id}'")
|
||||||
@@ -112,9 +109,9 @@ def reset_backend() -> dict[str, Any]:
|
|||||||
|
|
||||||
|
|
||||||
def describe_active_backend() -> str:
|
def describe_active_backend() -> str:
|
||||||
"""Human-readable description of the active project-level backend."""
|
"""Human-readable description of the active global backend."""
|
||||||
provider = get_active_provider()
|
provider = get_active_provider()
|
||||||
env = get_project_env()
|
env = get_env()
|
||||||
if provider is None:
|
if provider is None:
|
||||||
if env.get("ANTHROPIC_API_KEY") and not env.get("ANTHROPIC_AUTH_TOKEN"):
|
if env.get("ANTHROPIC_API_KEY") and not env.get("ANTHROPIC_AUTH_TOKEN"):
|
||||||
return "Claude (official API key)"
|
return "Claude (official API key)"
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ from myagents.claude_settings import (
|
|||||||
apply_provider,
|
apply_provider,
|
||||||
describe_active_backend,
|
describe_active_backend,
|
||||||
get_active_provider,
|
get_active_provider,
|
||||||
save_project_settings,
|
save_settings,
|
||||||
)
|
)
|
||||||
from myagents.secrets import get_key, has_key, remove_key, set_key
|
from myagents.secrets import get_key, has_key, remove_key, set_key
|
||||||
from myagents.settings import set_setting
|
from myagents.settings import set_setting
|
||||||
@@ -122,14 +122,13 @@ def backend_use(
|
|||||||
model=model,
|
model=model,
|
||||||
base_url=base_url,
|
base_url=base_url,
|
||||||
)
|
)
|
||||||
save_project_settings(settings)
|
save_settings(settings)
|
||||||
set_setting("backend_provider", provider.id)
|
set_setting("backend_provider", provider.id)
|
||||||
|
|
||||||
console.print(f"[bold green]Backend switched to {summary}.[/bold green]")
|
console.print(f"[bold green]Backend switched to {summary}.[/bold green]")
|
||||||
if provider.id == "claude":
|
if provider.id == "claude":
|
||||||
console.print(
|
console.print(
|
||||||
"[dim]Cleared third-party backend env vars from workspace "
|
"[dim]Cleared third-party backend env vars from ~/.claude/settings.json.[/dim]"
|
||||||
".agents/settings.json.[/dim]"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,185 @@
|
|||||||
|
"""Tests for myagents.commands.backend."""
|
||||||
|
|
||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from click.testing import CliRunner
|
||||||
|
|
||||||
|
from myagents import claude_settings as claude_settings_mod
|
||||||
|
from myagents import secrets as secrets_mod
|
||||||
|
from myagents.commands import backend as backend_mod
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture()
|
||||||
|
def fake_config_dirs(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> dict[str, Path]:
|
||||||
|
xiaohe_dir = tmp_path / ".xiaohe" / "agent"
|
||||||
|
claude_dir = tmp_path / ".claude"
|
||||||
|
monkeypatch.setattr(secrets_mod, "XIAOHE_CONFIG_DIR", xiaohe_dir)
|
||||||
|
monkeypatch.setattr(secrets_mod, "XIAOHE_CONFIG_PATH", xiaohe_dir / "config.json")
|
||||||
|
monkeypatch.setattr(
|
||||||
|
claude_settings_mod, "_settings_path", lambda: claude_dir / "settings.json"
|
||||||
|
)
|
||||||
|
return {"xiaohe": xiaohe_dir, "claude": claude_dir}
|
||||||
|
|
||||||
|
|
||||||
|
class TestBackendList:
|
||||||
|
def test_list_shows_providers(self) -> None:
|
||||||
|
result = CliRunner().invoke(backend_mod.backend_cmd, ["list"])
|
||||||
|
assert result.exit_code == 0
|
||||||
|
assert "deepseek" in result.output
|
||||||
|
assert "kimi" in result.output
|
||||||
|
assert "kimi-code" in result.output
|
||||||
|
assert "claude" in result.output
|
||||||
|
|
||||||
|
|
||||||
|
class TestBackendCurrent:
|
||||||
|
def test_current_shows_default_when_unset(
|
||||||
|
self, fake_config_dirs: dict[str, Path]
|
||||||
|
) -> None:
|
||||||
|
result = CliRunner().invoke(backend_mod.backend_cmd, ["current"])
|
||||||
|
assert result.exit_code == 0
|
||||||
|
assert "Claude (default / not configured)" in result.output
|
||||||
|
|
||||||
|
|
||||||
|
class TestBackendUse:
|
||||||
|
def test_use_deepseek_stores_env(self, fake_config_dirs: dict[str, Path]) -> None:
|
||||||
|
result = CliRunner().invoke(
|
||||||
|
backend_mod.backend_cmd,
|
||||||
|
["use", "deepseek", "--key", "sk-test", "--yes"],
|
||||||
|
)
|
||||||
|
assert result.exit_code == 0, result.output
|
||||||
|
settings_path = fake_config_dirs["claude"] / "settings.json"
|
||||||
|
settings = json.loads(settings_path.read_text())
|
||||||
|
assert settings["env"]["ANTHROPIC_AUTH_TOKEN"] == "sk-test"
|
||||||
|
assert settings["env"]["ANTHROPIC_BASE_URL"] == "https://api.deepseek.com/anthropic"
|
||||||
|
|
||||||
|
def test_use_kimi_code(self, fake_config_dirs: dict[str, Path]) -> None:
|
||||||
|
result = CliRunner().invoke(
|
||||||
|
backend_mod.backend_cmd,
|
||||||
|
["use", "kimi-code", "--key", "sk-test", "--yes"],
|
||||||
|
)
|
||||||
|
assert result.exit_code == 0, result.output
|
||||||
|
settings_path = fake_config_dirs["claude"] / "settings.json"
|
||||||
|
settings = json.loads(settings_path.read_text())
|
||||||
|
assert settings["env"]["ANTHROPIC_BASE_URL"] == "https://api.kimi.com/coding"
|
||||||
|
assert settings["env"]["ANTHROPIC_MODEL"] == "kimi-for-coding"
|
||||||
|
|
||||||
|
def test_use_unknown_provider_errors(self) -> None:
|
||||||
|
result = CliRunner().invoke(
|
||||||
|
backend_mod.backend_cmd, ["use", "openai", "--yes"]
|
||||||
|
)
|
||||||
|
assert result.exit_code != 0
|
||||||
|
assert "Unknown backend" in result.output
|
||||||
|
|
||||||
|
def test_use_without_key_prompts(self, fake_config_dirs: dict[str, Path]) -> None:
|
||||||
|
result = CliRunner().invoke(
|
||||||
|
backend_mod.backend_cmd,
|
||||||
|
["use", "deepseek", "--yes"],
|
||||||
|
input="sk-from-prompt\n",
|
||||||
|
)
|
||||||
|
assert result.exit_code == 0, result.output
|
||||||
|
settings_path = fake_config_dirs["claude"] / "settings.json"
|
||||||
|
settings = json.loads(settings_path.read_text())
|
||||||
|
assert settings["env"]["ANTHROPIC_AUTH_TOKEN"] == "sk-from-prompt"
|
||||||
|
|
||||||
|
def test_use_cancelled_does_not_write(self, fake_config_dirs: dict[str, Path]) -> None:
|
||||||
|
result = CliRunner().invoke(
|
||||||
|
backend_mod.backend_cmd,
|
||||||
|
["use", "deepseek", "--key", "sk-test"],
|
||||||
|
input="n\n",
|
||||||
|
)
|
||||||
|
assert result.exit_code == 0
|
||||||
|
assert "Cancelled" in result.output
|
||||||
|
settings_path = fake_config_dirs["claude"] / "settings.json"
|
||||||
|
assert not settings_path.exists()
|
||||||
|
|
||||||
|
def test_use_model_override(self, fake_config_dirs: dict[str, Path]) -> None:
|
||||||
|
result = CliRunner().invoke(
|
||||||
|
backend_mod.backend_cmd,
|
||||||
|
["use", "deepseek", "--key", "sk-test", "--model", "custom-model", "--yes"],
|
||||||
|
)
|
||||||
|
assert result.exit_code == 0, result.output
|
||||||
|
settings_path = fake_config_dirs["claude"] / "settings.json"
|
||||||
|
settings = json.loads(settings_path.read_text())
|
||||||
|
assert settings["env"]["ANTHROPIC_MODEL"] == "custom-model"
|
||||||
|
|
||||||
|
def test_use_claude_clears_third_party(self, fake_config_dirs: dict[str, Path]) -> None:
|
||||||
|
settings_path = fake_config_dirs["claude"] / "settings.json"
|
||||||
|
fake_config_dirs["claude"].mkdir(parents=True)
|
||||||
|
settings_path.write_text(
|
||||||
|
json.dumps(
|
||||||
|
{
|
||||||
|
"env": {
|
||||||
|
"ANTHROPIC_AUTH_TOKEN": "sk-test",
|
||||||
|
"ANTHROPIC_BASE_URL": "https://api.deepseek.com/anthropic",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
)
|
||||||
|
result = CliRunner().invoke(
|
||||||
|
backend_mod.backend_cmd, ["use", "claude", "--yes"]
|
||||||
|
)
|
||||||
|
assert result.exit_code == 0, result.output
|
||||||
|
settings = json.loads(settings_path.read_text())
|
||||||
|
assert "env" not in settings
|
||||||
|
|
||||||
|
|
||||||
|
class TestBackendReset:
|
||||||
|
def test_reset_alias(self, fake_config_dirs: dict[str, Path]) -> None:
|
||||||
|
result = CliRunner().invoke(
|
||||||
|
backend_mod.backend_cmd,
|
||||||
|
["use", "deepseek", "--key", "sk-test", "--yes"],
|
||||||
|
)
|
||||||
|
assert result.exit_code == 0
|
||||||
|
result = CliRunner().invoke(backend_mod.backend_cmd, ["reset", "--yes"])
|
||||||
|
assert result.exit_code == 0, result.output
|
||||||
|
settings_path = fake_config_dirs["claude"] / "settings.json"
|
||||||
|
settings = json.loads(settings_path.read_text())
|
||||||
|
assert "ANTHROPIC_AUTH_TOKEN" not in settings.get("env", {})
|
||||||
|
|
||||||
|
|
||||||
|
class TestBackendKeySet:
|
||||||
|
def test_key_set_stores_in_xiaohe_config(self, fake_config_dirs: dict[str, Path]) -> None:
|
||||||
|
result = CliRunner().invoke(
|
||||||
|
backend_mod.backend_key,
|
||||||
|
["set", "deepseek", "--key", "sk-test"],
|
||||||
|
)
|
||||||
|
assert result.exit_code == 0, result.output
|
||||||
|
config_path = fake_config_dirs["xiaohe"] / "config.json"
|
||||||
|
data = json.loads(config_path.read_text())
|
||||||
|
assert data["keys"]["deepseek"] == "sk-test"
|
||||||
|
|
||||||
|
def test_key_set_for_claude_errors(self) -> None:
|
||||||
|
result = CliRunner().invoke(
|
||||||
|
backend_mod.backend_key,
|
||||||
|
["set", "claude", "--key", "sk-test"],
|
||||||
|
)
|
||||||
|
assert result.exit_code != 0
|
||||||
|
assert "ANTHROPIC_API_KEY" in result.output
|
||||||
|
|
||||||
|
|
||||||
|
class TestBackendKeyRm:
|
||||||
|
def test_key_rm_removes_stored_key(self, fake_config_dirs: dict[str, Path]) -> None:
|
||||||
|
config_path = fake_config_dirs["xiaohe"] / "config.json"
|
||||||
|
fake_config_dirs["xiaohe"].mkdir(parents=True)
|
||||||
|
config_path.write_text(json.dumps({"keys": {"deepseek": "sk-test"}}))
|
||||||
|
|
||||||
|
result = CliRunner().invoke(
|
||||||
|
backend_mod.backend_key, ["rm", "deepseek", "--yes"]
|
||||||
|
)
|
||||||
|
assert result.exit_code == 0, result.output
|
||||||
|
data = json.loads(config_path.read_text())
|
||||||
|
assert "deepseek" not in data.get("keys", {})
|
||||||
|
|
||||||
|
def test_key_rm_without_yes_confirms(self, fake_config_dirs: dict[str, Path]) -> None:
|
||||||
|
config_path = fake_config_dirs["xiaohe"] / "config.json"
|
||||||
|
fake_config_dirs["xiaohe"].mkdir(parents=True)
|
||||||
|
config_path.write_text(json.dumps({"keys": {"deepseek": "sk-test"}}))
|
||||||
|
|
||||||
|
result = CliRunner().invoke(
|
||||||
|
backend_mod.backend_key, ["rm", "deepseek"], input="y\n"
|
||||||
|
)
|
||||||
|
assert result.exit_code == 0, result.output
|
||||||
|
data = json.loads(config_path.read_text())
|
||||||
|
assert "deepseek" not in data.get("keys", {})
|
||||||
@@ -2,9 +2,7 @@
|
|||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from myagents import backends as backends_mod
|
|
||||||
from myagents.backends import (
|
from myagents.backends import (
|
||||||
Provider,
|
|
||||||
build_provider_env,
|
build_provider_env,
|
||||||
detect_provider,
|
detect_provider,
|
||||||
get_provider,
|
get_provider,
|
||||||
|
|||||||
@@ -0,0 +1,139 @@
|
|||||||
|
"""Tests for myagents.claude_settings."""
|
||||||
|
|
||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from myagents import claude_settings as cs_mod
|
||||||
|
from myagents.backends import get_provider
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture()
|
||||||
|
def fake_settings_path(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path:
|
||||||
|
path = tmp_path / ".claude" / "settings.json"
|
||||||
|
monkeypatch.setattr(cs_mod, "_settings_path", lambda: path)
|
||||||
|
return path
|
||||||
|
|
||||||
|
|
||||||
|
class TestLoadSaveSettings:
|
||||||
|
def test_load_missing_returns_empty(self, fake_settings_path: Path) -> None:
|
||||||
|
assert cs_mod.load_settings() == {}
|
||||||
|
|
||||||
|
def test_save_and_load_roundtrip(self, fake_settings_path: Path) -> None:
|
||||||
|
cs_mod.save_settings({"effortLevel": "medium"})
|
||||||
|
assert cs_mod.load_settings()["effortLevel"] == "medium"
|
||||||
|
|
||||||
|
def test_load_corrupt_returns_empty(self, fake_settings_path: Path) -> None:
|
||||||
|
fake_settings_path.parent.mkdir(parents=True)
|
||||||
|
fake_settings_path.write_text("{not json")
|
||||||
|
assert cs_mod.load_settings() == {}
|
||||||
|
|
||||||
|
|
||||||
|
class TestApplyProvider:
|
||||||
|
def test_applies_deepseek_env(self, fake_settings_path: Path) -> None:
|
||||||
|
settings = cs_mod.apply_provider(get_provider("deepseek"), key="sk-test")
|
||||||
|
env = settings["env"]
|
||||||
|
assert env["ANTHROPIC_AUTH_TOKEN"] == "sk-test"
|
||||||
|
assert env["ANTHROPIC_BASE_URL"] == "https://api.deepseek.com/anthropic"
|
||||||
|
assert env["ANTHROPIC_MODEL"] == "deepseek-v4-pro"
|
||||||
|
assert env["ANTHROPIC_DEFAULT_FABLE_MODEL"] == "deepseek-v4-pro"
|
||||||
|
|
||||||
|
def test_model_override(self, fake_settings_path: Path) -> None:
|
||||||
|
settings = cs_mod.apply_provider(
|
||||||
|
get_provider("kimi"), key="sk-test", model="kimi-k2.5"
|
||||||
|
)
|
||||||
|
assert settings["env"]["ANTHROPIC_MODEL"] == "kimi-k2.5"
|
||||||
|
|
||||||
|
def test_base_url_override(self, fake_settings_path: Path) -> None:
|
||||||
|
settings = cs_mod.apply_provider(
|
||||||
|
get_provider("kimi"), key="sk-test", base_url="https://api.moonshot.cn/anthropic"
|
||||||
|
)
|
||||||
|
assert settings["env"]["ANTHROPIC_BASE_URL"] == "https://api.moonshot.cn/anthropic"
|
||||||
|
|
||||||
|
def test_preserves_unrelated_env(self, fake_settings_path: Path) -> None:
|
||||||
|
fake_settings_path.parent.mkdir(parents=True)
|
||||||
|
fake_settings_path.write_text(
|
||||||
|
json.dumps({"env": {"DISABLE_AUTOUPDATER": "1", "CUSTOM_VAR": "keep"}})
|
||||||
|
)
|
||||||
|
settings = cs_mod.apply_provider(get_provider("deepseek"), key="sk-test")
|
||||||
|
env = settings["env"]
|
||||||
|
assert env["DISABLE_AUTOUPDATER"] == "1"
|
||||||
|
assert env["CUSTOM_VAR"] == "keep"
|
||||||
|
assert env["ANTHROPIC_AUTH_TOKEN"] == "sk-test"
|
||||||
|
|
||||||
|
def test_removes_stale_managed_vars(self, fake_settings_path: Path) -> None:
|
||||||
|
fake_settings_path.parent.mkdir(parents=True)
|
||||||
|
fake_settings_path.write_text(
|
||||||
|
json.dumps(
|
||||||
|
{
|
||||||
|
"env": {
|
||||||
|
"ANTHROPIC_API_KEY": "old-key",
|
||||||
|
"ANTHROPIC_BASE_URL": "https://old.example.com",
|
||||||
|
"ANTHROPIC_MODEL": "old-model",
|
||||||
|
"ANTHROPIC_DEFAULT_OPUS_MODEL": "old-model",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
)
|
||||||
|
settings = cs_mod.apply_provider(get_provider("kimi"), key="sk-test")
|
||||||
|
env = settings["env"]
|
||||||
|
assert "ANTHROPIC_API_KEY" not in env
|
||||||
|
assert env["ANTHROPIC_BASE_URL"] == "https://api.moonshot.ai/anthropic"
|
||||||
|
assert env["ANTHROPIC_MODEL"] == "kimi-k2.6"
|
||||||
|
|
||||||
|
def test_claude_provider_clears_third_party_env(self, fake_settings_path: Path) -> None:
|
||||||
|
fake_settings_path.parent.mkdir(parents=True)
|
||||||
|
fake_settings_path.write_text(
|
||||||
|
json.dumps(
|
||||||
|
{
|
||||||
|
"env": {
|
||||||
|
"ANTHROPIC_AUTH_TOKEN": "sk-test",
|
||||||
|
"ANTHROPIC_BASE_URL": "https://api.deepseek.com/anthropic",
|
||||||
|
"ANTHROPIC_MODEL": "deepseek-v4-pro",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
)
|
||||||
|
settings = cs_mod.apply_provider(get_provider("claude"))
|
||||||
|
assert "env" not in settings
|
||||||
|
|
||||||
|
def test_claude_provider_preserves_user_api_key(self, fake_settings_path: Path) -> None:
|
||||||
|
fake_settings_path.parent.mkdir(parents=True)
|
||||||
|
fake_settings_path.write_text(json.dumps({"env": {"ANTHROPIC_API_KEY": "sk-ant"}}))
|
||||||
|
settings = cs_mod.apply_provider(get_provider("claude"))
|
||||||
|
assert settings["env"]["ANTHROPIC_API_KEY"] == "sk-ant"
|
||||||
|
|
||||||
|
def test_missing_key_raises(self, fake_settings_path: Path) -> None:
|
||||||
|
with pytest.raises(ValueError, match="API key required"):
|
||||||
|
cs_mod.apply_provider(get_provider("deepseek"))
|
||||||
|
|
||||||
|
|
||||||
|
class TestDetectActiveProvider:
|
||||||
|
def test_detects_from_global_env(self, fake_settings_path: Path) -> None:
|
||||||
|
fake_settings_path.parent.mkdir(parents=True)
|
||||||
|
fake_settings_path.write_text(
|
||||||
|
json.dumps({"env": {"ANTHROPIC_BASE_URL": "https://api.kimi.com/coding"}})
|
||||||
|
)
|
||||||
|
provider = cs_mod.get_active_provider()
|
||||||
|
assert provider is not None
|
||||||
|
assert provider.id == "kimi-code"
|
||||||
|
|
||||||
|
|
||||||
|
class TestDescribeActiveBackend:
|
||||||
|
def test_describes_kimi_code(self, fake_settings_path: Path) -> None:
|
||||||
|
fake_settings_path.parent.mkdir(parents=True)
|
||||||
|
fake_settings_path.write_text(
|
||||||
|
json.dumps(
|
||||||
|
{
|
||||||
|
"env": {
|
||||||
|
"ANTHROPIC_BASE_URL": "https://api.kimi.com/coding",
|
||||||
|
"ANTHROPIC_MODEL": "kimi-for-coding",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
)
|
||||||
|
assert "Kimi Code" in cs_mod.describe_active_backend()
|
||||||
|
|
||||||
|
def test_describes_default_when_unset(self, fake_settings_path: Path) -> None:
|
||||||
|
assert cs_mod.describe_active_backend() == "Claude (default / not configured)"
|
||||||
@@ -5,7 +5,17 @@ from unittest.mock import MagicMock, patch
|
|||||||
|
|
||||||
from click.testing import CliRunner
|
from click.testing import CliRunner
|
||||||
|
|
||||||
from myagents.entrypoints import claude_cli, codex_cli, hermes_cli, kimi_cli
|
from myagents.entrypoints import build_xiaohe_cli, claude_cli, codex_cli, hermes_cli, kimi_cli
|
||||||
|
|
||||||
|
|
||||||
|
class TestXiaoheBackend:
|
||||||
|
"""``xiaohe backend`` subcommand registration."""
|
||||||
|
|
||||||
|
def test_help_lists_backend(self) -> None:
|
||||||
|
runner = CliRunner()
|
||||||
|
result = runner.invoke(build_xiaohe_cli(), ["--help"])
|
||||||
|
assert result.exit_code == 0
|
||||||
|
assert "backend" in result.output
|
||||||
|
|
||||||
|
|
||||||
class TestMyclaudeEntrypoint:
|
class TestMyclaudeEntrypoint:
|
||||||
|
|||||||
@@ -0,0 +1,103 @@
|
|||||||
|
"""Tests for myagents.secrets."""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from myagents import secrets as secrets_mod
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture()
|
||||||
|
def fake_dirs(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> dict[str, Path]:
|
||||||
|
xiaohe_dir = tmp_path / ".xiaohe" / "agent"
|
||||||
|
mytoolkit_dir = tmp_path / ".mytoolkit"
|
||||||
|
monkeypatch.setattr(secrets_mod, "XIAOHE_CONFIG_DIR", xiaohe_dir)
|
||||||
|
monkeypatch.setattr(secrets_mod, "XIAOHE_CONFIG_PATH", xiaohe_dir / "config.json")
|
||||||
|
monkeypatch.setattr(secrets_mod, "MYTOOLKIT_CONFIG_PATH", mytoolkit_dir / "config.json")
|
||||||
|
return {"xiaohe": xiaohe_dir, "mytoolkit": mytoolkit_dir}
|
||||||
|
|
||||||
|
|
||||||
|
class TestGetKey:
|
||||||
|
def test_returns_xiaohe_key_first(self, fake_dirs: dict[str, Path]) -> None:
|
||||||
|
xiaohe_path = fake_dirs["xiaohe"] / "config.json"
|
||||||
|
mytoolkit_path = fake_dirs["mytoolkit"] / "config.json"
|
||||||
|
xiaohe_path.parent.mkdir(parents=True)
|
||||||
|
xiaohe_path.write_text(json.dumps({"keys": {"kimi": "xiaohe-key"}}))
|
||||||
|
mytoolkit_path.parent.mkdir(parents=True)
|
||||||
|
mytoolkit_path.write_text(json.dumps({"keys": {"kimi": "mtk-key"}}))
|
||||||
|
|
||||||
|
assert secrets_mod.get_key("kimi") == "xiaohe-key"
|
||||||
|
|
||||||
|
def test_falls_back_to_mytoolkit_key(self, fake_dirs: dict[str, Path]) -> None:
|
||||||
|
mytoolkit_path = fake_dirs["mytoolkit"] / "config.json"
|
||||||
|
mytoolkit_path.parent.mkdir(parents=True)
|
||||||
|
mytoolkit_path.write_text(json.dumps({"keys": {"kimi": "mtk-key"}}))
|
||||||
|
|
||||||
|
assert secrets_mod.get_key("kimi") == "mtk-key"
|
||||||
|
|
||||||
|
def test_returns_none_when_missing(self, fake_dirs: dict[str, Path]) -> None:
|
||||||
|
assert secrets_mod.get_key("missing") is None
|
||||||
|
|
||||||
|
def test_skips_empty_strings(self, fake_dirs: dict[str, Path]) -> None:
|
||||||
|
xiaohe_path = fake_dirs["xiaohe"] / "config.json"
|
||||||
|
xiaohe_path.parent.mkdir(parents=True)
|
||||||
|
xiaohe_path.write_text(json.dumps({"keys": {"kimi": ""}}))
|
||||||
|
|
||||||
|
assert secrets_mod.get_key("kimi") is None
|
||||||
|
|
||||||
|
|
||||||
|
class TestSetKey:
|
||||||
|
def test_stores_key_in_xiaohe_config(self, fake_dirs: dict[str, Path]) -> None:
|
||||||
|
secrets_mod.set_key("deepseek", "sk-test")
|
||||||
|
|
||||||
|
data = json.loads((fake_dirs["xiaohe"] / "config.json").read_text())
|
||||||
|
assert data["keys"]["deepseek"] == "sk-test"
|
||||||
|
|
||||||
|
def test_rejects_empty_key(self, fake_dirs: dict[str, Path]) -> None:
|
||||||
|
with pytest.raises(ValueError, match="cannot be empty"):
|
||||||
|
secrets_mod.set_key("deepseek", "")
|
||||||
|
|
||||||
|
def test_preserves_other_keys(self, fake_dirs: dict[str, Path]) -> None:
|
||||||
|
xiaohe_path = fake_dirs["xiaohe"] / "config.json"
|
||||||
|
xiaohe_path.parent.mkdir(parents=True)
|
||||||
|
xiaohe_path.write_text(json.dumps({"keys": {"other": "keep"}}))
|
||||||
|
|
||||||
|
secrets_mod.set_key("deepseek", "sk-test")
|
||||||
|
|
||||||
|
data = json.loads(xiaohe_path.read_text())
|
||||||
|
assert data["keys"]["other"] == "keep"
|
||||||
|
assert data["keys"]["deepseek"] == "sk-test"
|
||||||
|
|
||||||
|
|
||||||
|
class TestRemoveKey:
|
||||||
|
def test_removes_existing_key(self, fake_dirs: dict[str, Path]) -> None:
|
||||||
|
xiaohe_path = fake_dirs["xiaohe"] / "config.json"
|
||||||
|
xiaohe_path.parent.mkdir(parents=True)
|
||||||
|
xiaohe_path.write_text(json.dumps({"keys": {"deepseek": "sk-test"}}))
|
||||||
|
|
||||||
|
assert secrets_mod.remove_key("deepseek") is True
|
||||||
|
data = json.loads(xiaohe_path.read_text())
|
||||||
|
assert "deepseek" not in data.get("keys", {})
|
||||||
|
|
||||||
|
def test_returns_false_when_missing(self, fake_dirs: dict[str, Path]) -> None:
|
||||||
|
assert secrets_mod.remove_key("deepseek") is False
|
||||||
|
|
||||||
|
|
||||||
|
class TestPermissions:
|
||||||
|
@pytest.mark.skipif(sys.platform == "win32", reason="Unix permissions only")
|
||||||
|
def test_config_file_is_user_readable_only(self, fake_dirs: dict[str, Path]) -> None:
|
||||||
|
secrets_mod.set_key("kimi", "sk-test")
|
||||||
|
path = fake_dirs["xiaohe"] / "config.json"
|
||||||
|
mode = path.stat().st_mode
|
||||||
|
assert mode & 0o777 == 0o600
|
||||||
|
|
||||||
|
|
||||||
|
class TestHasKey:
|
||||||
|
def test_true_when_key_exists(self, fake_dirs: dict[str, Path]) -> None:
|
||||||
|
secrets_mod.set_key("kimi", "sk-test")
|
||||||
|
assert secrets_mod.has_key("kimi") is True
|
||||||
|
|
||||||
|
def test_false_when_missing(self, fake_dirs: dict[str, Path]) -> None:
|
||||||
|
assert secrets_mod.has_key("missing") is False
|
||||||
Reference in New Issue
Block a user