thoughtleaders-cli 0.6.44__tar.gz → 0.6.45__tar.gz

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 (93) hide show
  1. {thoughtleaders_cli-0.6.44 → thoughtleaders_cli-0.6.45}/.claude-plugin/plugin.json +1 -1
  2. {thoughtleaders_cli-0.6.44 → thoughtleaders_cli-0.6.45}/PKG-INFO +1 -1
  3. {thoughtleaders_cli-0.6.44 → thoughtleaders_cli-0.6.45}/pyproject.toml +1 -1
  4. {thoughtleaders_cli-0.6.44 → thoughtleaders_cli-0.6.45}/skills/tl/SKILL.md +92 -52
  5. {thoughtleaders_cli-0.6.44 → thoughtleaders_cli-0.6.45}/src/tl_cli/__init__.py +1 -1
  6. {thoughtleaders_cli-0.6.44 → thoughtleaders_cli-0.6.45}/src/tl_cli/commands/_comments_common.py +6 -4
  7. {thoughtleaders_cli-0.6.44 → thoughtleaders_cli-0.6.45}/src/tl_cli/commands/brands.py +26 -4
  8. {thoughtleaders_cli-0.6.44 → thoughtleaders_cli-0.6.45}/src/tl_cli/commands/channels.py +28 -8
  9. {thoughtleaders_cli-0.6.44 → thoughtleaders_cli-0.6.45}/src/tl_cli/commands/recommender.py +77 -8
  10. {thoughtleaders_cli-0.6.44 → thoughtleaders_cli-0.6.45}/.claude-plugin/marketplace.json +0 -0
  11. {thoughtleaders_cli-0.6.44 → thoughtleaders_cli-0.6.45}/.github/workflows/python-publish.yml +0 -0
  12. {thoughtleaders_cli-0.6.44 → thoughtleaders_cli-0.6.45}/.gitignore +0 -0
  13. {thoughtleaders_cli-0.6.44 → thoughtleaders_cli-0.6.45}/AGENTS.md +0 -0
  14. {thoughtleaders_cli-0.6.44 → thoughtleaders_cli-0.6.45}/CLAUDE.md +0 -0
  15. {thoughtleaders_cli-0.6.44 → thoughtleaders_cli-0.6.45}/LICENSE +0 -0
  16. {thoughtleaders_cli-0.6.44 → thoughtleaders_cli-0.6.45}/README.md +0 -0
  17. {thoughtleaders_cli-0.6.44 → thoughtleaders_cli-0.6.45}/agents/tl-analyst.md +0 -0
  18. {thoughtleaders_cli-0.6.44 → thoughtleaders_cli-0.6.45}/docs/architecture.md +0 -0
  19. {thoughtleaders_cli-0.6.44 → thoughtleaders_cli-0.6.45}/hooks/hooks.json +0 -0
  20. {thoughtleaders_cli-0.6.44 → thoughtleaders_cli-0.6.45}/hooks/scripts/load-tl-skill.mjs +0 -0
  21. {thoughtleaders_cli-0.6.44 → thoughtleaders_cli-0.6.45}/hooks/scripts/post-usage.sh +0 -0
  22. {thoughtleaders_cli-0.6.44 → thoughtleaders_cli-0.6.45}/hooks/scripts/pre-check.sh +0 -0
  23. {thoughtleaders_cli-0.6.44 → thoughtleaders_cli-0.6.45}/skills/tl/references/business-glossary.md +0 -0
  24. {thoughtleaders_cli-0.6.44 → thoughtleaders_cli-0.6.45}/skills/tl/references/elasticsearch-schema.md +0 -0
  25. {thoughtleaders_cli-0.6.44 → thoughtleaders_cli-0.6.45}/skills/tl/references/firebolt-schema.md +0 -0
  26. {thoughtleaders_cli-0.6.44 → thoughtleaders_cli-0.6.45}/skills/tl/references/postgres-schema.md +0 -0
  27. {thoughtleaders_cli-0.6.44 → thoughtleaders_cli-0.6.45}/skills/tl-import/SKILL.md +0 -0
  28. {thoughtleaders_cli-0.6.44 → thoughtleaders_cli-0.6.45}/skills/tl-report-builder/SKILL.md +0 -0
  29. {thoughtleaders_cli-0.6.44 → thoughtleaders_cli-0.6.45}/skills/tl-report-builder/examples/e2e_findings.md +0 -0
  30. {thoughtleaders_cli-0.6.44 → thoughtleaders_cli-0.6.45}/skills/tl-report-builder/examples/golden_queries.md +0 -0
  31. {thoughtleaders_cli-0.6.44 → thoughtleaders_cli-0.6.45}/skills/tl-report-builder/references/columns_brands.md +0 -0
  32. {thoughtleaders_cli-0.6.44 → thoughtleaders_cli-0.6.45}/skills/tl-report-builder/references/columns_channels.md +0 -0
  33. {thoughtleaders_cli-0.6.44 → thoughtleaders_cli-0.6.45}/skills/tl-report-builder/references/columns_content.md +0 -0
  34. {thoughtleaders_cli-0.6.44 → thoughtleaders_cli-0.6.45}/skills/tl-report-builder/references/columns_sponsorships.md +0 -0
  35. {thoughtleaders_cli-0.6.44 → thoughtleaders_cli-0.6.45}/skills/tl-report-builder/references/intelligence_filterset_schema.json +0 -0
  36. {thoughtleaders_cli-0.6.44 → thoughtleaders_cli-0.6.45}/skills/tl-report-builder/references/intelligence_widget_schema.json +0 -0
  37. {thoughtleaders_cli-0.6.44 → thoughtleaders_cli-0.6.45}/skills/tl-report-builder/references/report_glossary.md +0 -0
  38. {thoughtleaders_cli-0.6.44 → thoughtleaders_cli-0.6.45}/skills/tl-report-builder/references/sortable_columns.json +0 -0
  39. {thoughtleaders_cli-0.6.44 → thoughtleaders_cli-0.6.45}/skills/tl-report-builder/references/sponsorship_filterset_schema.json +0 -0
  40. {thoughtleaders_cli-0.6.44 → thoughtleaders_cli-0.6.45}/skills/tl-report-builder/references/sponsorship_widget_schema.json +0 -0
  41. {thoughtleaders_cli-0.6.44 → thoughtleaders_cli-0.6.45}/skills/tl-report-builder/references/widgets.md +0 -0
  42. {thoughtleaders_cli-0.6.44 → thoughtleaders_cli-0.6.45}/skills/tl-report-builder/tools/column_builder.md +0 -0
  43. {thoughtleaders_cli-0.6.44 → thoughtleaders_cli-0.6.45}/skills/tl-report-builder/tools/database_query.md +0 -0
  44. {thoughtleaders_cli-0.6.44 → thoughtleaders_cli-0.6.45}/skills/tl-report-builder/tools/keyword_research.md +0 -0
  45. {thoughtleaders_cli-0.6.44 → thoughtleaders_cli-0.6.45}/skills/tl-report-builder/tools/name_resolver.md +0 -0
  46. {thoughtleaders_cli-0.6.44 → thoughtleaders_cli-0.6.45}/skills/tl-report-builder/tools/sample_judge.md +0 -0
  47. {thoughtleaders_cli-0.6.44 → thoughtleaders_cli-0.6.45}/skills/tl-report-builder/tools/similar_channels.md +0 -0
  48. {thoughtleaders_cli-0.6.44 → thoughtleaders_cli-0.6.45}/skills/tl-report-builder/tools/topic_matcher.md +0 -0
  49. {thoughtleaders_cli-0.6.44 → thoughtleaders_cli-0.6.45}/skills/tl-report-builder/tools/widget_builder.md +0 -0
  50. {thoughtleaders_cli-0.6.44 → thoughtleaders_cli-0.6.45}/src/tl_cli/_completions.py +0 -0
  51. {thoughtleaders_cli-0.6.44 → thoughtleaders_cli-0.6.45}/src/tl_cli/auth/__init__.py +0 -0
  52. {thoughtleaders_cli-0.6.44 → thoughtleaders_cli-0.6.45}/src/tl_cli/auth/commands.py +0 -0
  53. {thoughtleaders_cli-0.6.44 → thoughtleaders_cli-0.6.45}/src/tl_cli/auth/finalize.py +0 -0
  54. {thoughtleaders_cli-0.6.44 → thoughtleaders_cli-0.6.45}/src/tl_cli/auth/login.py +0 -0
  55. {thoughtleaders_cli-0.6.44 → thoughtleaders_cli-0.6.45}/src/tl_cli/auth/pkce.py +0 -0
  56. {thoughtleaders_cli-0.6.44 → thoughtleaders_cli-0.6.45}/src/tl_cli/auth/token_store.py +0 -0
  57. {thoughtleaders_cli-0.6.44 → thoughtleaders_cli-0.6.45}/src/tl_cli/client/__init__.py +0 -0
  58. {thoughtleaders_cli-0.6.44 → thoughtleaders_cli-0.6.45}/src/tl_cli/client/errors.py +0 -0
  59. {thoughtleaders_cli-0.6.44 → thoughtleaders_cli-0.6.45}/src/tl_cli/client/http.py +0 -0
  60. {thoughtleaders_cli-0.6.44 → thoughtleaders_cli-0.6.45}/src/tl_cli/commands/__init__.py +0 -0
  61. {thoughtleaders_cli-0.6.44 → thoughtleaders_cli-0.6.45}/src/tl_cli/commands/ask.py +0 -0
  62. {thoughtleaders_cli-0.6.44 → thoughtleaders_cli-0.6.45}/src/tl_cli/commands/balance.py +0 -0
  63. {thoughtleaders_cli-0.6.44 → thoughtleaders_cli-0.6.45}/src/tl_cli/commands/bulk_import.py +0 -0
  64. {thoughtleaders_cli-0.6.44 → thoughtleaders_cli-0.6.45}/src/tl_cli/commands/changelog.py +0 -0
  65. {thoughtleaders_cli-0.6.44 → thoughtleaders_cli-0.6.45}/src/tl_cli/commands/credits.py +0 -0
  66. {thoughtleaders_cli-0.6.44 → thoughtleaders_cli-0.6.45}/src/tl_cli/commands/db.py +0 -0
  67. {thoughtleaders_cli-0.6.44 → thoughtleaders_cli-0.6.45}/src/tl_cli/commands/deals.py +0 -0
  68. {thoughtleaders_cli-0.6.44 → thoughtleaders_cli-0.6.45}/src/tl_cli/commands/describe.py +0 -0
  69. {thoughtleaders_cli-0.6.44 → thoughtleaders_cli-0.6.45}/src/tl_cli/commands/doctor.py +0 -0
  70. {thoughtleaders_cli-0.6.44 → thoughtleaders_cli-0.6.45}/src/tl_cli/commands/matches.py +0 -0
  71. {thoughtleaders_cli-0.6.44 → thoughtleaders_cli-0.6.45}/src/tl_cli/commands/proposals.py +0 -0
  72. {thoughtleaders_cli-0.6.44 → thoughtleaders_cli-0.6.45}/src/tl_cli/commands/reports.py +0 -0
  73. {thoughtleaders_cli-0.6.44 → thoughtleaders_cli-0.6.45}/src/tl_cli/commands/schema.py +0 -0
  74. {thoughtleaders_cli-0.6.44 → thoughtleaders_cli-0.6.45}/src/tl_cli/commands/setup.py +0 -0
  75. {thoughtleaders_cli-0.6.44 → thoughtleaders_cli-0.6.45}/src/tl_cli/commands/snapshots.py +0 -0
  76. {thoughtleaders_cli-0.6.44 → thoughtleaders_cli-0.6.45}/src/tl_cli/commands/sponsorships.py +0 -0
  77. {thoughtleaders_cli-0.6.44 → thoughtleaders_cli-0.6.45}/src/tl_cli/commands/uploads.py +0 -0
  78. {thoughtleaders_cli-0.6.44 → thoughtleaders_cli-0.6.45}/src/tl_cli/commands/whoami.py +0 -0
  79. {thoughtleaders_cli-0.6.44 → thoughtleaders_cli-0.6.45}/src/tl_cli/config.py +0 -0
  80. {thoughtleaders_cli-0.6.44 → thoughtleaders_cli-0.6.45}/src/tl_cli/filters.py +0 -0
  81. {thoughtleaders_cli-0.6.44 → thoughtleaders_cli-0.6.45}/src/tl_cli/hints.py +0 -0
  82. {thoughtleaders_cli-0.6.44 → thoughtleaders_cli-0.6.45}/src/tl_cli/main.py +0 -0
  83. {thoughtleaders_cli-0.6.44 → thoughtleaders_cli-0.6.45}/src/tl_cli/output/__init__.py +0 -0
  84. {thoughtleaders_cli-0.6.44 → thoughtleaders_cli-0.6.45}/src/tl_cli/output/formatter.py +0 -0
  85. {thoughtleaders_cli-0.6.44 → thoughtleaders_cli-0.6.45}/src/tl_cli/self_update.py +0 -0
  86. {thoughtleaders_cli-0.6.44 → thoughtleaders_cli-0.6.45}/tests/__init__.py +0 -0
  87. {thoughtleaders_cli-0.6.44 → thoughtleaders_cli-0.6.45}/tests/test_auth.py +0 -0
  88. {thoughtleaders_cli-0.6.44 → thoughtleaders_cli-0.6.45}/tests/test_filters.py +0 -0
  89. {thoughtleaders_cli-0.6.44 → thoughtleaders_cli-0.6.45}/tests/test_http_auth.py +0 -0
  90. {thoughtleaders_cli-0.6.44 → thoughtleaders_cli-0.6.45}/tests/test_output.py +0 -0
  91. {thoughtleaders_cli-0.6.44 → thoughtleaders_cli-0.6.45}/tests/test_reports.py +0 -0
  92. {thoughtleaders_cli-0.6.44 → thoughtleaders_cli-0.6.45}/tests/test_sponsorships.py +0 -0
  93. {thoughtleaders_cli-0.6.44 → thoughtleaders_cli-0.6.45}/uv.lock +0 -0
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tl-cli",
3
- "version": "0.6.44",
3
+ "version": "0.6.45",
4
4
  "description": "ThoughtLeaders CLI — query sponsorship deals, channels, brands, uploads, and intelligence from the terminal",
