Files
myagents/myagents/commands/backend.py
T

213 lines
6.5 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_project_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_project_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]"
)
@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()