91e75e620b
CI: cua-driver distro-compat matrix / debian:12 (glibc 2.36) (push) Has been cancelled
CI: SPDX Headers / Check SPDX headers (warn-only) (push) Has been cancelled
CD: Docs MCP Server / build (linux/amd64) (push) Has been cancelled
CD: Docs MCP Server / build (linux/arm64) (push) Has been cancelled
CD: Docs MCP Server / merge (push) Has been cancelled
CI: cua-driver distro-compat matrix / Resolve release version (push) Has been cancelled
CI: cua-driver distro-compat matrix / fedora:41 (glibc 2.40) (push) Has been cancelled
CI: cua-driver distro-compat matrix / rockylinux:9 (glibc 2.34) (push) Has been cancelled
CI: cua-driver distro-compat matrix / ubuntu:22.04 (glibc 2.35) (push) Has been cancelled
CI: cua-driver distro-compat matrix / ubuntu:24.04 (glibc 2.39) (push) Has been cancelled
CI: cua-driver distro-compat matrix / Distro compat summary (push) Has been cancelled
CI: Rust Linux unit / Rust Linux unit and compile (push) Has been cancelled
CI: Rust Windows unit / Rust Windows unit and compile (push) Has been cancelled
CI: Nix Linux Rust source / Nix / compositor build (push) Has been cancelled
CI: Nix Linux Rust source / Nix / driver package (push) Has been cancelled
CI: Nix Linux Rust source / Nix / Rust unit tests (push) Has been cancelled
393 lines
12 KiB
Python
393 lines
12 KiB
Python
"""Authentication commands for Cua CLI."""
|
|
|
|
import argparse
|
|
import os
|
|
from pathlib import Path
|
|
|
|
import aiohttp
|
|
from cua_cli.auth.browser import authenticate_via_browser
|
|
from cua_cli.auth.store import (
|
|
ACTIVE_WORKSPACE_KEY,
|
|
_get_store,
|
|
clear_credentials,
|
|
clear_legacy_credentials,
|
|
delete_workspace,
|
|
get_active_workspace,
|
|
get_api_key,
|
|
list_workspaces,
|
|
save_api_key,
|
|
save_workspace,
|
|
set_active_workspace,
|
|
)
|
|
from cua_cli.utils.async_utils import run_async
|
|
from cua_cli.utils.output import print_error, print_info, print_success
|
|
from cua_core.http import cua_version_headers
|
|
|
|
DEFAULT_API_BASE = "https://api.cua.ai"
|
|
|
|
|
|
def _get_api_base() -> str:
|
|
return os.environ.get("CUA_API_BASE", DEFAULT_API_BASE).rstrip("/")
|
|
|
|
|
|
def register_parser(subparsers: argparse._SubParsersAction) -> None:
|
|
"""Register the auth command and subcommands.
|
|
|
|
Args:
|
|
subparsers: The subparsers object from the main parser
|
|
"""
|
|
auth_parser = subparsers.add_parser(
|
|
"auth",
|
|
help="Authentication commands",
|
|
description="Manage authentication for Cua cloud services",
|
|
)
|
|
|
|
auth_subparsers = auth_parser.add_subparsers(
|
|
dest="auth_command",
|
|
help="Authentication command",
|
|
)
|
|
|
|
# login command
|
|
login_parser = auth_subparsers.add_parser(
|
|
"login",
|
|
help="Authenticate with Cua cloud",
|
|
description="Authenticate via browser or API key",
|
|
)
|
|
login_parser.add_argument(
|
|
"--api-key",
|
|
type=str,
|
|
help="API key for direct authentication (skips browser flow)",
|
|
)
|
|
|
|
# list command
|
|
auth_subparsers.add_parser(
|
|
"list",
|
|
help="List authenticated workspaces",
|
|
description="Show all authenticated workspaces",
|
|
)
|
|
|
|
# logout command
|
|
logout_parser = auth_subparsers.add_parser(
|
|
"logout",
|
|
help="Clear stored credentials",
|
|
description="Remove stored authentication credentials",
|
|
)
|
|
logout_parser.add_argument(
|
|
"--workspace",
|
|
type=str,
|
|
help="Remove credentials for a specific workspace slug",
|
|
)
|
|
logout_parser.add_argument(
|
|
"--all",
|
|
action="store_true",
|
|
help="Remove all workspace credentials",
|
|
)
|
|
|
|
# status command
|
|
auth_subparsers.add_parser(
|
|
"status",
|
|
help="Show authentication status and account info",
|
|
description="Display current user, credits, and API key info",
|
|
)
|
|
|
|
# env command
|
|
env_parser = auth_subparsers.add_parser(
|
|
"env",
|
|
help="Export API key to .env file",
|
|
description="Write CUA_API_KEY to .env file in current directory",
|
|
)
|
|
env_parser.add_argument(
|
|
"--file",
|
|
type=str,
|
|
default=".env",
|
|
help="Path to .env file (default: .env)",
|
|
)
|
|
|
|
|
|
def execute(args: argparse.Namespace) -> int:
|
|
"""Execute auth command based on subcommand.
|
|
|
|
Args:
|
|
args: Parsed command-line arguments
|
|
|
|
Returns:
|
|
Exit code (0 for success, non-zero for failure)
|
|
"""
|
|
cmd = getattr(args, "auth_command", None)
|
|
|
|
if cmd == "login":
|
|
return cmd_login(args)
|
|
elif cmd == "logout":
|
|
return cmd_logout(args)
|
|
elif cmd == "status":
|
|
return cmd_status(args)
|
|
elif cmd == "list":
|
|
return cmd_list(args)
|
|
elif cmd == "env":
|
|
return cmd_env(args)
|
|
else:
|
|
print_error("Usage: cua auth <command>")
|
|
print_info("Commands: login, logout, status, list, env")
|
|
return 1
|
|
|
|
|
|
def _fetch_me(api_key: str) -> tuple[int, dict]:
|
|
"""Call /v1/me to get workspace metadata for an API key."""
|
|
|
|
async def _do():
|
|
url = f"{_get_api_base()}/v1/me"
|
|
headers = {
|
|
"Authorization": f"Bearer {api_key}",
|
|
"Accept": "application/json",
|
|
**cua_version_headers(),
|
|
}
|
|
async with aiohttp.ClientSession() as session:
|
|
timeout = aiohttp.ClientTimeout(total=10)
|
|
async with session.get(url, headers=headers, timeout=timeout) as resp:
|
|
try:
|
|
data = await resp.json(content_type=None)
|
|
except Exception:
|
|
text = await resp.text()
|
|
return resp.status, {"error": text}
|
|
return resp.status, data
|
|
|
|
return run_async(_do())
|
|
|
|
|
|
def cmd_login(args: argparse.Namespace) -> int:
|
|
"""Handle the login command."""
|
|
if args.api_key:
|
|
# Direct API key authentication — discover workspace via /v1/me
|
|
api_key = args.api_key
|
|
print_info("Authenticating with provided API key...")
|
|
try:
|
|
status_code, data = _fetch_me(api_key)
|
|
except Exception as e:
|
|
print_error(f"Failed to reach API: {e}")
|
|
return 1
|
|
|
|
if status_code != 200:
|
|
print_error(f"API key validation failed (HTTP {status_code})")
|
|
return 1
|
|
|
|
ws = data.get("workspace", {})
|
|
org = data.get("organization", {})
|
|
slug = ws.get("slug")
|
|
if slug:
|
|
name = ws.get("name", slug)
|
|
org_name = org.get("name", "")
|
|
save_workspace(slug, api_key, name, org_name)
|
|
set_active_workspace(slug)
|
|
print_success(f"Authenticated and switched to workspace: {name} ({slug})")
|
|
else:
|
|
# Legacy fallback — no workspace metadata from API
|
|
save_api_key(api_key)
|
|
_get_store().delete(ACTIVE_WORKSPACE_KEY)
|
|
print_success("Successfully authenticated!")
|
|
else:
|
|
# Browser-based authentication
|
|
try:
|
|
result = run_async(authenticate_via_browser())
|
|
except TimeoutError as e:
|
|
print_error(str(e))
|
|
return 1
|
|
except RuntimeError as e:
|
|
print_error(str(e))
|
|
return 1
|
|
|
|
if result.workspace_slug:
|
|
save_workspace(
|
|
result.workspace_slug,
|
|
result.token,
|
|
result.workspace_name,
|
|
result.org_name,
|
|
)
|
|
set_active_workspace(result.workspace_slug)
|
|
print_success(
|
|
f"Authenticated and switched to workspace: "
|
|
f"{result.workspace_name} ({result.workspace_slug})"
|
|
)
|
|
else:
|
|
# Fallback if website didn't send workspace metadata
|
|
save_api_key(result.token)
|
|
_get_store().delete(ACTIVE_WORKSPACE_KEY)
|
|
print_success("Successfully authenticated!")
|
|
|
|
return 0
|
|
|
|
|
|
def cmd_logout(args: argparse.Namespace) -> int:
|
|
"""Handle the logout command."""
|
|
if getattr(args, "all", False):
|
|
clear_credentials()
|
|
print_success("All credentials cleared.")
|
|
return 0
|
|
|
|
ws_slug = getattr(args, "workspace", None)
|
|
if ws_slug:
|
|
delete_workspace(ws_slug)
|
|
active = get_active_workspace()
|
|
if active == ws_slug:
|
|
# Active workspace was removed — pick another or clear
|
|
remaining = list_workspaces()
|
|
if remaining:
|
|
set_active_workspace(remaining[0]["slug"])
|
|
print_info(
|
|
f"Switched to workspace: {remaining[0]['name']} ({remaining[0]['slug']})"
|
|
)
|
|
else:
|
|
_get_store().delete(ACTIVE_WORKSPACE_KEY)
|
|
print_success(f"Removed credentials for workspace: {ws_slug}")
|
|
return 0
|
|
|
|
# No flags — remove only the active workspace
|
|
active = get_active_workspace()
|
|
if active:
|
|
delete_workspace(active)
|
|
remaining = list_workspaces()
|
|
if remaining:
|
|
set_active_workspace(remaining[0]["slug"])
|
|
print_success(f"Logged out of workspace: {active}")
|
|
print_info(
|
|
f"Switched to workspace: {remaining[0]['name']} ({remaining[0]['slug']}). "
|
|
f"Use 'cua workspace set <slug>' to switch workspaces."
|
|
)
|
|
else:
|
|
_get_store().delete(ACTIVE_WORKSPACE_KEY)
|
|
print_success(f"Logged out of workspace: {active}")
|
|
else:
|
|
clear_legacy_credentials()
|
|
print_success("Credentials cleared.")
|
|
return 0
|
|
|
|
|
|
def cmd_list(args: argparse.Namespace) -> int:
|
|
"""Handle the list command — show all authenticated workspaces."""
|
|
workspaces = list_workspaces()
|
|
if not workspaces:
|
|
print_info("No authenticated workspaces. Run 'cua auth login' to add one.")
|
|
return 0
|
|
|
|
# Group by org
|
|
by_org: dict[str, list[dict]] = {}
|
|
for ws in workspaces:
|
|
org = ws["org"] or "Unknown"
|
|
by_org.setdefault(org, []).append(ws)
|
|
|
|
from rich.console import Console
|
|
|
|
c = Console()
|
|
for org, org_workspaces in by_org.items():
|
|
c.print(f"[bold]{org}[/bold]")
|
|
for ws in org_workspaces:
|
|
name = ws["name"] or ws["slug"]
|
|
slug = ws["slug"]
|
|
if ws["is_active"]:
|
|
c.print(f" [green]* {name} ({slug})[/green]")
|
|
else:
|
|
c.print(f" {name} ({slug})")
|
|
return 0
|
|
|
|
|
|
def cmd_status(args: argparse.Namespace) -> int:
|
|
"""Handle the status command — show auth status and account info."""
|
|
api_key = get_api_key()
|
|
if not api_key:
|
|
print_error("Not logged in. Run 'cua auth login' first.")
|
|
return 1
|
|
|
|
try:
|
|
status_code, data = _fetch_me(api_key)
|
|
except Exception as e:
|
|
print_error(f"Failed to reach API: {e}")
|
|
return 1
|
|
|
|
if status_code == 401:
|
|
# Don't mutate cached workspaces if the token came from the environment
|
|
if os.environ.get("CUA_API_KEY"):
|
|
print_error("Session expired. The CUA_API_KEY environment variable is invalid.")
|
|
return 1
|
|
|
|
active = get_active_workspace()
|
|
if active:
|
|
delete_workspace(active)
|
|
print_info(f"Removed expired credentials for workspace: {active}")
|
|
remaining = list_workspaces()
|
|
if remaining:
|
|
set_active_workspace(remaining[0]["slug"])
|
|
print_info(
|
|
f"Switched to workspace: {remaining[0]['name']} ({remaining[0]['slug']})"
|
|
)
|
|
else:
|
|
_get_store().delete(ACTIVE_WORKSPACE_KEY)
|
|
else:
|
|
clear_legacy_credentials()
|
|
print_info("Removed expired credentials.")
|
|
print_error("Session expired. Run 'cua auth login' to re-authenticate.")
|
|
return 1
|
|
|
|
if status_code != 200:
|
|
print_error(f"Failed to fetch account info (HTTP {status_code})")
|
|
return 1
|
|
|
|
ws = data.get("workspace", {})
|
|
org = data.get("organization", {})
|
|
credits = data.get("credits", {})
|
|
|
|
active_slug = get_active_workspace()
|
|
active_label = f" [active: {active_slug}]" if active_slug else ""
|
|
print_success(f"Logged in to cua.ai{active_label}")
|
|
print_info(f" Workspace: {ws.get('name', 'unknown')} ({ws.get('slug', 'unknown')})")
|
|
print_info(f" Organization: {org.get('name', 'unknown')} ({org.get('plan_type', 'unknown')})")
|
|
print_info(f" Credits: {credits.get('balance', 0):.2f} remaining")
|
|
|
|
return 0
|
|
|
|
|
|
def cmd_env(args: argparse.Namespace) -> int:
|
|
"""Handle the env command - export API key to .env file.
|
|
|
|
Args:
|
|
args: Parsed command-line arguments
|
|
|
|
Returns:
|
|
Exit code
|
|
"""
|
|
api_key = get_api_key()
|
|
if not api_key:
|
|
print_error("Not authenticated. Run 'cua auth login' first.")
|
|
return 1
|
|
|
|
env_file = Path(args.file)
|
|
env_line = f"CUA_API_KEY={api_key}"
|
|
|
|
if env_file.exists():
|
|
# Read existing content
|
|
content = env_file.read_text()
|
|
lines = content.splitlines()
|
|
|
|
# Check if CUA_API_KEY already exists
|
|
updated = False
|
|
for i, line in enumerate(lines):
|
|
if line.startswith("CUA_API_KEY="):
|
|
lines[i] = env_line
|
|
updated = True
|
|
break
|
|
|
|
if updated:
|
|
env_file.write_text("\n".join(lines) + "\n")
|
|
print_success(f"Updated CUA_API_KEY in {env_file}")
|
|
else:
|
|
# Append to file
|
|
with env_file.open("a") as f:
|
|
if content and not content.endswith("\n"):
|
|
f.write("\n")
|
|
f.write(env_line + "\n")
|
|
print_success(f"Added CUA_API_KEY to {env_file}")
|
|
else:
|
|
# Create new file
|
|
env_file.write_text(env_line + "\n")
|
|
print_success(f"Created {env_file} with CUA_API_KEY")
|
|
|
|
return 0
|