diff --git a/judgment/api_client.py b/judgment/api_client.py new file mode 100644 index 0000000..c30b855 --- /dev/null +++ b/judgment/api_client.py @@ -0,0 +1,163 @@ +"""Shared HTTP client for the Judgment platform REST API. + +Resolves the API key from (in priority order): + 1. JUDGMENT_API_KEY environment variable + 2. System keyring (service="judgment-cli") + 3. Config file (~/.judgment/config.toml [auth] api_key) + +Resolves the API URL from: + 1. JUDGMENT_API_URL environment variable + 2. Config file (~/.judgment/config.toml [defaults] api_url) + 3. Default: https://api.judgment.com +""" + +from __future__ import annotations + +import os +from typing import Any, Optional + +import httpx +import typer + +from .config import get_config_value, DEFAULT_API_URL +from .output import print_error + +# Keyring is optional — if the backend is not available we silently skip it. +try: + import keyring as _keyring +except Exception: # noqa: BLE001 + _keyring = None # type: ignore[assignment] + +KEYRING_SERVICE = "judgment-cli" +KEYRING_USERNAME = "api_key" + +_REQUEST_TIMEOUT = 30.0 + + +def _resolve_api_key() -> Optional[str]: + """Return the API key using the priority chain, or None.""" + # 1. Environment variable + key = os.environ.get("JUDGMENT_API_KEY") + if key: + return key + + # 2. System keyring + if _keyring is not None: + try: + key = _keyring.get_password(KEYRING_SERVICE, KEYRING_USERNAME) + if key: + return key + except Exception: # noqa: BLE001 + pass + + # 3. Config file + key = get_config_value("auth", "api_key") + if key: + return str(key) + + return None + + +def _resolve_api_url() -> str: + """Return the base API URL using the priority chain.""" + # 1. Environment variable + url = os.environ.get("JUDGMENT_API_URL") + if url: + return url.rstrip("/") + + # 2. Config file + url = get_config_value("defaults", "api_url") + if url: + return str(url).rstrip("/") + + return DEFAULT_API_URL + + +def _handle_error_response(response: httpx.Response) -> None: + """Inspect the HTTP response and emit user-friendly messages for common errors.""" + if response.is_success: + return + + status = response.status_code + + if status == 401: + print_error("Authentication failed. Run [bold]judgment login[/bold] to set a valid API key.") + raise typer.Exit(code=1) + elif status == 403: + print_error("Permission denied. You do not have access to this resource.") + raise typer.Exit(code=1) + elif status == 404: + print_error("Resource not found. Please check the ID or path and try again.") + raise typer.Exit(code=1) + elif status >= 500: + print_error(f"Server error ({status}). Please try again later or contact support.") + raise typer.Exit(code=1) + else: + # Generic fallback + try: + detail = response.json() + except Exception: + detail = response.text + print_error(f"Request failed ({status}): {detail}") + raise typer.Exit(code=1) + + +class JudgmentAPIClient: + """Thin wrapper around httpx that handles auth and error formatting.""" + + def __init__( + self, + api_key: Optional[str] = None, + api_url: Optional[str] = None, + ) -> None: + self._api_key = api_key or _resolve_api_key() + self._api_url = api_url or _resolve_api_url() + + # ------------------------------------------------------------------ + # Internal helpers + # ------------------------------------------------------------------ + + def _ensure_auth(self) -> str: + if not self._api_key: + print_error("No API key found. Run [bold]judgment login[/bold] to authenticate.") + raise typer.Exit(code=1) + return self._api_key + + def _headers(self) -> dict[str, str]: + key = self._ensure_auth() + return { + "Authorization": f"Bearer {key}", + "Content-Type": "application/json", + } + + def _url(self, path: str) -> str: + return f"{self._api_url}/{path.lstrip('/')}" + + # ------------------------------------------------------------------ + # Public HTTP methods + # ------------------------------------------------------------------ + + def get(self, path: str, **kwargs: Any) -> httpx.Response: + response = httpx.get(self._url(path), headers=self._headers(), timeout=_REQUEST_TIMEOUT, **kwargs) + _handle_error_response(response) + return response + + def post(self, path: str, **kwargs: Any) -> httpx.Response: + response = httpx.post(self._url(path), headers=self._headers(), timeout=_REQUEST_TIMEOUT, **kwargs) + _handle_error_response(response) + return response + + def put(self, path: str, **kwargs: Any) -> httpx.Response: + response = httpx.put(self._url(path), headers=self._headers(), timeout=_REQUEST_TIMEOUT, **kwargs) + _handle_error_response(response) + return response + + def patch(self, path: str, **kwargs: Any) -> httpx.Response: + response = httpx.patch(self._url(path), headers=self._headers(), timeout=_REQUEST_TIMEOUT, **kwargs) + _handle_error_response(response) + return response + + def delete(self, path: str, **kwargs: Any) -> httpx.Response: + response = httpx.delete(self._url(path), headers=self._headers(), timeout=_REQUEST_TIMEOUT, **kwargs) + _handle_error_response(response) + return response diff --git a/judgment/cli.py b/judgment/cli.py index ed65c23..7850acf 100644 --- a/judgment/cli.py +++ b/judgment/cli.py @@ -9,11 +9,23 @@ from .command_utils.self_host import deploy, create_https_listener from typing_extensions import Annotated +from .output import set_output_options +from .commands.config_cmd import config_app +from .commands.auth import auth_app +from .commands.orgs import orgs_app +from .commands.projects import projects_app -app = typer.Typer(help="Judgment CLI tool for managing self-hosted instances.", add_completion=False) + +app = typer.Typer(help="Judgment CLI tool for managing self-hosted instances and the Judgment platform.", add_completion=False) self_host_app = typer.Typer(help="Commands for self-hosting Judgment", add_completion=False) app.add_typer(self_host_app, name="self-host") +# Register new command groups +app.add_typer(config_app, name="config") +app.add_typer(auth_app, name="auth") +app.add_typer(orgs_app, name="orgs") +app.add_typer(projects_app, name="projects") + class ComputeSize(str, Enum): nano = "nano" micro = "micro" @@ -182,10 +194,21 @@ def https_listener(): -# Still require calling subcommand even if there is only one +# Global options callback @app.callback() -def callback(): - pass +def callback( + ctx: typer.Context, + json_output: bool = typer.Option(False, "--json", help="Output raw JSON instead of formatted tables"), + no_color: bool = typer.Option(False, "--no-color", help="Disable colored output"), + org_id: Optional[str] = typer.Option(None, "--org-id", help="Override default organization ID"), + project_id: Optional[str] = typer.Option(None, "--project-id", help="Override default project ID"), +) -> None: + """Judgment CLI tool for managing self-hosted instances and the Judgment platform.""" + set_output_options(json_mode=json_output, no_color=no_color) + # Store global options in context meta for subcommands to access + ctx.ensure_object(dict) + ctx.meta["org_id"] = org_id + ctx.meta["project_id"] = project_id def main(): app() diff --git a/judgment/commands/__init__.py b/judgment/commands/__init__.py new file mode 100644 index 0000000..719b10d --- /dev/null +++ b/judgment/commands/__init__.py @@ -0,0 +1 @@ +"""Judgment CLI command groups.""" diff --git a/judgment/commands/auth.py b/judgment/commands/auth.py new file mode 100644 index 0000000..76bb788 --- /dev/null +++ b/judgment/commands/auth.py @@ -0,0 +1,92 @@ +"""Auth commands for the Judgment CLI. + +judgment login — prompt for API key, validate, store +judgment logout — remove stored API key +judgment whoami — show current user info +""" + +from __future__ import annotations + +import typer + +from ..api_client import JudgmentAPIClient, KEYRING_SERVICE, KEYRING_USERNAME +from ..config import set_config_value +from ..output import print_detail, print_success, print_error, print_info + +auth_app = typer.Typer(help="Authenticate with the Judgment platform.", add_completion=False) + + +@auth_app.command("login") +def login( + api_key: str = typer.Option( + "", + "--api-key", + "-k", + help="API key (omit to be prompted interactively)", + ), +) -> None: + """Authenticate by providing an API key.""" + if not api_key: + api_key = typer.prompt("Enter your Judgment API key", hide_input=True) + + if not api_key: + print_error("No API key provided.") + raise typer.Exit(code=1) + + # Validate by calling /users/me + print_info("Validating API key...") + client = JudgmentAPIClient(api_key=api_key) + try: + response = client.get("/users/me") + except SystemExit: + # Re-raise typer.Exit from error handler + raise + except Exception as exc: + print_error(f"Could not reach the API: {exc}") + raise typer.Exit(code=1) + + user = response.json() + set_config_value("auth", "api_key", api_key) + + # Also store in keyring if available + try: + import keyring as _keyring + + _keyring.set_password(KEYRING_SERVICE, KEYRING_USERNAME, api_key) + except Exception: # noqa: BLE001 + pass + + print_success(f"Logged in as {user.get('email', user.get('name', 'unknown'))}") + + +@auth_app.command("logout") +def logout() -> None: + """Remove the stored API key.""" + set_config_value("auth", "api_key", "") + + # Also try to remove from keyring + try: + import keyring as _keyring + + _keyring.delete_password(KEYRING_SERVICE, KEYRING_USERNAME) + except Exception: # noqa: BLE001 + pass + + print_success("Logged out. API key removed.") + + +@auth_app.command("whoami") +def whoami() -> None: + """Show the currently authenticated user.""" + client = JudgmentAPIClient() + try: + response = client.get("/users/me") + except SystemExit: + raise + except Exception as exc: + print_error(f"Could not reach the API: {exc}") + raise typer.Exit(code=1) + + user = response.json() + fields = [k for k in user.keys()] + print_detail(user, fields) diff --git a/judgment/commands/config_cmd.py b/judgment/commands/config_cmd.py new file mode 100644 index 0000000..4d8071e --- /dev/null +++ b/judgment/commands/config_cmd.py @@ -0,0 +1,65 @@ +"""Config commands for the Judgment CLI. + +judgment config set +judgment config show +judgment config path +""" + +from __future__ import annotations + +import typer + +from ..config import load_config, set_config_value, get_config_path +from ..output import print_detail, print_success, print_error, get_console, is_json_mode, print_json + +config_app = typer.Typer(help="Manage CLI configuration.", add_completion=False) + + +@config_app.command("set") +def config_set( + key: str = typer.Argument(..., help="Config key in 'section.key' format (e.g. defaults.api_url)"), + value: str = typer.Argument(..., help="Value to set"), +) -> None: + """Set a configuration value.""" + parts = key.split(".", 1) + if len(parts) != 2: + print_error("Key must be in 'section.key' format (e.g. defaults.api_url)") + raise typer.Exit(code=1) + + section, config_key = parts + set_config_value(section, config_key, value) + print_success(f"Set {key} = {value}") + + +@config_app.command("show") +def config_show() -> None: + """Display the current configuration (API key is masked).""" + config = load_config() + + # Mask the API key for display + if "auth" in config and config["auth"].get("api_key"): + raw = str(config["auth"]["api_key"]) + if len(raw) > 8: + config["auth"]["api_key"] = raw[:4] + "****" + raw[-4:] + else: + config["auth"]["api_key"] = "****" + + if is_json_mode(): + print_json(config) + return + + console = get_console() + for section, values in config.items(): + console.print(f"\n[bold cyan]\\[{section}][/bold cyan]") + if isinstance(values, dict): + for k, v in values.items(): + console.print(f" {k} = {v}") + else: + console.print(f" {values}") + + +@config_app.command("path") +def config_path() -> None: + """Show the config file path.""" + console = get_console() + console.print(str(get_config_path())) diff --git a/judgment/commands/orgs.py b/judgment/commands/orgs.py new file mode 100644 index 0000000..5c4459b --- /dev/null +++ b/judgment/commands/orgs.py @@ -0,0 +1,90 @@ +"""Organization commands for the Judgment CLI. + +judgment orgs list +judgment orgs show +judgment orgs members +judgment orgs usage +""" + +from __future__ import annotations + +import typer + +from ..api_client import JudgmentAPIClient +from ..output import print_table, print_detail, print_error + +orgs_app = typer.Typer(help="Manage organizations.", add_completion=False) + + +@orgs_app.command("list") +def orgs_list() -> None: + """List all organizations you belong to.""" + client = JudgmentAPIClient() + try: + response = client.get("/organizations") + except SystemExit: + raise + except Exception as exc: + print_error(f"Could not reach the API: {exc}") + raise typer.Exit(code=1) + + data = response.json() + orgs = data if isinstance(data, list) else data.get("organizations", data.get("data", [])) + print_table(orgs, columns=["id", "name", "created_at"], title="Organizations") + + +@orgs_app.command("show") +def orgs_show( + org_id: str = typer.Argument(..., help="Organization ID"), +) -> None: + """Show details for an organization.""" + client = JudgmentAPIClient() + try: + response = client.get(f"/organizations/{org_id}") + except SystemExit: + raise + except Exception as exc: + print_error(f"Could not reach the API: {exc}") + raise typer.Exit(code=1) + + org = response.json() + fields = [k for k in org.keys()] + print_detail(org, fields) + + +@orgs_app.command("members") +def orgs_members( + org_id: str = typer.Argument(..., help="Organization ID"), +) -> None: + """List members of an organization.""" + client = JudgmentAPIClient() + try: + response = client.get(f"/organizations/{org_id}/members") + except SystemExit: + raise + except Exception as exc: + print_error(f"Could not reach the API: {exc}") + raise typer.Exit(code=1) + + data = response.json() + members = data if isinstance(data, list) else data.get("members", data.get("data", [])) + print_table(members, columns=["user_id", "email", "role"], title="Organization Members") + + +@orgs_app.command("usage") +def orgs_usage( + org_id: str = typer.Argument(..., help="Organization ID"), +) -> None: + """Show usage summary for an organization.""" + client = JudgmentAPIClient() + try: + response = client.get(f"/organizations/{org_id}/usage") + except SystemExit: + raise + except Exception as exc: + print_error(f"Could not reach the API: {exc}") + raise typer.Exit(code=1) + + usage = response.json() + fields = [k for k in usage.keys()] + print_detail(usage, fields) diff --git a/judgment/commands/projects.py b/judgment/commands/projects.py new file mode 100644 index 0000000..ea4b843 --- /dev/null +++ b/judgment/commands/projects.py @@ -0,0 +1,139 @@ +"""Project commands for the Judgment CLI. + +judgment projects list +judgment projects create +judgment projects show +judgment projects favorite +judgment projects unfavorite +""" + +from __future__ import annotations + +import typer + +from ..api_client import JudgmentAPIClient +from ..config import get_config_value +from ..output import print_table, print_detail, print_success, print_error + +projects_app = typer.Typer(help="Manage projects.", add_completion=False) + + +def _resolve_org_id(ctx: typer.Context) -> str: + """Return the org_id from the CLI context, config, or abort.""" + org_id: str | None = None + + # Walk the context chain to find org_id set by the global --org-id flag + if ctx: + current: typer.Context | None = ctx + while current: + meta = current.meta or {} + org_id = meta.get("org_id") + if org_id: + break + current = current.parent + + # Try config + if not org_id: + org_id = get_config_value("defaults", "org_id") + + if not org_id: + print_error("No org_id specified. Use --org-id flag or set defaults.org_id in config.") + raise typer.Exit(code=1) + return str(org_id) + + +@projects_app.command("list") +def projects_list( + ctx: typer.Context, + org_id: str = typer.Option("", "--org-id", help="Organization ID (overrides config default)"), +) -> None: + """List projects in an organization.""" + resolved_org_id = org_id or _resolve_org_id(ctx) + client = JudgmentAPIClient() + try: + response = client.get("/projects", params={"org_id": resolved_org_id}) + except SystemExit: + raise + except Exception as exc: + print_error(f"Could not reach the API: {exc}") + raise typer.Exit(code=1) + + data = response.json() + projects = data if isinstance(data, list) else data.get("projects", data.get("data", [])) + print_table(projects, columns=["id", "name", "created_at", "is_favorite"], title="Projects") + + +@projects_app.command("create") +def projects_create( + ctx: typer.Context, + name: str = typer.Argument(..., help="Name for the new project"), + org_id: str = typer.Option("", "--org-id", help="Organization ID (overrides config default)"), +) -> None: + """Create a new project.""" + resolved_org_id = org_id or _resolve_org_id(ctx) + client = JudgmentAPIClient() + try: + response = client.post("/projects", json={"name": name, "org_id": resolved_org_id}) + except SystemExit: + raise + except Exception as exc: + print_error(f"Could not reach the API: {exc}") + raise typer.Exit(code=1) + + project = response.json() + print_success(f"Created project '{name}' (id: {project.get('id', 'unknown')})") + fields = [k for k in project.keys()] + print_detail(project, fields) + + +@projects_app.command("show") +def projects_show( + project_id: str = typer.Argument(..., help="Project ID"), +) -> None: + """Show details for a project.""" + client = JudgmentAPIClient() + try: + response = client.get(f"/projects/{project_id}") + except SystemExit: + raise + except Exception as exc: + print_error(f"Could not reach the API: {exc}") + raise typer.Exit(code=1) + + project = response.json() + fields = [k for k in project.keys()] + print_detail(project, fields) + + +@projects_app.command("favorite") +def projects_favorite( + project_id: str = typer.Argument(..., help="Project ID"), +) -> None: + """Mark a project as favorite.""" + client = JudgmentAPIClient() + try: + client.put(f"/projects/{project_id}/favorite") + except SystemExit: + raise + except Exception as exc: + print_error(f"Could not reach the API: {exc}") + raise typer.Exit(code=1) + + print_success(f"Project {project_id} marked as favorite.") + + +@projects_app.command("unfavorite") +def projects_unfavorite( + project_id: str = typer.Argument(..., help="Project ID"), +) -> None: + """Remove a project from favorites.""" + client = JudgmentAPIClient() + try: + client.delete(f"/projects/{project_id}/favorite") + except SystemExit: + raise + except Exception as exc: + print_error(f"Could not reach the API: {exc}") + raise typer.Exit(code=1) + + print_success(f"Project {project_id} removed from favorites.") diff --git a/judgment/config.py b/judgment/config.py new file mode 100644 index 0000000..9c782f7 --- /dev/null +++ b/judgment/config.py @@ -0,0 +1,101 @@ +"""Configuration management for the Judgment CLI. + +Config file location: ~/.judgment/config.toml +""" + +from __future__ import annotations + +import sys +from pathlib import Path +from typing import Any + +import tomli_w + +if sys.version_info >= (3, 11): + import tomllib +else: + import tomli as tomllib + +CONFIG_DIR = Path.home() / ".judgment" +CONFIG_FILE = CONFIG_DIR / "config.toml" + +DEFAULT_API_URL = "https://api.judgment.com" + +DEFAULT_CONFIG: dict[str, Any] = { + "auth": { + "api_key": "", + }, + "defaults": { + "api_url": DEFAULT_API_URL, + "org_id": "", + "project_id": "", + }, + "output": { + "json": False, + "no_color": False, + }, +} + + +def _ensure_config_dir() -> None: + """Create the config directory if it doesn't exist.""" + CONFIG_DIR.mkdir(parents=True, exist_ok=True) + + +def load_config() -> dict[str, Any]: + """Load config from the TOML file, returning defaults if file doesn't exist.""" + if not CONFIG_FILE.exists(): + return _deep_copy_dict(DEFAULT_CONFIG) + + with open(CONFIG_FILE, "rb") as f: + data = tomllib.load(f) + + # Merge with defaults so missing keys get default values + merged = _deep_copy_dict(DEFAULT_CONFIG) + for section, values in data.items(): + if section in merged and isinstance(merged[section], dict) and isinstance(values, dict): + merged[section].update(values) + else: + merged[section] = values + return merged + + +def save_config(config: dict[str, Any]) -> None: + """Save config to the TOML file.""" + _ensure_config_dir() + with open(CONFIG_FILE, "wb") as f: + tomli_w.dump(config, f) + + +def get_config_value(section: str, key: str) -> Any: + """Get a single config value by section and key.""" + config = load_config() + return config.get(section, {}).get(key) + + +def set_config_value(section: str, key: str, value: Any) -> None: + """Set a single config value by section and key, creating section if needed.""" + config = load_config() + if section not in config: + config[section] = {} + # Convert string booleans + if isinstance(value, str) and value.lower() in ("true", "false"): + value = value.lower() == "true" + config[section][key] = value + save_config(config) + + +def get_config_path() -> Path: + """Return the path to the config file.""" + return CONFIG_FILE + + +def _deep_copy_dict(d: dict[str, Any]) -> dict[str, Any]: + """Simple deep copy for nested dicts.""" + result: dict[str, Any] = {} + for k, v in d.items(): + if isinstance(v, dict): + result[k] = _deep_copy_dict(v) + else: + result[k] = v + return result diff --git a/judgment/output.py b/judgment/output.py new file mode 100644 index 0000000..5ed32f0 --- /dev/null +++ b/judgment/output.py @@ -0,0 +1,109 @@ +"""Output formatting utilities for the Judgment CLI. + +Provides consistent output formatting using rich tables, JSON, and styled messages. +Respects --json and --no-color global flags. +""" + +from __future__ import annotations + +import json as json_lib +from typing import Any, Optional, Sequence + +from rich.console import Console +from rich.table import Table +from rich.theme import Theme + +# Shared console instance — no_color may be toggled at runtime via set_output_options +_console = Console( + theme=Theme( + { + "success": "bold green", + "error": "bold red", + "warning": "bold yellow", + "info": "bold cyan", + } + ) +) + +# Module-level flags (set once per invocation from the CLI callback) +_json_mode: bool = False +_no_color: bool = False + + +def set_output_options(*, json_mode: bool = False, no_color: bool = False) -> None: + """Configure output behaviour for the current invocation.""" + global _json_mode, _no_color, _console + _json_mode = json_mode + _no_color = no_color + if no_color: + _console = Console(no_color=True, highlight=False) + + +def get_console() -> Console: + """Return the shared console instance.""" + return _console + + +def is_json_mode() -> bool: + return _json_mode + + +# --------------------------------------------------------------------------- +# Public helpers +# --------------------------------------------------------------------------- + +def print_table( + data: Sequence[dict[str, Any]], + columns: Sequence[str], + title: Optional[str] = None, +) -> None: + """Render *data* as a rich table, or JSON if --json is active.""" + if _json_mode: + _console.print_json(json_lib.dumps(list(data), default=str)) + return + + table = Table(title=title, show_header=True, header_style="bold cyan") + for col in columns: + table.add_column(col) + for row in data: + table.add_row(*[str(row.get(col, "")) for col in columns]) + _console.print(table) + + +def print_json(data: Any) -> None: + """Print raw JSON regardless of --json flag.""" + _console.print_json(json_lib.dumps(data, default=str)) + + +def print_detail(data: dict[str, Any], fields: Sequence[str]) -> None: + """Render a key-value detail view, or JSON if --json is active.""" + if _json_mode: + _console.print_json(json_lib.dumps(data, default=str)) + return + + table = Table(show_header=False, box=None, padding=(0, 2)) + table.add_column("Field", style="bold cyan") + table.add_column("Value") + for field in fields: + table.add_row(field, str(data.get(field, ""))) + _console.print(table) + + +def print_error(message: str) -> None: + """Print a styled error message.""" + _console.print(f"[error]Error:[/error] {message}") + + +def print_success(message: str) -> None: + """Print a styled success message.""" + _console.print(f"[success]Success:[/success] {message}") + + +def print_warning(message: str) -> None: + """Print a styled warning message.""" + _console.print(f"[warning]Warning:[/warning] {message}") + + +def print_info(message: str) -> None: + """Print a styled informational message.""" + _console.print(f"[info]Info:[/info] {message}") diff --git a/pyproject.toml b/pyproject.toml index 6e051d9..4153cc8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -15,10 +15,16 @@ dependencies = [ "supabase>=1.20.0", "psycopg2-binary>=2.9.0", "boto3>=1.30.0", + "httpx>=0.24.0", + "rich>=13.0.0", + "keyring>=24.0.0", + "pydantic>=2.0.0", + "tomli>=2.0.0; python_version < '3.11'", + "tomli-w>=1.0.0", ] [project.scripts] judgment = "judgment.cli:app" [tool.hatch.build.targets.wheel] -packages = ["judgment"] \ No newline at end of file +packages = ["judgment"] \ No newline at end of file