feat(cli): add OAuth login/status/logout commands
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -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
93
nanobot/cli/oauth.py
Normal 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
55
tests/test_cli_oauth.py
Normal 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
|
||||
Reference in New Issue
Block a user