thoughtleaders-cli 0.7.4__py3-none-any.whl → 0.7.6__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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: thoughtleaders-cli
3
- Version: 0.7.4
3
+ Version: 0.7.6
4
4
  Summary: ThoughtLeaders CLI — query sponsorship data, channels, brands, and intelligence
5
5
  Project-URL: Homepage, https://thoughtleaders.io
6
6
  Project-URL: Repository, https://github.com/ThoughtLeaders-io/thoughtleaders-cli
@@ -1,4 +1,4 @@
1
- tl_cli/__init__.py,sha256=xK0gP7uGAavYeHM3ixCoYhRg6AEnE_DI83z7_FnnInc,112
1
+ tl_cli/__init__.py,sha256=Aq3mKDHNRP4nTdWfXRVP0mw3Gi2UrDf1gZYuf1CJBTM,112
2
2
  tl_cli/_completions.py,sha256=kOyEUqC26vbYvyXWi513WX8fF73qQLR5WWuRSe_wqyk,164
3
3
  tl_cli/_typer_utils.py,sha256=ZiZsCVmEznPvBw-dYbr3tu3zWZ0iN6kjoQmK3gMqD28,860
4
4
  tl_cli/config.py,sha256=UV_OYTXuQnAIqbi_oVCXx0hhIdZWR678RRapVv51UwQ,1859
@@ -7,9 +7,8 @@ tl_cli/hints.py,sha256=cT8kuDtkAZqwXkc2RV0Yg_abofK-g9UiXwTTBunX78U,1557
7
7
  tl_cli/main.py,sha256=A_8b2SQjBKATxrjO7AGC5Ab1QWlP35gGo4TWzYZtlOM,5806
8
8
  tl_cli/self_update.py,sha256=akXOWYgBX2otyaVlx9CDl04gG2s_hYigE2Vkpubt0SA,18302
9
9
  tl_cli/auth/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
10
- tl_cli/auth/commands.py,sha256=NtM5WGnReXrpZ3bysNudUYT_TcRRPE0Ho1EwbRgM_Ws,6285
11
- tl_cli/auth/finalize.py,sha256=74x8U39dK4nhEnIUMKhX5rNsn1Qjjm8or1n1nUH0SbQ,3346
12
- tl_cli/auth/login.py,sha256=6Jhfw7_eXGxZvUfNP33AZPRmnqmu_scvgt4AcOydsrE,10665
10
+ tl_cli/auth/commands.py,sha256=BiT-87UbZjsOJKtjv2dKcCwbAg4-Dp4oICRM0c97TFg,7409
11
+ tl_cli/auth/login.py,sha256=Rf65nhN7sjUNcaHYlsO7oYCiHqhGo8e8XwoGI2S4mgM,11487
13
12
  tl_cli/auth/pkce.py,sha256=4Q6Ip-TeZFNG9c3swXNi4gH7mdMkltKa62gZZNybt8U,658
14
13
  tl_cli/auth/token_store.py,sha256=TcZnUol4-8r0jMEJhOPmABCX12_5RkAln2xfWPNdmHk,3275
15
14
  tl_cli/client/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
@@ -40,14 +39,14 @@ tl_cli/commands/whoami.py,sha256=aUXwBRwh1vAGrvz8CKGfHYtEOKJCIDfwrGesKAwYZMk,786
40
39
  tl_cli/output/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
41
40
  tl_cli/output/formatter.py,sha256=pqlKmb2nZ1Z2e1A9m8l5mgVemJinVAP4in1tUzFWHno,22522
42
41
  tl_cli/_plugin/.claude-plugin/marketplace.json,sha256=l56PMmyjfGXNGlV30wRyOAe74B6gJNCVNCxgsBbSNxc,446
43
- tl_cli/_plugin/.claude-plugin/plugin.json,sha256=8RBuXpnRetjlzB0k2KrWjRquy_KrutSRFcioWI6r1Lo,466
42
+ tl_cli/_plugin/.claude-plugin/plugin.json,sha256=djTkN3fvnL-cnoGePHYiQbudE6-RuK4sETDz-HflKkU,466
44
43
  tl_cli/_plugin/agents/tl-analyst.md,sha256=6J3X3NANkWg6OOUCvNirkN4ulIk80KSumPncDUBt75E,6761
