diff --git a/nanobot/cli/commands.py b/nanobot/cli/commands.py index 33b33f541..3b03869e2 100644 --- a/nanobot/cli/commands.py +++ b/nanobot/cli/commands.py @@ -1487,10 +1487,12 @@ provider_app = typer.Typer(help="Manage providers") app.add_typer(provider_app, name="provider") -_LOGIN_HANDLERS: dict[str, callable] = {} +_LOGIN_HANDLERS: dict[str, Any] = {} +_LOGOUT_HANDLERS: dict[str, Any] = {} def _register_login(name: str): + """注册 OAuth 登录处理器。""" def decorator(fn): _LOGIN_HANDLERS[name] = fn return fn @@ -1498,11 +1500,16 @@ def _register_login(name: str): return decorator -@provider_app.command("login") -def provider_login( - provider: str = typer.Argument(..., help="OAuth provider (e.g. 'openai-codex', 'github-copilot')"), -): - """Authenticate with an OAuth provider.""" +def _register_logout(name: str): + """注册 OAuth 登出处理器。""" + def decorator(fn): + _LOGOUT_HANDLERS[name] = fn + return fn + return decorator + + +def _resolve_oauth_provider(provider: str): + """解析并校验 OAuth provider 配置。""" from nanobot.providers.registry import PROVIDERS key = provider.replace("-", "_") @@ -1511,6 +1518,15 @@ def provider_login( names = ", ".join(s.name.replace("_", "-") for s in PROVIDERS if s.is_oauth) console.print(f"[red]Unknown OAuth provider: {provider}[/red] Supported: {names}") raise typer.Exit(1) + return spec + + +@provider_app.command("login") +def provider_login( + provider: str = typer.Argument(..., help="OAuth provider (e.g. 'openai-codex', 'github-copilot')"), +): + """Authenticate with an OAuth provider.""" + spec = _resolve_oauth_provider(provider) handler = _LOGIN_HANDLERS.get(spec.name) if not handler: @@ -1521,6 +1537,22 @@ def provider_login( handler() +@provider_app.command("logout") +def provider_logout( + provider: str = typer.Argument(..., help="OAuth provider (e.g. 'openai-codex', 'github-copilot')"), +): + """Log out from an OAuth provider.""" + spec = _resolve_oauth_provider(provider) + + handler = _LOGOUT_HANDLERS.get(spec.name) + if not handler: + console.print(f"[red]Logout not implemented for {spec.label}[/red]") + raise typer.Exit(1) + + console.print(f"{__logo__} OAuth Logout - {spec.label}\n") + handler() + + @_register_login("openai_codex") def _login_openai_codex() -> None: try: @@ -1544,6 +1576,33 @@ def _login_openai_codex() -> None: raise typer.Exit(1) +@_register_logout("openai_codex") +def _logout_openai_codex() -> None: + """清理 OpenAI Codex 的本地 OAuth 凭证。""" + try: + from oauth_cli_kit.providers import OPENAI_CODEX_PROVIDER + from oauth_cli_kit.storage import FileTokenStorage + except ImportError: + console.print("[red]oauth_cli_kit not installed. Run: pip install oauth-cli-kit[/red]") + raise typer.Exit(1) + + storage = FileTokenStorage(token_filename=OPENAI_CODEX_PROVIDER.token_filename) + removed_paths: list[Path] = [] + + for path in (storage.get_token_path(), storage.get_token_path().with_suffix(".lock")): + if path.exists(): + path.unlink() + removed_paths.append(path) + + if not removed_paths: + console.print("[yellow]! No local OAuth credentials found for OpenAI Codex[/yellow]") + return + + console.print("[green]✓ Logged out from OpenAI Codex[/green]") + for path in removed_paths: + console.print(f"[dim]Removed: {path}[/dim]") + + @_register_login("github_copilot") def _login_github_copilot() -> None: try: diff --git a/tests/cli/test_commands.py b/tests/cli/test_commands.py index 50ede9095..ce2fe6b0e 100644 --- a/tests/cli/test_commands.py +++ b/tests/cli/test_commands.py @@ -220,6 +220,32 @@ def test_config_dump_excludes_oauth_provider_blocks(): assert "githubCopilot" not in providers +def test_provider_logout_openai_codex_removes_local_oauth_files(tmp_path, monkeypatch): + token_path = tmp_path / "auth" / "oauth.json" + lock_path = token_path.with_suffix(".lock") + token_path.parent.mkdir(parents=True, exist_ok=True) + token_path.write_text("{}", encoding="utf-8") + lock_path.write_text("", encoding="utf-8") + monkeypatch.setenv("OAUTH_CLI_KIT_TOKEN_PATH", str(token_path)) + + result = runner.invoke(app, ["provider", "logout", "openai-codex"]) + + assert result.exit_code == 0 + assert not token_path.exists() + assert not lock_path.exists() + assert "Logged out from OpenAI Codex" in result.stdout + + +def test_provider_logout_openai_codex_succeeds_when_no_local_oauth_file(monkeypatch, tmp_path): + token_path = tmp_path / "auth" / "oauth.json" + monkeypatch.setenv("OAUTH_CLI_KIT_TOKEN_PATH", str(token_path)) + + result = runner.invoke(app, ["provider", "logout", "openai-codex"]) + + assert result.exit_code == 0 + assert "No local OAuth credentials found for OpenAI Codex" in result.stdout + + def test_config_matches_explicit_ollama_prefix_without_api_key(): config = Config() config.agents.defaults.model = "ollama/llama3.2"