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.
Files changed (59) hide show
  1. thoughtleaders_cli-0.5.0.dist-info/METADATA +215 -0
  2. thoughtleaders_cli-0.5.0.dist-info/RECORD +59 -0
  3. thoughtleaders_cli-0.5.0.dist-info/WHEEL +4 -0
  4. thoughtleaders_cli-0.5.0.dist-info/entry_points.txt +2 -0
  5. thoughtleaders_cli-0.5.0.dist-info/licenses/LICENSE +21 -0
  6. tl_cli/__init__.py +3 -0
  7. tl_cli/_completions.py +4 -0
  8. tl_cli/_plugin/.claude-plugin/marketplace.json +17 -0
  9. tl_cli/_plugin/.claude-plugin/plugin.json +12 -0
  10. tl_cli/_plugin/agents/tl-analyst.md +66 -0
  11. tl_cli/_plugin/commands/tl-balance.md +10 -0
  12. tl_cli/_plugin/commands/tl-brands.md +16 -0
  13. tl_cli/_plugin/commands/tl-channels.md +31 -0
  14. tl_cli/_plugin/commands/tl-reports.md +16 -0
  15. tl_cli/_plugin/commands/tl-sponsorships.md +23 -0
  16. tl_cli/_plugin/commands/tl.md +28 -0
  17. tl_cli/_plugin/hooks/hooks.json +26 -0
  18. tl_cli/_plugin/hooks/scripts/post-usage.sh +26 -0
  19. tl_cli/_plugin/hooks/scripts/pre-check.sh +30 -0
  20. tl_cli/_plugin/skills/tl/SKILL.md +413 -0
  21. tl_cli/_plugin/skills/tl/references/business-glossary.md +159 -0
  22. tl_cli/_plugin/skills/tl/references/elasticsearch-schema.md +259 -0
  23. tl_cli/_plugin/skills/tl/references/firebolt-schema.md +208 -0
  24. tl_cli/_plugin/skills/tl/references/postgres-schema.md +269 -0
  25. tl_cli/auth/__init__.py +0 -0
  26. tl_cli/auth/commands.py +49 -0
  27. tl_cli/auth/login.py +328 -0
  28. tl_cli/auth/pkce.py +21 -0
  29. tl_cli/auth/token_store.py +98 -0
  30. tl_cli/client/__init__.py +0 -0
  31. tl_cli/client/errors.py +72 -0
  32. tl_cli/client/http.py +109 -0
  33. tl_cli/commands/__init__.py +0 -0
  34. tl_cli/commands/ask.py +54 -0
  35. tl_cli/commands/balance.py +68 -0
  36. tl_cli/commands/brands.py +174 -0
  37. tl_cli/commands/changelog.py +119 -0
  38. tl_cli/commands/channels.py +291 -0
  39. tl_cli/commands/comments.py +63 -0
  40. tl_cli/commands/db.py +104 -0
  41. tl_cli/commands/deals.py +52 -0
  42. tl_cli/commands/describe.py +166 -0
  43. tl_cli/commands/doctor.py +70 -0
  44. tl_cli/commands/matches.py +69 -0
  45. tl_cli/commands/proposals.py +69 -0
  46. tl_cli/commands/reports.py +346 -0
  47. tl_cli/commands/schema.py +55 -0
  48. tl_cli/commands/setup.py +401 -0
  49. tl_cli/commands/snapshots.py +93 -0
  50. tl_cli/commands/sponsorships.py +193 -0
  51. tl_cli/commands/uploads.py +84 -0
  52. tl_cli/commands/whoami.py +206 -0
  53. tl_cli/config.py +55 -0
  54. tl_cli/filters.py +88 -0
  55. tl_cli/hints.py +53 -0
  56. tl_cli/main.py +209 -0
  57. tl_cli/output/__init__.py +0 -0
  58. tl_cli/output/formatter.py +436 -0
  59. 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] + "…"