thoughtleaders-cli 0.5.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.
- thoughtleaders_cli-0.5.0.dist-info/METADATA +215 -0
- thoughtleaders_cli-0.5.0.dist-info/RECORD +59 -0
- thoughtleaders_cli-0.5.0.dist-info/WHEEL +4 -0
- thoughtleaders_cli-0.5.0.dist-info/entry_points.txt +2 -0
- thoughtleaders_cli-0.5.0.dist-info/licenses/LICENSE +21 -0
- tl_cli/__init__.py +3 -0
- tl_cli/_completions.py +4 -0
- tl_cli/_plugin/.claude-plugin/marketplace.json +17 -0
- tl_cli/_plugin/.claude-plugin/plugin.json +12 -0
- tl_cli/_plugin/agents/tl-analyst.md +66 -0
- tl_cli/_plugin/commands/tl-balance.md +10 -0
- tl_cli/_plugin/commands/tl-brands.md +16 -0
- tl_cli/_plugin/commands/tl-channels.md +31 -0
- tl_cli/_plugin/commands/tl-reports.md +16 -0
- tl_cli/_plugin/commands/tl-sponsorships.md +23 -0
- tl_cli/_plugin/commands/tl.md +28 -0
- tl_cli/_plugin/hooks/hooks.json +26 -0
- tl_cli/_plugin/hooks/scripts/post-usage.sh +26 -0
- tl_cli/_plugin/hooks/scripts/pre-check.sh +30 -0
- tl_cli/_plugin/skills/tl/SKILL.md +413 -0
- tl_cli/_plugin/skills/tl/references/business-glossary.md +159 -0
- tl_cli/_plugin/skills/tl/references/elasticsearch-schema.md +259 -0
- tl_cli/_plugin/skills/tl/references/firebolt-schema.md +208 -0
- tl_cli/_plugin/skills/tl/references/postgres-schema.md +269 -0
- tl_cli/auth/__init__.py +0 -0
- tl_cli/auth/commands.py +49 -0
- tl_cli/auth/login.py +328 -0
- tl_cli/auth/pkce.py +21 -0
- tl_cli/auth/token_store.py +98 -0
- tl_cli/client/__init__.py +0 -0
- tl_cli/client/errors.py +72 -0
- tl_cli/client/http.py +109 -0
- tl_cli/commands/__init__.py +0 -0
- tl_cli/commands/ask.py +54 -0
- tl_cli/commands/balance.py +68 -0
- tl_cli/commands/brands.py +174 -0
- tl_cli/commands/changelog.py +119 -0
- tl_cli/commands/channels.py +291 -0
- tl_cli/commands/comments.py +63 -0
- tl_cli/commands/db.py +104 -0
- tl_cli/commands/deals.py +52 -0
- tl_cli/commands/describe.py +166 -0
- tl_cli/commands/doctor.py +70 -0
- tl_cli/commands/matches.py +69 -0
- tl_cli/commands/proposals.py +69 -0
- tl_cli/commands/reports.py +346 -0
- tl_cli/commands/schema.py +55 -0
- tl_cli/commands/setup.py +401 -0
- tl_cli/commands/snapshots.py +93 -0
- tl_cli/commands/sponsorships.py +193 -0
- tl_cli/commands/uploads.py +84 -0
- tl_cli/commands/whoami.py +206 -0
- tl_cli/config.py +55 -0
- tl_cli/filters.py +88 -0
- tl_cli/hints.py +53 -0
- tl_cli/main.py +209 -0
- tl_cli/output/__init__.py +0 -0
- tl_cli/output/formatter.py +436 -0
- tl_cli/self_update.py +173 -0
tl_cli/main.py
ADDED
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
"""TL CLI — ThoughtLeaders command-line interface.
|
|
2
|
+
|
|
3
|
+
Query sponsorship data, channels, brands, and intelligence.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
import re
|
|
7
|
+
import sys
|
|
8
|
+
import traceback
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import Optional
|
|
11
|
+
|
|
12
|
+
import click
|
|
13
|
+
import typer
|
|
14
|
+
from rich.console import Console
|
|
15
|
+
from rich.markdown import Markdown
|
|
16
|
+
|
|
17
|
+
from tl_cli import __version__
|
|
18
|
+
from tl_cli import config as tl_config
|
|
19
|
+
from tl_cli.auth.commands import app as auth_app
|
|
20
|
+
from tl_cli.commands.ask import app as ask_app
|
|
21
|
+
from tl_cli.commands.balance import app as balance_app
|
|
22
|
+
from tl_cli.commands.changelog import changelog_command
|
|
23
|
+
from tl_cli.commands.brands import app as brands_app
|
|
24
|
+
from tl_cli.commands.channels import app as channels_app
|
|
25
|
+
from tl_cli.commands.comments import app as comments_app
|
|
26
|
+
from tl_cli.commands.db import app as db_app
|
|
27
|
+
from tl_cli.commands.deals import app as deals_app
|
|
28
|
+
from tl_cli.commands.matches import app as matches_app
|
|
29
|
+
from tl_cli.commands.proposals import app as proposals_app
|
|
30
|
+
from tl_cli.commands.sponsorships import app as sponsorships_app
|
|
31
|
+
from tl_cli.commands.describe import app as describe_app
|
|
32
|
+
from tl_cli.commands.schema import app as schema_app
|
|
33
|
+
from tl_cli.commands.doctor import app as doctor_app
|
|
34
|
+
from tl_cli.commands.reports import app as reports_app
|
|
35
|
+
from tl_cli.commands.setup import app as setup_app
|
|
36
|
+
from tl_cli.commands.snapshots import app as snapshots_app
|
|
37
|
+
from tl_cli.commands.uploads import app as uploads_app
|
|
38
|
+
from tl_cli.commands.whoami import app as whoami_app
|
|
39
|
+
|
|
40
|
+
app = typer.Typer(
|
|
41
|
+
name="tl",
|
|
42
|
+
help=f"ThoughtLeaders CLI v{__version__} — query sponsorship data, channels, brands, and intelligence.",
|
|
43
|
+
no_args_is_help=True,
|
|
44
|
+
rich_markup_mode="rich",
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def version_callback(value: bool) -> None:
|
|
49
|
+
if value:
|
|
50
|
+
print(f"tl-cli {__version__}")
|
|
51
|
+
raise typer.Exit()
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
@app.callback()
|
|
55
|
+
def main(
|
|
56
|
+
version: bool = typer.Option(
|
|
57
|
+
False, "--version", "-v", callback=version_callback, is_eager=True,
|
|
58
|
+
help="Show version",
|
|
59
|
+
),
|
|
60
|
+
debug: bool = typer.Option(
|
|
61
|
+
False, "--debug", help="Show detailed error information",
|
|
62
|
+
),
|
|
63
|
+
) -> None:
|
|
64
|
+
"""ThoughtLeaders CLI."""
|
|
65
|
+
tl_config.debug = debug
|
|
66
|
+
|
|
67
|
+
# Skip hints/warnings for setup commands
|
|
68
|
+
import sys
|
|
69
|
+
if "setup" not in sys.argv:
|
|
70
|
+
# First-run hint
|
|
71
|
+
from tl_cli.auth.token_store import load_tokens
|
|
72
|
+
tokens = load_tokens()
|
|
73
|
+
if not tokens:
|
|
74
|
+
err = Console(stderr=True)
|
|
75
|
+
err.print("[dim]Welcome to tl-cli! Get started:[/dim]")
|
|
76
|
+
err.print("[dim] tl auth login # authenticate[/dim]")
|
|
77
|
+
err.print("[dim] tl setup claude # install Claude Code plugin[/dim]")
|
|
78
|
+
err.print("[dim] tl setup opencode # install OpenCode skill[/dim]")
|
|
79
|
+
err.print()
|
|
80
|
+
|
|
81
|
+
from tl_cli.commands.setup import check_plugin_version
|
|
82
|
+
for warn in check_plugin_version():
|
|
83
|
+
Console(stderr=True).print(f"[yellow]{warn}[/yellow]")
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
# System
|
|
87
|
+
app.add_typer(auth_app, name="auth")
|
|
88
|
+
app.add_typer(setup_app, name="setup")
|
|
89
|
+
|
|
90
|
+
# Data commands (primary interface)
|
|
91
|
+
app.add_typer(sponsorships_app, name="sponsorships")
|
|
92
|
+
app.add_typer(matches_app, name="matches")
|
|
93
|
+
app.add_typer(proposals_app, name="proposals")
|
|
94
|
+
app.add_typer(deals_app, name="deals")
|
|
95
|
+
app.add_typer(uploads_app, name="uploads")
|
|
96
|
+
app.add_typer(channels_app, name="channels")
|
|
97
|
+
app.add_typer(brands_app, name="brands")
|
|
98
|
+
app.add_typer(snapshots_app, name="snapshots")
|
|
99
|
+
app.add_typer(reports_app, name="reports")
|
|
100
|
+
app.add_typer(comments_app, name="comments")
|
|
101
|
+
app.add_typer(db_app, name="db")
|
|
102
|
+
|
|
103
|
+
# Discoverability
|
|
104
|
+
app.add_typer(describe_app, name="describe")
|
|
105
|
+
app.add_typer(schema_app, name="schema")
|
|
106
|
+
app.add_typer(balance_app, name="balance")
|
|
107
|
+
app.add_typer(doctor_app, name="doctor")
|
|
108
|
+
app.add_typer(whoami_app, name="whoami")
|
|
109
|
+
|
|
110
|
+
# `changelog` is a single command (not a sub-typer) so positional version args
|
|
111
|
+
# don't get interpreted as subcommand names.
|
|
112
|
+
app.command(
|
|
113
|
+
name="changelog",
|
|
114
|
+
context_settings={"allow_extra_args": True, "ignore_unknown_options": True},
|
|
115
|
+
)(changelog_command)
|
|
116
|
+
|
|
117
|
+
# AI fallback
|
|
118
|
+
app.add_typer(ask_app, name="ask")
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
@app.command(name="update")
|
|
122
|
+
def update_command() -> None:
|
|
123
|
+
"""Check for a newer version and upgrade if one is available."""
|
|
124
|
+
from tl_cli.self_update import force_upgrade
|
|
125
|
+
force_upgrade()
|
|
126
|
+
raise typer.Exit()
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def _get_terminology() -> str | None:
|
|
130
|
+
"""Extract the Terminology section from README.md.
|
|
131
|
+
|
|
132
|
+
Tries to locate README.md relative to the package source first,
|
|
133
|
+
then falls back to importlib.metadata.
|
|
134
|
+
"""
|
|
135
|
+
try:
|
|
136
|
+
text = None
|
|
137
|
+
readme = Path(__file__).resolve().parent.parent.parent / "README.md"
|
|
138
|
+
if readme.is_file():
|
|
139
|
+
text = readme.read_text()
|
|
140
|
+
else:
|
|
141
|
+
from importlib.metadata import metadata
|
|
142
|
+
text = metadata("thoughtleaders-cli").get_payload()
|
|
143
|
+
if not text:
|
|
144
|
+
return None
|
|
145
|
+
match = re.search(r"^# Terminology\s*\n(.+?)(?=\n# |\Z)", text, re.DOTALL | re.MULTILINE)
|
|
146
|
+
if not match:
|
|
147
|
+
return None
|
|
148
|
+
return match.group(1).strip()
|
|
149
|
+
except Exception:
|
|
150
|
+
return None
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
@app.command(name="help", hidden=True)
|
|
154
|
+
def help_command(
|
|
155
|
+
ctx: typer.Context,
|
|
156
|
+
command: Optional[str] = typer.Argument(None, help="Command to show help for"),
|
|
157
|
+
) -> None:
|
|
158
|
+
"""Show help for the CLI or a specific command."""
|
|
159
|
+
root_ctx = ctx.parent
|
|
160
|
+
root_cmd = root_ctx.command
|
|
161
|
+
|
|
162
|
+
if command is None:
|
|
163
|
+
click.echo(root_cmd.get_help(root_ctx))
|
|
164
|
+
terminology = _get_terminology()
|
|
165
|
+
if terminology:
|
|
166
|
+
import shutil
|
|
167
|
+
term_width = shutil.get_terminal_size().columns
|
|
168
|
+
console = Console(width=int(term_width * 0.9))
|
|
169
|
+
console.print(Markdown(terminology))
|
|
170
|
+
console.print()
|
|
171
|
+
raise typer.Exit()
|
|
172
|
+
|
|
173
|
+
# Look up the subcommand
|
|
174
|
+
sub_cmd = root_cmd.get_command(root_ctx, command)
|
|
175
|
+
if sub_cmd is None:
|
|
176
|
+
click.echo(f"Unknown command: {command}", err=True)
|
|
177
|
+
raise typer.Exit(1)
|
|
178
|
+
|
|
179
|
+
sub_ctx = click.Context(sub_cmd, info_name=command, parent=root_ctx)
|
|
180
|
+
click.echo(sub_cmd.get_help(sub_ctx))
|
|
181
|
+
raise typer.Exit()
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def cli() -> None:
|
|
185
|
+
"""Entry point that wraps the Typer app with top-level error handling.
|
|
186
|
+
|
|
187
|
+
The `finally` block runs the post-command version check for pipx/uv
|
|
188
|
+
installs on every exit path — normal return, typer's SystemExit, or
|
|
189
|
+
the sys.exit(1) in the error branch. Silent on failure.
|
|
190
|
+
"""
|
|
191
|
+
from tl_cli.self_update import check_and_upgrade
|
|
192
|
+
try:
|
|
193
|
+
app()
|
|
194
|
+
except SystemExit:
|
|
195
|
+
raise
|
|
196
|
+
except Exception as exc:
|
|
197
|
+
if tl_config.debug:
|
|
198
|
+
traceback.print_exc(file=sys.stderr)
|
|
199
|
+
else:
|
|
200
|
+
Console(stderr=True).print(f"[red]Error:[/red] {exc}")
|
|
201
|
+
Console(stderr=True).print("[dim]Run with --debug for details.[/dim]")
|
|
202
|
+
sys.exit(1)
|
|
203
|
+
finally:
|
|
204
|
+
if "update" not in sys.argv:
|
|
205
|
+
check_and_upgrade()
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
if __name__ == "__main__":
|
|
209
|
+
cli()
|
|
File without changes
|
|
@@ -0,0 +1,436 @@
|
|
|
1
|
+
"""TTY-aware output formatting.
|
|
2
|
+
|
|
3
|
+
- Terminal (TTY): Rich tables with styled output
|
|
4
|
+
- Piped (non-TTY): Clean JSON
|
|
5
|
+
- Explicit flags: --json, --csv, --md override detection
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import csv
|
|
9
|
+
import io
|
|
10
|
+
import json
|
|
11
|
+
import sys
|
|
12
|
+
|
|
13
|
+
from rich.console import Console
|
|
14
|
+
from rich.table import Table
|
|
15
|
+
|
|
16
|
+
# Stderr console for status messages (never pollutes piped data)
|
|
17
|
+
err_console = Console(stderr=True)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def detect_format(json_flag: bool, csv_flag: bool, md_flag: bool, toon_flag: bool = False) -> str:
|
|
21
|
+
"""Determine output format from flags and TTY detection."""
|
|
22
|
+
if json_flag:
|
|
23
|
+
return "json"
|
|
24
|
+
if csv_flag:
|
|
25
|
+
return "csv"
|
|
26
|
+
if md_flag:
|
|
27
|
+
return "md"
|
|
28
|
+
if toon_flag:
|
|
29
|
+
return "toon"
|
|
30
|
+
if sys.stdout.isatty():
|
|
31
|
+
return "table"
|
|
32
|
+
return "json"
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def output(
|
|
36
|
+
data: dict,
|
|
37
|
+
fmt: str,
|
|
38
|
+
columns: list[str] | None = None,
|
|
39
|
+
title: str | None = None,
|
|
40
|
+
column_config: dict[str, dict] | None = None,
|
|
41
|
+
) -> None:
|
|
42
|
+
"""Format and print API response data.
|
|
43
|
+
|
|
44
|
+
Args:
|
|
45
|
+
data: API response dict with 'results', 'total', 'usage', '_breadcrumbs'
|
|
46
|
+
fmt: Output format ('table', 'json', 'csv', 'md')
|
|
47
|
+
columns: Which fields to show in table/csv/md mode. If None, auto-detect from data.
|
|
48
|
+
title: Optional title for table mode.
|
|
49
|
+
"""
|
|
50
|
+
results = data.get("results", [])
|
|
51
|
+
total = data.get("total")
|
|
52
|
+
usage = data.get("usage")
|
|
53
|
+
breadcrumbs = data.get("_breadcrumbs", [])
|
|
54
|
+
|
|
55
|
+
if fmt == "json":
|
|
56
|
+
print(json.dumps(data, indent=2, default=str))
|
|
57
|
+
return
|
|
58
|
+
|
|
59
|
+
if not results:
|
|
60
|
+
err_console.print("[dim]No results found.[/dim]")
|
|
61
|
+
_print_usage(usage)
|
|
62
|
+
return
|
|
63
|
+
|
|
64
|
+
if columns is None:
|
|
65
|
+
columns = _auto_columns(results)
|
|
66
|
+
|
|
67
|
+
column_types = data.get("column_types")
|
|
68
|
+
|
|
69
|
+
if fmt == "csv":
|
|
70
|
+
_output_csv(results, columns)
|
|
71
|
+
elif fmt == "md":
|
|
72
|
+
_output_markdown(results, columns, column_types)
|
|
73
|
+
elif fmt == "toon":
|
|
74
|
+
_output_toon(results, columns)
|
|
75
|
+
else:
|
|
76
|
+
_output_table(results, columns, title, total, column_config, column_types)
|
|
77
|
+
|
|
78
|
+
_print_pagination_notice(data)
|
|
79
|
+
_print_usage(usage)
|
|
80
|
+
_print_breadcrumbs(breadcrumbs)
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def output_single(data: dict, fmt: str) -> None:
|
|
84
|
+
"""Format and print a single record (detail view).
|
|
85
|
+
|
|
86
|
+
Nested list-of-dict values (e.g. `adspots`) are rendered as indented
|
|
87
|
+
sub-tables in table/md mode, and as a flattened cross-product in csv mode
|
|
88
|
+
(one row per nested item with parent fields repeated).
|
|
89
|
+
"""
|
|
90
|
+
results = data.get("results", data)
|
|
91
|
+
usage = data.get("usage")
|
|
92
|
+
breadcrumbs = data.get("_breadcrumbs", [])
|
|
93
|
+
|
|
94
|
+
if fmt == "json":
|
|
95
|
+
print(json.dumps(data, indent=2, default=str))
|
|
96
|
+
return
|
|
97
|
+
|
|
98
|
+
# Unwrap single-item list
|
|
99
|
+
record = results[0] if isinstance(results, list) and len(results) == 1 else results
|
|
100
|
+
if not isinstance(record, dict):
|
|
101
|
+
print(json.dumps(results, indent=2, default=str))
|
|
102
|
+
return
|
|
103
|
+
|
|
104
|
+
if fmt == "toon":
|
|
105
|
+
_output_toon_single(record)
|
|
106
|
+
elif fmt == "csv":
|
|
107
|
+
_output_detail_csv(record)
|
|
108
|
+
else:
|
|
109
|
+
_output_detail(record)
|
|
110
|
+
|
|
111
|
+
_print_usage(usage)
|
|
112
|
+
_print_breadcrumbs(breadcrumbs)
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def _auto_columns(results: list[dict]) -> list[str]:
|
|
116
|
+
"""Pick columns from the first result, limiting to a reasonable set."""
|
|
117
|
+
if not results:
|
|
118
|
+
return []
|
|
119
|
+
keys = list(results[0].keys())
|
|
120
|
+
# Show at most 8 columns in table mode to keep it readable
|
|
121
|
+
return keys[:8]
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
_NUMERIC_DATA_TYPES = {"number", "num_days", "currency"}
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def _resolve_numeric_columns(
|
|
128
|
+
results: list[dict],
|
|
129
|
+
columns: list[str],
|
|
130
|
+
column_types: dict[str, str] | None = None,
|
|
131
|
+
) -> set[str]:
|
|
132
|
+
"""Determine which columns are numeric using server metadata first,
|
|
133
|
+
then auto-detection from values as a fallback."""
|
|
134
|
+
if column_types:
|
|
135
|
+
known = {col for col in columns if column_types.get(col) in _NUMERIC_DATA_TYPES}
|
|
136
|
+
# For columns not in column_types, fall back to auto-detection
|
|
137
|
+
unknown = [col for col in columns if col not in column_types]
|
|
138
|
+
if unknown:
|
|
139
|
+
known |= _detect_numeric_columns(results, unknown)
|
|
140
|
+
return known
|
|
141
|
+
return _detect_numeric_columns(results, columns)
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def _detect_numeric_columns(results: list[dict], columns: list[str]) -> set[str]:
|
|
145
|
+
"""Scan result rows to find columns where all non-None values are numeric.
|
|
146
|
+
|
|
147
|
+
Handles int, float, and string representations of numbers (e.g. Django
|
|
148
|
+
DecimalField values serialized as "1437.50").
|
|
149
|
+
"""
|
|
150
|
+
numeric = set(columns)
|
|
151
|
+
for row in results[:50]: # sample first 50 rows
|
|
152
|
+
for col in list(numeric):
|
|
153
|
+
val = row.get(col)
|
|
154
|
+
if val is None or val == "":
|
|
155
|
+
continue
|
|
156
|
+
if isinstance(val, bool):
|
|
157
|
+
numeric.discard(col)
|
|
158
|
+
elif isinstance(val, (int, float)):
|
|
159
|
+
continue
|
|
160
|
+
elif isinstance(val, str):
|
|
161
|
+
try:
|
|
162
|
+
float(val)
|
|
163
|
+
except (ValueError, OverflowError):
|
|
164
|
+
numeric.discard(col)
|
|
165
|
+
else:
|
|
166
|
+
numeric.discard(col)
|
|
167
|
+
# Don't treat ID-like columns as numeric
|
|
168
|
+
for col in list(numeric):
|
|
169
|
+
if col.endswith("_id") or col == "id" or "publication" in col:
|
|
170
|
+
numeric.discard(col)
|
|
171
|
+
# Columns where every sampled value was None/empty aren't meaningfully numeric
|
|
172
|
+
for col in list(numeric):
|
|
173
|
+
if not any(row.get(col) not in (None, "") for row in results[:50]):
|
|
174
|
+
numeric.discard(col)
|
|
175
|
+
return numeric
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def _format_numeric(val: object, decimals: bool = False, currency: bool = False) -> str:
|
|
179
|
+
"""Format a numeric value for table display.
|
|
180
|
+
|
|
181
|
+
Args:
|
|
182
|
+
decimals: If True, always show 2 decimal places (column has fractional values).
|
|
183
|
+
currency: If True, prefix with '$ '.
|
|
184
|
+
"""
|
|
185
|
+
if val is None or val == "":
|
|
186
|
+
return ""
|
|
187
|
+
if isinstance(val, bool):
|
|
188
|
+
return str(val)
|
|
189
|
+
# Coerce to float for uniform handling
|
|
190
|
+
try:
|
|
191
|
+
f = float(val)
|
|
192
|
+
except (ValueError, TypeError, OverflowError):
|
|
193
|
+
return str(val)
|
|
194
|
+
if decimals or currency:
|
|
195
|
+
text = f"{f:,.2f}"
|
|
196
|
+
else:
|
|
197
|
+
text = f"{int(f):,}" if f == int(f) else f"{f:,.2f}"
|
|
198
|
+
if currency:
|
|
199
|
+
text = f"$ {text}"
|
|
200
|
+
return text
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def _column_has_decimals(results: list[dict], col: str) -> bool:
|
|
204
|
+
"""Check if any non-None value in a column has a fractional part."""
|
|
205
|
+
for row in results[:100]:
|
|
206
|
+
val = row.get(col)
|
|
207
|
+
if val is None or val == "":
|
|
208
|
+
continue
|
|
209
|
+
try:
|
|
210
|
+
f = float(val)
|
|
211
|
+
if f != int(f):
|
|
212
|
+
return True
|
|
213
|
+
except (ValueError, TypeError, OverflowError):
|
|
214
|
+
pass
|
|
215
|
+
return False
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
def _output_table(
|
|
219
|
+
results: list[dict],
|
|
220
|
+
columns: list[str],
|
|
221
|
+
title: str | None,
|
|
222
|
+
total: int | None,
|
|
223
|
+
column_config: dict[str, dict] | None = None,
|
|
224
|
+
column_types: dict[str, str] | None = None,
|
|
225
|
+
) -> None:
|
|
226
|
+
"""Rich table output for TTY.
|
|
227
|
+
|
|
228
|
+
column_config maps column names to kwargs passed to table.add_column(),
|
|
229
|
+
e.g. {"price": {"justify": "right"}}.
|
|
230
|
+
Numeric columns are determined from server-provided column_types first,
|
|
231
|
+
then auto-detected from values as a fallback.
|
|
232
|
+
"""
|
|
233
|
+
console = Console()
|
|
234
|
+
column_config = column_config or {}
|
|
235
|
+
numeric_cols = _resolve_numeric_columns(results, columns, column_types)
|
|
236
|
+
col_decimals = {col: _column_has_decimals(results, col) for col in numeric_cols}
|
|
237
|
+
col_currency = {col for col in columns if (column_types or {}).get(col) == "currency"}
|
|
238
|
+
header = title or "Results"
|
|
239
|
+
if total is not None:
|
|
240
|
+
header += f" ({len(results)} of {total})"
|
|
241
|
+
|
|
242
|
+
table = Table(title=header, show_lines=False)
|
|
243
|
+
for col in columns:
|
|
244
|
+
extra = column_config.get(col, {})
|
|
245
|
+
if col in numeric_cols and "justify" not in extra:
|
|
246
|
+
extra = {**extra, "justify": "right"}
|
|
247
|
+
table.add_column(col, overflow="ellipsis", max_width=40, **extra)
|
|
248
|
+
|
|
249
|
+
for row in results:
|
|
250
|
+
cells = []
|
|
251
|
+
for col in columns:
|
|
252
|
+
val = row.get(col, "")
|
|
253
|
+
if col in numeric_cols:
|
|
254
|
+
cells.append(_format_numeric(val, decimals=col_decimals.get(col, False), currency=col in col_currency))
|
|
255
|
+
else:
|
|
256
|
+
cells.append(_truncate(str(val), 40))
|
|
257
|
+
table.add_row(*cells)
|
|
258
|
+
|
|
259
|
+
console.print(table)
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
def _output_csv(results: list[dict], columns: list[str]) -> None:
|
|
263
|
+
"""CSV output to stdout."""
|
|
264
|
+
writer = csv.DictWriter(sys.stdout, fieldnames=columns, extrasaction="ignore")
|
|
265
|
+
writer.writeheader()
|
|
266
|
+
for row in results:
|
|
267
|
+
writer.writerow({k: row.get(k, "") for k in columns})
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
def _output_markdown(results: list[dict], columns: list[str], column_types: dict[str, str] | None = None) -> None:
|
|
271
|
+
"""Markdown table output."""
|
|
272
|
+
numeric_cols = _resolve_numeric_columns(results, columns, column_types)
|
|
273
|
+
col_decimals = {col: _column_has_decimals(results, col) for col in numeric_cols}
|
|
274
|
+
col_currency = {col for col in columns if (column_types or {}).get(col) == "currency"}
|
|
275
|
+
# Header
|
|
276
|
+
print("| " + " | ".join(columns) + " |")
|
|
277
|
+
alignments = ["---:" if col in numeric_cols else "---" for col in columns]
|
|
278
|
+
print("| " + " | ".join(alignments) + " |")
|
|
279
|
+
# Rows
|
|
280
|
+
for row in results:
|
|
281
|
+
values = []
|
|
282
|
+
for col in columns:
|
|
283
|
+
val = row.get(col, "")
|
|
284
|
+
if col in numeric_cols:
|
|
285
|
+
values.append(_format_numeric(val, decimals=col_decimals.get(col, False), currency=col in col_currency))
|
|
286
|
+
else:
|
|
287
|
+
values.append(str(val).replace("\n", " ").replace("|", "\\|"))
|
|
288
|
+
print("| " + " | ".join(values) + " |")
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+
def _output_toon(results: list[dict], columns: list[str]) -> None:
|
|
292
|
+
"""TOON (Token-Oriented Object Notation) output for LLM consumption."""
|
|
293
|
+
from toon_format import encode
|
|
294
|
+
# Build column-filtered rows for uniform tabular encoding
|
|
295
|
+
rows = [{col: row.get(col) for col in columns} for row in results]
|
|
296
|
+
print(encode(rows))
|
|
297
|
+
|
|
298
|
+
|
|
299
|
+
def _output_toon_single(record: dict) -> None:
|
|
300
|
+
"""TOON output for a single detail record."""
|
|
301
|
+
from toon_format import encode
|
|
302
|
+
print(encode(record))
|
|
303
|
+
|
|
304
|
+
|
|
305
|
+
_RIGHT_ALIGN_COLS = {"price", "cost", "cpm"}
|
|
306
|
+
|
|
307
|
+
|
|
308
|
+
def _is_list_of_dicts(value: object) -> bool:
|
|
309
|
+
return isinstance(value, list) and bool(value) and all(isinstance(v, dict) for v in value)
|
|
310
|
+
|
|
311
|
+
|
|
312
|
+
def _output_detail(record: dict) -> None:
|
|
313
|
+
"""Pretty-print a single record as key-value pairs.
|
|
314
|
+
|
|
315
|
+
If a value is a non-empty list of dicts, it's rendered as an indented
|
|
316
|
+
sub-table beneath its label instead of stringified. Empty lists show
|
|
317
|
+
`(none)` to signal "no entries" explicitly rather than printing `[]`.
|
|
318
|
+
"""
|
|
319
|
+
console = Console()
|
|
320
|
+
nested_items = [(k, v) for k, v in record.items() if _is_list_of_dicts(v)]
|
|
321
|
+
empty_list_items = [k for k, v in record.items() if isinstance(v, list) and not v]
|
|
322
|
+
nested_or_empty_keys = {k for k, _ in nested_items} | set(empty_list_items)
|
|
323
|
+
flat_items = [(k, v) for k, v in record.items() if k not in nested_or_empty_keys]
|
|
324
|
+
|
|
325
|
+
max_key_len = max((len(k) for k, _ in flat_items), default=0)
|
|
326
|
+
for key, value in flat_items:
|
|
327
|
+
# List that's not list-of-dicts → stringify as JSON for readability
|
|
328
|
+
if isinstance(value, list):
|
|
329
|
+
display = json.dumps(value, default=str)
|
|
330
|
+
else:
|
|
331
|
+
display = value
|
|
332
|
+
label = f"[bold]{key:<{max_key_len}}[/bold]"
|
|
333
|
+
console.print(f" {label} {display}")
|
|
334
|
+
|
|
335
|
+
for key, rows in nested_items:
|
|
336
|
+
console.print(f"\n [bold]{key}[/bold] ({len(rows)}):")
|
|
337
|
+
sub_cols = list(rows[0].keys())
|
|
338
|
+
sub_table = Table(show_header=True, padding=(0, 1))
|
|
339
|
+
for col in sub_cols:
|
|
340
|
+
kwargs: dict = {"overflow": "ellipsis", "max_width": 40}
|
|
341
|
+
if col in _RIGHT_ALIGN_COLS:
|
|
342
|
+
kwargs["justify"] = "right"
|
|
343
|
+
sub_table.add_column(col, **kwargs)
|
|
344
|
+
for row in rows:
|
|
345
|
+
sub_table.add_row(*[_format_cell(row.get(col)) for col in sub_cols])
|
|
346
|
+
console.print(sub_table)
|
|
347
|
+
|
|
348
|
+
for key in empty_list_items:
|
|
349
|
+
console.print(f"\n [bold]{key}[/bold]: [dim](none)[/dim]")
|
|
350
|
+
|
|
351
|
+
|
|
352
|
+
def _format_cell(value: object) -> str:
|
|
353
|
+
if value is None:
|
|
354
|
+
return ""
|
|
355
|
+
return _truncate(str(value), 40)
|
|
356
|
+
|
|
357
|
+
|
|
358
|
+
def _output_detail_csv(record: dict) -> None:
|
|
359
|
+
"""Flatten a detail record to CSV.
|
|
360
|
+
|
|
361
|
+
Flat fields become columns on every row. Nested list-of-dict fields are
|
|
362
|
+
cross-joined: one output row per nested item, with parent fields repeated
|
|
363
|
+
and nested fields prefixed with `<key>_` to avoid collisions (parent and
|
|
364
|
+
nested items may share field names like `id` or `name`).
|
|
365
|
+
|
|
366
|
+
Records with no nested items emit a single row of flat fields. If there
|
|
367
|
+
are multiple nested list fields, the rows are cross-joined.
|
|
368
|
+
"""
|
|
369
|
+
flat = {k: ("" if v is None else v) for k, v in record.items() if not isinstance(v, list)}
|
|
370
|
+
nested = [(k, v) for k, v in record.items() if _is_list_of_dicts(v)]
|
|
371
|
+
|
|
372
|
+
# Build header: flat columns + prefixed nested columns
|
|
373
|
+
columns = list(flat.keys())
|
|
374
|
+
for key, rows in nested:
|
|
375
|
+
for col in rows[0].keys():
|
|
376
|
+
columns.append(f"{key}_{col}")
|
|
377
|
+
|
|
378
|
+
writer = csv.DictWriter(sys.stdout, fieldnames=columns, extrasaction="ignore")
|
|
379
|
+
writer.writeheader()
|
|
380
|
+
|
|
381
|
+
# No nested items → single row
|
|
382
|
+
if not nested:
|
|
383
|
+
writer.writerow(flat)
|
|
384
|
+
return
|
|
385
|
+
|
|
386
|
+
# Cross-join: cartesian product over nested lists. In practice there's
|
|
387
|
+
# usually one nested field (e.g. adspots), giving N rows per record.
|
|
388
|
+
from itertools import product
|
|
389
|
+
for combo in product(*(rows for _, rows in nested)):
|
|
390
|
+
row = dict(flat)
|
|
391
|
+
for (key, _), item in zip(nested, combo):
|
|
392
|
+
for col, val in item.items():
|
|
393
|
+
row[f"{key}_{col}"] = "" if val is None else val
|
|
394
|
+
writer.writerow(row)
|
|
395
|
+
|
|
396
|
+
|
|
397
|
+
def _print_pagination_notice(data: dict) -> None:
|
|
398
|
+
"""Print a visible notice when there are more pages of results."""
|
|
399
|
+
if data.get("has_more") and data.get("next_offset") is not None:
|
|
400
|
+
total = data.get("total", "?")
|
|
401
|
+
next_offset = data["next_offset"]
|
|
402
|
+
shown = len(data.get("results", []))
|
|
403
|
+
err_console.print(
|
|
404
|
+
f"[yellow]Showing {shown} of {total} results. "
|
|
405
|
+
f"Use --offset {next_offset} for the next page.[/yellow]"
|
|
406
|
+
)
|
|
407
|
+
|
|
408
|
+
|
|
409
|
+
def _print_usage(usage: dict | None) -> None:
|
|
410
|
+
"""Print credit usage to stderr."""
|
|
411
|
+
if not usage:
|
|
412
|
+
return
|
|
413
|
+
charged = usage.get("credits_charged", 0)
|
|
414
|
+
remaining = usage.get("balance_remaining")
|
|
415
|
+
if remaining is not None:
|
|
416
|
+
err_console.print(f"[dim]{charged} credits · {remaining} remaining[/dim]")
|
|
417
|
+
elif charged:
|
|
418
|
+
err_console.print(f"[dim]{charged} credits used[/dim]")
|
|
419
|
+
|
|
420
|
+
|
|
421
|
+
def _print_breadcrumbs(breadcrumbs: list[dict]) -> None:
|
|
422
|
+
"""Print next-command suggestions to stderr."""
|
|
423
|
+
if not breadcrumbs:
|
|
424
|
+
return
|
|
425
|
+
err_console.print()
|
|
426
|
+
for bc in breadcrumbs[:3]:
|
|
427
|
+
hint = bc.get("hint", "")
|
|
428
|
+
cmd = bc.get("command", "")
|
|
429
|
+
err_console.print(f"[dim] → {hint}:[/dim] [cyan]{cmd}[/cyan]")
|
|
430
|
+
|
|
431
|
+
|
|
432
|
+
def _truncate(s: str, max_len: int) -> str:
|
|
433
|
+
"""Truncate a string to max_len, adding ellipsis if needed."""
|
|
434
|
+
if len(s) <= max_len:
|
|
435
|
+
return s
|
|
436
|
+
return s[: max_len - 1] + "…"
|