- 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)
212 lines
6.4 KiB
Python
212 lines
6.4 KiB
Python
"""``xiaohe backend`` — switch Claude Code's model backend."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import click
|
|
from rich.console import Console
|
|
|
|
from myagents.backends import (
|
|
Provider,
|
|
get_provider,
|
|
is_third_party,
|
|
list_providers,
|
|
)
|
|
from myagents.claude_settings import (
|
|
apply_provider,
|
|
describe_active_backend,
|
|
get_active_provider,
|
|
save_settings,
|
|
)
|
|
from myagents.secrets import get_key, has_key, remove_key, set_key
|
|
from myagents.settings import set_setting
|
|
|
|
stderr_console = Console(stderr=True)
|
|
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)
|
|
except click.UsageError:
|
|
# click.prompt raises when stdin is not a TTY. Fall back to /dev/tty.
|
|
try:
|
|
with open("/dev/tty", encoding="utf-8") as tty: # noqa: PTH123
|
|
return tty.readline().rstrip("\n")
|
|
except OSError as exc:
|
|
raise click.ClickException(
|
|
f"Cannot read API key interactively: {exc}"
|
|
) from exc
|
|
|
|
|
|
@click.group("backend")
|
|
def backend_cmd() -> None:
|
|
"""Switch Claude Code's model backend (DeepSeek, Kimi, Kimi Code, Claude)."""
|
|
|
|
|
|
@backend_cmd.command("list")
|
|
def backend_list() -> None:
|
|
"""List built-in backends and the active one."""
|
|
active = get_active_provider()
|
|
console.print("[bold]Built-in backends:[/bold]")
|
|
for provider in list_providers():
|
|
marker = ""
|
|
if active and active.id == provider.id:
|
|
marker = " [green](current)[/green]"
|
|
key_status = "[dim]no key[/dim]"
|
|
if has_key(provider.key_name):
|
|
key_status = "[cyan]key stored[/cyan]"
|
|
if provider.id == "claude":
|
|
key_status = "[dim]official[/dim]"
|
|
console.print(
|
|
f" {provider.id:12} {provider.display_name:18} "
|
|
f"{provider.default_model or '-':20} {key_status}{marker}"
|
|
)
|
|
|
|
|
|
@backend_cmd.command("current")
|
|
def backend_current() -> None:
|
|
"""Show the active backend for this workspace."""
|
|
console.print(f"[bold]Current backend:[/bold] {describe_active_backend()}")
|
|
|
|
|
|
@backend_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 for this provider")
|
|
@click.option("--base-url", help="Override the provider base URL")
|
|
@click.option("--yes", "-y", is_flag=True, help="Skip confirmation prompt")
|
|
def backend_use(
|
|
provider_id: str,
|
|
key: str | None,
|
|
model: str | None,
|
|
base_url: str | None,
|
|
yes: bool,
|
|
) -> None:
|
|
"""Switch Claude Code to the given backend."""
|
|
try:
|
|
provider = get_provider(provider_id)
|
|
except KeyError:
|
|
known = ", ".join(p.id for p in list_providers())
|
|
raise click.ClickException(
|
|
f"Unknown backend '{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 backend.")
|
|
else:
|
|
resolved_key = None
|
|
|
|
summary = provider.display_name
|
|
if model:
|
|
summary += f" (model: {model})"
|
|
if base_url:
|
|
summary += f" (base-url: {base_url})"
|
|
|
|
if not yes and not click.confirm(
|
|
f"Set backend to {summary} for this workspace?",
|
|
default=True,
|
|
err=True,
|
|
):
|
|
console.print("Cancelled.")
|
|
return
|
|
|
|
settings = apply_provider(
|
|
provider,
|
|
key=resolved_key,
|
|
model=model,
|
|
base_url=base_url,
|
|
)
|
|
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 ~/.claude/settings.json.[/dim]"
|
|
)
|
|
|
|
|
|
@backend_cmd.command("reset")
|
|
@click.option("--yes", "-y", is_flag=True, help="Skip confirmation prompt")
|
|
def backend_reset(yes: bool) -> None:
|
|
"""Reset to the official Claude backend."""
|
|
ctx = click.get_current_context()
|
|
ctx.invoke(backend_use, provider_id="claude", yes=yes)
|
|
|
|
|
|
@backend_cmd.group("key")
|
|
def backend_key() -> None:
|
|
"""Manage stored API keys."""
|
|
|
|
|
|
@backend_key.command("set")
|
|
@click.argument("provider_id")
|
|
@click.option("--key", help="API key (scripting only; appears in shell history)")
|
|
def backend_key_set(provider_id: str, key: str | None) -> None:
|
|
"""Store an API key for a backend without switching to it."""
|
|
try:
|
|
provider = get_provider(provider_id)
|
|
except KeyError:
|
|
known = ", ".join(p.id for p in list_providers())
|
|
raise click.ClickException(
|
|
f"Unknown backend '{provider_id}'. Choose from: {known}"
|
|
)
|
|
|
|
if provider.id == "claude":
|
|
raise click.ClickException(
|
|
"Use the ANTHROPIC_API_KEY environment variable for official Claude auth."
|
|
)
|
|
|
|
resolved_key = key
|
|
if not resolved_key:
|
|
resolved_key = _secure_prompt_key(provider)
|
|
if not resolved_key:
|
|
raise click.ClickException("API key cannot be empty.")
|
|
|
|
set_key(provider.key_name, resolved_key)
|
|
console.print(
|
|
f"[green]Stored {provider.display_name} key in ~/.xiaohe/agent/config.json.[/green]"
|
|
)
|
|
|
|
|
|
@backend_key.command("rm")
|
|
@click.argument("provider_id")
|
|
@click.option(
|
|
"--yes",
|
|
"-y",
|
|
is_flag=True,
|
|
help="Skip the confirmation prompt",
|
|
)
|
|
def backend_key_rm(provider_id: str, yes: bool) -> None:
|
|
"""Remove the stored API key for a backend."""
|
|
try:
|
|
provider = get_provider(provider_id)
|
|
except KeyError:
|
|
known = ", ".join(p.id for p in list_providers())
|
|
raise click.ClickException(
|
|
f"Unknown backend '{provider_id}'. Choose from: {known}"
|
|
)
|
|
|
|
if not yes and not click.confirm(
|
|
f"Remove stored key for {provider.display_name}?",
|
|
default=False,
|
|
err=True,
|
|
):
|
|
console.print("Cancelled.")
|
|
return
|
|
|
|
if remove_key(provider.key_name):
|
|
console.print(f"[green]Removed {provider.display_name} key.[/green]")
|
|
else:
|
|
console.print(f"[yellow]No stored key for {provider.display_name}.[/yellow]")
|
|
|
|
|
|
if __name__ == "__main__":
|
|
backend_cmd()
|