5
5
  "author": {
6
6
  "name": "ThoughtLeaders",
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: thoughtleaders-cli
3
- Version: 0.6.44
3
+ Version: 0.6.45
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
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "thoughtleaders-cli"
7
- version = "0.6.44"
7
+ version = "0.6.45"
8
8
  description = "ThoughtLeaders CLI — query sponsorship data, channels, brands, and intelligence"
9
9
  readme = "README.md"
10
10
  license = "MIT"
@@ -1,41 +1,47 @@
1
1
  ---
2
2
  name: tl
3
3
  description: |
4
- Query and analyze ThoughtLeaders business data using the `tl` CLI. Default to raw database queries via `tl db pg|fb|es` for anything non-trivial (joins, aggregations, multi-condition filters, anything that would otherwise need post-processing). Use this skill for ANALYTICAL questions about channels, brands and sponsorships: counts, metrics, trends, time-series, distributions, single-record drill-downs, revenue / pipeline-weighting math, view-curve analysis, cross-source business questions. Examples: "How many deals did we close last quarter?", "What's the weighted pipeline by sales owner?", "Show me the view curve for video X", "Find mentions of Surfshark in transcripts", "Investigate this video".
4
+ Query and analyze YouTube sponsorship data using the `tl` CLI. Use this skill for data exploration and questions about channels, brands and sponsorships: counts, metrics, trends, time-series, distributions, single-record drill-downs, revenue / pipeline-weighting math, view-curve analysis, cross-source business questions. Examples: "How many deals did we close last quarter?", "What's the weighted pipeline by sales owner?", "Show me the view curve for video X", "Find mentions of Surfshark in transcripts", "Investigate this video".
5
5
  ---
6
6
 
7
7
  # ThoughtLeaders Data Analyst
8
8
 
9
- Run the `tl` CLI to query ThoughtLeaders' sponsorship platform data. Use it to answer questions about deals, channels, brands, uploads, metrics, etc.
10
-
11
9
  ## Core Principles
12
10
 
13
- **Default to raw database queries.** For anything beyond a trivially simple lookup, reach for `tl db pg|fb|es`. Avoid the structured `tl <resource>` commands (`sponsorships list`, `channels show`, `brands history`, etc.).
14
-
15
- Always run `tl schema pg|fb|es` before writing a raw query.
11
+ Run the `tl` CLI to query ThoughtLeaders' sponsorship platform data. Use it to answer questions about deals, channels, brands, uploads, metrics, etc. Use raw database queries via `tl db pg|fb|es` for everything.
16
12
 
17
- **When you only need the schema of one table, you MUST call `tl schema pg <table>` (or `tl schema fb <table>`) never the unscoped form**, to reduce token counts. ES has no per-table form (the index is a single document shape) `tl schema es` is the only call there.
13
+ Always run `tl schema pg|fb|es` before writing a raw query. When you only need the schema of one table, you MUST call `tl schema pg <table>` (or `tl schema fb <table>`). Avoid calling the unscoped form, to reduce token counts. ES has no per-table form (the index is a single document shape), so `tl schema es` is the only call there.
18
14
 
19
- **Process data with shell tools, not your context window.** Don't pull large result sets into your reasoning context just to filter, sort, count, or extract a field that wastes tokens and slows you down. Pipe `tl … --json` (or `--csv`, or `--toon`) into `jq`, `yq`, `rg`, or `duckdb`, as appropriate, and read only the answer back. Pick the tool by shape:
15
+ **Process data with shell tools, not your context window.** Don't pull large result sets into your reasoning context just to filter, sort, count, or extract a field - that wastes tokens and slows you down. Pipe `tl … --json` (or `--csv`, or `--toon`) into `jq`, `yq`, `rg`, or `duckdb`, as appropriate, and read only the answer back. Pick the tool by shape:
20
16
 
21
17
  - **`jq`** — filter, project, and transform JSON. The default for `tl … --json` post-processing.
22
18
  ```bash
23
- tl sponsorships list status:sold --json | jq '.results[] | select(.price > 5000) | {id, brand, price}'
19
+ tl db pg "SELECT id, weighted_price FROM thoughtleaders_adlink
20
+ WHERE publish_status = 3 AND price > 5000
21
+ LIMIT 500 OFFSET 0" --json \
22
+ | jq '.results[] | {id, price: .weighted_price}'
24
23
  ```
25
24
  - **`yq`** — same idea for YAML/TOML, useful when reading config files or `--md` blocks.
26
- - **`rg`** — fast text search across CLI output, transcripts, and the codebase. Better than `grep` for searching large `--csv` exports or transcript dumps.
25
+ - **`rg`** — fast text search across CLI output, transcripts, and the codebase. Better than `grep` for searching large `--csv` exports or transcript dumps from ES.
27
26
  ```bash
28
27
  tl db es '{"size":500,"query":{"term":{"channel.id":5607}},"_source":["id","transcript"]}' --json | rg -o "NordVPN[^.]*"
29
28
  ```
30
29
  - **`duckdb`** — embedded analytical SQL over CSV/JSON files. Use when you need joins, aggregations, or window functions across multiple `tl` exports without spinning up a database.
31
30
  ```bash
32
- tl deals list purchase-date-start:2026-01 --csv > deals.csv
31
+ tl db pg "SELECT al.id, b.name AS brand, al.weighted_price AS price
32
+ FROM thoughtleaders_adlink al
33
+ JOIN thoughtleaders_profile p ON p.id = al.creator_profile_id
34
+ JOIN thoughtleaders_profile_brands pb ON pb.profile_id = p.id
35
+ JOIN thoughtleaders_brand b ON b.id = pb.brand_id
36
+ WHERE al.publish_status = 3
37
+ AND al.purchase_date >= '2026-01-01'
38
+ LIMIT 500 OFFSET 0" --csv > deals.csv
33
39
  duckdb -c "SELECT brand, SUM(price) AS revenue FROM 'deals.csv' GROUP BY brand ORDER BY revenue DESC LIMIT 10"
34
40
  ```
35
41
 
36
- The pattern is always: server-side narrowing first (filter in the `tl db` query or the structured filters), then shell tool to shape the result, then read only the final summary into context. If `tl doctor` reports any of these as missing, ask the user to install them — `tl-internal setup` installs all four by default.
42
+ The pattern is always: server-side narrowing first (usuakky by filters in the `tl db` query, but could be from similarity searches), then shell tool to shape the result, then read only the final summary into context. If `tl doctor` reports any of these as missing, ask the user to install them — `tl-internal setup` installs all four by default.
37
43
 
38
- Always assume there will be more than 1 page of results. You MUST always use `--limit` and `--offset` options in the `tl list` commands to retrieve the entire data set (all pages, until the total records are fetched). You must also always use pagination in scripts you write to collect results. The maximum number of results per page is 500.
44
+ 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 500.
39
45
 
40
46
  Retry after 5 seconds if the server returns a "connection denied" or a "server error" on any request.
41
47
 
@@ -49,7 +55,7 @@ This section defines business terminology. Any other skill files, command, and p
49
55
 
50
56
  ThoughtLeaders is a sponsorship marketplace connecting **Brands** (advertisers / media buyers) with **Channels** (YouTube creators, podcasters / media sellers).
51
57
 
52
- The centre of the data model is **Sponsorships** — business relationships between brands and channels. Sponsorships have a funnel of types, from broad to narrow:
58
+ The centre of the data model are **Sponsorships** — business relationships between brands and channels. Sponsorships statuses form a sales funnel, from broad to narrow:
53
59
 
54
60
  - **Sponsorships** — the broadest category, encompassing all stages, stored in the `thoughtleaders_adlink` table.
55
61
  - **Matches** — possible brand-channel pairings that ThoughtLeaders thinks could work
@@ -58,33 +64,35 @@ The centre of the data model is **Sponsorships** — business relationships betw
58
64
 
59
65
  Sponsorships are sometimes called "Ads" or "Ad campaigns". **"AdLink"** is another name for the same thing — it's the term the database uses (`thoughtleaders_adlink`) and shows up across internal code, schema docs, and AM Slack threads. Treat "sponsorship" and "adlink" as interchangeable; the user-facing word is "sponsorship," the engineering/DB word is "adlink."
60
66
 
61
- The CLI has shortcut commands for each type: `tl matches`, `tl proposals`, `tl deals`. These filter `tl sponsorships` by status.
67
+ The CLI has shortcut commands for each type: `tl matches`, `tl proposals`, `tl deals`. These are aliases for `tl sponsorships` with filtering by status.
62
68
 
63
69
  Other key concepts:
70
+ - **Channels** — YouTube channels, but could also be podcasts
71
+ - **Brands** — Entities (usually companies / organizations, but could be narrowed down to individual brands of a company)
64
72
  - **Uploads** — YouTube videos indexed from Elasticsearch
65
73
  - **Snapshots** — historical time-series metrics for channels and videos (Firebolt)
66
74
  - **Reports** — saved report configurations that can be re-run
67
- - **Comments** — notes attached to sponsorships
68
- - **Adspots** — types of ads a channel carries (e.g. mention, dedicated video, product placement). Returned by `tl channels show`; each carries price/cost.
69
- - **Profiles** — per-organization actors that own sponsorship records on behalf of either side of a deal. A profile is buyer-side or seller-side:
75
+ - **Comments** — notes attached to sponsorships, channels, or brands
76
+ - **Adspots** — types of ads a channel is willing to publish (e.g. mention, dedicated video, product placement). Returned by `tl channels show`; each carries price/cost.
77
+ - **Profiles** — actors that own sponsorship records on behalf of either side of a deal. A profile is either buyer-side or seller-side:
70
78
  - *Buyer-side (brand) profiles* — represent a sponsoring brand. Each brand profile has an M2M link to at most one `Brand` record (which are the actual advertiser identities). On a sponsorship, `creator_profile` is the buyer-side profile.
71
79
  - *Seller-side (publisher) profiles* — attached to a `Publication`, which in turn owns one or more `Channel` records. A channel's adspots therefore inherit ownership through `channel.publication.profile`.
72
80
  - **How to tell them apart** — three signals on the `thoughtleaders_profile` row, used in this order:
73
81
  1. **`persona`** (canonical) — `1=Brand`, `4=Media Agency`, `3=Talent Manager` are buyer-side; `2=Creator`, `5=Creator Service` are seller-side. May be null on legacy rows.
74
82
  2. **`is_advertiser` / `is_publisher`** booleans — feature flags; either or both can be true for staff-style profiles, but on normal user profiles they reliably mark side.
75
83
  - Org scoping for sponsorships is profile-mediated: a sponsorship belongs to your org if **either** `creator_profile.organization` (brand side) **or** `ad_spot.channel.publication.profile.organization` (publisher side) matches yours.
76
- - **MSN** (Media Selling Network) — the ~11k YouTube channels that have opted in to receive sponsorship offers. A channels is in the MSN group if the `channel.media_selling_network_join_date` field is not null.
84
+ - **MSN** (Media Selling Network) — the ~12k YouTube channels that have opted in to receive sponsorship offers. A channels is in the MSN group if the `channel.media_selling_network_join_date` field is not null.
77
85
  - **MBN** (Media Buying Network) — the brand-side counterpart to MSN: brand profiles that have opted in to receive proposed sponsorships. A profile is in the MBN group if the `profile.media_buying_network_join_date` field is not null.
78
- - **TPP** (ThoughtLeaders Partner Program, a.k.a. "TL channels") — the ~169 channels TL has the closest working relationship with. A channel is in the TPP group if `channel.is_tl_channel` is True. **Prefer TPP channels when booking**: they respond fastest, are the easiest to close, and don't need an outreach round-trip — treat them as immediately bookable. TPP is a strict subset of MSN, so the same booking rules (one active mention adspot, etc.) apply.
79
- - **`demographics_updated_at`** (on channel detail) — ISO timestamp of when demographic screenshots were last uploaded and processed via OCR. If non-null, the channel has demographics screenshots on file. If null, no screenshots have been uploaded. Use this to check whether a channel has demographics data from screenshots.
86
+ - **TPP** (ThoughtLeaders Partner Program, a.k.a. "TL channels") — the ~170 channels TL has the closest working relationship with. A channel is in the TPP group if `channel.is_tl_channel` is True. **Prefer TPP channels when booking**: they respond fastest, are the easiest to close, and don't need an outreach round-trip — treat them as immediately bookable. TPP is a strict subset of MSN, so the same booking rules (one active mention adspot, etc.) apply.
87
+ - **`demographics_updated_at`** (on channels) — If non-null, the channel has demographics screenshots on file. If null, no demographics screenshots have been uploaded. Use this to check whether a channel has demographics data from screenshots.
80
88
  - **`impression`** (on channels) — projected views per video on that channel. Forward-looking estimate. May be null when not yet computed.
81
89
  - **`views`** (on sponsorships) — actual view count of the sold and published sponsored video, accessible when `article_id` is set.
82
- - **`impressions_guarantee`** (on sponsorships) — projected/guaranteed impressions for the sponsorship. Numeric; rounded to int in list output.
83
- - **Sponsorship detail fields** (returned by `tl sponsorships show <id> --json`) — in addition to the list-view columns, the detail payload includes `integration` (raw int), `publish_count`, `common_name`, `outreach_email`, nested `publisher` (`first_name`, `last_name`, `email`), nested `brand_contact` (`first_name`, `last_name`, `email`), and `brand.organization_name`. Use these when generating IOs, contracts, or outreach.
90
+ - **`impressions_guarantee`** (on sponsorships) — projected/guaranteed impressions for the sponsorship. Numeric.
91
+ - **Sponsorship detail fields** (returned by `tl sponsorships show <id> --json`) — the detail payload includes `integration` (raw int), `publish_count`, `common_name`, `outreach_email`, nested `publisher` (`first_name`, `last_name`, `email`), nested `brand_contact` (`first_name`, `last_name`, `email`), and `brand.organization_name`. Use these when generating IOs, contracts, or outreach.
84
92
  - **CPM** has two distinct meanings depending on level — pick the one the user actually wants:
85
93
  - **Channel CPM** = `(adspot.price / channel.impression) * 1000` — projected price per thousand projected views. Used for pricing decisions **before** a sponsorship is sold. Available for channels with active adspots via `tl channels show <channel_id>`.
86
94
  - **Sponsorship CPM** = calculated in either of two ways: if `views` is present, then CPM is `(sponsorship.price / sponsorship.views) × 1000`, meaning realized cost per thousand actual views, computed post-publication. If `views` is null, Compute from the sponsorship's `price` and the channel's `impression` fields.
87
- - **CPM does not have a range filter.** To find sponsorships in a CPM range (e.g. "around $15"), fetch the record set with other filters first, then apply the CPM range in post-processing (jq, Python, etc.) on the returned `cpm` field. Plan queries and pagination accordingly — the server cannot reduce the result count based on CPM.
95
+ - CPM does not have a range filter. To find sponsorships in a CPM range (e.g. "around $15"), fetch the record set with other filters first, then apply the CPM range in post-processing (jq, Python, etc.) on the returned `cpm` field. Plan queries and pagination accordingly — the server cannot reduce the result count based on CPM.
88
96
  - **Sponsorship dates** — each sponsorship has four distinct dates, useful for different queries:
89
97
  - **`created_at`** — when the sponsorship record was created in the system
90
98
  - **`purchase_date`** — when the sponsorship was purchased (i.e. when the deal was made); These make up bookings.
@@ -97,13 +105,13 @@ Users see data scoped by their organization and plan:
97
105
  - **Media sellers** see sponsorships where their org is the publisher. They see `cost` but never `price`.
98
106
  - **Intelligence plan** is required for accessing information not strictly related to the user's organisation.
99
107
 
100
- When querying sponsorship bookings, query by `status:sold` and filter the the date range only by `purchase_date`. Otherwise, query for state:sold by `created_at`.
108
+ When querying sponsorship bookings, query by `status:sold` and filter the the date range only by `purchase_date`. Otherwise, query for `status:sold` and filter by `created_at`.
101
109
 
102
110
  ## Methodology
103
111
 
104
112
  Where possible, if searching for a sponsorship match between channels and brands, first search for what do similar brands sponsor / which brands is the channel usually sponsored by. The similarity judgement should be preferably based on similar topics, similar upload frequency, similar channel sizes, and only after all that, on demographics.
105
113
 
106
- Use the `tl channels similar` and `tl brands similar` commands to explore 1:1 similarity between known channels or brands. For category- or topic-driven discovery (e.g. "find me Cooking channels", "who scores high on USA share?"), use `tl recommender top-channels "<tag>"` (or `top-brands`/`top-profiles`) against the recommender — that's faster, ranked by category-strength. Run `tl recommender tags` to discover the valid tag names.
114
+ Use the `tl channels similar` and `tl brands similar` commands to find channels or brands similar to a particular channel or brand. For category- or topic-driven discovery (e.g. "Find me Cooking channels", "Who scores high on USA share?"), use `tl recommender top-channels "<tag>"` (or `top-brands`/`top-profiles`) against the recommender — that's faster, ranked by category-strength. Run `tl recommender tags` to discover the valid tag names.
107
115
 
108
116
  ## Workflow
109
117
 
@@ -144,20 +152,30 @@ Prefer writing Python code, shell code, or `jq` commands that fetche or analysis
144
152
  Note that if you're working on Windows, you need to set up UTF-8 in the console, because all of these commands return UTF-8 data.
145
153
 
146
154
  ### Data queries
155
+
156
+ **Filtered queries go through `tl db pg|fb|es`.** Write the SELECT/ES body yourself, and freely perform joins and aggregations. The show/create/update commands exist because they target a single record by ID. Where needed, write Python scripts or duckdb queries to join data from different databases.
157
+
158
+ Filter-to-SQL examples (deals/matches/proposals all live on `thoughtleaders_adlink`, differentiated by `publish_status`):
159
+
160
+ | Want | Raw-DB equivalent |
161
+ | --- | --- |
162
+ | All sponsorships matching filters | `tl db pg "SELECT … FROM thoughtleaders_adlink WHERE …"` |
163
+ | Sold deals (`publish_status=3`) | `tl db pg "SELECT … FROM thoughtleaders_adlink WHERE publish_status = 3"` |
164
+ | Matched (`publish_status=7`) | `tl db pg "SELECT … FROM thoughtleaders_adlink WHERE publish_status = 7"` |
165
+ | Proposed (`publish_status=0`) | `tl db pg "SELECT … FROM thoughtleaders_adlink WHERE publish_status = 0"` |
166
+ | Video uploads from ElasticSearch | `tl db es '{"size":N,"query":{"term":{"channel.id":<id>}}}'` |
167
+
168
+ Single-record / mutation commands remain:
169
+
147
170
  ```bash
148
- tl sponsorships list [filters...] # Sponsorships
149
171
  tl sponsorships show <id> # Sponsorship detail
150
172
  tl sponsorships create --channel <id> --brand <id> # Create proposal
151
173
  tl sponsorships update <id> '<json>' # Update a sponsorship
152
- tl deals list [filters...] # Shortcut: agreed-upon sponsorships (status:deal)
153
174
  tl deals show <id> # Deal detail
154
- tl matches list [filters...] # Shortcut: possible brand-channel pairings (status:match)
155
175
  tl matches show <id> # Match detail
156
176
  tl matches create --channel <id> --brand <id> # Create match
157
- tl proposals list [filters...] # Shortcut: proposed matches (status:proposal)
158
177
  tl proposals show <id> # Proposal detail
159
178
  tl proposals create --channel <id> --brand <id> # Create proposal
160
- tl uploads list [filters...] # Video uploads from ES
161
179
  tl uploads show <id> # Upload detail
162
180
  tl channels show <id-or-name> # Channel detail (accepts numeric ID or name) — for channel search use raw SQL on thoughtleaders_channel
163
181
  tl channels find <query> # Resolve a string to {id, name}; accepts name/slug, YouTube URL/handle/ID, video URL (queues a scrape if no match)
@@ -177,7 +195,9 @@ tl recommender top-profiles "<tag>" # Top brand profiles loaded on a similari
177
195
  tl recommender top-brands "<tag>" # Top brands (deduped from profiles) loaded on a similarity tag
178
196
  tl recommender inspect-channel <ref> # Show a channel's similarity-profile breakdown (Intelligence)
179
197
  tl recommender inspect-brand <ref> # Show a brand profile's ideal similarity-profile breakdown (Intelligence)
180
- tl recommender similar-to-profile <id> # Channels closest to a brand profile's ideal profile (Intelligence)
198
+ tl recommender channels-for-profile <id> # Find channels closest to a brand profile's ideal profile (Intelligence)
199
+ tl recommender channels-for-brand <ref> # Same as above but takes a brand ref; uses the brand's newest profile with a vector (Intelligence)
200
+ tl recommender brands-for-channel <ref> # Brands most likely to sponsor a channel; runs the channel's vector against the brand-profile index (Intelligence)
181
201
  tl snapshots channel <id> # Channel metrics over time (Firebolt-backed)
182
202
  tl snapshots video <id> --channel <id> # Video view curve (--channel required!)
183
203
  tl reports # List saved reports
@@ -306,7 +326,7 @@ Reasons to write a raw query (the common case):
306
326
  - **Multi-condition filtering** — compound boolean, `NOT IN`/`EXISTS`, `WHERE col IS NULL` on hidden fields, mixed range + enum + text predicates: write the SQL/ES body, don't over-fetch and post-filter.
307
327
  - **Fields the structured commands don't expose** — e.g. `media_selling_network_join_date` (only the `msn` boolean is surfaced), `weighted_price`, `tx_data`, raw `publish_status` integer, etc.
308
328
 
309
- Structured commands are still the right tool for: single-record `show` by ID, plain filtered `list` (one or two filters that the structured vocabulary already supports), saved `tl reports run`, and `tl snapshots channel|video` (these wrap interpolation logic you'd otherwise reimplement).
329
+ Structured commands are still the right tool for: single-record `show` by ID, saved `tl reports run`, and `tl snapshots channel|video` (these wrap interpolation logic you'd otherwise reimplement). Anything that would have been a "filtered list" goes through `tl db pg|fb|es`.
310
330
 
311
331
  | Need | Use |
312
332
  |---|---|
@@ -317,8 +337,7 @@ Structured commands are still the right tool for: single-record `show` by ID, pl
317
337
  | Transcript / brand-mention search inside video content | **`tl db es`** (no structured equivalent for content text) |
318
338
  | Custom Firebolt shape (milestone-age slices, multi-channel growth comparisons) | **`tl db fb`** |
319
339
  | Single-record detail lookup by ID | `tl <resource> show <id>` |
320
- | Plain filtered list with one or two simple filters | `tl <resource> list` |
321
- | Channel/brand similarity (server-implemented similarity search) | `tl channels similar`, `tl brands similar` |
340
+ | Channel/brand similarity (server-implemented similarity search) | `tl channels similar`, `tl brands similar`, `tl recommender ...` |
322
341
  | Saved reports | `tl reports`, `tl reports run` |
323
342
  | Time-series view-curve / channel growth (default shape with interpolation) | `tl snapshots channel`, `tl snapshots video` |
324
343
 
@@ -435,17 +454,6 @@ tl changelog since v0.4.10 # Notes from v0.4.10 to latest
435
454
  tl changelog --md > CHANGELOG.md # Capture for a doc
436
455
  ```
437
456
 
438
- `tl changelog` summaries are LLM-generated server-side from full commit messages and cached per version, so repeat calls are fast and don't re-bill the LLM. The release date and a 2–4 sentence prose summary come back per version.
439
-
440
- ### Filter syntax
441
- Structured list commands accept `key:value` filters (use them for trivially simple lookups):
442
- ```bash
443
- tl sponsorships list status:sold brand:"Nike" purchase-date:2026-01
444
- tl uploads list channel:12345 type:longform
445
- ```
446
-
447
- Date filters accept keywords: `today`, `yesterday`, `tomorrow`.
448
-
449
457
  #### Channel discovery — recommender first, raw SQL second
450
458
 
451
459
  For category- or demographic-driven discovery, **use the recommender, not `content_category` SQL.** The recommender ranks channels by how strongly they load on a category/demographic tag (similarity scores), instead of forcing exact equality on a single integer code. It also returns the matching brand profiles alongside the channels — useful when the user actually wants to know "who buys this kind of inventory."
@@ -511,8 +519,8 @@ While analysing results, you must always examine the `results` field in the JSON
511
519
 
512
520
  Every query costs credits. Before running expensive queries:
513
521
  1. Check the credit rate: `tl describe show <resource> --json | jq '.credits'` and the user balance.
514
- 2. **List endpoints (sponsorships/channels/uploads/snapshots/comments/reports/db) are priced non-linearly:** `cost = 1 + mult × 0.126 × n^1.2`, where `mult` is the per-resource complexity factor (1.0 for cheap reads, 1.2 for snapshots, 1.3 for reports, 1.4 for raw db). Detail/history/similar endpoints are linear (`rate × results`). See the table in the command list above.
515
- 3. Estimate cost from the formula or the table; for non-list endpoints use `results × rate`.
522
+ 2. **Multi-row endpoints (snapshots, comments, reports, `tl db pg|fb|es`) are priced non-linearly:** `cost = 1 + mult × 0.126 × n^1.2`, where `mult` is the per-resource complexity factor (1.0 for cheap reads, 1.2 for snapshots, 1.3 for reports, 1.4 for raw db). Detail/history/similar endpoints are linear (`rate × results`).
523
+ 3. Estimate cost from the formula or the table; for non-row-priced endpoints use `results × rate`.
516
524
  4. If estimated cost is more than 10% of the remaining balance, ask the user to confirm the operation before running.
517
525
 
518
526
  ## Data Scoping
@@ -520,7 +528,7 @@ Every query costs credits. Before running expensive queries:
520
528
  Users only see data their plan allows:
521
529
  - **Media buyers** see deals where their org is the brand. They see `price` but never `cost`.
522
530
  - **Media sellers** see deals where their org is the publisher. They see `cost` but never `price`.
523
- - **Intelligence plan** required for `tl brands`, the full `tl recommender` surface, and full `tl uploads list`.
531
+ - **Intelligence plan** required for `tl brands`, the full `tl recommender` surface, and `tl db es` access to full transcript / brand-mention data.
524
532
  - **Paid plan** required for `tl snapshots`.
525
533
 
526
534
  ## Important: Status Labels
@@ -535,7 +543,15 @@ When presenting sponsorship status data, always use human-readable labels — ne
535
543
 
536
544
  "Show me my sold sponsorships this quarter":
537
545
  ```bash
538
- tl deals list purchase-date-start:2026-01-01 --json
546
+ tl db pg "SELECT al.id, al.weighted_price, al.purchase_date, b.name AS brand
547
+ FROM thoughtleaders_adlink al
548
+ JOIN thoughtleaders_profile p ON p.id = al.creator_profile_id
549
+ JOIN thoughtleaders_profile_brands pb ON pb.profile_id = p.id
550
+ JOIN thoughtleaders_brand b ON b.id = pb.brand_id
551
+ WHERE al.publish_status = 3
552
+ AND al.purchase_date >= '2026-01-01'
553
+ ORDER BY al.purchase_date DESC
554
+ LIMIT 500 OFFSET 0" --json
539
555
  ```
540
556
 
541
557
  "What channels does Nike sponsor?":
@@ -587,7 +603,14 @@ tl recommender top-channels "Cooking" msn:yes --limit 100 --json \
587
603
 
588
604
  "Show sold sponsorships targeting mobile US audiences":
589
605
  ```bash
590
- tl sponsorships list status:sold primary-device:mobile min-us-share:60 --json
606
+ tl db pg "SELECT al.id, c.channel_name, c.demographic_device_primary, c.demographic_usa_share, al.weighted_price
607
+ FROM thoughtleaders_adlink al
608
+ JOIN thoughtleaders_adspot s ON s.id = al.ad_spot_id
609
+ JOIN thoughtleaders_channel c ON c.id = s.channel_id
610
+ WHERE al.publish_status = 3
611
+ AND c.demographic_device_primary = 'mobile'
612
+ AND c.demographic_usa_share >= 60
613
+ LIMIT 500 OFFSET 0" --json
591
614
  ```
592
615
 
593
616
  "Find channels similar to one I know" (similarity recommender, 25 credits per call):
@@ -612,6 +635,23 @@ tl recommender top-brands "USA share" mbn:yes --limit 30 # Top brands (ded
612
635
  tl recommender top-channels "Tech" exclude-for-profile:842 # Drop channels already proposed for profile 842
613
636
  tl recommender inspect-channel 29834 # Per-tag breakdown of a channel's vector
614
637
  tl recommender inspect-brand Nike # Per-tag breakdown of a brand's ideal profile
615
- tl recommender similar-to-profile 842 --limit 30 # Channels closest to a brand profile's ideal profile
638
+ tl recommender channels-for-profile 842 --limit 30 # Channels closest to a brand profile's ideal profile
639
+ tl recommender channels-for-profile 842 msn:yes language:en # Same, filtered to English MSN channels
640
+ tl recommender channels-for-brand Nike --limit 30 # Same, but takes a brand ref (uses the brand's newest profile with a vector)
641
+ tl recommender channels-for-brand 6037 msn:yes language:en --limit 30
642
+ tl recommender brands-for-channel 29834 --limit 30 # Brands likely to sponsor this channel
643
+ tl recommender brands-for-channel "MrBeast" mbn:yes --limit 30 # Same, restricted to MBN brand profiles
616
644
  ```
645
+
646
+ **Filters on the recommender commands:**
647
+
648
+ | Command | Filters |
649
+ | --- | --- |
650
+ | `top-channels` | `msn:<yes\|no\|all>` (default all), `exclude-for-profile:<id>` |
651
+ | `top-profiles` | `mbn:<yes\|no\|all>` (default all), `exclude-for-channel:<id>` |
652
+ | `top-brands` | `mbn:<yes\|no\|all>` (default all) |
653
+ | `channels-for-profile` | `language:<iso>` (default `en`), `msn:<yes\|no>` (default `no`) |
654
+ | `channels-for-brand` | same as `channels-for-profile` |
655
+ | `brands-for-channel` | `mbn:<yes\|no\|all>` (default `all`) |
656
+
617
657
  Use `tl recommender top` for category/topic discovery (it's ranked) and `tl channels similar` / `tl brands similar` for 1:1 lookalike searches.
@@ -1,3 +1,3 @@
1
1
  """ThoughtLeaders CLI — query sponsorship data, channels, brands, and intelligence."""
2
2
 
3
- __version__ = "0.6.44"
3
+ __version__ = "0.6.45"
@@ -73,23 +73,25 @@ def register_comment_commands(app: typer.Typer, entity_type: str, entity_label:
73
73
  text (e.g. "sponsorship", "channel").
74
74
  """
75
75
 
76
- @app.command("comment-list")
76
+ # `help=` is passed explicitly because Typer reads the function's
77
+ # `__doc__` for the per-subcommand help text, and `f"""…"""` as the
78
+ # first statement is a runtime f-string expression — not a docstring
79
+ # — so `__doc__` ends up None and the help column renders blank.
80
+ @app.command("comment-list", help=f"List comments on a {entity_label} (free, no credits).")
77
81
  def comment_list(
78
82
  entity_id: str = typer.Argument(..., help=f"{entity_label.capitalize()} ID"),
79
83
  json_output: bool = typer.Option(False, "--json", help="JSON output"),
80
84
  toon_output: bool = typer.Option(False, "--toon", help="TOON output (token-efficient for LLMs)"),
81
85
  ) -> None:
82
- f"""List comments on a {entity_label} (free, no credits)."""
83
86
  list_comments(entity_type, entity_id, json_output, toon_output)
84
87
 
85
- @app.command("comment-add")
88
+ @app.command("comment-add", help=f"Add a comment to a {entity_label} (free, no credits).")
86
89
  def comment_add(
87
90
  entity_id: str = typer.Argument(..., help=f"{entity_label.capitalize()} ID"),
88
91
  message: str = typer.Argument(..., help="Comment text"),
89
92
  json_output: bool = typer.Option(False, "--json", help="JSON output"),
90
93
  toon_output: bool = typer.Option(False, "--toon", help="TOON output (token-efficient for LLMs)"),
91
94
  ) -> None:
92
- f"""Add a comment to a {entity_label} (free, no credits)."""
93
95
  add_comment(entity_type, entity_id, message, json_output, toon_output)
94
96
 
95
97
  @app.command("comment-edit")
@@ -168,12 +168,20 @@ def history_stats_cmd(
168
168
  @app.command("find")
169
169
  def find_cmd(
170
170
  query: str = typer.Argument(..., help="Brand name, slug, domain, or keyword"),
171
+ json_output: bool = typer.Option(False, "--json", help="JSON output"),
172
+ csv_output: bool = typer.Option(False, "--csv", help="CSV output"),
173
+ md_output: bool = typer.Option(False, "--md", help="Markdown output"),
174
+ toon_output: bool = typer.Option(False, "--toon", help="TOON output (token-efficient for LLMs)"),
171
175
  ) -> None:
172
- """Resolve a string to a single brand and print {id, name} as JSON.
176
+ """Resolve a string to a single brand.
173
177
 
174
178
  Searches across name, slug, website domain, and the brand's keyword
175
- fields (kw + keywords). Ambiguous matches return an error with the
176
- candidate IDs and names so the caller can pick a better query.
179
+ fields (kw + keywords). Default output is a pretty `id name` line on
180
+ stdout; pass --json / --csv / --md / --toon for machine-readable
181
+ output (the JSON shape is `{"id": ..., "name": ...}`).
182
+
183
+ Ambiguous matches return an error with the candidate IDs and names so
184
+ the caller can pick a better query.
177
185
 
178
186
  Examples:
179
187
  tl brands find Nike
@@ -181,12 +189,26 @@ def find_cmd(
181
189
  tl brands find https://www.nike.com/
182
190
  tl brands find 21416
183
191
  """
192
+ fmt = detect_format(json_output, csv_output, md_output, toon_output)
184
193
  client = get_client()
185
194
  try:
186
195
  data = client.get("/brands/find", params={"q": query})
187
196
  results = data.get("results", [])
188
197
  record = results[0] if results else {}
189
- print(_json.dumps({"id": record.get("id"), "name": record.get("name")}, ensure_ascii=False))
198
+ if fmt == "table":
199
+ bid = record.get("id")
200
+ name = record.get("name")
201
+ if bid is None:
202
+ Console(stderr=True).print("[yellow]No match.[/yellow]")
203
+ raise typer.Exit(1)
204
+ Console().print(f"[bold yellow]{bid}[/bold yellow] {name}")
205
+ else:
206
+ output(
207
+ {"results": [{"id": record.get("id"), "name": record.get("name")}]},
208
+ fmt,
209
+ columns=["id", "name"],
210
+ title=f"Brand match for {query}",
211
+ )
190
212
  except ApiError as e:
191
213
  if e.status_code == 400 and isinstance(e.raw, dict) and e.raw.get("candidates"):
192
214
  _print_brand_find_candidates(e.detail, e.raw["candidates"])
@@ -285,8 +285,12 @@ def update_cmd(
285
285
  @app.command("find")
286
286
  def find_cmd(
287
287
  query: str = typer.Argument(..., help="Name, slug, YouTube URL, handle, channel ID, or video URL"),
288
+ json_output: bool = typer.Option(False, "--json", help="JSON output"),
289
+ csv_output: bool = typer.Option(False, "--csv", help="CSV output"),
290
+ md_output: bool = typer.Option(False, "--md", help="Markdown output"),
291
+ toon_output: bool = typer.Option(False, "--toon", help="TOON output (token-efficient for LLMs)"),
288
292
  ) -> None:
289
- """Resolve a string to a single channel and print {id, name} as JSON.
293
+ """Resolve a string to a single channel.
290
294
 
291
295
  Accepts:
292
296
  - A partial channel name or slug (ILIKE match)
@@ -296,6 +300,10 @@ def find_cmd(
296
300
  - A YouTube video URL — the video's channel is resolved via the
297
301
  platform's article index
298
302
 
303
+ Default output is a pretty `id name` line on stdout. Pass --json /
304
+ --csv / --md / --toon for machine-readable output (the JSON shape is
305
+ `{"id": ..., "name": ...}`).
306
+
299
307
  Ambiguous matches return an error with candidate IDs and names.
300
308
  If the input is a YouTube URL and no channel matches, the URL is
301
309
  queued for scraping; retry the command later.
@@ -306,12 +314,26 @@ def find_cmd(
306
314
  tl channels find https://www.youtube.com/watch?v=dQw4w9WgXcQ
307
315
  tl channels find UCX6OQ3DkcsbYNE6H8uQQuVA
308
316
  """
317
+ fmt = detect_format(json_output, csv_output, md_output, toon_output)
309
318
  client = get_client()
310
319
  try:
311
320
  data = client.get("/channels/find", params={"q": query})
312
321
  results = data.get("results", [])
313
322
  record = results[0] if results else {}
314
- print(_json.dumps({"id": record.get("id"), "name": record.get("name")}, ensure_ascii=False))
323
+ if fmt == "table":
324
+ cid = record.get("id")
325
+ name = record.get("name")
326
+ if cid is None:
327
+ Console(stderr=True).print("[yellow]No match.[/yellow]")
328
+ raise typer.Exit(1)
329
+ Console().print(f"[bold cyan]{cid}[/bold cyan] {name}")
330
+ else:
331
+ output(
332
+ {"results": [{"id": record.get("id"), "name": record.get("name")}]},
333
+ fmt,
334
+ columns=["id", "name"],
335
+ title=f"Channel match for {query}",
336
+ )
315
337
  except ApiError as e:
316
338
  if e.status_code == 400 and isinstance(e.raw, dict) and e.raw.get("candidates"):
317
339
  _print_channel_candidates(e.detail, e.raw["candidates"])
@@ -319,12 +341,10 @@ def find_cmd(
319
341
  if e.status_code == 404 and isinstance(e.raw, dict) and e.raw.get("queued"):
320
342
  err = Console(stderr=True)
321
343
  err.print(f"[yellow]{e.detail}[/yellow]")
322
- print(_json.dumps({
323
- "error": e.detail,
324
- "queued": True,
325
- "queued_channel_id": e.raw.get("queued_channel_id"),
326
- "queued_url": e.raw.get("queued_url"),
327
- }, ensure_ascii=False))
344
+ err.print(
345
+ f" [dim]queued_channel_id={e.raw.get('queued_channel_id')}"
346
+ f" queued_url={e.raw.get('queued_url')}[/dim]"
347
+ )
328
348
  raise typer.Exit(1)
329
349
  handle_api_error(e)
330
350
  finally:
@@ -268,8 +268,8 @@ def inspect_brand_cmd(
268
268
  client.close()
269
269
 
270
270
 
271
- @app.command("similar-to-profile")
272
- def similar_to_profile_cmd(
271
+ @app.command("channels-for-profile")
272
+ def channels_for_profile_cmd(
273
273
  profile_id: int = typer.Argument(..., help="Profile ID (numeric)"),
274
274
  args: list[str] = typer.Argument(None, help="Filters (key:value pairs)."),
275
275
  json_output: bool = typer.Option(False, "--json", help="JSON output"),
@@ -288,8 +288,8 @@ def similar_to_profile_cmd(
288
288
  msn:<yes|no> Restrict to MSN channels (default: no)
289
289
 
290
290
  Examples:
291
- tl recommender similar-to-profile 842
292
- tl recommender similar-to-profile 842 msn:yes --limit 30
291
+ tl recommender channels-for-profile 842
292
+ tl recommender channels-for-profile 842 msn:yes --limit 30
293
293
  """
294
294
  fmt = detect_format(json_output, csv_output, md_output, toon_output)
295
295
  filters = parse_filters(args or [])
@@ -315,8 +315,63 @@ def similar_to_profile_cmd(
315
315
  client.close()
316
316
 
317
317
 
318
- @app.command("similar-brands-to-channel")
319
- def similar_brands_to_channel_cmd(
318
+ @app.command("channels-for-brand")
319
+ def channels_for_brand_cmd(
320
+ brand_ref: str = typer.Argument(..., help="Brand ID, name, slug, or domain (resolved via tl brands find)"),
321
+ args: list[str] = typer.Argument(None, help="Filters (key:value pairs)."),
322
+ json_output: bool = typer.Option(False, "--json", help="JSON output"),
323
+ csv_output: bool = typer.Option(False, "--csv", help="CSV output"),
324
+ md_output: bool = typer.Option(False, "--md", help="Markdown output"),
325
+ toon_output: bool = typer.Option(False, "--toon", help="TOON output (token-efficient for LLMs)"),
326
+ limit: int = typer.Option(20, "--limit", "-l", help="Max results (1-100)"),
327
+ ) -> None:
328
+ """Channels closest to a brand's ideal similarity profile.
329
+
330
+ Resolves the brand to its most-recently-created profile that has an
331
+ indexed search vector and runs the same KNN that
332
+ `channels-for-profile` uses. Costs 25 credits per call. Intelligence
333
+ plan required. Channels the brand has already worked with or been
334
+ proposed are excluded.
335
+
336
+ Filters:
337
+ language:<iso> Content language (default: en)
338
+ msn:<yes|no> Restrict to MSN channels (default: no)
339
+
340
+ Examples:
341
+ tl recommender channels-for-brand 6037
342
+ tl recommender channels-for-brand Nike msn:yes --limit 30
343
+ """
344
+ fmt = detect_format(json_output, csv_output, md_output, toon_output)
345
+ filters = parse_filters(args or [])
346
+ params = {k: v for k, v in filters.items() if k in {"language", "msn"}}
347
+ params["limit"] = str(limit)
348
+ encoded = urllib.parse.quote(brand_ref, safe="")
349
+ client = get_client()
350
+ try:
351
+ data = client.get(f"/recommender/brands/{encoded}/channels-for-profile", params=params)
352
+ for r in data.get("results", []):
353
+ score = r.get("score")
354
+ if isinstance(score, (int, float)) and fmt in ("table", "md"):
355
+ r["score"] = f"{score * 100:.1f}%"
356
+ title = f"Channels for brand {brand_ref}"
357
+ prof = data.get("profile") or {}
358
+ if prof.get("brand_name") and prof.get("id"):
359
+ title = f"Channels for {prof['brand_name']} (via profile {prof['id']})"
360
+ output(
361
+ data,
362
+ fmt,
363
+ columns=["score", "channel_id", "channel_name", "slug"],
364
+ title=title,
365
+ column_config={"score": {"justify": "right"}},
366
+ )
367
+ except ApiError as e:
368
+ handle_api_error(e)
369
+ finally:
370
+ client.close()
371
+
372
+
373
+ @app.command("brands-for-channel")
374
+ def brands_for_channel_cmd(
320
375
  channel_ref: str = typer.Argument(..., help="Channel ID (numeric) or name (partial match, must be unique)"),
321
376
  args: list[str] = typer.Argument(None, help="Filters (key:value pairs)."),
322
377
  json_output: bool = typer.Option(False, "--json", help="JSON output"),
@@ -335,8 +390,8 @@ def similar_brands_to_channel_cmd(
335
390
  mbn:<yes|no|all> MBN membership of the underlying profile (default: all)
336
391
 
337
392
  Examples:
338
- tl recommender similar-brands-to-channel 12345
339
- tl recommender similar-brands-to-channel "MrBeast" mbn:yes --limit 30
393
+ tl recommender brands-for-channel 12345
394
+ tl recommender brands-for-channel "MrBeast" mbn:yes --limit 30
340
395
  """
341
396
  fmt = detect_format(json_output, csv_output, md_output, toon_output)
342
397
  filters = parse_filters(args or [])
@@ -361,3 +416,17 @@ def similar_brands_to_channel_cmd(
361
416
  _handle_recommender_error(e)
362
417
  finally:
363
418
  client.close()
419
+
420
+
421
+ @app.command("similar-brands-to-channel", hidden=True)
422
+ def similar_brands_to_channel_cmd(
423
+ channel_ref: str = typer.Argument(..., help="Channel ID (numeric) or name (partial match, must be unique)"),
424
+ args: list[str] = typer.Argument(None, help="Filters (key:value pairs)."),
425
+ json_output: bool = typer.Option(False, "--json", help="JSON output"),
426
+ csv_output: bool = typer.Option(False, "--csv", help="CSV output"),
427
+ md_output: bool = typer.Option(False, "--md", help="Markdown output"),
428
+ toon_output: bool = typer.Option(False, "--toon", help="TOON output (token-efficient for LLMs)"),
429
+ limit: int = typer.Option(20, "--limit", "-l", help="Max results (1-100)"),
430
+ ) -> None:
431
+ """Hidden alias for `brands-for-channel` (older name kept for back-compat)."""
432
+ brands_for_channel_cmd(channel_ref, args, json_output, csv_output, md_output, toon_output, limit)