feat: add OAuth login command for OpenAI Codex

This commit is contained in:
pinhua33 2026-02-09 15:39:30 +08:00
parent ae908e0dcd
commit fc67d11da9

View File

@ -4,9 +4,9 @@ import asyncio
import atexit import atexit
import os import os
import signal import signal
import sys
from pathlib import Path from pathlib import Path
import select import select
import sys
import typer import typer
from rich.console import Console from rich.console import Console
@ -95,148 +95,382 @@ def _enable_line_editing() -> None:
except Exception: except Exception:
pass pass
history_file = Path.home() / ".nanobot" / "history" / "cli_history"
history_file.parent.mkdir(parents=True, exist_ok=True)
_HISTORY_FILE = history_file
try: try:
import readline as _READLINE import readline
import atexit except ImportError:
# Detect libedit (macOS) vs GNU readline (Linux)
if hasattr(_READLINE, "__doc__") and _READLINE.__doc__ and "libedit" in _READLINE.__doc__:
_USING_LIBEDIT = True
hist_file = Path.home() / ".nanobot_history"
_HISTORY_FILE = hist_file
try:
_READLINE.read_history_file(str(hist_file))
except FileNotFoundError:
pass
# Enable common readline settings
_READLINE.parse_and_bind("bind -v" if _USING_LIBEDIT else "set editing-mode vi")
_READLINE.parse_and_bind("set show-all-if-ambiguous on")
_READLINE.parse_and_bind("set colored-completion-prefix on")
if not _HISTORY_HOOK_REGISTERED:
atexit.register(_save_history)
_HISTORY_HOOK_REGISTERED = True
except Exception:
return return
_READLINE = readline
_USING_LIBEDIT = "libedit" in (readline.__doc__ or "").lower()
try:
if _USING_LIBEDIT:
readline.parse_and_bind("bind ^I rl_complete")
else:
readline.parse_and_bind("tab: complete")
readline.parse_and_bind("set editing-mode emacs")
except Exception:
pass
try:
readline.read_history_file(str(history_file))
except Exception:
pass
if not _HISTORY_HOOK_REGISTERED:
atexit.register(_save_history)
_HISTORY_HOOK_REGISTERED = True
def _prompt_text() -> str:
"""Build a readline-friendly colored prompt."""
if _READLINE is None:
return "You: "
# libedit on macOS does not honor GNU readline non-printing markers.
if _USING_LIBEDIT:
return "\033[1;34mYou:\033[0m "
return "\001\033[1;34m\002You:\001\033[0m\002 "
def _print_agent_response(response: str, render_markdown: bool) -> None:
"""Render assistant response with consistent terminal styling."""
content = response or ""
body = Markdown(content) if render_markdown else Text(content)
console.print()
console.print(
Panel(
body,
title=f"{__logo__} nanobot",
title_align="left",
border_style="cyan",
padding=(0, 1),
)
)
console.print()
def _is_exit_command(command: str) -> bool:
"""Return True when input should end interactive chat."""
return command.lower() in EXIT_COMMANDS
async def _read_interactive_input_async() -> str: async def _read_interactive_input_async() -> str:
"""Async wrapper around synchronous input() (runs in thread pool).""" """Read user input with arrow keys and history (runs input() in a thread)."""
loop = asyncio.get_running_loop()
return await loop.run_in_executor(None, lambda: input(f"{__logo__} "))
def _is_exit_command(text: str) -> bool:
return text.strip().lower() in EXIT_COMMANDS
# ---------------------------------------------------------------------------
# OAuth and Authentication helpers
# ---------------------------------------------------------------------------
def _handle_oauth_login(provider: str) -> None:
"""Handle OAuth login flow for supported providers."""
from nanobot.providers.registry import get_oauth_handler
oauth_handler = get_oauth_handler(provider)
if oauth_handler is None:
console.print(f"[red]OAuth is not supported for provider: {provider}[/red]")
console.print("[yellow]Supported OAuth providers: github-copilot[/yellow]")
raise typer.Exit(1)
try: try:
result = oauth_handler.authenticate() return await asyncio.to_thread(input, _prompt_text())
if result.success: except EOFError as exc:
console.print(f"[green]✓ {result.message}[/green]") raise KeyboardInterrupt from exc
if result.token_path:
console.print(f"[dim]Token saved to: {result.token_path}[/dim]")
else:
console.print(f"[red]✗ {result.message}[/red]")
raise typer.Exit(1)
except Exception as e:
console.print(f"[red]OAuth authentication failed: {e}[/red]")
raise typer.Exit(1)
# --------------------------------------------------------------------------- def version_callback(value: bool):
# @agent decorator and public API helpers if value:
# --------------------------------------------------------------------------- console.print(f"{__logo__} nanobot v{__version__}")
raise typer.Exit()
_agent_registry: dict[str, callable] = {}
def _get_agent(name: str | None = None) -> callable | None: @app.callback()
"""Retrieve a registered agent function by name.""" def main(
if name is None: version: bool = typer.Option(
# Return the first registered agent if no name specified None, "--version", "-v", callback=version_callback, is_eager=True
return next(iter(_agent_registry.values())) if _agent_registry else None ),
return _agent_registry.get(name) ):
"""nanobot - Personal AI Assistant."""
pass
def agent(name: str | None = None, model: str | None = None, prompt: str | None = None): # ============================================================================
"""Decorator to register an agent function. # Onboard / Setup
# ============================================================================
@app.command()
def onboard():
"""Initialize nanobot configuration and workspace."""
from nanobot.config.loader import get_config_path, save_config
from nanobot.config.schema import Config
from nanobot.utils.helpers import get_workspace_path
Args: config_path = get_config_path()
name: Optional name for the agent (defaults to function name)
model: Optional model override (e.g., "gpt-4o", "claude-3-opus") if config_path.exists():
prompt: Optional system prompt for the agent console.print(f"[yellow]Config already exists at {config_path}[/yellow]")
""" if not typer.confirm("Overwrite?"):
def decorator(func): raise typer.Exit()
agent_name = name or func.__name__
_agent_registry[agent_name] = func # Create default config
func._agent_config = {"model": model, "prompt": prompt} config = Config()
return func save_config(config)
return decorator console.print(f"[green]✓[/green] Created config at {config_path}")
# Create workspace
workspace = get_workspace_path()
console.print(f"[green]✓[/green] Created workspace at {workspace}")
# Create default bootstrap files
_create_workspace_templates(workspace)
console.print(f"\n{__logo__} nanobot is ready!")
console.print("\nNext steps:")
console.print(" 1. Add your API key to [cyan]~/.nanobot/config.json[/cyan]")
console.print(" Get one at: https://openrouter.ai/keys")
console.print(" 2. Chat: [cyan]nanobot agent -m \"Hello!\"[/cyan]")
console.print("\n[dim]Want Telegram/WhatsApp? See: https://github.com/HKUDS/nanobot#-chat-apps[/dim]")
# ---------------------------------------------------------------------------
# Built-in CLI commands
# ---------------------------------------------------------------------------
@app.command()
def login( def _create_workspace_templates(workspace: Path):
provider: str = typer.Argument(..., help="Provider to authenticate with (e.g., 'github-copilot')"), """Create default workspace template files."""
): templates = {
"""Authenticate with an OAuth provider.""" "AGENTS.md": """# Agent Instructions
_handle_oauth_login(provider)
You are a helpful AI assistant. Be concise, accurate, and friendly.
## Guidelines
- Always explain what you're doing before taking actions
- Ask for clarification when the request is ambiguous
- Use tools to help accomplish tasks
- Remember important information in your memory files
""",
"SOUL.md": """# Soul
I am nanobot, a lightweight AI assistant.
## Personality
- Helpful and friendly
- Concise and to the point
- Curious and eager to learn
## Values
- Accuracy over speed
- User privacy and safety
- Transparency in actions
""",
"USER.md": """# User
Information about the user goes here.
## Preferences
- Communication style: (casual/formal)
- Timezone: (your timezone)
- Language: (your preferred language)
""",
}
for filename, content in templates.items():
file_path = workspace / filename
if not file_path.exists():
file_path.write_text(content)
console.print(f" [dim]Created {filename}[/dim]")
# Create memory directory and MEMORY.md
memory_dir = workspace / "memory"
memory_dir.mkdir(exist_ok=True)
memory_file = memory_dir / "MEMORY.md"
if not memory_file.exists():
memory_file.write_text("""# Long-term Memory
This file stores important information that should persist across sessions.
## User Information
(Important facts about the user)
## Preferences
(User preferences learned over time)
## Important Notes
(Things to remember)
""")
console.print(" [dim]Created memory/MEMORY.md[/dim]")
def _make_provider(config):
"""Create LiteLLMProvider from config. Exits if no API key found."""
from nanobot.providers.litellm_provider import LiteLLMProvider
p = config.get_provider()
model = config.agents.defaults.model
if not (p and p.api_key) and not model.startswith("bedrock/"):
console.print("[red]Error: No API key configured.[/red]")
console.print("Set one in ~/.nanobot/config.json under providers section")
raise typer.Exit(1)
return LiteLLMProvider(
api_key=p.api_key if p else None,
api_base=config.get_api_base(),
default_model=model,
extra_headers=p.extra_headers if p else None,
provider_name=config.get_provider_name(),
)
# ============================================================================
# Gateway / Server
# ============================================================================
@app.command() @app.command()
def version(): def gateway(
"""Show version information.""" port: int = typer.Option(18790, "--port", "-p", help="Gateway port"),
console.print(f"{__logo__} nanobot {__version__}") verbose: bool = typer.Option(False, "--verbose", "-v", help="Verbose output"),
@app.command(name="agent")
def run_agent(
name: str | None = typer.Argument(None, help="Name of the agent to run"),
message: str = typer.Option(None, "--message", "-m", help="Single message to send to the agent"),
model: str = typer.Option(None, "--model", help="Override the model for this run"),
markdown: bool = typer.Option(True, "--markdown/--no-markdown", help="Render response as markdown"),
session_id: str = typer.Option("cli", "--session", "-s", help="Session ID for this conversation"),
): ):
"""Run an interactive AI agent session.""" """Start the nanobot gateway."""
import asyncio from nanobot.config.loader import load_config, get_data_dir
from nanobot.bus.queue import MessageBus
from nanobot.agent.loop import AgentLoop from nanobot.agent.loop import AgentLoop
from nanobot.channels.manager import ChannelManager
from nanobot.session.manager import SessionManager
from nanobot.cron.service import CronService
from nanobot.cron.types import CronJob
from nanobot.heartbeat.service import HeartbeatService
# Get the agent function if verbose:
agent_func = _get_agent(name) import logging
if agent_func is None: logging.basicConfig(level=logging.DEBUG)
if name:
console.print(f"[red]Agent '{name}' not found[/red]")
else:
console.print("[yellow]No agents registered. Use @agent decorator to register agents.[/yellow]")
raise typer.Exit(1)
# Initialize agent loop console.print(f"{__logo__} Starting nanobot gateway on port {port}...")
agent_config = getattr(agent_func, '_agent_config', {})
agent_model = model or agent_config.get('model')
agent_prompt = agent_config.get('prompt')
agent_loop = AgentLoop(model=agent_model, system_prompt=agent_prompt) config = load_config()
bus = MessageBus()
provider = _make_provider(config)
session_manager = SessionManager(config.workspace_path)
# Create cron service first (callback set after agent creation)
cron_store_path = get_data_dir() / "cron" / "jobs.json"
cron = CronService(cron_store_path)
# Create agent with cron service
agent = AgentLoop(
bus=bus,
provider=provider,
workspace=config.workspace_path,
model=config.agents.defaults.model,
max_iterations=config.agents.defaults.max_tool_iterations,
brave_api_key=config.tools.web.search.api_key or None,
exec_config=config.tools.exec,
cron_service=cron,
restrict_to_workspace=config.tools.restrict_to_workspace,
session_manager=session_manager,
)
# Set cron callback (needs agent)
async def on_cron_job(job: CronJob) -> str | None:
"""Execute a cron job through the agent."""
response = await agent.process_direct(
job.payload.message,
session_key=f"cron:{job.id}",
channel=job.payload.channel or "cli",
chat_id=job.payload.to or "direct",
)
if job.payload.deliver and job.payload.to:
from nanobot.bus.events import OutboundMessage
await bus.publish_outbound(OutboundMessage(
channel=job.payload.channel or "cli",
chat_id=job.payload.to,
content=response or ""
))
return response
cron.on_job = on_cron_job
# Create heartbeat service
async def on_heartbeat(prompt: str) -> str:
"""Execute heartbeat through the agent."""
return await agent.process_direct(prompt, session_key="heartbeat")
heartbeat = HeartbeatService(
workspace=config.workspace_path,
on_heartbeat=on_heartbeat,
interval_s=30 * 60, # 30 minutes
enabled=True
)
# Create channel manager
channels = ChannelManager(config, bus, session_manager=session_manager)
if channels.enabled_channels:
console.print(f"[green]✓[/green] Channels enabled: {', '.join(channels.enabled_channels)}")
else:
console.print("[yellow]Warning: No channels enabled[/yellow]")
cron_status = cron.status()
if cron_status["jobs"] > 0:
console.print(f"[green]✓[/green] Cron: {cron_status['jobs']} scheduled jobs")
console.print(f"[green]✓[/green] Heartbeat: every 30m")
async def run():
try:
await cron.start()
await heartbeat.start()
await asyncio.gather(
agent.run(),
channels.start_all(),
)
except KeyboardInterrupt:
console.print("\nShutting down...")
heartbeat.stop()
cron.stop()
agent.stop()
await channels.stop_all()
asyncio.run(run())
# ============================================================================
# Agent Commands
# ============================================================================
@app.command()
def agent(
message: str = typer.Option(None, "--message", "-m", help="Message to send to the agent"),
session_id: str = typer.Option("cli:default", "--session", "-s", help="Session ID"),
markdown: bool = typer.Option(True, "--markdown/--no-markdown", help="Render assistant output as Markdown"),
logs: bool = typer.Option(False, "--logs/--no-logs", help="Show nanobot runtime logs during chat"),
):
"""Interact with the agent directly."""
from nanobot.config.loader import load_config
from nanobot.bus.queue import MessageBus
from nanobot.agent.loop import AgentLoop
from loguru import logger
config = load_config()
bus = MessageBus()
provider = _make_provider(config)
if logs:
logger.enable("nanobot")
else:
logger.disable("nanobot")
agent_loop = AgentLoop(
bus=bus,
provider=provider,
workspace=config.workspace_path,
brave_api_key=config.tools.web.search.api_key or None,
exec_config=config.tools.exec,
restrict_to_workspace=config.tools.restrict_to_workspace,
)
# Show spinner when logs are off (no output to miss); skip when logs are on
def _thinking_ctx():
if logs:
from contextlib import nullcontext
return nullcontext()
return console.status("[dim]nanobot is thinking...[/dim]", spinner="dots")
if message: if message:
# Single message mode # Single message mode
async def run_once(): async def run_once():
@ -283,55 +517,374 @@ def run_agent(
_restore_terminal() _restore_terminal()
console.print("\nGoodbye!") console.print("\nGoodbye!")
break break
except EOFError:
_save_history()
_restore_terminal()
console.print("\nGoodbye!")
break
asyncio.run(run_interactive()) asyncio.run(run_interactive())
def _thinking_ctx(): # ============================================================================
"""Context manager for showing thinking indicator.""" # Channel Commands
from rich.live import Live # ============================================================================
from rich.spinner import Spinner
channels_app = typer.Typer(help="Manage channels")
app.add_typer(channels_app, name="channels")
@channels_app.command("status")
def channels_status():
"""Show channel status."""
from nanobot.config.loader import load_config
config = load_config()
table = Table(title="Channel Status")
table.add_column("Channel", style="cyan")
table.add_column("Enabled", style="green")
table.add_column("Configuration", style="yellow")
# WhatsApp
wa = config.channels.whatsapp
table.add_row(
"WhatsApp",
"" if wa.enabled else "",
wa.bridge_url
)
dc = config.channels.discord
table.add_row(
"Discord",
"" if dc.enabled else "",
dc.gateway_url
)
class ThinkingSpinner: # Telegram
def __enter__(self): tg = config.channels.telegram
self.live = Live(Spinner("dots", text="Thinking..."), console=console, refresh_per_second=10) tg_config = f"token: {tg.token[:10]}..." if tg.token else "[dim]not configured[/dim]"
self.live.start() table.add_row(
return self "Telegram",
"" if tg.enabled else "",
tg_config
)
console.print(table)
def _get_bridge_dir() -> Path:
"""Get the bridge directory, setting it up if needed."""
import shutil
import subprocess
# User's bridge location
user_bridge = Path.home() / ".nanobot" / "bridge"
# Check if already built
if (user_bridge / "dist" / "index.js").exists():
return user_bridge
# Check for npm
if not shutil.which("npm"):
console.print("[red]npm not found. Please install Node.js >= 18.[/red]")
raise typer.Exit(1)
# Find source bridge: first check package data, then source dir
pkg_bridge = Path(__file__).parent.parent / "bridge" # nanobot/bridge (installed)
src_bridge = Path(__file__).parent.parent.parent / "bridge" # repo root/bridge (dev)
source = None
if (pkg_bridge / "package.json").exists():
source = pkg_bridge
elif (src_bridge / "package.json").exists():
source = src_bridge
if not source:
console.print("[red]Bridge source not found.[/red]")
console.print("Try reinstalling: pip install --force-reinstall nanobot")
raise typer.Exit(1)
console.print(f"{__logo__} Setting up bridge...")
# Copy to user directory
user_bridge.parent.mkdir(parents=True, exist_ok=True)
if user_bridge.exists():
shutil.rmtree(user_bridge)
shutil.copytree(source, user_bridge, ignore=shutil.ignore_patterns("node_modules", "dist"))
# Install and build
try:
console.print(" Installing dependencies...")
subprocess.run(["npm", "install"], cwd=user_bridge, check=True, capture_output=True)
def __exit__(self, exc_type, exc_val, exc_tb): console.print(" Building...")
self.live.stop() subprocess.run(["npm", "run", "build"], cwd=user_bridge, check=True, capture_output=True)
return False
console.print("[green]✓[/green] Bridge ready\n")
except subprocess.CalledProcessError as e:
console.print(f"[red]Build failed: {e}[/red]")
if e.stderr:
console.print(f"[dim]{e.stderr.decode()[:500]}[/dim]")
raise typer.Exit(1)
return ThinkingSpinner() return user_bridge
def _print_agent_response(response: str, render_markdown: bool = True): @channels_app.command("login")
"""Print agent response with optional markdown rendering.""" def channels_login():
if render_markdown: """Link device via QR code."""
console.print(Markdown(response)) import subprocess
bridge_dir = _get_bridge_dir()
console.print(f"{__logo__} Starting bridge...")
console.print("Scan the QR code to connect.\n")
try:
subprocess.run(["npm", "start"], cwd=bridge_dir, check=True)
except subprocess.CalledProcessError as e:
console.print(f"[red]Bridge failed: {e}[/red]")
except FileNotFoundError:
console.print("[red]npm not found. Please install Node.js.[/red]")
# ============================================================================
# Cron Commands
# ============================================================================
cron_app = typer.Typer(help="Manage scheduled tasks")
app.add_typer(cron_app, name="cron")
@cron_app.command("list")
def cron_list(
all: bool = typer.Option(False, "--all", "-a", help="Include disabled jobs"),
):
"""List scheduled jobs."""
from nanobot.config.loader import get_data_dir
from nanobot.cron.service import CronService
store_path = get_data_dir() / "cron" / "jobs.json"
service = CronService(store_path)
jobs = service.list_jobs(include_disabled=all)
if not jobs:
console.print("No scheduled jobs.")
return
table = Table(title="Scheduled Jobs")
table.add_column("ID", style="cyan")
table.add_column("Name")
table.add_column("Schedule")
table.add_column("Status")
table.add_column("Next Run")
import time
for job in jobs:
# Format schedule
if job.schedule.kind == "every":
sched = f"every {(job.schedule.every_ms or 0) // 1000}s"
elif job.schedule.kind == "cron":
sched = job.schedule.expr or ""
else:
sched = "one-time"
# Format next run
next_run = ""
if job.state.next_run_at_ms:
next_time = time.strftime("%Y-%m-%d %H:%M", time.localtime(job.state.next_run_at_ms / 1000))
next_run = next_time
status = "[green]enabled[/green]" if job.enabled else "[dim]disabled[/dim]"
table.add_row(job.id, job.name, sched, status, next_run)
console.print(table)
@cron_app.command("add")
def cron_add(
name: str = typer.Option(..., "--name", "-n", help="Job name"),
message: str = typer.Option(..., "--message", "-m", help="Message for agent"),
every: int = typer.Option(None, "--every", "-e", help="Run every N seconds"),
cron_expr: str = typer.Option(None, "--cron", "-c", help="Cron expression (e.g. '0 9 * * *')"),
at: str = typer.Option(None, "--at", help="Run once at time (ISO format)"),
deliver: bool = typer.Option(False, "--deliver", "-d", help="Deliver response to channel"),
to: str = typer.Option(None, "--to", help="Recipient for delivery"),
channel: str = typer.Option(None, "--channel", help="Channel for delivery (e.g. 'telegram', 'whatsapp')"),
):
"""Add a scheduled job."""
from nanobot.config.loader import get_data_dir
from nanobot.cron.service import CronService
from nanobot.cron.types import CronSchedule
# Determine schedule type
if every:
schedule = CronSchedule(kind="every", every_ms=every * 1000)
elif cron_expr:
schedule = CronSchedule(kind="cron", expr=cron_expr)
elif at:
import datetime
dt = datetime.datetime.fromisoformat(at)
schedule = CronSchedule(kind="at", at_ms=int(dt.timestamp() * 1000))
else: else:
console.print(response) console.print("[red]Error: Must specify --every, --cron, or --at[/red]")
console.print() raise typer.Exit(1)
store_path = get_data_dir() / "cron" / "jobs.json"
service = CronService(store_path)
job = service.add_job(
name=name,
schedule=schedule,
message=message,
deliver=deliver,
to=to,
channel=channel,
)
console.print(f"[green]✓[/green] Added job '{job.name}' ({job.id})")
@cron_app.command("remove")
def cron_remove(
job_id: str = typer.Argument(..., help="Job ID to remove"),
):
"""Remove a scheduled job."""
from nanobot.config.loader import get_data_dir
from nanobot.cron.service import CronService
store_path = get_data_dir() / "cron" / "jobs.json"
service = CronService(store_path)
if service.remove_job(job_id):
console.print(f"[green]✓[/green] Removed job {job_id}")
else:
console.print(f"[red]Job {job_id} not found[/red]")
@cron_app.command("enable")
def cron_enable(
job_id: str = typer.Argument(..., help="Job ID"),
disable: bool = typer.Option(False, "--disable", help="Disable instead of enable"),
):
"""Enable or disable a job."""
from nanobot.config.loader import get_data_dir
from nanobot.cron.service import CronService
store_path = get_data_dir() / "cron" / "jobs.json"
service = CronService(store_path)
job = service.enable_job(job_id, enabled=not disable)
if job:
status = "disabled" if disable else "enabled"
console.print(f"[green]✓[/green] Job '{job.name}' {status}")
else:
console.print(f"[red]Job {job_id} not found[/red]")
@cron_app.command("run")
def cron_run(
job_id: str = typer.Argument(..., help="Job ID to run"),
force: bool = typer.Option(False, "--force", "-f", help="Run even if disabled"),
):
"""Manually run a job."""
from nanobot.config.loader import get_data_dir
from nanobot.cron.service import CronService
store_path = get_data_dir() / "cron" / "jobs.json"
service = CronService(store_path)
async def run():
return await service.run_job(job_id, force=force)
if asyncio.run(run()):
console.print(f"[green]✓[/green] Job executed")
else:
console.print(f"[red]Failed to run job {job_id}[/red]")
# ============================================================================
# Status Commands
# ============================================================================
@app.command() @app.command()
def setup(): def status():
"""Interactive setup wizard for nanobot.""" """Show nanobot status."""
console.print(Panel.fit( from nanobot.config.loader import load_config, get_config_path
f"{__logo__} Welcome to nanobot setup!\n\n"
"This wizard will help you configure nanobot.", config_path = get_config_path()
title="Setup", config = load_config()
border_style="green" workspace = config.workspace_path
))
console.print(f"{__logo__} nanobot Status\n")
console.print(f"Config: {config_path} {'[green]✓[/green]' if config_path.exists() else '[red]✗[/red]'}")
console.print(f"Workspace: {workspace} {'[green]✓[/green]' if workspace.exists() else '[red]✗[/red]'}")
if config_path.exists():
from nanobot.providers.registry import PROVIDERS
console.print(f"Model: {config.agents.defaults.model}")
# Check API keys from registry
for spec in PROVIDERS:
p = getattr(config.providers, spec.name, None)
if p is None:
continue
if spec.is_local:
# Local deployments show api_base instead of api_key
if p.api_base:
console.print(f"{spec.label}: [green]✓ {p.api_base}[/green]")
else:
console.print(f"{spec.label}: [dim]not set[/dim]")
else:
has_key = bool(p.api_key)
console.print(f"{spec.label}: {'[green]✓[/green]' if has_key else '[dim]not set[/dim]'}")
# ============================================================================
# OAuth Login
# ============================================================================
@app.command()
def login(
provider: str = typer.Argument(..., help="OAuth provider to authenticate with (e.g., 'openai-codex')"),
):
"""Authenticate with an OAuth provider."""
console.print(f"{__logo__} OAuth Login - {provider}\n")
# TODO: Implement setup wizard if provider == "openai-codex":
console.print("[yellow]Setup wizard coming soon![/yellow]") try:
from oauth_cli_kit import get_token as get_codex_token
def main(): console.print("[cyan]Starting OpenAI Codex authentication...[/cyan]")
"""Main entry point for the CLI.""" console.print("A browser window will open for you to authenticate.\n")
app()
token = get_codex_token()
if token and token.access:
console.print(f"[green]✓ Successfully authenticated with OpenAI Codex![/green]")
console.print(f"[dim]Account ID: {token.account_id}[/dim]")
else:
console.print("[red]✗ Authentication failed[/red]")
raise typer.Exit(1)
except ImportError:
console.print("[red]oauth_cli_kit not installed. Run: pip install oauth-cli-kit[/red]")
raise typer.Exit(1)
except Exception as e:
console.print(f"[red]Authentication error: {e}[/red]")
raise typer.Exit(1)
else:
console.print(f"[red]Unknown OAuth provider: {provider}[/red]")
console.print("[yellow]Supported providers: openai-codex[/yellow]")
raise typer.Exit(1)
if __name__ == "__main__": if __name__ == "__main__":
main() app()