45
44
  tl_cli/_plugin/agents/youtube-comment-classifier.md,sha256=S5lr_htA98FIX0su8FJ2ntiHfbdK8OB2NQKC4lTnQcw,2178
46
45
  tl_cli/_plugin/hooks/hooks.json,sha256=FSWibw1xAjA-suFV3fR8btIb2kQ82LQ08otTr-NpmFw,835
47
46
  tl_cli/_plugin/hooks/scripts/load-tl-skill.mjs,sha256=EBsyZ-caei-CBJsRtqzJXJs_20O3H22MuVmDpu96umo,805
48
47
  tl_cli/_plugin/hooks/scripts/post-usage.sh,sha256=WVvZLkZik6lbeZ20Kh-wgm4JkRFHFN0Uwl4C8S3Y0sY,759
49
48
  tl_cli/_plugin/hooks/scripts/pre-check.sh,sha256=E9KeuXy6yeHEBOnOFW4hDW-Et-Dbp1Oh--3WXKfOX78,898
50
- tl_cli/_plugin/skills/tl/SKILL.md,sha256=YDpZ3DtP_HjEfZtC17ELG9kk-OixRXNvRL7VWwrrMXI,59099
49
+ tl_cli/_plugin/skills/tl/SKILL.md,sha256=R8pU0aU-QrRoawdX-F9mT3ci9inwWBN744xFMciTrmQ,61239
51
50
  tl_cli/_plugin/skills/tl/references/business-glossary.md,sha256=FCS-qBOGpdJCmHdglRGRjAuTQAtzpxJNpMkEWThuvlI,17779
52
51
  tl_cli/_plugin/skills/tl/references/elasticsearch-schema.md,sha256=OpHvixZ8UcZYJd8GdwgumryFyKwPAxj3AvPkl1QreMY,9316
53
52
  tl_cli/_plugin/skills/tl/references/firebolt-schema.md,sha256=KagpSWWEWIRfsAWz271PvAqVbSPvWLoogWhCA_XFSZw,10642
@@ -111,8 +110,8 @@ tl_cli/_plugin/skills/tl-top-partnerships/SKILL.md,sha256=hvH05hIaGlc0RfTE0GLBtD
111
110
  tl_cli/_plugin/skills/tl-top-partnerships/scripts/top_partnerships.py,sha256=_13W6-HuD_jtl7AWQQcZQ0SQO9qODMymlcL-1s4-VwU,13248
112
111
  tl_cli/_plugin/skills/tl-views-guarantee/SKILL.md,sha256=IH7q1WJDWri9TWJMiga1FMGJO_GKSbWwaDS6CVNZ9c0,9270
113
112
  tl_cli/_plugin/skills/tl-views-guarantee/scripts/vg.py,sha256=Qp5poinHEqh9374anq0bLtlxj2YL6ipBicaT960-Cws,15825
