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
@@ -0,0 +1,119 @@
1
+ """tl changelog — Show release notes for one or more CLI versions."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import sys
7
+
8
+ import typer
9
+ from rich.console import Console
10
+ from rich.markdown import Markdown
11
+ from rich.panel import Panel
12
+ from rich.text import Text
13
+
14
+ from tl_cli import __version__
15
+ from tl_cli.client.errors import ApiError, handle_api_error
16
+ from tl_cli.client.http import get_client
17
+ from tl_cli.self_update import _fetch_latest_version, _version_tuple
18
+
19
+
20
+ def _normalize(v: str) -> str:
21
+ v = v.strip()
22
+ if not v:
23
+ return ''
24
+ return v if v.startswith('v') else f'v{v}'
25
+
26
+
27
+ def _build_request_body(args: list[str]) -> tuple[dict, str | None]:
28
+ """Translate CLI args into the API request body. Returns (body, error_or_none)."""
29
+ if not args:
30
+ # No args: bound the request to either the current version (when up to
31
+ # date) or strictly the gap between current and latest. Never request
32
+ # the full version history.
33
+ latest = _fetch_latest_version()
34
+ current = f'v{__version__}'
35
+ if not latest:
36
+ return {'versions': [current]}, None
37
+ try:
38
+ if _version_tuple(latest) <= _version_tuple(__version__):
39
+ return {'versions': [current]}, None
40
+ except ValueError:
41
+ return {'versions': [current]}, None
42
+ return {'since': current}, None
43
+
44
+ if len(args) >= 2 and args[0].lower() == 'since':
45
+ since = _normalize(args[1])
46
+ if not since:
47
+ return {}, "'since' requires a version (e.g. tl changelog since v0.4.10)"
48
+ return {'since': since}, None
49
+
50
+ versions = [_normalize(v) for v in args if v.strip()]
51
+ if not versions:
52
+ return {}, 'no valid versions in arguments'
53
+ return {'versions': versions}, None
54
+
55
+
56
+ def _render(data: dict, json_output: bool, md_output: bool) -> None:
57
+ results = data.get('results', []) or []
58
+
59
+ if json_output or (not sys.stdout.isatty() and not md_output):
60
+ print(json.dumps(data, indent=2, default=str))
61
+ return
62
+
63
+ if not results:
64
+ Console(stderr=True).print('[dim]No changelog entries returned.[/dim]')
65
+ return
66
+
67
+ if md_output:
68
+ for entry in results:
69
+ print(f"## {entry.get('version', '?')}")
70
+ date = entry.get('release_date') or ''
71
+ if date:
72
+ print(f"_Released {date}_\n")
73
+ print(entry.get('summary', '').strip() or '_(no summary)_')
74
+ print()
75
+ return
76
+
77
+ console = Console()
78
+ for entry in results:
79
+ version = entry.get('version', '?')
80
+ date = entry.get('release_date') or ''
81
+ title = Text(version, style='bold cyan')
82
+ if date:
83
+ title.append(f" · released {date}", style='dim')
84
+ body = entry.get('summary', '').strip() or '_(no summary)_'
85
+ console.print(Panel(Markdown(body), title=title, title_align='left', border_style='cyan'))
86
+
87
+
88
+ def changelog_command(
89
+ ctx: typer.Context,
90
+ json_output: bool = typer.Option(False, '--json', help='JSON output'),
91
+ md_output: bool = typer.Option(False, '--md', help='Markdown output (good for piping into a doc)'),
92
+ ) -> None:
93
+ """Show release notes for tl-cli versions.
94
+
95
+ Positional arguments are version numbers, or the special form
96
+ `since <version>` to get everything between that version and the latest
97
+ release. With no arguments, shows the current version's notes (or the
98
+ gap from current to latest if you're behind).
99
+
100
+ Examples:
101
+ tl changelog # current version (or current..latest if outdated)
102
+ tl changelog v0.4.17 v0.4.18 # explicit list
103
+ tl changelog since v0.4.10 # everything from v0.4.10 to latest
104
+ tl changelog --md > CHANGELOG.md # capture for a doc
105
+ """
106
+ raw_args = [a for a in (ctx.args or []) if a and not a.startswith('-')]
107
+ body, err = _build_request_body(raw_args)
108
+ if err:
109
+ Console(stderr=True).print(f'[red]Error:[/red] {err}')
110
+ raise typer.Exit(2)
111
+
112
+ client = get_client()
113
+ try:
114
+ data = client.post('/changelog', json_body=body)
115
+ _render(data, json_output, md_output)
116
+ except ApiError as e:
117
+ handle_api_error(e)
118
+ finally:
119
+ client.close()
@@ -0,0 +1,291 @@
1
+ """tl channels — Search and show YouTube channels."""
2
+
3
+ import urllib.parse
4
+
5
+ import typer
6
+ from rich.console import Console
7
+
8
+ from tl_cli.client.errors import ApiError, handle_api_error
9
+ from tl_cli.client.http import get_client
10
+ from tl_cli.filters import parse_filters
11
+ from tl_cli.hints import detail_hint
12
+ from tl_cli.output.formatter import detect_format, output, output_single
13
+
14
+ app = typer.Typer(help="YouTube channels (search, detail, and similar-channel recommendations)")
15
+
16
+ # Columns for the `similar` endpoint result table. The server enriches every
17
+ # row so the user can size up each suggestion without follow-up queries.
18
+ SIMILAR_COLUMNS = ["score", "channel_id", "name", "msn", "tpp", "subscribers", "projected_views", "total_views", "cpm", "audience"]
19
+ SIMILAR_COLUMN_CONFIG = {
20
+ "score": {"justify": "right"},
21
+ "subscribers": {"justify": "right"},
22
+ "projected_views": {"justify": "right"},
23
+ "total_views": {"justify": "right"},
24
+ "cpm": {"justify": "right"},
25
+ }
26
+
27
+
28
+ @app.callback(invoke_without_command=True)
29
+ def channels(ctx: typer.Context) -> None:
30
+ """YouTube channels — search and detail."""
31
+ if ctx.invoked_subcommand is None:
32
+ ctx.invoke(list_cmd, args=[], json_output=False, csv_output=False, md_output=False, limit=50, offset=0)
33
+
34
+
35
+ @app.command("list")
36
+ def list_cmd(
37
+ args: list[str] = typer.Argument(None, help="Filters (key:value pairs). Run 'tl describe show channels' for available filters."),
38
+ json_output: bool = typer.Option(False, "--json", help="JSON output"),
39
+ csv_output: bool = typer.Option(False, "--csv", help="CSV output"),
40
+ md_output: bool = typer.Option(False, "--md", help="Markdown output"),
41
+ toon_output: bool = typer.Option(False, "--toon", help="TOON output (token-efficient for LLMs)"),
42
+ limit: int = typer.Option(50, "--limit", "-l", help="Max results"),
43
+ offset: int = typer.Option(0, "--offset", help="Pagination offset"),
44
+ ) -> None:
45
+ """Search channels with optional filters.
46
+
47
+ Examples:
48
+ tl channels list # List channels
49
+ tl channels list category:cooking min-subs:100k # Search with filters
50
+ """
51
+ fmt = detect_format(json_output, csv_output, md_output, toon_output)
52
+ filters = parse_filters(args or [])
53
+
54
+ client = get_client()
55
+ try:
56
+ params = {**filters, "limit": str(limit), "offset": str(offset)}
57
+ data = client.get("/channels", params=params)
58
+ for r in data.get("results", []):
59
+ r["channel_id"] = r.pop("id", None)
60
+ output(
61
+ data,
62
+ fmt,
63
+ columns=["channel_id", "name", "url", "msn", "tpp", "subscribers", "gender", "countries", "category", "sponsorship_score", "trend"],
64
+ title="Channels",
65
+ )
66
+ except ApiError as e:
67
+ handle_api_error(e)
68
+ finally:
69
+ client.close()
70
+
71
+
72
+ @app.command("show")
73
+ def show_cmd(
74
+ channel_ref: str = typer.Argument(..., help="Channel ID (numeric) or name (partial match, must be unique)"),
75
+ json_output: bool = typer.Option(False, "--json", help="JSON output"),
76
+ csv_output: bool = typer.Option(False, "--csv", help="CSV output (flattens adspots: one row per adspot)"),
77
+ toon_output: bool = typer.Option(False, "--toon", help="TOON output (token-efficient for LLMs)"),
78
+ ) -> None:
79
+ """Show channel detail by ID or name (includes active adspots).
80
+
81
+ Accepts either a numeric channel ID or a partial name. Names that
82
+ match more than one active channel return a 400 with the candidate
83
+ IDs listed so you can retry with a specific ID.
84
+
85
+ Examples:
86
+ tl channels show 12345
87
+ tl channels show "Economics Explained"
88
+ tl channels show 12345 --csv > channel.csv
89
+ """
90
+ fmt = detect_format(json_output, csv_output, False, toon_output)
91
+
92
+ encoded_ref = urllib.parse.quote(channel_ref, safe="")
93
+ client = get_client()
94
+ try:
95
+ data = client.get(f"/channels/{encoded_ref}")
96
+ for i, r in enumerate(data.get("results", []) if isinstance(data.get("results"), list) else []):
97
+ renamed = {}
98
+ for k, v in r.items():
99
+ if k == "id":
100
+ renamed["channel_id"] = v
101
+ else:
102
+ renamed[k] = v
103
+ data["results"][i] = renamed
104
+ output_single(data, fmt)
105
+ if fmt == "table" and data.get("show_cta"):
106
+ record = data.get("results", data)
107
+ if isinstance(record, list) and record:
108
+ record = record[0]
109
+ if isinstance(record, dict):
110
+ hint = detail_hint(client, channel=record.get("name"))
111
+ if hint:
112
+ Console(stderr=True).print(f"\n[yellow]{hint}[/yellow]")
113
+ except ApiError as e:
114
+ _handle_channel_api_error(e)
115
+ finally:
116
+ client.close()
117
+
118
+
119
+ def _handle_channel_api_error(e: ApiError) -> None:
120
+ """Print a candidates list for 400 responses with `candidates` in the
121
+ body (ambiguous channel name) and exit 1; otherwise defer to the
122
+ default handler. Used by both `show` and `similar` since they share
123
+ the server-side _resolve_channel helper and the same error shape.
124
+ """
125
+ if e.status_code == 400 and isinstance(e.raw, dict) and e.raw.get("candidates"):
126
+ err = Console(stderr=True)
127
+ err.print(f"[yellow]{e.detail}[/yellow]")
128
+ err.print()
129
+ err.print("[bold]Candidates:[/bold]")
130
+ err.print(f" {'channel_id':>10} {'subscribers':>12} name")
131
+ err.print(f" {'-' * 10} {'-' * 12} {'-' * 40}")
132
+ for c in e.raw["candidates"]:
133
+ subs = c.get("subscribers") or 0
134
+ err.print(f" {c['channel_id']:>10} {subs:>12,} {c['name']}")
135
+ raise typer.Exit(1)
136
+ handle_api_error(e)
137
+
138
+
139
+ def _format_score(results: list[dict]) -> list[dict]:
140
+ """Convert raw cosine score (0.0-1.0) to percentage string for table/csv/md."""
141
+ for row in results:
142
+ score = row.get("score")
143
+ if isinstance(score, (int, float)):
144
+ row["score"] = f"{score * 100:.1f}%"
145
+ return results
146
+
147
+
148
+ def _do_similar(channel_ref: str, args: list[str], fmt: str, limit: int) -> None:
149
+ """Shared implementation for `similar` and `look-alike`.
150
+
151
+ Server-side filters: language, msn, min-score (passed through in the
152
+ query string). Client-side filters: category, min-subs, max-subs,
153
+ exclude (applied to the returned, enriched rows).
154
+ """
155
+ filters = parse_filters(args)
156
+
157
+ # Split filters into server-side and client-side sets.
158
+ server_keys = {"language", "msn", "min-score"}
159
+ server_params = {k: filters.pop(k) for k in list(filters) if k in server_keys}
160
+ server_params["limit"] = str(limit)
161
+
162
+ encoded_ref = urllib.parse.quote(channel_ref, safe="")
163
+ client = get_client()
164
+ try:
165
+ data = client.get(f"/channels/{encoded_ref}/similar", params=server_params)
166
+
167
+ # Client-side post-filters
168
+ results = data.get("results", [])
169
+ if "category" in filters:
170
+ target = filters["category"]
171
+ results = [r for r in results if str(r.get("category", "")) == target]
172
+ if "min-subs" in filters:
173
+ try:
174
+ n = int(filters["min-subs"])
175
+ results = [r for r in results if (r.get("subscribers") or 0) >= n]
176
+ except ValueError:
177
+ pass
178
+ if "max-subs" in filters:
179
+ try:
180
+ n = int(filters["max-subs"])
181
+ results = [r for r in results if (r.get("subscribers") or 0) <= n]
182
+ except ValueError:
183
+ pass
184
+ if "exclude" in filters:
185
+ excluded = {int(x) for x in filters["exclude"].split(",") if x.strip().isdigit()}
186
+ results = [r for r in results if r.get("id") not in excluded]
187
+
188
+ data["results"] = results
189
+ for r in data["results"]:
190
+ r["channel_id"] = r.pop("id", None)
191
+ if fmt in ("table", "md"):
192
+ _format_score(data["results"])
193
+ output(
194
+ data,
195
+ fmt,
196
+ columns=SIMILAR_COLUMNS,
197
+ title=f"Channels similar to {channel_ref}",
198
+ column_config=SIMILAR_COLUMN_CONFIG,
199
+ )
200
+ except ApiError as e:
201
+ _handle_channel_api_error(e)
202
+ finally:
203
+ client.close()
204
+
205
+
206
+ @app.command("similar")
207
+ def similar_cmd(
208
+ channel_ref: str = typer.Argument(..., help="Channel ID (numeric) or name (partial match, must be unique)"),
209
+ args: list[str] = typer.Argument(None, help="Filters (key:value pairs). Run 'tl describe show channels' for available filters."),
210
+ json_output: bool = typer.Option(False, "--json", help="JSON output"),
211
+ csv_output: bool = typer.Option(False, "--csv", help="CSV output"),
212
+ md_output: bool = typer.Option(False, "--md", help="Markdown output"),
213
+ toon_output: bool = typer.Option(False, "--toon", help="TOON output (token-efficient for LLMs)"),
214
+ limit: int = typer.Option(20, "--limit", "-l", help="Max results (1-100)"),
215
+ ) -> None:
216
+ """Find channels similar to a given one (by id or name).
217
+
218
+ Costs 50 credits per call. Intelligence plan required. Results are
219
+ ranked by cosine similarity and enriched with subscribers, impression,
220
+ total_views, category, and the channel's representative CPM.
221
+
222
+ Server-side filters (pushed to the recommender):
223
+ language:<iso> Restrict to a content language (default: en)
224
+ msn:<true|false> Restrict to Media Selling Network (default: true)
225
+ min-score:<0-1> Minimum cosine similarity (default: 0.5)
226
+
227
+ Client-side post-filters (applied after fetch):
228
+ category:<code> Keep only rows matching this content_category
229
+ min-subs:<N> Subscribers >= N
230
+ max-subs:<N> Subscribers <= N
231
+ exclude:<id,id,…> Drop specific channel ids
232
+
233
+ Examples:
234
+ tl channels similar 12345
235
+ tl channels similar "MrBeast" language:en msn:false
236
+ tl channels similar 12345 min-score:0.7 min-subs:1000000 --limit 10
237
+ """
238
+ fmt = detect_format(json_output, csv_output, md_output, toon_output)
239
+ _do_similar(channel_ref, args or [], fmt, limit)
240
+
241
+
242
+ @app.command("history")
243
+ def history_cmd(
244
+ channel_ref: str = typer.Argument(..., help="Channel ID (numeric) or name (partial match, must be unique)"),
245
+ json_output: bool = typer.Option(False, "--json", help="JSON output"),
246
+ csv_output: bool = typer.Option(False, "--csv", help="CSV output"),
247
+ md_output: bool = typer.Option(False, "--md", help="Markdown output"),
248
+ toon_output: bool = typer.Option(False, "--toon", help="TOON output (token-efficient for LLMs)"),
249
+ limit: int = typer.Option(50, "--limit", "-l", help="Max results"),
250
+ offset: int = typer.Option(0, "--offset", help="Pagination offset"),
251
+ ) -> None:
252
+ """Show a channel's sponsorship history (videos with detected sponsors).
253
+
254
+ Requires an Intelligence plan.
255
+
256
+ Examples:
257
+ tl channels history 157060
258
+ tl channels history "Economics Explained"
259
+ """
260
+ fmt = detect_format(json_output, csv_output, md_output, toon_output)
261
+ encoded_ref = urllib.parse.quote(channel_ref, safe="")
262
+ client = get_client()
263
+ try:
264
+ params = {"limit": str(limit), "offset": str(offset)}
265
+ data = client.get(f"/channels/{encoded_ref}/history", params=params)
266
+ channel_name = data.get("channel", {}).get("name", channel_ref)
267
+ output(
268
+ data,
269
+ fmt,
270
+ columns=["video_id", "title", "brands", "views", "publication_date", "is_tl"],
271
+ title=f"Channel History: {channel_name}",
272
+ )
273
+ except ApiError as e:
274
+ _handle_channel_api_error(e)
275
+ finally:
276
+ client.close()
277
+
278
+
279
+ @app.command("look-alike", hidden=True)
280
+ def look_alike_cmd(
281
+ channel_ref: str = typer.Argument(..., help="Channel ID or name"),
282
+ args: list[str] = typer.Argument(None, help="Filters (key:value pairs). Run 'tl describe show channels' for available filters."),
283
+ json_output: bool = typer.Option(False, "--json", help="JSON output"),
284
+ csv_output: bool = typer.Option(False, "--csv", help="CSV output"),
285
+ md_output: bool = typer.Option(False, "--md", help="Markdown output"),
286
+ toon_output: bool = typer.Option(False, "--toon", help="TOON output (token-efficient for LLMs)"),
287
+ limit: int = typer.Option(20, "--limit", "-l", help="Max results"),
288
+ ) -> None:
289
+ """Alias for `tl channels similar` (matches internal "look-alike channels" terminology)."""
290
+ fmt = detect_format(json_output, csv_output, md_output, toon_output)
291
+ _do_similar(channel_ref, args or [], fmt, limit)
@@ -0,0 +1,63 @@
1
+ """tl comments — List and add comments on sponsorships."""
2
+
3
+ import typer
4
+
5
+ from tl_cli.client.errors import ApiError, handle_api_error
6
+ from tl_cli.client.http import get_client
7
+ from tl_cli.output.formatter import detect_format, output, output_single
8
+
9
+ app = typer.Typer(help="Comments on sponsorships (free, no credits)")
10
+
11
+
12
+ @app.command("list")
13
+ def list_cmd(
14
+ adlink_id: int = typer.Argument(..., help="Sponsorship (adlink) ID"),
15
+ json_output: bool = typer.Option(False, "--json", help="JSON output"),
16
+ toon_output: bool = typer.Option(False, "--toon", help="TOON output (token-efficient for LLMs)"),
17
+ ) -> None:
18
+ """List comments on a sponsorship (free, no credits).
19
+
20
+ Examples:
21
+ tl comments list 12345
22
+ """
23
+ fmt = detect_format(json_output, False, False, toon_output)
24
+
25
+ client = get_client()
26
+ try:
27
+ data = client.get(f"/comments/{adlink_id}")
28
+ for r in data.get("results", []):
29
+ r["comment_id"] = r.pop("id", None)
30
+ output(
31
+ data,
32
+ fmt,
33
+ columns=["comment_id", "author", "text", "created_at"],
34
+ title=f"Comments on Sponsorship #{adlink_id}",
35
+ )
36
+ except ApiError as e:
37
+ handle_api_error(e)
38
+ finally:
39
+ client.close()
40
+
41
+
42
+ @app.command("add")
43
+ def add_comment(
44
+ adlink_id: int = typer.Argument(..., help="Sponsorship (adlink) ID"),
45
+ message: str = typer.Argument(..., help="Comment text"),
46
+ json_output: bool = typer.Option(False, "--json", help="JSON output"),
47
+ toon_output: bool = typer.Option(False, "--toon", help="TOON output (token-efficient for LLMs)"),
48
+ ) -> None:
49
+ """Add a comment to a sponsorship (free, no credits).
50
+
51
+ Examples:
52
+ tl comments add 12345 "Looks good, ready to send"
53
+ """
54
+ fmt = detect_format(json_output, False, False, toon_output)
55
+
56
+ client = get_client()
57
+ try:
58
+ data = client.post(f"/comments/{adlink_id}", json_body={"text": message})
59
+ output_single(data, fmt)
60
+ except ApiError as e:
61
+ handle_api_error(e)
62
+ finally:
63
+ client.close()
tl_cli/commands/db.py ADDED
@@ -0,0 +1,104 @@
1
+ """tl db — Run raw queries against PostgreSQL, Firebolt, or Elasticsearch."""
2
+
3
+ import json
4
+ import sys
5
+
6
+ import typer
7
+
8
+ from tl_cli.client.errors import ApiError, handle_api_error
9
+ from tl_cli.client.http import get_client
10
+ from tl_cli.output.formatter import detect_format, output
11
+
12
+ app = typer.Typer(help="Raw read-only queries against PostgreSQL, Firebolt, or Elasticsearch (full-access only)")
13
+
14
+
15
+ def _read_query(query: str | None) -> str:
16
+ if query is not None and query != "-":
17
+ return query
18
+ if sys.stdin.isatty():
19
+ raise typer.BadParameter("Provide a query argument or pipe one on stdin")
20
+ return sys.stdin.read()
21
+
22
+
23
+ def _run(path: str, body: dict, fmt: str, title: str) -> None:
24
+ client = get_client()
25
+ try:
26
+ data = client.post(path, json_body=body)
27
+ output(data, fmt, title=title)
28
+ aggs = data.get("aggregations")
29
+ if aggs and fmt != "json":
30
+ from rich.console import Console
31
+ from rich.json import JSON
32
+ console = Console()
33
+ console.print("\n[bold]Aggregations[/bold]")
34
+ console.print(JSON(json.dumps(aggs, default=str)))
35
+ except ApiError as e:
36
+ handle_api_error(e)
37
+ finally:
38
+ client.close()
39
+
40
+
41
+ @app.command("pg")
42
+ def pg_cmd(
43
+ query: str = typer.Argument(None, help="Raw PostgreSQL SELECT (or '-' to read from stdin)"),
44
+ json_output: bool = typer.Option(False, "--json", help="JSON output"),
45
+ csv_output: bool = typer.Option(False, "--csv", help="CSV output"),
46
+ md_output: bool = typer.Option(False, "--md", help="Markdown output"),
47
+ toon_output: bool = typer.Option(False, "--toon", help="TOON output"),
48
+ ) -> None:
49
+ """Run a raw PostgreSQL SELECT query.
50
+
51
+ Examples:
52
+ tl db pg "SELECT id, name FROM thoughtleaders_brand LIMIT 10 OFFSET 0"
53
+ cat query.sql | tl db pg -
54
+ """
55
+ fmt = detect_format(json_output, csv_output, md_output, toon_output)
56
+ sql = _read_query(query)
57
+ _run("/raw/pg", {"query": sql}, fmt, "Postgres results")
58
+
59
+
60
+ @app.command("fb")
61
+ def fb_cmd(
62
+ query: str = typer.Argument(None, help="Raw Firebolt SELECT (or '-' to read from stdin)"),
63
+ json_output: bool = typer.Option(False, "--json", help="JSON output"),
64
+ csv_output: bool = typer.Option(False, "--csv", help="CSV output"),
65
+ md_output: bool = typer.Option(False, "--md", help="Markdown output"),
66
+ toon_output: bool = typer.Option(False, "--toon", help="TOON output"),
67
+ ) -> None:
68
+ """Run a raw Firebolt SELECT query.
69
+
70
+ The query must filter the leading index column of the table (channel_id
71
+ for article_metrics, id for channel_metrics) — see the Firebolt schema.
72
+
73
+ Examples:
74
+ tl db fb "SELECT scrape_date, view_count FROM article_metrics WHERE channel_id = 5607 AND id = 'EjeGzoQI3gQ'"
75
+ """
76
+ fmt = detect_format(json_output, csv_output, md_output, toon_output)
77
+ sql = _read_query(query)
78
+ _run("/raw/fb", {"query": sql}, fmt, "Firebolt results")
79
+
80
+
81
+ @app.command("es")
82
+ def es_cmd(
83
+ query: str = typer.Argument(None, help="Elasticsearch search body as JSON (or '-' to read from stdin)"),
84
+ json_output: bool = typer.Option(False, "--json", help="JSON output"),
85
+ csv_output: bool = typer.Option(False, "--csv", help="CSV output"),
86
+ md_output: bool = typer.Option(False, "--md", help="Markdown output"),
87
+ toon_output: bool = typer.Option(False, "--toon", help="TOON output"),
88
+ ) -> None:
89
+ """Run a raw Elasticsearch search query.
90
+
91
+ The index is fixed server-side; the client cannot select it.
92
+
93
+ Examples:
94
+ tl db es '{"size": 5, "query": {"term": {"channel.id": 5607}}}'
95
+ tl db es '{"size": 0, "aggs": {"by_channel": {"terms": {"field": "channel.id"}}}}'
96
+ """
97
+ fmt = detect_format(json_output, csv_output, md_output, toon_output)
98
+ raw = _read_query(query)
99
+ try:
100
+ body_query = json.loads(raw)
101
+ except json.JSONDecodeError as exc:
102
+ raise typer.BadParameter(f"Query is not valid JSON: {exc}") from exc
103
+
104
+ _run("/raw/es", {"query": body_query}, fmt, "Elasticsearch results")
@@ -0,0 +1,52 @@
1
+ """tl deals — Shortcut for contractually agreed-upon sponsorships."""
2
+
3
+ from typing import Optional
4
+
5
+ import typer
6
+
7
+ from tl_cli.commands.sponsorships import do_create, do_list, do_show
8
+ from tl_cli.output.formatter import detect_format
9
+
10
+ app = typer.Typer(help="Deals — agreed-upon sponsorships (shortcut for sponsorships status:deal)")
11
+
12
+
13
+ @app.callback(invoke_without_command=True)
14
+ def deals(ctx: typer.Context) -> None:
15
+ """Deals — contractually agreed-upon sponsorships."""
16
+ if ctx.invoked_subcommand is None:
17
+ ctx.invoke(list_cmd, args=[], json_output=False, csv_output=False, md_output=False, limit=50, offset=0)
18
+
19
+
20
+ @app.command("list")
21
+ def list_cmd(
22
+ args: list[str] = typer.Argument(None, help="Filters (key:value pairs). Run 'tl describe show sponsorships' for available filters."),
23
+ json_output: bool = typer.Option(False, "--json", help="JSON output"),
24
+ csv_output: bool = typer.Option(False, "--csv", help="CSV output"),
25
+ md_output: bool = typer.Option(False, "--md", help="Markdown output"),
26
+ toon_output: bool = typer.Option(False, "--toon", help="TOON output (token-efficient for LLMs)"),
27
+ limit: int = typer.Option(50, "--limit", "-l", help="Max results"),
28
+ offset: int = typer.Option(0, "--offset", help="Pagination offset"),
29
+ ) -> None:
30
+ """List deals with optional filters.
31
+
32
+ Examples:
33
+ tl deals list # List recent deals
34
+ tl deals list brand:"Nike" # Filter deals
35
+ """
36
+ fmt = detect_format(json_output, csv_output, md_output, toon_output)
37
+ do_list(args or [], fmt, limit, offset, default_status="deal", title="Deals")
38
+
39
+
40
+ @app.command("show")
41
+ def show_cmd(
42
+ item_id: str = typer.Argument(..., help="Sponsorship ID"),
43
+ json_output: bool = typer.Option(False, "--json", help="JSON output"),
44
+ toon_output: bool = typer.Option(False, "--toon", help="TOON output (token-efficient for LLMs)"),
45
+ ) -> None:
46
+ """Show deal detail by ID.
47
+
48
+ Examples:
49
+ tl deals show 12345
50
+ """
51
+ fmt = detect_format(json_output, False, False, toon_output)
52
+ do_show(item_id, fmt)