diff --git a/myagents/claude_settings.py b/myagents/claude_settings.py index 1b8f259..dc3b8ed 100644 --- a/myagents/claude_settings.py +++ b/myagents/claude_settings.py @@ -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: - - local settings > project settings > user settings - -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``. +This module targets the user-level file at ``~/.claude/settings.json``, +mirroring the behavior of ``cc-switch``: the selected backend applies +globally to all Claude Code sessions. """ from __future__ import annotations @@ -23,12 +18,11 @@ from myagents.backends import ( build_provider_env, detect_provider, ) -from myagents.project_root import get_workspace_root def _settings_path() -> Path: - """Return the project-level Claude Code settings path.""" - return get_workspace_root() / ".agents" / "settings.json" + """Return the global Claude Code settings path.""" + return Path.home() / ".claude" / "settings.json" 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") -def load_project_settings() -> dict[str, Any]: - """Load the project-level Claude Code settings dict.""" +def load_settings() -> dict[str, Any]: + """Load the global Claude Code settings dict.""" return _load_json(_settings_path()) -def save_project_settings(data: dict[str, Any]) -> None: - """Persist the project-level Claude Code settings dict.""" +def save_settings(data: dict[str, Any]) -> None: + """Persist the global Claude Code settings dict.""" _save_json(_settings_path(), data) -def get_project_env() -> dict[str, str]: - """Return the current ``env`` block from project settings.""" - env = load_project_settings().get("env", {}) +def get_env() -> dict[str, str]: + """Return the current ``env`` block from global settings.""" + env = load_settings().get("env", {}) return {k: v for k, v in env.items() if isinstance(v, str)} def get_active_provider() -> Provider | None: - """Detect the active provider from project-level env.""" - return detect_provider(get_project_env()) + """Detect the active provider from global env.""" + return detect_provider(get_env()) def apply_provider( @@ -72,24 +66,27 @@ def apply_provider( model: str | None = None, base_url: str | None = None, ) -> 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 - ``save_project_settings``. + ``save_settings``. """ - settings = load_project_settings() + settings = load_settings() env: dict[str, 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: env.pop(var, None) if provider.id == "claude": - # Official Claude: no third-party env vars needed. Any ANTHROPIC_API_KEY - # the user manages separately is left untouched. - pass + # Official Claude: no third-party env vars needed. Restore a user-managed + # ANTHROPIC_API_KEY if present; otherwise leave it cleared. + if preserved_api_key: + env["ANTHROPIC_API_KEY"] = preserved_api_key else: if not key: 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: - """Human-readable description of the active project-level backend.""" + """Human-readable description of the active global backend.""" provider = get_active_provider() - env = get_project_env() + env = get_env() if provider is None: if env.get("ANTHROPIC_API_KEY") and not env.get("ANTHROPIC_AUTH_TOKEN"): return "Claude (official API key)" diff --git a/myagents/commands/backend.py b/myagents/commands/backend.py index 94265cb..892f2f6 100644 --- a/myagents/commands/backend.py +++ b/myagents/commands/backend.py @@ -15,7 +15,7 @@ from myagents.claude_settings import ( apply_provider, describe_active_backend, get_active_provider, - save_project_settings, + save_settings, ) from myagents.secrets import get_key, has_key, remove_key, set_key from myagents.settings import set_setting @@ -122,14 +122,13 @@ def backend_use( model=model, base_url=base_url, ) - save_project_settings(settings) + save_settings(settings) set_setting("backend_provider", provider.id) console.print(f"[bold green]Backend switched to {summary}.[/bold green]") if provider.id == "claude": console.print( - "[dim]Cleared third-party backend env vars from workspace " - ".agents/settings.json.[/dim]" + "[dim]Cleared third-party backend env vars from ~/.claude/settings.json.[/dim]" ) diff --git a/tests/test_backend_command.py b/tests/test_backend_command.py new file mode 100644 index 0000000..2088886 --- /dev/null +++ b/tests/test_backend_command.py @@ -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", {}) diff --git a/tests/test_backends.py b/tests/test_backends.py index b031989..b3e4ee5 100644 --- a/tests/test_backends.py +++ b/tests/test_backends.py @@ -2,9 +2,7 @@ import pytest -from myagents import backends as backends_mod from myagents.backends import ( - Provider, build_provider_env, detect_provider, get_provider, diff --git a/tests/test_claude_settings.py b/tests/test_claude_settings.py new file mode 100644 index 0000000..9abe67c --- /dev/null +++ b/tests/test_claude_settings.py @@ -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)" diff --git a/tests/test_entrypoints.py b/tests/test_entrypoints.py index 179cd74..8591d0f 100644 --- a/tests/test_entrypoints.py +++ b/tests/test_entrypoints.py @@ -5,7 +5,17 @@ from unittest.mock import MagicMock, patch 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: diff --git a/tests/test_secrets.py b/tests/test_secrets.py new file mode 100644 index 0000000..daea200 --- /dev/null +++ b/tests/test_secrets.py @@ -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