114
- thoughtleaders_cli-0.7.4.dist-info/METADATA,sha256=MPMDYcGN8eGh3eYWdKN3mDoPGz8E5sijXa2c2FpOybI,18452
115
- thoughtleaders_cli-0.7.4.dist-info/WHEEL,sha256=mffPy8wBnZQn2VnJUU5jE99KsxaSfiyMHV9Yt0aLVxs,87
116
- thoughtleaders_cli-0.7.4.dist-info/entry_points.txt,sha256=umZp-1BkGkHDG0bNZXpTXrjwW0HGf9IDFN40eAWuuvg,39
117
- thoughtleaders_cli-0.7.4.dist-info/licenses/LICENSE,sha256=RUfdfLsn6jygiyrnnVUHt6r4IPwr2rbDm9Kixgtu8fo,1071
118
- thoughtleaders_cli-0.7.4.dist-info/RECORD,,
113
+ thoughtleaders_cli-0.7.6.dist-info/METADATA,sha256=P7oqDlNfz_pjBEWzS7pWmSnS8KTUFNEYiEV7H5D-ZYI,18452
114
+ thoughtleaders_cli-0.7.6.dist-info/WHEEL,sha256=mffPy8wBnZQn2VnJUU5jE99KsxaSfiyMHV9Yt0aLVxs,87
115
+ thoughtleaders_cli-0.7.6.dist-info/entry_points.txt,sha256=umZp-1BkGkHDG0bNZXpTXrjwW0HGf9IDFN40eAWuuvg,39
116
+ thoughtleaders_cli-0.7.6.dist-info/licenses/LICENSE,sha256=RUfdfLsn6jygiyrnnVUHt6r4IPwr2rbDm9Kixgtu8fo,1071
117
+ thoughtleaders_cli-0.7.6.dist-info/RECORD,,
tl_cli/__init__.py CHANGED
@@ -1,3 +1,3 @@
1
1
  """ThoughtLeaders CLI — query sponsorship data, channels, brands, and intelligence."""
2
2
 
3
- __version__ = "0.7.4"
3
+ __version__ = "0.7.6"
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tl-cli",
3
- "version": "0.7.4",
3
+ "version": "0.7.6",
4
4
  "description": "ThoughtLeaders CLI — query sponsorship deals, channels, brands, uploads, and intelligence from the terminal",
