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,326 @@
1
+ """Usage logs command."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json as _json
6
+ import sys
7
+ from typing import Annotated
8
+
9
+ import typer
10
+
11
+ from ..config import get_client
12
+ from ..constants import extract_items, format_ts, parse_date, quota_to_usd
13
+ from ..errors import handle_errors
14
+ from ..formatters import OutputFormat, output, resolve_format
15
+
16
+ LOG_TYPE_MAP = {
17
+ "unknown": 0,
18
+ "topup": 1,
19
+ "consume": 2,
20
+ "manage": 3,
21
+ "system": 4,
22
+ "error": 5,
23
+ "refund": 6,
24
+ }
25
+
26
+ _LOG_TYPE_NAMES = {v: k for k, v in LOG_TYPE_MAP.items()}
27
+
28
+
29
+ def _parse_other(other_raw: str | None) -> dict:
30
+ """Parse the ``other`` JSON string from a log entry, returning {} on failure."""
31
+ if not other_raw:
32
+ return {}
33
+ try:
34
+ parsed = _json.loads(other_raw)
35
+ return parsed if isinstance(parsed, dict) else {}
36
+ except (ValueError, TypeError):
37
+ return {}
38
+
39
+
40
+ def _find_log_by_id(
41
+ client: object,
42
+ *,
43
+ request_id: str,
44
+ log_type: int | None = None,
45
+ model_name: str | None = None,
46
+ token_name: str | None = None,
47
+ start_timestamp: int | None = None,
48
+ end_timestamp: int | None = None,
49
+ group: str | None = None,
50
+ max_pages: int = 10,
51
+ ) -> dict | None:
52
+ """Search through paginated logs to find the entry matching a request ID."""
53
+ for pg in range(1, max_pages + 1):
54
+ resp = client.list_logs( # type: ignore[union-attr]
55
+ page=pg,
56
+ page_size=100,
57
+ log_type=log_type,
58
+ model_name=model_name,
59
+ token_name=token_name,
60
+ start_timestamp=start_timestamp,
61
+ end_timestamp=end_timestamp,
62
+ group=group,
63
+ )
64
+ items = extract_items(resp)
65
+ if not items:
66
+ break
67
+ for item in items:
68
+ if item.get("request_id") == request_id:
69
+ return item
70
+ return None
71
+
72
+
73
+ def _format_log_detail(log: dict) -> dict:
74
+ """Build a rich key-value detail card from a single log entry."""
75
+ other = _parse_other(log.get("other"))
76
+ prompt_tokens = log.get("prompt_tokens", 0)
77
+ completion_tokens = log.get("completion_tokens", 0)
78
+ quota = log.get("quota", 0)
79
+
80
+ info: dict[str, str | int | float] = {}
81
+ info["Request ID"] = log.get("request_id", "") or "—"
82
+ info["Response ID"] = log.get("response_id", "") or "—"
83
+ info["Time"] = format_ts(log.get("created_at", 0))
84
+ info["Model"] = log.get("model_name", "") or "—"
85
+ info["Token Name"] = log.get("token_name", "") or "—"
86
+ info["Group"] = log.get("group", "") or "—"
87
+ info["Stream"] = "Yes" if log.get("is_stream") else "No"
88
+
89
+ # Tokens
90
+ info["Prompt Tokens"] = f"{prompt_tokens:,}"
91
+ info["Completion Tokens"] = f"{completion_tokens:,}"
92
+ cache_tokens = other.get("cache_tokens", 0)
93
+ if cache_tokens:
94
+ info["Cache Tokens"] = f"{cache_tokens:,}"
95
+
96
+ # Cost
97
+ info["Cost (USD)"] = quota_to_usd(quota)
98
+ info["Quota (raw)"] = f"{quota:,}"
99
+
100
+ # Pricing breakdown from 'other'
101
+ model_ratio = other.get("model_ratio")
102
+ if model_ratio is not None:
103
+ info["Model Ratio"] = model_ratio
104
+ completion_ratio = other.get("completion_ratio")
105
+ if completion_ratio is not None:
106
+ info["Completion Ratio"] = f"{completion_ratio}x"
107
+ group_ratio = other.get("group_ratio")
108
+ if group_ratio is not None:
109
+ info["Group Ratio"] = group_ratio
110
+ model_price = other.get("model_price")
111
+ if model_price is not None:
112
+ info["Model Price"] = "default" if model_price == -1 else model_price
113
+ cache_ratio = other.get("cache_ratio")
114
+ if cache_ratio is not None:
115
+ info["Cache Ratio"] = cache_ratio
116
+
117
+ # Timing
118
+ use_time = log.get("use_time", 0)
119
+ info["Duration"] = f"{use_time:,} ms" if use_time else "—"
120
+ frt = other.get("frt")
121
+ if frt and frt > 0:
122
+ info["First Token"] = f"{frt:,} ms"
123
+
124
+ # Path
125
+ request_path = other.get("request_path")
126
+ if request_path:
127
+ info["Endpoint"] = request_path
128
+
129
+ return info
130
+
131
+
132
+ def _format_log_row(log: dict, *, detail: bool = False) -> dict:
133
+ row: dict = {
134
+ "time": format_ts(log.get("created_at", 0)),
135
+ "type": _LOG_TYPE_NAMES.get(log.get("type", 0), "unknown"),
136
+ "model": log.get("model_name", "") or "—",
137
+ "token": log.get("token_name", "") or "—",
138
+ "prompt": log.get("prompt_tokens", 0),
139
+ "completion": log.get("completion_tokens", 0),
140
+ "cost": quota_to_usd(log.get("quota", 0)),
141
+ "duration_ms": log.get("use_time", 0),
142
+ "stream": "Yes" if log.get("is_stream") else "No",
143
+ }
144
+ if detail:
145
+ row["request_id"] = log.get("request_id", "") or "—"
146
+ row["response_id"] = log.get("response_id", "") or "—"
147
+ other = _parse_other(log.get("other"))
148
+ row["model_ratio"] = other.get("model_ratio", "—")
149
+ row["group_ratio"] = other.get("group_ratio", "—")
150
+ row["completion_ratio"] = other.get("completion_ratio", "—")
151
+ model_price = other.get("model_price")
152
+ row["model_price"] = model_price if model_price is not None else "—"
153
+ return row
154
+
155
+
156
+ @handle_errors
157
+ def logs(
158
+ ctx: typer.Context,
159
+ model: Annotated[str | None, typer.Option("--model", "-m", help="Filter by model name.")] = None,
160
+ token_name: Annotated[str | None, typer.Option("--token-name", "-t", help="Filter by token name.")] = None,
161
+ log_type: Annotated[
162
+ str | None,
163
+ typer.Option("--type", help="Filter by type: consume, topup, error, refund, manage, system."),
164
+ ] = None,
165
+ search: Annotated[str | None, typer.Option("--search", "-s", help="[deprecated] Search logs by keyword.")] = None,
166
+ start: Annotated[str | None, typer.Option("--start", help="Start date (YYYY-MM-DD, ISO 8601, or Unix ts).")] = None,
167
+ end: Annotated[str | None, typer.Option("--end", help="End date (YYYY-MM-DD, ISO 8601, or Unix ts).")] = None,
168
+ group: Annotated[str | None, typer.Option("--group", "-g", help="Filter by group.")] = None,
169
+ request_id: Annotated[
170
+ str | None,
171
+ typer.Option("--request-id", help="Look up cost by request ID (X-Cometapi-Request-Id header)."),
172
+ ] = None,
173
+ detail: Annotated[
174
+ bool,
175
+ typer.Option("--detail", help="Show extended columns (request ID, pricing ratios)."),
176
+ ] = False,
177
+ page: Annotated[int, typer.Option("--page", "-p", help="Page number.")] = 1,
178
+ limit: Annotated[int, typer.Option("--limit", "-l", help="Results per page.")] = 20,
179
+ export: Annotated[bool, typer.Option("--export", help="Export logs as CSV to stdout.")] = False,
180
+ output_format: Annotated[OutputFormat | None, typer.Option("--format", "-f", help="Output format.")] = None,
181
+ json_output: Annotated[bool, typer.Option("--json", help="Output as JSON.")] = False,
182
+ ) -> None:
183
+ """Show your usage logs (requires access token)."""
184
+ from ..console import console, err_console
185
+
186
+ fmt = resolve_format(ctx, json_output, output_format)
187
+ client = get_client(require_access_token=True)
188
+
189
+ # Parse date options
190
+ start_ts = parse_date(start, "start") if start else None
191
+ end_ts = parse_date(end, "end") if end else None
192
+
193
+ # --request-id: find the full log entry and display detail card
194
+ if request_id:
195
+ if export:
196
+ err_console.print("[red]--request-id cannot be used with --export.[/]")
197
+ raise typer.Exit(code=2)
198
+
199
+ type_int = None
200
+ if log_type:
201
+ type_int = LOG_TYPE_MAP.get(log_type.lower())
202
+
203
+ log_entry = _find_log_by_id(
204
+ client,
205
+ request_id=request_id,
206
+ log_type=type_int,
207
+ model_name=model,
208
+ token_name=token_name,
209
+ start_timestamp=start_ts,
210
+ end_timestamp=end_ts,
211
+ group=group,
212
+ )
213
+
214
+ if log_entry is None:
215
+ err_console.print(
216
+ f"[red]No log found for request_id=[/]{request_id}\n"
217
+ "[dim]The entry may be older than the search window. "
218
+ "Try narrowing with --start/--end or --model.[/]"
219
+ )
220
+ raise typer.Exit(code=1)
221
+
222
+ if fmt == OutputFormat.JSON:
223
+ output(log_entry, fmt)
224
+ return
225
+
226
+ info = _format_log_detail(log_entry)
227
+ output(info, fmt, title="Request Detail")
228
+ return
229
+
230
+ # Server-side CSV export — write raw bytes to stdout and return early
231
+ if export:
232
+ type_int = None
233
+ if log_type:
234
+ type_int = LOG_TYPE_MAP.get(log_type.lower())
235
+ if type_int is None:
236
+ valid = ", ".join(LOG_TYPE_MAP.keys())
237
+ err_console.print(f"[red]Invalid log type:[/] {log_type}. Valid types: {valid}")
238
+ raise typer.Exit(code=2)
239
+ csv_bytes = client.export_logs(
240
+ log_type=type_int,
241
+ model_name=model,
242
+ token_name=token_name,
243
+ start_timestamp=start_ts,
244
+ end_timestamp=end_ts,
245
+ group=group,
246
+ )
247
+ sys.stdout.buffer.write(csv_bytes)
248
+ return
249
+
250
+ if search:
251
+ err_console.print(
252
+ "[yellow]Warning:[/] --search only matches log type codes on the server, "
253
+ "not keywords. Use --request-id, --model, or --token-name for filtering."
254
+ )
255
+ ignored = []
256
+ if model:
257
+ ignored.append("--model")
258
+ if token_name:
259
+ ignored.append("--token-name")
260
+ if log_type:
261
+ ignored.append("--type")
262
+ if start:
263
+ ignored.append("--start")
264
+ if end:
265
+ ignored.append("--end")
266
+ if group:
267
+ ignored.append("--group")
268
+ if page != 1:
269
+ ignored.append("--page")
270
+ if limit != 20:
271
+ ignored.append("--limit")
272
+ if ignored:
273
+ err_console.print(
274
+ f"[yellow]Warning:[/] --search ignores other filters: {', '.join(ignored)}"
275
+ )
276
+ resp = client.search_logs(keyword=search)
277
+ else:
278
+ type_int = None
279
+ if log_type:
280
+ type_int = LOG_TYPE_MAP.get(log_type.lower())
281
+ if type_int is None:
282
+ valid = ", ".join(LOG_TYPE_MAP.keys())
283
+ err_console.print(f"[red]Invalid log type:[/] {log_type}. Valid types: {valid}")
284
+ raise typer.Exit(code=2)
285
+
286
+ resp = client.list_logs(
287
+ page=page,
288
+ page_size=limit,
289
+ log_type=type_int,
290
+ model_name=model,
291
+ token_name=token_name,
292
+ start_timestamp=start_ts,
293
+ end_timestamp=end_ts,
294
+ group=group,
295
+ )
296
+
297
+ data = extract_items(resp)
298
+
299
+ if fmt == OutputFormat.JSON:
300
+ output(data, fmt)
301
+ return
302
+
303
+ if not data:
304
+ console.print("[dim]No logs found.[/]")
305
+ return
306
+
307
+ rows = [_format_log_row(log, detail=detail) for log in data]
308
+ columns: dict[str, str] = {
309
+ "time": "dim",
310
+ "type": "cyan",
311
+ "model": "green",
312
+ "token": "yellow",
313
+ "prompt": "green",
314
+ "completion": "green",
315
+ "cost": "red",
316
+ "duration_ms": "dim",
317
+ "stream": "dim",
318
+ }
319
+ if detail:
320
+ columns["request_id"] = "blue"
321
+ columns["response_id"] = "blue"
322
+ columns["model_ratio"] = "magenta"
323
+ columns["group_ratio"] = "magenta"
324
+ columns["completion_ratio"] = "magenta"
325
+ columns["model_price"] = "magenta"
326
+ output(rows, fmt, title="Usage Logs", columns=columns)
@@ -0,0 +1,44 @@
1
+ """Models command."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Annotated
6
+
7
+ import typer
8
+
9
+ from ..config import get_client
10
+ from ..errors import handle_errors
11
+ from ..formatters import OutputFormat, output, resolve_format
12
+
13
+
14
+ @handle_errors
15
+ def models(
16
+ ctx: typer.Context,
17
+ search: Annotated[str | None, typer.Option("--search", "-s", help="Filter models by name.")] = None,
18
+ limit: Annotated[int | None, typer.Option("--limit", "-l", help="Max number of models to show.")] = None,
19
+ output_format: Annotated[OutputFormat | None, typer.Option("--format", "-f", help="Output format.")] = None,
20
+ json_output: Annotated[bool, typer.Option("--json", help="Output as JSON.")] = False,
21
+ ) -> None:
22
+ """List available models."""
23
+ fmt = resolve_format(ctx, json_output, output_format)
24
+ client = get_client()
25
+ result = client.models.list()
26
+
27
+ rows = sorted(
28
+ [{"id": m.id, "owned_by": m.owned_by} for m in result],
29
+ key=lambda r: r["id"],
30
+ )
31
+
32
+ if search:
33
+ term = search.lower()
34
+ rows = [r for r in rows if term in r["id"].lower()]
35
+
36
+ if limit and limit > 0:
37
+ rows = rows[:limit]
38
+
39
+ output(
40
+ rows,
41
+ fmt,
42
+ title=f"Available Models ({len(rows)})",
43
+ columns={"id": "cyan", "owned_by": "green"},
44
+ )
@@ -0,0 +1,134 @@
1
+ """Interactive command REPL for CometAPI CLI."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import shlex
6
+
7
+ from prompt_toolkit import PromptSession
8
+ from prompt_toolkit.completion import WordCompleter
9
+ from prompt_toolkit.formatted_text import HTML
10
+ from prompt_toolkit.history import FileHistory
11
+ from rich.panel import Panel
12
+ from rich.text import Text
13
+
14
+ from ..config import CONFIG_DIR, get_value, mask_secret
15
+ from ..console import console
16
+ from ..errors import handle_errors
17
+
18
+ REPL_COMMANDS = [
19
+ "chat", "models", "balance", "account", "stats",
20
+ "tokens", "logs", "tasks",
21
+ "doctor", "init", "config", "help", "exit", "quit",
22
+ ]
23
+
24
+
25
+ def _repl_banner() -> Panel:
26
+ from cometapi_cli import __version__
27
+
28
+ api_key = get_value("api_key", "COMETAPI_KEY")
29
+ key_display = mask_secret(api_key) if api_key else "[not set]"
30
+
31
+ text = Text.assemble(
32
+ ("CometAPI REPL", "bold cyan"),
33
+ (f" v{__version__}", "dim"),
34
+ "\n",
35
+ ("API Key: ", "dim"),
36
+ (key_display, "green" if api_key else "red"),
37
+ "\n",
38
+ ("Type any command without 'cometapi' prefix. 'help' for commands, 'exit' to quit.", "dim"),
39
+ )
40
+ return Panel(text, border_style="cyan", padding=(0, 1))
41
+
42
+
43
+ def _show_help() -> None:
44
+ console.print("\n[bold]Available commands:[/]")
45
+ console.print(" [cyan]chat [message][/] Send a message or start chat REPL")
46
+ console.print(" [cyan]models[/] List available models")
47
+ console.print(" [cyan]balance[/] Show account balance")
48
+ console.print(" [cyan]account[/] Show account info")
49
+ console.print(" [cyan]stats[/] Show usage statistics")
50
+ console.print(" [cyan]tokens[/] List your API keys")
51
+ console.print(" [cyan]logs[/] Show usage logs")
52
+ console.print(" [cyan]tasks[/] Show async task logs")
53
+ console.print(" [cyan]doctor[/] Run diagnostics")
54
+ console.print(" [cyan]init[/] Run setup wizard")
55
+ console.print(" [cyan]config ...[/] Manage configuration")
56
+ console.print(" [cyan]help[/] Show this help")
57
+ console.print(" [cyan]exit[/] Exit REPL")
58
+ console.print()
59
+
60
+
61
+ @handle_errors
62
+ def run_repl() -> None:
63
+ """Start interactive command REPL."""
64
+ import sys
65
+
66
+ console.print(_repl_banner())
67
+
68
+ history_file = CONFIG_DIR / "repl_history"
69
+ CONFIG_DIR.mkdir(parents=True, exist_ok=True)
70
+
71
+ completer = WordCompleter(
72
+ REPL_COMMANDS + [
73
+ "--json", "--format", "--model", "--system",
74
+ "--search", "--limit", "--help", "--page",
75
+ "--type", "--token-name", "--start", "--end",
76
+ "--group", "--export", "--platform", "--task-id",
77
+ "--status", "--action",
78
+ ],
79
+ ignore_case=True,
80
+ )
81
+
82
+ session: PromptSession[str] = PromptSession(
83
+ history=FileHistory(str(history_file)),
84
+ completer=completer,
85
+ )
86
+
87
+ while True:
88
+ try:
89
+ user_input = session.prompt(HTML("<b><ansicyan>cometapi → </ansicyan></b>")).strip()
90
+ except (EOFError, KeyboardInterrupt):
91
+ console.print("\n[dim]Goodbye![/]")
92
+ break
93
+
94
+ if not user_input:
95
+ continue
96
+
97
+ if user_input.lower() in ("exit", "quit", "q"):
98
+ console.print("[dim]Goodbye![/]")
99
+ break
100
+
101
+ if user_input.lower() == "help":
102
+ _show_help()
103
+ continue
104
+
105
+ # Parse the input and dispatch through the Typer app
106
+ try:
107
+ args = shlex.split(user_input)
108
+ except ValueError as e:
109
+ console.print(f"[red]Parse error: {e}[/]")
110
+ continue
111
+
112
+ # Strip leading "cometapi" if user types it by habit
113
+ if args and args[0] == "cometapi":
114
+ args = args[1:]
115
+
116
+ if not args:
117
+ continue
118
+
119
+ # Prevent recursive REPL
120
+ if args[0] == "repl":
121
+ console.print("[yellow]Already in REPL mode.[/]")
122
+ continue
123
+
124
+ from ..app import app
125
+
126
+ try:
127
+ app(args, standalone_mode=False)
128
+ except SystemExit:
129
+ pass
130
+ except Exception as e:
131
+ console.print(f"[red bold]Error:[/] {e}")
132
+
133
+ # Reset stdout in case streaming left it in a bad state
134
+ sys.stdout.flush()
@@ -0,0 +1,39 @@
1
+ """Stats command."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Annotated
6
+
7
+ import typer
8
+
9
+ from ..config import get_client
10
+ from ..errors import handle_errors
11
+ from ..formatters import OutputFormat, output, resolve_format
12
+
13
+
14
+ @handle_errors
15
+ def stats(
16
+ ctx: typer.Context,
17
+ output_format: Annotated[OutputFormat | None, typer.Option("--format", "-f", help="Output format.")] = None,
18
+ json_output: Annotated[bool, typer.Option("--json", help="Output as JSON.")] = False,
19
+ ) -> None:
20
+ """Show your CometAPI usage statistics (requires access token)."""
21
+ fmt = resolve_format(ctx, json_output, output_format)
22
+ client = get_client(require_access_token=True)
23
+ resp = client.get_user_stats()
24
+ data = resp.get("data", {})
25
+
26
+ display = {
27
+ "requests": data.get("month_req_count", "N/A"),
28
+ "request_rate_change": f"{data.get('req_rate', 0):.1f}%",
29
+ "usage": f"${data.get('month_usage', 0):.4f}",
30
+ "usage_rate_change": f"{data.get('usage_rate', 0):.1f}%",
31
+ "success_rate": f"{data.get('month_success_rate', 0):.1f}%",
32
+ "predicted_days_left": data.get("predicted_days", "N/A"),
33
+ }
34
+
35
+ # For JSON output, pass raw data for maximum information
36
+ if fmt == OutputFormat.JSON:
37
+ output(data, fmt)
38
+ else:
39
+ output(display, fmt, title="CometAPI Usage Statistics (This Month)")
@@ -0,0 +1,130 @@
1
+ """Task logs command — async tasks (Suno, Midjourney, Luma, Kling, etc.)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Annotated
6
+
7
+ import typer
8
+
9
+ from ..config import get_client
10
+ from ..constants import extract_items, format_ts, parse_date, quota_to_usd
11
+ from ..errors import handle_errors
12
+ from ..formatters import OutputFormat, output, resolve_format
13
+
14
+ VALID_PLATFORMS = [
15
+ "suno", "mj", "luma", "replicate", "kling",
16
+ "runwayml", "runway", "jimeng", "volcengine", "sora", "bria", "flux",
17
+ ]
18
+
19
+ VALID_STATUSES = [
20
+ "SUBMITTED", "QUEUED", "IN_PROGRESS", "FAILURE", "SUCCESS", "UNKNOWN",
21
+ ]
22
+
23
+
24
+ def _duration(submit_ts: int, finish_ts: int) -> str:
25
+ """Calculate human-readable duration between two timestamps."""
26
+ if not submit_ts or not finish_ts:
27
+ return "—"
28
+ secs = finish_ts - submit_ts
29
+ if secs < 0:
30
+ return "—"
31
+ if secs < 60:
32
+ return f"{secs}s"
33
+ mins = secs // 60
34
+ remaining = secs % 60
35
+ return f"{mins}m{remaining}s"
36
+
37
+
38
+ def _format_task_row(task: dict) -> dict:
39
+ return {
40
+ "time": format_ts(task.get("created_at", 0)),
41
+ "platform": task.get("platform", "") or "—",
42
+ "task_id": task.get("task_id", "") or "—",
43
+ "action": task.get("action", "") or "—",
44
+ "status": task.get("status", "") or "—",
45
+ "model": task.get("model_name", "") or "—",
46
+ "progress": task.get("progress", "") or "—",
47
+ "cost": quota_to_usd(task.get("quota", 0)),
48
+ "duration": _duration(task.get("submit_time", 0), task.get("finish_time", 0)),
49
+ }
50
+
51
+
52
+ @handle_errors
53
+ def tasks(
54
+ ctx: typer.Context,
55
+ platform: Annotated[
56
+ str | None,
57
+ typer.Option("--platform", "-p", help=f"Filter by platform: {', '.join(VALID_PLATFORMS)}."),
58
+ ] = None,
59
+ task_id: Annotated[str | None, typer.Option("--task-id", help="Filter by task ID.")] = None,
60
+ status: Annotated[
61
+ str | None,
62
+ typer.Option("--status", "-s", help=f"Filter by status: {', '.join(VALID_STATUSES)}."),
63
+ ] = None,
64
+ action: Annotated[str | None, typer.Option("--action", "-a", help="Filter by action type.")] = None,
65
+ start: Annotated[str | None, typer.Option("--start", help="Start date (YYYY-MM-DD, ISO 8601, or Unix ts).")] = None,
66
+ end: Annotated[str | None, typer.Option("--end", help="End date (YYYY-MM-DD, ISO 8601, or Unix ts).")] = None,
67
+ page: Annotated[int, typer.Option("--page", help="Page number.")] = 1,
68
+ limit: Annotated[int, typer.Option("--limit", "-l", help="Results per page.")] = 20,
69
+ output_format: Annotated[OutputFormat | None, typer.Option("--format", "-f", help="Output format.")] = None,
70
+ json_output: Annotated[bool, typer.Option("--json", help="Output as JSON.")] = False,
71
+ ) -> None:
72
+ """Show your async task logs — Suno, MJ, Luma, Kling, etc. (requires access token)."""
73
+ fmt = resolve_format(ctx, json_output, output_format)
74
+ client = get_client(require_access_token=True)
75
+
76
+ # Validate platform
77
+ if platform and platform.lower() not in VALID_PLATFORMS:
78
+ from ..console import err_console
79
+
80
+ valid = ", ".join(VALID_PLATFORMS)
81
+ err_console.print(f"[red]Invalid platform:[/] {platform}. Valid platforms: {valid}")
82
+ raise typer.Exit(code=2)
83
+
84
+ # Validate status
85
+ if status and status.upper() not in VALID_STATUSES:
86
+ from ..console import err_console
87
+
88
+ valid = ", ".join(VALID_STATUSES)
89
+ err_console.print(f"[red]Invalid status:[/] {status}. Valid statuses: {valid}")
90
+ raise typer.Exit(code=2)
91
+
92
+ start_ts = parse_date(start, "start") if start else None
93
+ end_ts = parse_date(end, "end") if end else None
94
+
95
+ resp = client.list_tasks(
96
+ page=page,
97
+ page_size=limit,
98
+ platform=platform.lower() if platform else None,
99
+ task_id=task_id,
100
+ status=status.upper() if status else None,
101
+ action=action,
102
+ start_timestamp=start_ts,
103
+ end_timestamp=end_ts,
104
+ )
105
+
106
+ data = extract_items(resp)
107
+
108
+ if fmt == OutputFormat.JSON:
109
+ output(data, fmt)
110
+ return
111
+
112
+ if not data:
113
+ from ..console import console
114
+
115
+ console.print("[dim]No tasks found.[/]")
116
+ return
117
+
118
+ rows = [_format_task_row(t) for t in data]
119
+ columns = {
120
+ "time": "dim",
121
+ "platform": "magenta",
122
+ "task_id": "cyan",
123
+ "action": "yellow",
124
+ "status": "green",
125
+ "model": "green",
126
+ "progress": "dim",
127
+ "cost": "red",
128
+ "duration": "dim",
129
+ }
130
+ output(rows, fmt, title="Task Logs", columns=columns)