diff --git a/myagents/commands/completion_install.py b/myagents/commands/completion_install.py index 2719698..101ec67 100644 --- a/myagents/commands/completion_install.py +++ b/myagents/commands/completion_install.py @@ -132,16 +132,18 @@ def completions_installed( *, install_root: Path | None = None, ) -> bool: - """Check whether the zsh completion for the first prog is current.""" - if not progs: - return True - prog_name, _ = progs[0] - for target in completion_targets(prog_name, install_root=install_root): - if target.shell == "zsh" and target.installed: - marker = f"_{prog_name.upper()}_COMPLETE" - if marker in target.path.read_text(encoding="utf-8"): - return True - return False + """Check whether the zsh completion for every prog is current.""" + for prog_name, _ in progs: + ok = False + for target in completion_targets(prog_name, install_root=install_root): + if target.shell == "zsh" and target.installed: + marker = f"_{prog_name.upper()}_COMPLETE" + if marker in target.path.read_text(encoding="utf-8"): + ok = True + break + if not ok: + return False + return bool(progs) def ensure_completions_installed( diff --git a/myagents/commands/first_run.py b/myagents/commands/first_run.py new file mode 100644 index 0000000..46ce397 --- /dev/null +++ b/myagents/commands/first_run.py @@ -0,0 +1,173 @@ +"""First-run detection: guide user through provider setup when nothing is configured. + +Called from ``xiaohe_main()`` on every invocation. When no auth is detected +(no env vars, no stored keys, no login credentials), prints a menu and +walks the user through selecting a provider and entering an API key. +""" + +from __future__ import annotations + +import os +from pathlib import Path + +import click +from rich.console import Console + +from myagents.backends import ( + PROVIDERS, + THIRD_PARTY_PROVIDER_IDS, + Provider, +) +from myagents.claude_settings import ( + apply_provider, + get_active_provider, + load_settings, + save_settings, +) +from myagents.secrets import has_key, set_key + +stderr_console = Console(stderr=True) + + +def _has_claude_login() -> bool: + """Check for Claude Code OAuth login credentials file.""" + creds = Path.home() / ".claude" / ".credentials.json" + return creds.is_file() + + +def is_any_auth_configured() -> bool: + """Return True when at least one usable auth method is available.""" + # 1. Official Claude: env var or login credentials file + if os.environ.get("ANTHROPIC_API_KEY") or os.environ.get("ANTHROPIC_AUTH_TOKEN"): + return True + if _has_claude_login(): + return True + + # 2. Third-party provider active in Claude Code settings + if get_active_provider() is not None: + return True + + # 3. ANTHROPIC_API_KEY set in ~/.claude/settings.json env block + env = load_settings().get("env", {}) + if isinstance(env, dict) and env.get("ANTHROPIC_API_KEY"): + return True + + # 4. Stored third-party keys (even if not currently active) + for pid in THIRD_PARTY_PROVIDER_IDS: + if has_key(PROVIDERS[pid].key_name): + return True + + return False + + +def _secure_prompt_key(provider: Provider) -> str: + """Prompt for an API key with hidden input.""" + try: + return click.prompt( + f"Enter {provider.display_name} API key", + hide_input=True, + err=True, + ) + except click.UsageError: + try: + with open("/dev/tty", encoding="utf-8") as tty: + return tty.readline().rstrip("\n") + except OSError as exc: + raise click.ClickException( + f"Cannot read API key interactively: {exc}" + ) from exc + + +def _persist_claude_api_key(key: str) -> None: + """Write ANTHROPIC_API_KEY to ~/.claude/settings.json env block.""" + settings = load_settings() + env: dict = {k: v for k, v in settings.get("env", {}).items() + if isinstance(v, str)} + env["ANTHROPIC_API_KEY"] = key + settings["env"] = env + save_settings(settings) + + +def _run_setup_wizard() -> None: + """Interactive guided setup: choose provider and enter API key.""" + providers = [ + PROVIDERS["claude"], + PROVIDERS["deepseek"], + PROVIDERS["kimi"], + PROVIDERS["kimi-code"], + ] + + stderr_console.print( + "\n[bold yellow]No model provider configured yet.[/bold yellow]" + ) + stderr_console.print( + "[dim]Claude Code needs authentication. Pick one:[/dim]\n" + ) + for i, p in enumerate(providers, 1): + stderr_console.print(f" {i}) {p.display_name}") + stderr_console.print( + " [dim]Or press Enter to skip (set up later with:" + " xiaohe switch provider )[/dim]" + ) + + try: + choice = click.prompt( + "Choose", default="", show_default=False, err=True + ) + except (click.Abort, EOFError): + stderr_console.print("[dim]Skipped. Run 'xiaohe switch provider' later.[/dim]") + return + + choice = choice.strip() + if not choice: + stderr_console.print("[dim]Skipped. Run 'xiaohe switch provider' later.[/dim]") + return + + try: + idx = int(choice) - 1 + if idx < 0 or idx >= len(providers): + raise ValueError + except ValueError: + stderr_console.print(f"[red]Invalid choice: {choice}[/red]") + return + + provider = providers[idx] + stderr_console.print(f"\n[bold]{provider.display_name}[/bold] selected.") + + if provider.id == "claude": + key = _secure_prompt_key(provider) + if not key: + stderr_console.print("[yellow]No key entered — skipped.[/yellow]") + return + _persist_claude_api_key(key) + stderr_console.print( + f"[green]{provider.display_name} API key saved to" + f" ~/.claude/settings.json.[/green]" + ) + else: + key = _secure_prompt_key(provider) + if not key: + stderr_console.print("[yellow]No key entered — skipped.[/yellow]") + return + set_key(provider.key_name, key) + settings = apply_provider(provider, key=key) + save_settings(settings) + stderr_console.print( + f"[green]Switched provider to {provider.display_name}.[/green]" + ) + + stderr_console.print() + + +def ensure_auth_configured() -> None: + """Check auth state on ``xiaohe`` startup; run wizard if unconfigured.""" + if is_any_auth_configured(): + return + try: + _run_setup_wizard() + except Exception: + # Never block xiaohe launch on a setup error. + stderr_console.print( + "[yellow]Setup interrupted." + " Run 'xiaohe switch provider' later to configure.[/yellow]" + ) diff --git a/myagents/entrypoints.py b/myagents/entrypoints.py index ac0c5c5..dba972c 100644 --- a/myagents/entrypoints.py +++ b/myagents/entrypoints.py @@ -110,6 +110,8 @@ def xiaohe_main() -> None: from myagents.commands.completion_install import ( ensure_completions_installed, ) + from myagents.commands.first_run import ensure_auth_configured ensure_completions_installed(_progs()) + ensure_auth_configured() build_xiaohe_cli()()