diff --git a/myagents/commands/provider.py b/myagents/commands/provider.py index 0e1c81f..14e0f34 100644 --- a/myagents/commands/provider.py +++ b/myagents/commands/provider.py @@ -25,7 +25,6 @@ console = Console() def _secure_prompt_key(provider: Provider) -> str: - """Prompt securely for an API key, falling back to /dev/tty when needed.""" prompt_text = f"Enter {provider.display_name} API key" try: return click.prompt(prompt_text, hide_input=True, err=True) @@ -40,7 +39,7 @@ def _secure_prompt_key(provider: Provider) -> str: def provider_list_table() -> None: - """Print the provider list table (reusable from info command).""" + """Print the provider list (reusable from info command).""" active = get_active_provider() console.print("[bold]Available providers:[/bold]") for provider in list_providers(): @@ -58,51 +57,19 @@ def provider_list_table() -> None: ) -@click.group("provider") -def provider_cmd() -> None: - """Switch Claude Code's LLM provider (DeepSeek, Kimi, Kimi Code, Claude).""" - - -@provider_cmd.command("list") -def provider_list() -> None: - """List available providers and the active one.""" - provider_list_table() - - -@provider_cmd.command("current") -def provider_current() -> None: - """Show the active provider.""" - console.print(f"[bold]Active provider:[/bold] {describe_active_backend()}") - - -@provider_cmd.command("use") -@click.argument("provider_id") -@click.option("--key", help="API key (scripting only; appears in shell history)") -@click.option("--model", help="Override the default model") -@click.option("--base-url", help="Override the provider base URL") -@click.option("--yes", "-y", is_flag=True, help="Skip confirmation prompt") -def provider_use( - provider_id: str, - key: str | None, - model: str | None, - base_url: str | None, - yes: bool, +def _do_switch( + provider: Provider, + key: str | None = None, + model: str | None = None, + base_url: str | None = None, + yes: bool = False, ) -> None: - """Switch to the given provider.""" - try: - provider = get_provider(provider_id) - except KeyError: - known = ", ".join(p.id for p in list_providers()) - raise click.ClickException( - f"Unknown provider '{provider_id}'. Choose from: {known}" - ) - if is_third_party(provider): resolved_key = key or get_key(provider.key_name) if not resolved_key: resolved_key = _secure_prompt_key(provider) if not resolved_key: - raise click.ClickException("API key is required for this provider.") + raise click.ClickException("API key is required.") else: resolved_key = None @@ -121,10 +88,7 @@ def provider_use( return settings = apply_provider( - provider, - key=resolved_key, - model=model, - base_url=base_url, + provider, key=resolved_key, model=model, base_url=base_url ) save_settings(settings) set_setting("backend_provider", provider.id) @@ -136,12 +100,56 @@ def provider_use( ) -@provider_cmd.command("reset") +@click.group("provider", invoke_without_command=True) +@click.argument("provider_id", required=False) +@click.option("--key", help="API key (scripting only; appears in shell history)") +@click.option("--model", help="Override the default model") +@click.option("--base-url", help="Override the provider base URL") @click.option("--yes", "-y", is_flag=True, help="Skip confirmation prompt") -def provider_reset(yes: bool) -> None: - """Reset to the official Claude provider.""" - ctx = click.get_current_context() - ctx.invoke(provider_use, provider_id="claude", yes=yes) +@click.pass_context +def provider_cmd( + ctx: click.Context, + provider_id: str | None, + key: str | None, + model: str | None, + base_url: str | None, + yes: bool, +) -> None: + """Switch Claude Code's LLM provider. + + \b + xiaohe switch provider # list available providers + xiaohe switch provider deepseek # switch to DeepSeek + xiaohe switch provider claude # reset to official Claude + """ + if ctx.invoked_subcommand is not None: + return + + if not provider_id: + provider_list_table() + return + + try: + provider = get_provider(provider_id) + except KeyError: + known = ", ".join(p.id for p in list_providers()) + raise click.ClickException( + f"Unknown provider '{provider_id}'. Choose from: {known}" + ) + + _do_switch(provider, key=key, model=model, base_url=base_url, yes=yes) + + +@provider_cmd.command("list") +def provider_list() -> None: + """List available providers.""" + provider_list_table() + + +@provider_cmd.command("current") +def provider_current() -> None: + """Show the active provider.""" + console.print(f"[bold]Active provider:[/bold] {describe_active_backend()}") @provider_cmd.group("key") diff --git a/tests/test_backend_command.py b/tests/test_backend_command.py index 2088886..c7afb48 100644 --- a/tests/test_backend_command.py +++ b/tests/test_backend_command.py @@ -1,4 +1,4 @@ -"""Tests for myagents.commands.backend.""" +"""Tests for myagents.commands.provider (xiaohe switch provider).""" import json from pathlib import Path @@ -8,7 +8,7 @@ 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 +from myagents.commands.provider import provider_cmd @pytest.fixture() @@ -23,9 +23,9 @@ def fake_config_dirs(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> dict[st return {"xiaohe": xiaohe_dir, "claude": claude_dir} -class TestBackendList: +class TestProviderList: def test_list_shows_providers(self) -> None: - result = CliRunner().invoke(backend_mod.backend_cmd, ["list"]) + result = CliRunner().invoke(provider_cmd, ["list"]) assert result.exit_code == 0 assert "deepseek" in result.output assert "kimi" in result.output @@ -33,20 +33,25 @@ class TestBackendList: assert "claude" in result.output -class TestBackendCurrent: +class TestProviderCurrent: def test_current_shows_default_when_unset( self, fake_config_dirs: dict[str, Path] ) -> None: - result = CliRunner().invoke(backend_mod.backend_cmd, ["current"]) + result = CliRunner().invoke(provider_cmd, ["current"]) assert result.exit_code == 0 - assert "Claude (default / not configured)" in result.output + assert "Claude (default" in result.output or "Claude (official" in result.output -class TestBackendUse: - def test_use_deepseek_stores_env(self, fake_config_dirs: dict[str, Path]) -> None: +class TestProviderSwitch: + def test_bare_invocation_lists(self) -> None: + result = CliRunner().invoke(provider_cmd) + assert result.exit_code == 0 + assert "Available providers" in result.output + assert "deepseek" in result.output + + def test_switch_to_deepseek(self, fake_config_dirs: dict[str, Path]) -> None: result = CliRunner().invoke( - backend_mod.backend_cmd, - ["use", "deepseek", "--key", "sk-test", "--yes"], + provider_cmd, ["deepseek", "--key", "sk-test", "--yes"] ) assert result.exit_code == 0, result.output settings_path = fake_config_dirs["claude"] / "settings.json" @@ -54,10 +59,9 @@ class TestBackendUse: 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: + def test_switch_to_kimi_code(self, fake_config_dirs: dict[str, Path]) -> None: result = CliRunner().invoke( - backend_mod.backend_cmd, - ["use", "kimi-code", "--key", "sk-test", "--yes"], + provider_cmd, ["kimi-code", "--key", "sk-test", "--yes"] ) assert result.exit_code == 0, result.output settings_path = fake_config_dirs["claude"] / "settings.json" @@ -65,46 +69,39 @@ class TestBackendUse: 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"] - ) + def test_unknown_provider_errors(self) -> None: + result = CliRunner().invoke(provider_cmd, ["openai", "--yes"]) assert result.exit_code != 0 - assert "Unknown backend" in result.output + assert "Unknown provider" in result.output - def test_use_without_key_prompts(self, fake_config_dirs: dict[str, Path]) -> None: + def test_prompts_for_missing_key(self, fake_config_dirs: dict[str, Path]) -> None: result = CliRunner().invoke( - backend_mod.backend_cmd, - ["use", "deepseek", "--yes"], - input="sk-from-prompt\n", + provider_cmd, ["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: + def test_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", + provider_cmd, ["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: + def test_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"], + provider_cmd, ["deepseek", "--key", "sk-test", "--model", "custom", "--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" + assert settings["env"]["ANTHROPIC_MODEL"] == "custom" - def test_use_claude_clears_third_party(self, fake_config_dirs: dict[str, Path]) -> None: + def test_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( @@ -117,33 +114,16 @@ class TestBackendUse: } ) ) - result = CliRunner().invoke( - backend_mod.backend_cmd, ["use", "claude", "--yes"] - ) + result = CliRunner().invoke(provider_cmd, ["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: +class TestProviderKeySet: 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"], + provider_cmd, ["key", "set", "deepseek", "--key", "sk-test"] ) assert result.exit_code == 0, result.output config_path = fake_config_dirs["xiaohe"] / "config.json" @@ -152,22 +132,19 @@ class TestBackendKeySet: def test_key_set_for_claude_errors(self) -> None: result = CliRunner().invoke( - backend_mod.backend_key, - ["set", "claude", "--key", "sk-test"], + provider_cmd, ["key", "set", "claude", "--key", "sk-test"] ) assert result.exit_code != 0 assert "ANTHROPIC_API_KEY" in result.output -class TestBackendKeyRm: +class TestProviderKeyRm: 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"] - ) + result = CliRunner().invoke(provider_cmd, ["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", {}) @@ -177,9 +154,7 @@ class TestBackendKeyRm: 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" - ) + result = CliRunner().invoke(provider_cmd, ["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_entrypoints.py b/tests/test_entrypoints.py index 8591d0f..f5b228e 100644 --- a/tests/test_entrypoints.py +++ b/tests/test_entrypoints.py @@ -8,14 +8,20 @@ from click.testing import CliRunner from myagents.entrypoints import build_xiaohe_cli, claude_cli, codex_cli, hermes_cli, kimi_cli -class TestXiaoheBackend: - """``xiaohe backend`` subcommand registration.""" +class TestXiaoheProvider: + """``xiaohe switch provider`` subcommand registration.""" - def test_help_lists_backend(self) -> None: + def test_help_lists_switch(self) -> None: runner = CliRunner() result = runner.invoke(build_xiaohe_cli(), ["--help"]) assert result.exit_code == 0 - assert "backend" in result.output + assert "switch" in result.output + + def test_help_lists_info(self) -> None: + runner = CliRunner() + result = runner.invoke(build_xiaohe_cli(), ["--help"]) + assert result.exit_code == 0 + assert "info" in result.output class TestMyclaudeEntrypoint: