feat(cli): add OAuth login/status/logout commands

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
wylab
2026-02-13 13:16:23 +01:00
parent 5f9af317c4
commit f444e94ff7
3 changed files with 151 additions and 0 deletions

View File

@@ -507,6 +507,9 @@ def agent(
channels_app = typer.Typer(help="Manage channels")
app.add_typer(channels_app, name="channels")
from nanobot.cli.oauth import oauth_app
app.add_typer(oauth_app, name="oauth")
@channels_app.command("status")
def channels_status():

93
nanobot/cli/oauth.py Normal file
View File

@@ -0,0 +1,93 @@
"""OAuth CLI commands for subscription authentication."""
from pathlib import Path
from typing import Optional
import typer
from rich.console import Console
oauth_app = typer.Typer(help="Manage OAuth authentication for subscription-based providers")
console = Console()
@oauth_app.command("login")
def login(
provider: str = typer.Argument("anthropic", help="Provider name"),
token: Optional[str] = typer.Option(None, "--token", "-t", help="OAuth token (from claude setup-token)"),
):
"""Login to a provider using OAuth.
For Anthropic Claude Max/Pro, run 'claude setup-token' and paste the token here.
Example:
nanobot oauth login anthropic --token sk-ant-oat01-xxx
"""
from nanobot.config.oauth_store import OAuthStore
from nanobot.config.schema import OAuthCredentials
if provider != "anthropic":
console.print(f"[red]OAuth login for {provider} not yet supported[/red]")
return
if not token:
console.print("Please provide your OAuth token:")
console.print(" 1. Run: claude setup-token")
console.print(" 2. Copy the sk-ant-oat01-... token")
console.print(" 3. Run: nanobot oauth login anthropic --token <your-token>")
console.print()
token = typer.prompt("Token", hide_input=True)
if not token or "sk-ant-oat" not in token:
console.print("[red]Invalid token. Must contain sk-ant-oat[/red]")
return
store = OAuthStore(Path.home() / ".nanobot")
creds = OAuthCredentials(
access_token=token,
token_type="token" # setup-token doesn't expire
)
store.save(provider, creds)
console.print(f"[green]Successfully saved {provider} OAuth credentials![/green]")
@oauth_app.command("status")
def status():
"""Show OAuth credential status."""
from nanobot.config.oauth_store import OAuthStore
store = OAuthStore(Path.home() / ".nanobot")
providers = ["anthropic"]
found_any = False
for provider in providers:
creds = store.load(provider)
if creds:
found_any = True
st = "valid"
if creds.is_expired:
st = "EXPIRED"
elif creds.expires_soon:
st = "expires soon"
token_preview = creds.access_token[:20] + "..."
console.print(f" {provider}: {token_preview} ({st})")
if not found_any:
console.print("No OAuth credentials configured.")
console.print("Run: nanobot oauth login anthropic --token <token>")
@oauth_app.command("logout")
def logout(
provider: str = typer.Argument("anthropic", help="Provider name"),
):
"""Remove OAuth credentials for a provider."""
from nanobot.config.oauth_store import OAuthStore
store = OAuthStore(Path.home() / ".nanobot")
if store.delete(provider):
console.print(f"[green]Removed {provider} OAuth credentials[/green]")
else:
console.print(f"No credentials found for {provider}")

55
tests/test_cli_oauth.py Normal file
View File

@@ -0,0 +1,55 @@
"""Test OAuth CLI commands."""
import pytest
import tempfile
from pathlib import Path
from typer.testing import CliRunner
from nanobot.cli.oauth import oauth_app
@pytest.fixture
def runner():
return CliRunner()
def test_oauth_login_help(runner):
"""Login command should have help text."""
result = runner.invoke(oauth_app, ["login", "--help"])
assert result.exit_code == 0
assert "token" in result.output.lower()
def test_oauth_status_no_credentials(runner, tmp_path, monkeypatch):
"""Status should show no credentials when none exist."""
monkeypatch.setenv("HOME", str(tmp_path))
result = runner.invoke(oauth_app, ["status"])
assert result.exit_code == 0
assert "No OAuth credentials" in result.output
def test_oauth_login_and_status(runner, tmp_path, monkeypatch):
"""Login should save credentials, status should show them."""
monkeypatch.setenv("HOME", str(tmp_path))
result = runner.invoke(oauth_app, ["login", "--token", "sk-ant-oat01-test-xxx"])
assert result.exit_code == 0
assert "Successfully saved" in result.output
result = runner.invoke(oauth_app, ["status"])
assert result.exit_code == 0
assert "sk-ant-oat01-test-x" in result.output
def test_oauth_logout(runner, tmp_path, monkeypatch):
"""Logout should remove credentials."""
monkeypatch.setenv("HOME", str(tmp_path))
runner.invoke(oauth_app, ["login", "--token", "sk-ant-oat01-test-xxx"])
result = runner.invoke(oauth_app, ["logout"])
assert result.exit_code == 0
assert "Removed" in result.output
def test_oauth_login_invalid_token(runner, tmp_path, monkeypatch):
"""Login should reject non-OAuth tokens."""
monkeypatch.setenv("HOME", str(tmp_path))
result = runner.invoke(oauth_app, ["login", "--token", "sk-ant-api03-regular"])
assert result.exit_code == 0
assert "Invalid token" in result.output