feat(cli): add provider logout command

- Implement \
anobot provider logout <provider>\ to clear OAuth credentials.
- Add \_LOGOUT_HANDLERS\ registration mechanism mirroring login.
- Implement logout for \openai-codex\ by deleting local \oauth-cli-kit\ token and lock files.
- Fallback gracefully when attempting to logout from providers lacking local credentials or implementations.
- Fixes #2665
This commit is contained in:
mikaku9944 2026-04-02 01:27:33 +08:00 committed by Xubin Ren
parent 0f32c0451e
commit 387988b8e9
2 changed files with 91 additions and 6 deletions

View File

@ -1487,10 +1487,12 @@ provider_app = typer.Typer(help="Manage providers")
app.add_typer(provider_app, name="provider") 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): def _register_login(name: str):
"""注册 OAuth 登录处理器。"""
def decorator(fn): def decorator(fn):
_LOGIN_HANDLERS[name] = fn _LOGIN_HANDLERS[name] = fn
return fn return fn
@ -1498,11 +1500,16 @@ def _register_login(name: str):
return decorator return decorator
@provider_app.command("login") def _register_logout(name: str):
def provider_login( """注册 OAuth 登出处理器。"""
provider: str = typer.Argument(..., help="OAuth provider (e.g. 'openai-codex', 'github-copilot')"), def decorator(fn):
): _LOGOUT_HANDLERS[name] = fn
"""Authenticate with an OAuth provider.""" return fn
return decorator
def _resolve_oauth_provider(provider: str):
"""解析并校验 OAuth provider 配置。"""
from nanobot.providers.registry import PROVIDERS from nanobot.providers.registry import PROVIDERS
key = provider.replace("-", "_") key = provider.replace("-", "_")
@ -1511,6 +1518,15 @@ def provider_login(
names = ", ".join(s.name.replace("_", "-") for s in PROVIDERS if s.is_oauth) names = ", ".join(s.name.replace("_", "-") for s in PROVIDERS if s.is_oauth)
console.print(f"[red]Unknown OAuth provider: {provider}[/red] Supported: {names}") console.print(f"[red]Unknown OAuth provider: {provider}[/red] Supported: {names}")
raise typer.Exit(1) 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) handler = _LOGIN_HANDLERS.get(spec.name)
if not handler: if not handler:
@ -1521,6 +1537,22 @@ def provider_login(
handler() 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") @_register_login("openai_codex")
def _login_openai_codex() -> None: def _login_openai_codex() -> None:
try: try:
@ -1544,6 +1576,33 @@ def _login_openai_codex() -> None:
raise typer.Exit(1) 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") @_register_login("github_copilot")
def _login_github_copilot() -> None: def _login_github_copilot() -> None:
try: try:

View File

@ -220,6 +220,32 @@ def test_config_dump_excludes_oauth_provider_blocks():
assert "githubCopilot" not in providers 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(): def test_config_matches_explicit_ollama_prefix_without_api_key():
config = Config() config = Config()
config.agents.defaults.model = "ollama/llama3.2" config.agents.defaults.model = "ollama/llama3.2"