5
5
  "author": {
6
6
  "name": "ThoughtLeaders",
@@ -50,6 +50,13 @@ The pattern is always: server-side narrowing first (usually by filters in the `t
50
50
 
51
51
  Always assume there will be more than 1 page of results. You MUST always pass `LIMIT` and `OFFSET` to every `tl db pg|fb|es` query (and use the response envelope's `next_offset` / breadcrumbs to walk forward) so the entire data set is retrieved. The maximum number of rows per page is present in the output of `whoami`.
52
52
 
53
+ **Counts, totals, and breakdowns: aggregate in the query engine — never page through records to count them.** A "how many / total / average / per-X" question is ONE aggregation query, not N pages of rows summed in your head:
54
+ - `tl db pg` — `SELECT COUNT(*) …`, or `SELECT col, COUNT(*) AS n … GROUP BY col ORDER BY n DESC`. Also `SUM`/`AVG`/`MIN`/`MAX`/`date_trunc`. Returns one/few rows regardless of table size. (`LIMIT`/`OFFSET` still required — an aggregate is one row, so `LIMIT 1 OFFSET 0` is fine.)
55
+ - `tl db es` — aggregation body with `"size": 0` (returns zero hits, only the agg result): `value_count`/`cardinality` for counts, `terms` for per-group, `sum`/`avg` for metrics, `date_histogram` for time series. Add `"track_total_hits": true` to get an exact match count. One aggregation block per body (see ES reference) — run multiple calls for a multi-metric dashboard.
56
+ - Structured list commands and list endpoints already return the full match count as `total` in the response envelope — request `--limit 1` and read `total` instead of fetching every row.
57
+
58
+ Fetching all rows to count/sum/group them is wrong: it is slow, costs credits per row returned, and silently undercounts once you hit the page cap.
59
+
53
60
  Retry after 5 seconds if the server returns a "connection denied" or a "server error" on any request.
54
61
 
55
62
  Where possible reference sponsorships, brands, channel by numeric IDs.
@@ -360,6 +367,13 @@ cat query.json | tl db es -
360
367
 
361
368
  See [references/elasticsearch-schema.md](references/elasticsearch-schema.md) for accepted top-level keys, query types, size/depth limits, scripting/aggregation rules, and the field catalogue.
362
369
 
370
+ **Article docs in ES carry only `channel.id` — not a usable channel name. Resolve names from PG, in a two-step script.** Whenever you query article/upload docs and the output needs channel names, do NOT hand-map ids in context and do NOT `ILIKE` on names — write a script that:
371
+ 1. runs `tl db es … --json` with `channel.id` in `_source`, then collects the **distinct** channel ids;
372
+ 2. runs `tl db pg "SELECT id, channel_name FROM thoughtleaders_channel WHERE id IN (<ids>)" --json` to build an `{id: channel_name}` map;
373
+ 3. merges the map onto the ES rows by `channel.id` and emits the enriched result.
374
+
375
+ Prefer Python for the script (write it to `/tmp`); a `jq`+`xargs` one-liner is fine for a single page (worked example under *Brand sponsorship history*). Always go ES→PG in this order (PG `IN (...)` on the ids ES returned) — one PG round-trip for the whole page, never one query per article.
376
+
363
377
  #### `tl db fb` — Firebolt
364
378
 
365
379
  ```bash
tl_cli/auth/commands.py CHANGED
@@ -8,9 +8,9 @@ from tl_cli._typer_utils import AlphaSortedTyperGroup
8
8
  from rich.console import Console
9
9
  from rich.prompt import Prompt
10
10
 
11
- from tl_cli.auth.finalize import finalize_signup
12
- from tl_cli.auth.login import login_browser, login_device_code
11
+ from tl_cli.auth.login import login_browser, login_device_code, revoke_refresh_token
13
12
  from tl_cli.auth.token_store import KIND_API_KEY, StoredTokens, clear_tokens, load_tokens, save_tokens
13
+ from tl_cli.config import get_config
14
14
 
15
15
  app = typer.Typer(cls=AlphaSortedTyperGroup, help="Authentication commands")
16
16
  console = Console(stderr=True)
@@ -106,8 +106,6 @@ def login_cmd() -> None:
106
106
  else:
107
107
  login_browser()
108
108
 
109
- finalize_signup()
110
-
111
109
 
112
110
  def _login_api_key() -> None:
113
111
  """Store a user-supplied API key as the active credential.
@@ -171,7 +169,27 @@ def _login_api_key() -> None:
171
169
 
172
170
  @app.command("logout")
173
171
  def logout_cmd() -> None:
174
- """Clear stored authentication tokens."""
172
+ """Log out: revoke the refresh token at Auth0, then clear stored tokens."""
173
+ tokens = load_tokens()
174
+ # Revoke the long-lived credential server-side so a leaked/synced copy of
175
+ # the local token store can't keep minting access tokens. Best-effort —
176
+ # API-key auth has no refresh token, and an offline revoke must not block
177
+ # clearing local credentials.
178
+ if tokens and not tokens.is_api_key and tokens.refresh_token:
179
+ if revoke_refresh_token(tokens.refresh_token):
180
+ console.print("[dim]Refresh token revoked at Auth0.[/dim]")
181
+ else:
182
+ console.print(
183
+ "[yellow]Could not reach Auth0 to revoke the refresh token; "
184
+ "clearing local credentials anyway.[/yellow]"
185
+ )
186
+ # Revoking the refresh token doesn't end the browser SSO session that
187
+ # the interactive login established. Point the user at Auth0's logout
188
+ # URL so the next `tl auth login` doesn't silently SSO straight back in.
189
+ logout_url = f"https://{get_config().auth0_domain}/logout"
190
+ console.print(
191
+ f"To end your Auth0 browser session, visit: [cyan]{logout_url}[/cyan]"
192
+ )
175
193
  clear_tokens()
176
194
  console.print("[green]Logged out successfully.[/green]")
177
195
 
tl_cli/auth/login.py CHANGED
@@ -212,6 +212,29 @@ def refresh_access_token(refresh_token: str) -> StoredTokens:
212
212
  return tokens
213
213
 
214
214
 
215
+ def revoke_refresh_token(refresh_token: str) -> bool:
216
+ """Best-effort revocation of a refresh token at Auth0 (RFC 7009).
217
+
218
+ Invalidates the long-lived credential server-side so it can no longer mint
219
+ new access tokens. Public-client call — `client_id` only, no secret. Returns
220
+ True on success; never raises — network / Auth0 errors are swallowed so
221
+ `tl auth logout` can still clear the local credentials when offline.
222
+ """
223
+ config = get_config()
224
+ try:
225
+ response = httpx.post(
226
+ f"https://{config.auth0_domain}/oauth/revoke",
227
+ json={
228
+ "client_id": config.auth0_client_id,
229
+ "token": refresh_token,
230
+ },
231
+ timeout=10,
232
+ )
233
+ except httpx.HTTPError:
234
+ return False
235
+ return response.status_code == 200
236
+
237
+
215
238
  def _exchange_code(
216
239
  code: str,
217
240
  code_verifier: str,
tl_cli/auth/finalize.py DELETED
@@ -1,88 +0,0 @@
1
- """Server-side signup finalize, called once per login.
2
-
3
- After Auth0 returns a valid token the CLI asks the server whether an
4
- account exists for the email. If yes, this is a no-op. If no, the CLI
5
- prompts for a persona (Media Buyer or Creator) and POSTs it back; the
6
- server creates the User, Organization, Profile and CreditAccount.
7
-
8
- Errors here never abort login — the user can always retry the prompt
9
- on the next call. We do print a clear message so it's obvious whether
10
- the account is fully set up.
11
- """
12
-
13
- from __future__ import annotations
14
-
15
- import typer
16
- from rich.console import Console
17
- from rich.prompt import Prompt
18
-
19
- from tl_cli.client.errors import ApiError
20
- from tl_cli.client.http import get_client
21
-
22
- console = Console(stderr=True)
23
-
24
- PERSONA_LABEL_TO_KEY = {
25
- "Media Buyer": "media_buyer",
26
- "Creator": "creator",
27
- }
28
-
29
-
30
- def finalize_signup() -> None:
31
- """POST /auth/finalize, prompting for persona if the server asks for one."""
32
- client = get_client()
33
- try:
34
- # First call: no body. Server tells us whether persona is required.
35
- try:
36
- result = client.post("/auth/finalize", {})
37
- except ApiError as exc:
38
- if exc.status_code == 400 and isinstance(exc.raw, dict) and exc.raw.get("code") == "persona_required":
39
- result = _prompt_and_finalize(client, exc.raw.get("allowed_personas") or [])
40
- elif exc.status_code == 404:
41
- # Server predates this endpoint — silently skip; legacy
42
- # accounts already exist and don't need provisioning.
43
- return
44
- else:
45
- console.print(f"[yellow]Could not finalize signup: {exc.detail}[/yellow]")
46
- return
47
-
48
- if result.get("created"):
49
- org = result.get("organization", {})
50
- console.print(
51
- f"[green]Account created for {org.get('name', 'your organization')}.[/green] "
52
- "Run [bold]tl balance[/bold] to see your starter credits."
53
- )
54
- finally:
55
- client.close()
56
-
57
-
58
- def _prompt_and_finalize(client, allowed: list[str]) -> dict:
59
- """Prompt the user for a persona, then retry /auth/finalize."""
60
- console.print()
61
- console.print("[bold]Welcome to ThoughtLeaders![/bold] We need one more detail to set up your account.")
62
- console.print(" [cyan]1[/cyan] — Media Buyer (brands and agencies buying sponsorships)")
63
- console.print(" [cyan]2[/cyan] — Creator (channels selling sponsorships)")
64
-
65
- persona_key: str | None = None
66
- while persona_key is None:
67
- choice = Prompt.ask("I am a", choices=["1", "2"], default="1", console=console)
68
- candidate = "media_buyer" if choice == "1" else "creator"
69
- if allowed and candidate not in allowed:
70
- console.print(f"[yellow]Server rejects persona '{candidate}'. Allowed: {', '.join(allowed)}.[/yellow]")
71
- continue
72
- persona_key = candidate
73
-
74
- org_name = Prompt.ask(
75
- "Organization name (optional, leave blank to use your email)",
76
- default="",
77
- console=console,
78
- ).strip()
79
-
80
- body = {"persona": persona_key}
81
- if org_name:
82
- body["organization_name"] = org_name
83
-
84
- try:
85
- return client.post("/auth/finalize", body)
86
- except ApiError as exc:
87
- console.print(f"[red]Signup failed:[/red] {exc.detail}")
88
- raise typer.Exit(1)