cometapi-cli 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,87 @@
1
+ """Tokens (API keys) management command."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Annotated
6
+
7
+ import typer
8
+
9
+ from ..config import get_client, mask_secret
10
+ from ..constants import extract_items, format_ts, quota_to_usd
11
+ from ..errors import handle_errors
12
+ from ..formatters import OutputFormat, output, resolve_format
13
+
14
+ _STATUS_MAP = {1: "active", 2: "disabled", 3: "expired", 4: "exhausted"}
15
+
16
+
17
+ def _quota_to_usd(quota: int | float) -> str:
18
+ if quota < 0:
19
+ return "Unlimited"
20
+ return quota_to_usd(quota, precision=2)
21
+
22
+
23
+ def _format_ts_short(ts: int) -> str:
24
+ return format_ts(ts, fmt="%Y-%m-%d %H:%M")
25
+
26
+
27
+ def _format_token_row(t: dict) -> dict:
28
+ key = t.get("key", "")
29
+ unlimited = t.get("unlimited_quota", False)
30
+ return {
31
+ "id": t.get("id", ""),
32
+ "name": t.get("name", "") or "—",
33
+ "key": mask_secret(key) if key else "—",
34
+ "status": _STATUS_MAP.get(t.get("status", 0), "unknown"),
35
+ "balance": "Unlimited" if unlimited else _quota_to_usd(t.get("remain_quota", 0)),
36
+ "used": _quota_to_usd(t.get("used_quota", 0)),
37
+ "created": _format_ts_short(t.get("created_time", 0)),
38
+ "accessed": _format_ts_short(t.get("accessed_time", 0)),
39
+ "expires": "Never" if t.get("expired_time", 0) == -1 else _format_ts_short(t.get("expired_time", 0)),
40
+ }
41
+
42
+
43
+ @handle_errors
44
+ def tokens(
45
+ ctx: typer.Context,
46
+ search: Annotated[str | None, typer.Option("--search", "-s", help="Search tokens by name or key.")] = None,
47
+ page: Annotated[int, typer.Option("--page", "-p", help="Page number.")] = 1,
48
+ limit: Annotated[int, typer.Option("--limit", "-l", help="Results per page.")] = 20,
49
+ output_format: Annotated[OutputFormat | None, typer.Option("--format", "-f", help="Output format.")] = None,
50
+ json_output: Annotated[bool, typer.Option("--json", help="Output as JSON.")] = False,
51
+ ) -> None:
52
+ """List and search your API keys (requires access token)."""
53
+ fmt = resolve_format(ctx, json_output, output_format)
54
+ client = get_client(require_access_token=True)
55
+
56
+ if search:
57
+ resp = client.search_tokens(keyword=search)
58
+ # Search returns a flat list — apply client-side limit if specified
59
+ data = extract_items(resp)
60
+ if limit and len(data) > limit:
61
+ data = data[:limit]
62
+ else:
63
+ resp = client.list_tokens(page=page, page_size=limit)
64
+ data = extract_items(resp)
65
+
66
+ if fmt == OutputFormat.JSON:
67
+ output(data, fmt)
68
+ return
69
+
70
+ if not data:
71
+ from ..console import console
72
+ console.print("[dim]No tokens found.[/]")
73
+ return
74
+
75
+ rows = [_format_token_row(t) for t in data]
76
+ columns = {
77
+ "id": "cyan",
78
+ "name": "green",
79
+ "key": "yellow",
80
+ "status": "green",
81
+ "balance": "green",
82
+ "used": "red",
83
+ "created": "dim",
84
+ "accessed": "dim",
85
+ "expires": "dim",
86
+ }
87
+ output(rows, fmt, title="API Keys (Tokens)", columns=columns)
cometapi_cli/config.py ADDED
@@ -0,0 +1,102 @@
1
+ """Configuration file management for CometAPI CLI."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ import sys
7
+ from pathlib import Path
8
+ from typing import Any
9
+
10
+ if sys.version_info >= (3, 11):
11
+ import tomllib
12
+ else:
13
+ import tomli as tomllib
14
+
15
+ import tomli_w
16
+
17
+ from .errors import ConfigError, ConfigMissing
18
+
19
+ CONFIG_DIR = Path.home() / ".config" / "cometapi"
20
+ CONFIG_FILE = CONFIG_DIR / "config.toml"
21
+
22
+ VALID_KEYS = {"api_key", "access_token", "base_url", "default_model", "output_format"}
23
+
24
+
25
+ def load_config() -> dict[str, Any]:
26
+ """Load config from TOML file. Returns empty dict if file doesn't exist."""
27
+ if not CONFIG_FILE.exists():
28
+ return {}
29
+ try:
30
+ return tomllib.loads(CONFIG_FILE.read_text(encoding="utf-8"))
31
+ except Exception as e:
32
+ raise ConfigError(
33
+ f"Config file is corrupt or unreadable: {CONFIG_FILE}\n{e}\n"
34
+ "Fix the file or delete it and run [bold]cometapi init[/bold]."
35
+ )
36
+
37
+
38
+ def save_config(config: dict[str, Any]) -> None:
39
+ """Save config to TOML file with restrictive permissions."""
40
+ CONFIG_DIR.mkdir(parents=True, exist_ok=True)
41
+ CONFIG_FILE.write_bytes(tomli_w.dumps(config).encode("utf-8"))
42
+ CONFIG_FILE.chmod(0o600)
43
+
44
+
45
+ def get_value(key: str, env_var: str | None = None, default: Any = None) -> Any:
46
+ """Get a config value with priority: config file > env var > default."""
47
+ cfg = load_config()
48
+ val = cfg.get(key)
49
+ if val:
50
+ return val
51
+ if env_var:
52
+ env_val = os.environ.get(env_var)
53
+ if env_val:
54
+ return env_val
55
+ return default
56
+
57
+
58
+ def mask_secret(value: str, show_last: int = 4) -> str:
59
+ """Mask a secret value, showing only the last N characters."""
60
+ if not value:
61
+ return ""
62
+ if len(value) <= show_last:
63
+ return "****"
64
+ return "****" + value[-show_last:]
65
+
66
+
67
+ def get_client(require_access_token: bool = False, **overrides: Any) -> Any:
68
+ """Create a CometClient with config file fallback.
69
+
70
+ Priority: override > config file > env var > default.
71
+ """
72
+ from cometapi_cli.client import CometClient
73
+
74
+ cfg = load_config()
75
+
76
+ api_key = overrides.get("api_key") or cfg.get("api_key") or os.environ.get("COMETAPI_KEY")
77
+ access_token = overrides.get("access_token") or cfg.get("access_token") or os.environ.get("COMETAPI_ACCESS_TOKEN")
78
+ base_url = overrides.get("base_url") or cfg.get("base_url") or os.environ.get("COMETAPI_BASE_URL")
79
+
80
+ if not api_key:
81
+ raise ConfigMissing(
82
+ "API key not configured. Set COMETAPI_KEY or run [bold]cometapi init[/bold].\n"
83
+ "Create one at: https://www.cometapi.com/console/token"
84
+ )
85
+
86
+ if require_access_token and not access_token:
87
+ raise ConfigMissing(
88
+ "Access token not configured. This command requires COMETAPI_ACCESS_TOKEN.\n"
89
+ "Run [bold]cometapi init[/bold] to configure, or set the environment variable.\n"
90
+ "Get one at: https://www.cometapi.com/console/personal"
91
+ )
92
+
93
+ return CometClient(
94
+ api_key=api_key,
95
+ access_token=access_token or None,
96
+ base_url=base_url or None,
97
+ )
98
+
99
+
100
+ def get_default_model() -> str:
101
+ """Get the default model from config, env, or fallback."""
102
+ return get_value("default_model", env_var="COMETAPI_DEFAULT_MODEL", default="gpt-5.4")
@@ -0,0 +1,8 @@
1
+ """Shared Rich console instances."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from rich.console import Console
6
+
7
+ console = Console()
8
+ err_console = Console(stderr=True)
@@ -0,0 +1,55 @@
1
+ """Shared constants and helpers for CometAPI CLI commands."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from datetime import datetime, timezone
6
+
7
+ import typer
8
+
9
+ # $1.00 USD = 500,000 quota units
10
+ QUOTA_PER_UNIT = 500_000.0
11
+
12
+
13
+ def quota_to_usd(quota: int | float, precision: int = 4) -> str:
14
+ """Convert backend quota units to a formatted USD string."""
15
+ if not quota:
16
+ return "$0.00"
17
+ return f"${quota / QUOTA_PER_UNIT:,.{precision}f}"
18
+
19
+
20
+ def format_ts(ts: int, fmt: str = "%Y-%m-%d %H:%M:%S") -> str:
21
+ """Format a Unix timestamp as UTC string."""
22
+ if not ts:
23
+ return "—"
24
+ return datetime.fromtimestamp(ts, tz=timezone.utc).strftime(fmt)
25
+
26
+
27
+ def parse_date(value: str, label: str) -> int:
28
+ """Parse a date string into a Unix timestamp (UTC).
29
+
30
+ Accepts YYYY-MM-DD, ISO 8601, or a raw Unix timestamp.
31
+ Exits with code 2 on invalid input.
32
+ """
33
+ if value.isdigit():
34
+ return int(value)
35
+ for date_fmt in ("%Y-%m-%dT%H:%M:%S", "%Y-%m-%d"):
36
+ try:
37
+ dt = datetime.strptime(value, date_fmt).replace(tzinfo=timezone.utc)
38
+ return int(dt.timestamp())
39
+ except ValueError:
40
+ continue
41
+ from .console import err_console
42
+
43
+ err_console.print(
44
+ f"[red]Invalid {label} date:[/] {value}. "
45
+ "Use YYYY-MM-DD, ISO 8601, or a Unix timestamp."
46
+ )
47
+ raise typer.Exit(code=2)
48
+
49
+
50
+ def extract_items(resp: dict) -> list:
51
+ """Extract items from paginated or flat API response."""
52
+ data = resp.get("data", [])
53
+ if isinstance(data, dict):
54
+ return data.get("items", data.get("data", []))
55
+ return data
cometapi_cli/errors.py ADDED
@@ -0,0 +1,113 @@
1
+ """Error handling and exit codes for CometAPI CLI."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import functools
6
+ from collections.abc import Callable
7
+ from enum import IntEnum
8
+ from typing import Any, TypeVar
9
+
10
+ import typer
11
+
12
+ F = TypeVar("F", bound=Callable[..., Any])
13
+
14
+
15
+ class ExitCode(IntEnum):
16
+ """Unix sysexits.h-compatible exit codes."""
17
+
18
+ SUCCESS = 0
19
+ ERROR = 1
20
+ USAGE = 2
21
+ CONFIG_MISSING = 64
22
+ SERVICE_UNAVAILABLE = 69
23
+ AUTH_ERROR = 77
24
+ CONFIG_ERROR = 78
25
+
26
+
27
+ class CometCLIError(Exception):
28
+ """Base exception for CLI errors with structured exit codes."""
29
+
30
+ def __init__(self, message: str, exit_code: ExitCode = ExitCode.ERROR):
31
+ self.message = message
32
+ self.exit_code = exit_code
33
+ super().__init__(message)
34
+
35
+
36
+ class ConfigMissing(CometCLIError):
37
+ """Required configuration value is missing."""
38
+
39
+ def __init__(self, message: str):
40
+ super().__init__(message, ExitCode.CONFIG_MISSING)
41
+
42
+
43
+ class ConfigError(CometCLIError):
44
+ """Configuration file is corrupt or unreadable."""
45
+
46
+ def __init__(self, message: str):
47
+ super().__init__(message, ExitCode.CONFIG_ERROR)
48
+
49
+
50
+ class AuthError(CometCLIError):
51
+ """Authentication failed."""
52
+
53
+ def __init__(self, message: str):
54
+ super().__init__(message, ExitCode.AUTH_ERROR)
55
+
56
+
57
+ class ServiceUnavailable(CometCLIError):
58
+ """API service is unreachable."""
59
+
60
+ def __init__(self, message: str):
61
+ super().__init__(message, ExitCode.SERVICE_UNAVAILABLE)
62
+
63
+
64
+ def handle_errors(func: F) -> F:
65
+ """Decorator that catches exceptions and produces user-friendly error output."""
66
+
67
+ @functools.wraps(func)
68
+ def wrapper(*args: Any, **kwargs: Any) -> Any:
69
+ from .console import err_console
70
+
71
+ try:
72
+ return func(*args, **kwargs)
73
+ except CometCLIError as e:
74
+ err_console.print(f"[red bold]Error:[/] {e.message}")
75
+ raise typer.Exit(code=e.exit_code)
76
+ except KeyboardInterrupt:
77
+ err_console.print("\n[dim]Interrupted.[/]")
78
+ raise typer.Exit(code=130)
79
+ except (typer.Exit, typer.Abort, SystemExit):
80
+ raise
81
+ except Exception as e:
82
+ exc_type = type(e).__name__
83
+ msg = str(e)
84
+
85
+ if "AuthenticationError" in exc_type:
86
+ err_console.print(f"[red bold]Authentication failed:[/] {msg}")
87
+ err_console.print(
88
+ "[dim]Check your COMETAPI_KEY or run [bold]cometapi init[/bold] to configure.[/]"
89
+ )
90
+ err_console.print(
91
+ "[dim]Create a new key at: https://www.cometapi.com/console/token[/]"
92
+ )
93
+ raise typer.Exit(code=ExitCode.AUTH_ERROR)
94
+
95
+ if "APIConnectionError" in exc_type or "ConnectError" in exc_type:
96
+ err_console.print(f"[red bold]Connection failed:[/] {msg}")
97
+ err_console.print(
98
+ "[dim]Check your network or run [bold]cometapi doctor[/bold] to diagnose.[/]"
99
+ )
100
+ raise typer.Exit(code=ExitCode.SERVICE_UNAVAILABLE)
101
+
102
+ if "api_key" in msg.lower() or "COMETAPI_KEY" in msg or "OPENAI_API_KEY" in msg:
103
+ err_console.print("[red bold]API key not configured.[/]")
104
+ err_console.print("[dim]Set COMETAPI_KEY or run [bold]cometapi init[/bold].[/]")
105
+ err_console.print(
106
+ "[dim]Create one at: https://www.cometapi.com/console/token[/]"
107
+ )
108
+ raise typer.Exit(code=ExitCode.CONFIG_MISSING)
109
+
110
+ err_console.print(f"[red bold]Error:[/] {msg}")
111
+ raise typer.Exit(code=ExitCode.ERROR)
112
+
113
+ return wrapper # type: ignore[return-value]
@@ -0,0 +1,156 @@
1
+ """Multi-format output rendering."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import csv
6
+ import io
7
+ import json
8
+ from collections.abc import Sequence
9
+ from enum import Enum
10
+ from typing import Any
11
+
12
+ import typer
13
+ from rich.table import Table
14
+
15
+ from .console import console
16
+
17
+
18
+ class OutputFormat(str, Enum):
19
+ """Supported output formats."""
20
+
21
+ TABLE = "table"
22
+ JSON = "json"
23
+ YAML = "yaml"
24
+ CSV = "csv"
25
+ MARKDOWN = "markdown"
26
+
27
+
28
+ def resolve_format(
29
+ ctx: typer.Context,
30
+ json_output: bool = False,
31
+ output_format: OutputFormat | None = None,
32
+ ) -> OutputFormat:
33
+ """Resolve output format: per-command --json > per-command --format > global --format > config file."""
34
+ if json_output:
35
+ return OutputFormat.JSON
36
+ if output_format is not None:
37
+ return output_format
38
+ global_fmt = (ctx.obj or {}).get("format", None)
39
+ if global_fmt is not None:
40
+ return global_fmt
41
+ # Fall back to config file output_format
42
+ from .config import get_value
43
+
44
+ cfg_fmt = get_value("output_format")
45
+ if cfg_fmt:
46
+ try:
47
+ return OutputFormat(cfg_fmt)
48
+ except ValueError:
49
+ pass
50
+ return OutputFormat.TABLE
51
+
52
+
53
+ def _render_json(data: Any) -> None:
54
+ print(json.dumps(data, ensure_ascii=False, default=str, indent=2))
55
+
56
+
57
+ def _render_yaml(data: Any) -> None:
58
+ import yaml
59
+
60
+ print(yaml.dump(data, allow_unicode=True, default_flow_style=False).rstrip())
61
+
62
+
63
+ def _render_csv(rows: Sequence[dict[str, Any]]) -> None:
64
+ if not rows:
65
+ return
66
+ buf = io.StringIO()
67
+ writer = csv.DictWriter(buf, fieldnames=list(rows[0].keys()))
68
+ writer.writeheader()
69
+ writer.writerows(rows)
70
+ print(buf.getvalue().rstrip())
71
+
72
+
73
+ def _render_kv_table(data: dict[str, Any], title: str = "") -> None:
74
+ """Render a single dict as a Field/Value table."""
75
+ table = Table(title=title or None)
76
+ table.add_column("Field", style="cyan", no_wrap=True)
77
+ table.add_column("Value", style="green")
78
+ for key, value in data.items():
79
+ display_key = key.replace("_", " ").title()
80
+ table.add_row(display_key, str(value))
81
+ console.print(table)
82
+
83
+
84
+ def _render_list_table(
85
+ rows: Sequence[dict[str, Any]],
86
+ title: str = "",
87
+ columns: dict[str, str] | None = None,
88
+ ) -> None:
89
+ """Render a list of dicts as a columnar table."""
90
+ if not rows:
91
+ console.print("[dim]No data.[/]")
92
+ return
93
+ if columns is None:
94
+ columns = {k: ("cyan" if i == 0 else "green") for i, k in enumerate(rows[0].keys())}
95
+ table = Table(title=title or None)
96
+ for col_name, style in columns.items():
97
+ table.add_column(col_name.replace("_", " ").title(), style=style, no_wrap=True)
98
+ for row in rows:
99
+ table.add_row(*(str(row.get(k, "")) for k in columns))
100
+ console.print(table)
101
+
102
+
103
+ def _render_kv_markdown(data: dict[str, Any], title: str = "") -> None:
104
+ lines = []
105
+ if title:
106
+ lines.append(f"## {title}\n")
107
+ lines.append("| Field | Value |")
108
+ lines.append("| --- | --- |")
109
+ for key, value in data.items():
110
+ display_key = key.replace("_", " ").title()
111
+ lines.append(f"| {display_key} | {value} |")
112
+ print("\n".join(lines))
113
+
114
+
115
+ def _render_list_markdown(rows: Sequence[dict[str, Any]], title: str = "") -> None:
116
+ if not rows:
117
+ return
118
+ headers = list(rows[0].keys())
119
+ lines = []
120
+ if title:
121
+ lines.append(f"## {title}\n")
122
+ lines.append("| " + " | ".join(h.replace("_", " ").title() for h in headers) + " |")
123
+ lines.append("| " + " | ".join("---" for _ in headers) + " |")
124
+ for row in rows:
125
+ lines.append("| " + " | ".join(str(row.get(h, "")) for h in headers) + " |")
126
+ print("\n".join(lines))
127
+
128
+
129
+ def output(
130
+ data: Any,
131
+ fmt: OutputFormat,
132
+ title: str = "",
133
+ columns: dict[str, str] | None = None,
134
+ ) -> None:
135
+ """Dispatch data to the appropriate formatter.
136
+
137
+ - dict → rendered as key-value table (TABLE/MARKDOWN) or as-is (JSON/YAML/CSV)
138
+ - list[dict] → rendered as columnar table
139
+ """
140
+ if fmt == OutputFormat.JSON:
141
+ _render_json(data)
142
+ elif fmt == OutputFormat.YAML:
143
+ _render_yaml(data)
144
+ elif fmt == OutputFormat.CSV:
145
+ rows = [data] if isinstance(data, dict) else data
146
+ _render_csv(rows)
147
+ elif fmt == OutputFormat.MARKDOWN:
148
+ if isinstance(data, dict):
149
+ _render_kv_markdown(data, title)
150
+ else:
151
+ _render_list_markdown(data, title)
152
+ else: # TABLE
153
+ if isinstance(data, dict):
154
+ _render_kv_table(data, title)
155
+ else:
156
+ _render_list_table(data, title, columns)
cometapi_cli/main.py ADDED
@@ -0,0 +1,8 @@
1
+ """Backward compatibility — use cometapi_cli.app instead."""
2
+
3
+ from cometapi_cli.app import app # noqa: F401
4
+
5
+ __all__ = ["app"]
6
+
7
+ if __name__ == "__main__":
8
+ app()