thoughtleaders-cli 0.6.38__tar.gz → 0.6.40__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 (95) hide show
  1. {thoughtleaders_cli-0.6.38 → thoughtleaders_cli-0.6.40}/.claude-plugin/plugin.json +1 -1
  2. {thoughtleaders_cli-0.6.38 → thoughtleaders_cli-0.6.40}/PKG-INFO +1 -1
  3. {thoughtleaders_cli-0.6.38 → thoughtleaders_cli-0.6.40}/pyproject.toml +1 -1
  4. {thoughtleaders_cli-0.6.38 → thoughtleaders_cli-0.6.40}/skills/tl/SKILL.md +129 -32
  5. {thoughtleaders_cli-0.6.38 → thoughtleaders_cli-0.6.40}/skills/tl-report-builder/SKILL.md +90 -0
  6. {thoughtleaders_cli-0.6.38 → thoughtleaders_cli-0.6.40}/src/tl_cli/__init__.py +1 -1
  7. thoughtleaders_cli-0.6.40/src/tl_cli/auth/commands.py +130 -0
  8. {thoughtleaders_cli-0.6.38 → thoughtleaders_cli-0.6.40}/src/tl_cli/auth/token_store.py +22 -3
  9. {thoughtleaders_cli-0.6.38 → thoughtleaders_cli-0.6.40}/src/tl_cli/client/http.py +12 -3
  10. {thoughtleaders_cli-0.6.38 → thoughtleaders_cli-0.6.40}/src/tl_cli/commands/brands.py +34 -0
  11. {thoughtleaders_cli-0.6.38 → thoughtleaders_cli-0.6.40}/src/tl_cli/commands/channels.py +51 -0
  12. {thoughtleaders_cli-0.6.38 → thoughtleaders_cli-0.6.40}/src/tl_cli/commands/doctor.py +6 -2
  13. {thoughtleaders_cli-0.6.38 → thoughtleaders_cli-0.6.40}/src/tl_cli/commands/setup.py +161 -37
  14. {thoughtleaders_cli-0.6.38 → thoughtleaders_cli-0.6.40}/src/tl_cli/commands/sponsorships.py +49 -3
  15. {thoughtleaders_cli-0.6.38 → thoughtleaders_cli-0.6.40}/src/tl_cli/self_update.py +6 -1
  16. thoughtleaders_cli-0.6.40/tests/test_auth.py +78 -0
  17. thoughtleaders_cli-0.6.40/tests/test_http_auth.py +62 -0
  18. thoughtleaders_cli-0.6.38/src/tl_cli/auth/commands.py +0 -57
  19. thoughtleaders_cli-0.6.38/tests/test_auth.py +0 -45
  20. {thoughtleaders_cli-0.6.38 → thoughtleaders_cli-0.6.40}/.claude-plugin/marketplace.json +0 -0
  21. {thoughtleaders_cli-0.6.38 → thoughtleaders_cli-0.6.40}/.github/workflows/python-publish.yml +0 -0
  22. {thoughtleaders_cli-0.6.38 → thoughtleaders_cli-0.6.40}/.gitignore +0 -0
  23. {thoughtleaders_cli-0.6.38 → thoughtleaders_cli-0.6.40}/AGENTS.md +0 -0
  24. {thoughtleaders_cli-0.6.38 → thoughtleaders_cli-0.6.40}/CLAUDE.md +0 -0
  25. {thoughtleaders_cli-0.6.38 → thoughtleaders_cli-0.6.40}/LICENSE +0 -0
  26. {thoughtleaders_cli-0.6.38 → thoughtleaders_cli-0.6.40}/README.md +0 -0
  27. {thoughtleaders_cli-0.6.38 → thoughtleaders_cli-0.6.40}/agents/tl-analyst.md +0 -0
  28. {thoughtleaders_cli-0.6.38 → thoughtleaders_cli-0.6.40}/docs/architecture.md +0 -0
  29. {thoughtleaders_cli-0.6.38 → thoughtleaders_cli-0.6.40}/hooks/hooks.json +0 -0
  30. {thoughtleaders_cli-0.6.38 → thoughtleaders_cli-0.6.40}/hooks/scripts/load-tl-skill.mjs +0 -0
  31. {thoughtleaders_cli-0.6.38 → thoughtleaders_cli-0.6.40}/hooks/scripts/post-usage.sh +0 -0
  32. {thoughtleaders_cli-0.6.38 → thoughtleaders_cli-0.6.40}/hooks/scripts/pre-check.sh +0 -0
  33. {thoughtleaders_cli-0.6.38 → thoughtleaders_cli-0.6.40}/skills/tl/references/business-glossary.md +0 -0
  34. {thoughtleaders_cli-0.6.38 → thoughtleaders_cli-0.6.40}/skills/tl/references/elasticsearch-schema.md +0 -0
  35. {thoughtleaders_cli-0.6.38 → thoughtleaders_cli-0.6.40}/skills/tl/references/firebolt-schema.md +0 -0
  36. {thoughtleaders_cli-0.6.38 → thoughtleaders_cli-0.6.40}/skills/tl/references/postgres-schema.md +0 -0
  37. {thoughtleaders_cli-0.6.38 → thoughtleaders_cli-0.6.40}/skills/tl-import/SKILL.md +0 -0
  38. {thoughtleaders_cli-0.6.38 → thoughtleaders_cli-0.6.40}/skills/tl-report-builder/examples/e2e_findings.md +0 -0
  39. {thoughtleaders_cli-0.6.38 → thoughtleaders_cli-0.6.40}/skills/tl-report-builder/examples/golden_queries.md +0 -0
  40. {thoughtleaders_cli-0.6.38 → thoughtleaders_cli-0.6.40}/skills/tl-report-builder/references/columns_brands.md +0 -0
  41. {thoughtleaders_cli-0.6.38 → thoughtleaders_cli-0.6.40}/skills/tl-report-builder/references/columns_channels.md +0 -0
  42. {thoughtleaders_cli-0.6.38 → thoughtleaders_cli-0.6.40}/skills/tl-report-builder/references/columns_content.md +0 -0
  43. {thoughtleaders_cli-0.6.38 → thoughtleaders_cli-0.6.40}/skills/tl-report-builder/references/columns_sponsorships.md +0 -0
  44. {thoughtleaders_cli-0.6.38 → thoughtleaders_cli-0.6.40}/skills/tl-report-builder/references/intelligence_filterset_schema.json +0 -0
  45. {thoughtleaders_cli-0.6.38 → thoughtleaders_cli-0.6.40}/skills/tl-report-builder/references/intelligence_widget_schema.json +0 -0
  46. {thoughtleaders_cli-0.6.38 → thoughtleaders_cli-0.6.40}/skills/tl-report-builder/references/report_glossary.md +0 -0
  47. {thoughtleaders_cli-0.6.38 → thoughtleaders_cli-0.6.40}/skills/tl-report-builder/references/sortable_columns.json +0 -0
  48. {thoughtleaders_cli-0.6.38 → thoughtleaders_cli-0.6.40}/skills/tl-report-builder/references/sponsorship_filterset_schema.json +0 -0
  49. {thoughtleaders_cli-0.6.38 → thoughtleaders_cli-0.6.40}/skills/tl-report-builder/references/sponsorship_widget_schema.json +0 -0
  50. {thoughtleaders_cli-0.6.38 → thoughtleaders_cli-0.6.40}/skills/tl-report-builder/references/widgets.md +0 -0
  51. {thoughtleaders_cli-0.6.38 → thoughtleaders_cli-0.6.40}/skills/tl-report-builder/tools/column_builder.md +0 -0
  52. {thoughtleaders_cli-0.6.38 → thoughtleaders_cli-0.6.40}/skills/tl-report-builder/tools/database_query.md +0 -0
  53. {thoughtleaders_cli-0.6.38 → thoughtleaders_cli-0.6.40}/skills/tl-report-builder/tools/keyword_research.md +0 -0
  54. {thoughtleaders_cli-0.6.38 → thoughtleaders_cli-0.6.40}/skills/tl-report-builder/tools/name_resolver.md +0 -0
  55. {thoughtleaders_cli-0.6.38 → thoughtleaders_cli-0.6.40}/skills/tl-report-builder/tools/sample_judge.md +0 -0
  56. {thoughtleaders_cli-0.6.38 → thoughtleaders_cli-0.6.40}/skills/tl-report-builder/tools/similar_channels.md +0 -0
  57. {thoughtleaders_cli-0.6.38 → thoughtleaders_cli-0.6.40}/skills/tl-report-builder/tools/topic_matcher.md +0 -0
  58. {thoughtleaders_cli-0.6.38 → thoughtleaders_cli-0.6.40}/skills/tl-report-builder/tools/widget_builder.md +0 -0
  59. {thoughtleaders_cli-0.6.38 → thoughtleaders_cli-0.6.40}/src/tl_cli/_completions.py +0 -0
  60. {thoughtleaders_cli-0.6.38 → thoughtleaders_cli-0.6.40}/src/tl_cli/auth/__init__.py +0 -0
  61. {thoughtleaders_cli-0.6.38 → thoughtleaders_cli-0.6.40}/src/tl_cli/auth/finalize.py +0 -0
  62. {thoughtleaders_cli-0.6.38 → thoughtleaders_cli-0.6.40}/src/tl_cli/auth/login.py +0 -0
  63. {thoughtleaders_cli-0.6.38 → thoughtleaders_cli-0.6.40}/src/tl_cli/auth/pkce.py +0 -0
  64. {thoughtleaders_cli-0.6.38 → thoughtleaders_cli-0.6.40}/src/tl_cli/client/__init__.py +0 -0
  65. {thoughtleaders_cli-0.6.38 → thoughtleaders_cli-0.6.40}/src/tl_cli/client/errors.py +0 -0
  66. {thoughtleaders_cli-0.6.38 → thoughtleaders_cli-0.6.40}/src/tl_cli/commands/__init__.py +0 -0
  67. {thoughtleaders_cli-0.6.38 → thoughtleaders_cli-0.6.40}/src/tl_cli/commands/_comments_common.py +0 -0
  68. {thoughtleaders_cli-0.6.38 → thoughtleaders_cli-0.6.40}/src/tl_cli/commands/ask.py +0 -0
  69. {thoughtleaders_cli-0.6.38 → thoughtleaders_cli-0.6.40}/src/tl_cli/commands/balance.py +0 -0
  70. {thoughtleaders_cli-0.6.38 → thoughtleaders_cli-0.6.40}/src/tl_cli/commands/bulk_import.py +0 -0
  71. {thoughtleaders_cli-0.6.38 → thoughtleaders_cli-0.6.40}/src/tl_cli/commands/changelog.py +0 -0
  72. {thoughtleaders_cli-0.6.38 → thoughtleaders_cli-0.6.40}/src/tl_cli/commands/credits.py +0 -0
  73. {thoughtleaders_cli-0.6.38 → thoughtleaders_cli-0.6.40}/src/tl_cli/commands/db.py +0 -0
  74. {thoughtleaders_cli-0.6.38 → thoughtleaders_cli-0.6.40}/src/tl_cli/commands/deals.py +0 -0
  75. {thoughtleaders_cli-0.6.38 → thoughtleaders_cli-0.6.40}/src/tl_cli/commands/describe.py +0 -0
  76. {thoughtleaders_cli-0.6.38 → thoughtleaders_cli-0.6.40}/src/tl_cli/commands/matches.py +0 -0
  77. {thoughtleaders_cli-0.6.38 → thoughtleaders_cli-0.6.40}/src/tl_cli/commands/proposals.py +0 -0
  78. {thoughtleaders_cli-0.6.38 → thoughtleaders_cli-0.6.40}/src/tl_cli/commands/recommender.py +0 -0
  79. {thoughtleaders_cli-0.6.38 → thoughtleaders_cli-0.6.40}/src/tl_cli/commands/reports.py +0 -0
  80. {thoughtleaders_cli-0.6.38 → thoughtleaders_cli-0.6.40}/src/tl_cli/commands/schema.py +0 -0
  81. {thoughtleaders_cli-0.6.38 → thoughtleaders_cli-0.6.40}/src/tl_cli/commands/snapshots.py +0 -0
  82. {thoughtleaders_cli-0.6.38 → thoughtleaders_cli-0.6.40}/src/tl_cli/commands/uploads.py +0 -0
  83. {thoughtleaders_cli-0.6.38 → thoughtleaders_cli-0.6.40}/src/tl_cli/commands/whoami.py +0 -0
  84. {thoughtleaders_cli-0.6.38 → thoughtleaders_cli-0.6.40}/src/tl_cli/config.py +0 -0
  85. {thoughtleaders_cli-0.6.38 → thoughtleaders_cli-0.6.40}/src/tl_cli/filters.py +0 -0
  86. {thoughtleaders_cli-0.6.38 → thoughtleaders_cli-0.6.40}/src/tl_cli/hints.py +0 -0
  87. {thoughtleaders_cli-0.6.38 → thoughtleaders_cli-0.6.40}/src/tl_cli/main.py +0 -0
  88. {thoughtleaders_cli-0.6.38 → thoughtleaders_cli-0.6.40}/src/tl_cli/output/__init__.py +0 -0
  89. {thoughtleaders_cli-0.6.38 → thoughtleaders_cli-0.6.40}/src/tl_cli/output/formatter.py +0 -0
  90. {thoughtleaders_cli-0.6.38 → thoughtleaders_cli-0.6.40}/tests/__init__.py +0 -0
  91. {thoughtleaders_cli-0.6.38 → thoughtleaders_cli-0.6.40}/tests/test_filters.py +0 -0
  92. {thoughtleaders_cli-0.6.38 → thoughtleaders_cli-0.6.40}/tests/test_output.py +0 -0
  93. {thoughtleaders_cli-0.6.38 → thoughtleaders_cli-0.6.40}/tests/test_reports.py +0 -0
  94. {thoughtleaders_cli-0.6.38 → thoughtleaders_cli-0.6.40}/tests/test_sponsorships.py +0 -0
  95. {thoughtleaders_cli-0.6.38 → thoughtleaders_cli-0.6.40}/uv.lock +0 -0
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tl-cli",
3
- "version": "0.6.38",
3
+ "version": "0.6.40",
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.38
3
+ Version: 0.6.40
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.38"
7
+ version = "0.6.40"
8
8
  description = "ThoughtLeaders CLI — query sponsorship data, channels, brands, and intelligence"
9
9
  readme = "README.md"
10
10
  license = "MIT"
@@ -151,44 +151,46 @@ Note that if you're working on Windows, you need to set up UTF-8 in the console,
151
151
 
152
152
  ### Data queries
153
153
  ```bash
154
- tl sponsorships list [filters...] # Sponsorships — list curve, mult 1.0
155
- tl sponsorships show <id> # Sponsorship detail (2 credits)
156
- tl sponsorships create --channel <id> --brand <id> # Create proposal (free)
157
- tl sponsorships update <id> '<json>' # Update a sponsorship (2 credits)
158
- tl deals list [filters...] # Shortcut: agreed-upon sponsorships (status:deal); same curve as sponsorships list
159
- tl deals show <id> # Deal detail (2 credits)
160
- tl matches list [filters...] # Shortcut: possible brand-channel pairings (status:match); same curve
161
- tl matches show <id> # Match detail (2 credits)
162
- tl matches create --channel <id> --brand <id> # Create match (free)
163
- tl proposals list [filters...] # Shortcut: proposed matches (status:proposal); same curve
164
- tl proposals show <id> # Proposal detail (2 credits)
165
- tl proposals create --channel <id> --brand <id> # Create proposal (free)
166
- tl uploads list [filters...] # Video uploads from ES — list curve, mult 1.0
167
- tl uploads show <id> # Upload detail (2 credits)
168
- tl channels show <id-or-name> # Channel detail (2 credits; accepts numeric ID or name) — for channel search use raw SQL on thoughtleaders_channel
169
- tl channels update <id> '<json>' # Update a channel (2 credits)
170
- tl channels history <id-or-name> # Sponsorship history (5 credits/result, linear)
171
- tl channels similar <id-or-name> # Similarity recommender (25 credits flat; Intelligence plan)
172
- tl brands show <id-or-name> # Brand detail (1 credit)
173
- tl brands history <id-or-name> # Sponsorship history (5 credits/result, linear)
154
+ tl sponsorships list [filters...] # Sponsorships
155
+ tl sponsorships show <id> # Sponsorship detail
156
+ tl sponsorships create --channel <id> --brand <id> # Create proposal
157
+ tl sponsorships update <id> '<json>' # Update a sponsorship
158
+ tl deals list [filters...] # Shortcut: agreed-upon sponsorships (status:deal)
159
+ tl deals show <id> # Deal detail
160
+ tl matches list [filters...] # Shortcut: possible brand-channel pairings (status:match)
161
+ tl matches show <id> # Match detail
162
+ tl matches create --channel <id> --brand <id> # Create match
163
+ tl proposals list [filters...] # Shortcut: proposed matches (status:proposal)
164
+ tl proposals show <id> # Proposal detail
165
+ tl proposals create --channel <id> --brand <id> # Create proposal
166
+ tl uploads list [filters...] # Video uploads from ES
167
+ tl uploads show <id> # Upload detail
168
+ tl channels show <id-or-name> # Channel detail (accepts numeric ID or name) — for channel search use raw SQL on thoughtleaders_channel
169
+ 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)
170
+ tl channels update <id> '<json>' # Update a channel
171
+ tl channels history <id-or-name> # Sponsorship history
172
+ tl channels similar <id-or-name> # Similarity recommender (Intelligence plan)
173
+ tl brands show <id-or-name> # Brand detail
174
+ tl brands find <query> # Resolve a string to {id, name}; matches name, slug, domain, or keyword
175
+ tl brands history <id-or-name> # Sponsorship history
174
176
  tl brands history <query> --channel <id> # Brand mentions on specific channel
175
- tl brands history-stats <id-or-name> # Aggregate roll-up: counts, total/avg/median views, first/last seen, by-year, top channels (5 credits flat)
177
+ tl brands history-stats <id-or-name> # Aggregate roll-up: counts, total/avg/median views, first/last seen, by-year, top channels
176
178
  tl brands history-stats <q> --channel <id> # Same roll-up, narrowed to one channel
177
- tl brands similar <id-or-name> # Find similar brands via similarity search (25 credits flat)
178
- tl recommender tags [query] # List similarity tag names — categories, demographics, formats (free)
179
- tl recommender top-channels "<tag>" # Top channels loaded on a similarity tag (25 credits; Intelligence)
180
- tl recommender top-profiles "<tag>" # Top brand profiles loaded on a similarity tag (25 credits)
181
- tl recommender top-brands "<tag>" # Top brands (deduped from profiles) loaded on a similarity tag (25 credits)
182
- tl recommender inspect-channel <ref> # Show a channel's similarity-profile breakdown (25 credits; Intelligence)
183
- tl recommender inspect-brand <ref> # Show a brand profile's ideal similarity-profile breakdown (25 credits; Intelligence)
184
- tl recommender similar-to-profile <id> # Channels closest to a brand profile's ideal profile (25 credits; Intelligence)
179
+ tl brands similar <id-or-name> # Find similar brands via similarity search
180
+ tl recommender tags [query] # List similarity tag names — categories, demographics, formats
181
+ tl recommender top-channels "<tag>" # Top channels loaded on a similarity tag (Intelligence)
182
+ tl recommender top-profiles "<tag>" # Top brand profiles loaded on a similarity tag
183
+ tl recommender top-brands "<tag>" # Top brands (deduped from profiles) loaded on a similarity tag
184
+ tl recommender inspect-channel <ref> # Show a channel's similarity-profile breakdown (Intelligence)
185
+ tl recommender inspect-brand <ref> # Show a brand profile's ideal similarity-profile breakdown (Intelligence)
186
+ tl recommender similar-to-profile <id> # Channels closest to a brand profile's ideal profile (Intelligence)
185
187
  tl snapshots channel <id> # Channel metrics over time (Firebolt-backed)
186
188
  tl snapshots video <id> --channel <id> # Video view curve (--channel required!)
187
189
  tl reports # List saved reports
188
- tl reports run <id> # Run a saved report (credits vary)
190
+ tl reports run <id> # Run a saved report
189
191
  tl <entity> comment-list <id> # List comments on a sponsorship/channel/brand/upload
190
- tl <entity> comment-add <id> "msg" # Add a comment (free)
191
- tl <entity> comment-edit <comment-id> "msg" # Edit own comment (author or superuser; free)
192
+ tl <entity> comment-add <id> "msg" # Add a comment
193
+ tl <entity> comment-edit <comment-id> "msg" # Edit own comment (author or superuser)
192
194
  ```
193
195
 
194
196
  **Credit costs are server-authoritative — run `tl describe` (overview) or `tl describe show <resource>` (one resource) to see the current rates and multipliers for every endpoint. Do not memorise rate values — they change.**
@@ -211,6 +213,86 @@ tl channels update 12345 '{"demographic_male_share": 55, "demographic_usa_share"
211
213
 
212
214
  Each call costs 2 credits. If a request is rejected with a 400, the response body names the offending key — read it and retry with a smaller body. If the user wants to edit something the API rejects, the change has to be made in the app or by a human with DB access.
213
215
 
216
+ ### Creating and vetting sponsorships
217
+
218
+ This is the end-to-end workflow for proposing a sponsorship, then moving it through the funnel as the two sides respond. Three create commands plus `tl sponsorships update` cover every state transition the CLI exposes.
219
+
220
+ #### Creating a sponsorship
221
+
222
+ `tl sponsorships create` always creates the adlink in **proposed** status. Use the `tl matches create` or `tl proposals create` shortcuts when you specifically want a `matched` adlink or want a clearer log of intent — they share the same backend and accept the same flags.
223
+
224
+ ```bash
225
+ # Minimum: channel ID + brand ID. Creates a proposal (publish_status=PREVIEW=0).
226
+ tl sponsorships create --channel 5607 --brand 11459
227
+
228
+ # Optional price (USD):
229
+ tl sponsorships create --channel 5607 --brand 11459 --price 2500
230
+
231
+ # Short flags:
232
+ tl sponsorships create -c 5607 -b 11459 -p 2500
233
+
234
+ # JSON body — same shape as the server expects. Use this form when the
235
+ # fields come from another tool, or when scripting:
236
+ tl sponsorships create '{"channel_id": 5607, "brand_id": 11459, "price": 2500}'
237
+
238
+ # Capture the new adlink id for follow-up update calls:
239
+ tl sponsorships create -c 5607 -b 11459 --json | jq -r '.results[0].sponsorship_id'
240
+
241
+ # Shortcuts (delegated to the same server endpoint with a preset status):
242
+ tl matches create -c 5607 -b 11459 # creates with publish_status=matched (7)
243
+ tl proposals create -c 5607 -b 11459 # creates with publish_status=proposed (0)
244
+ ```
245
+
246
+ Required: `--channel/-c <int>`, `--brand/-b <int>` (or the equivalent keys in the JSON body). Optional: `--price/-p <float>`, `--json`, `--toon`. **JSON and command-line flags are mutually exclusive on `tl sponsorships create` — pass one form or the other, never both.** The JSON body accepts `channel_id`, `brand_id`, `price`, and optionally `status`; defaults to `status: "proposed"` if omitted. Returns the created adlink with a `tl sponsorships show <id>` hint.
247
+
248
+ The adlink is owned by the **brand's** advertiser profile (not the calling user's profile) and its `list` FK is set to the requested brand — so the new sponsorship appears under the brand's pipeline, not the AM's.
249
+
250
+ #### Vetting (state transitions via `tl sponsorships update`)
251
+
252
+ After a sponsorship exists, `tl sponsorships update <id> '<json>'` is the single CLI lever for moving it through the funnel. The interesting transitions for vetting are below — pass `publish_status` as a string label (the integer code also works but the label is clearer for both humans and the audit log).
253
+
254
+ | Action | Command | What it means |
255
+ |---|---|---|
256
+ | **Accept** (either side, in negotiation) | `tl sponsorships update <id> '{"publish_status": "pending"}'` | Moves the adlink to `PENDING` — both sides are working it but it's not yet sold. |
257
+ | **Mark sold** (deal finalised) | `tl sponsorships update <id> '{"publish_status": "sold"}'` | Final commercial step. Sets purchase semantics server-side. |
258
+ | **Reject — Advertiser side** | `tl sponsorships update <id> '{"publish_status": "advertiser_reject"}'` | Maps to `DENY` ("Rejected by Advertiser"). Use when the *brand* turns the offer down. |
259
+ | **Reject — Publisher side** | `tl sponsorships update <id> '{"publish_status": "publisher_reject"}'` | Maps to `REJECT` ("Rejected by Publisher"). Use when the *channel* turns the offer down. |
260
+ | **Reject — Agency** | `tl sponsorships update <id> '{"publish_status": "agency_reject"}'` | When an agency intermediary kills the deal. |
261
+
262
+ **Choosing the right rejection label** — match the label to the side actually rejecting:
263
+
264
+ - The CLI caller is acting for / on behalf of an **Advertiser** (Brand) → use `advertiser_reject`.
265
+ - The CLI caller is acting for / on behalf of a **Publisher** (Channel) → use `publisher_reject`.
266
+ - If you don't know which side, ask before running the update — the two labels are not interchangeable downstream (they drive different reporting and different KPIs).
267
+
268
+ **`rejection_reason` is mandatory whenever you set `publish_status` to any rejection label** (`advertiser_reject`, `publisher_reject`, or `agency_reject`). Do not issue a rejection update without it — a rejection with no reason is treated as an incomplete record by downstream reporting and AM workflows. If the user hasn't given you a reason, ask before running the update. Add `rejection_reason_details` whenever the user gives you more context — it's free-form supporting text and is fine to omit when the short `rejection_reason` is self-evident.
269
+
270
+ ```bash
271
+ tl sponsorships update 98765 '{
272
+ "publish_status": "advertiser_reject",
273
+ "rejection_reason": "off-brand audience",
274
+ "rejection_reason_details": "Brand wants 18-34 male; channel skews 35-54 female"
275
+ }'
276
+ ```
277
+
278
+ Full set of `publish_status` labels the CLI accepts: `proposed`, `unavailable`, `pending`, `sold`, `advertiser_reject`, `publisher_reject`, `proposal_approved`, `matched`, `outreach`, `agency_reject`. Numeric codes (0–9) are also accepted but labels are preferred.
279
+
280
+ #### Worked example: propose → reject
281
+
282
+ ```bash
283
+ # 1. Create the proposal and capture its id.
284
+ sid=$(tl sponsorships create -c 5607 -b 11459 -p 2500 --json | jq -r '.results[0].sponsorship_id')
285
+
286
+ # 2. Later, the brand declines. rejection_reason is mandatory.
287
+ tl sponsorships update "$sid" '{
288
+ "publish_status": "advertiser_reject",
289
+ "rejection_reason": "budget cut for Q3"
290
+ }'
291
+
292
+ # 3. Verify final state.
293
+ tl sponsorships show "$sid" --json | jq '{id, status, rejection_reason}'
294
+ ```
295
+
214
296
  ### Raw queries (`tl db`)
215
297
 
216
298
  `tl db pg|fb|es` is the default tool. Reach for it whenever the question is anything beyond a trivially simple lookup — and use the structured commands only for those trivial cases (single-record `show`, plain filtered `list`). Don't paginate-and-reduce in your head when one SQL or ES body would do it server-side.
@@ -477,6 +559,21 @@ tl reports --json # Find the report ID first
477
559
  tl reports run 42 --json
478
560
  ```
479
561
 
562
+ "Look up a channel or brand from whatever the user pasted":
563
+ ```bash
564
+ # Channel: accepts name, slug, YouTube channel URL, handle (@…), raw channel ID
565
+ # (UC…), or any video URL. On ambiguity returns 400 with candidate {id, name};
566
+ # on an unrecognised YouTube URL it queues a scrape and returns 404 with the
567
+ # QueuedChannel record so the caller knows to retry later.
568
+ tl channels find "https://www.youtube.com/@MrBeast"
569
+ tl channels find "https://www.youtube.com/watch?v=dQw4w9WgXcQ"
570
+ tl channels find UCX6OQ3DkcsbYNE6H8uQQuVA
571
+
572
+ # Brand: matches name, slug, website domain, or any keyword in kw/keywords.
573
+ tl brands find nike.com
574
+ tl brands find "Just Do It"
575
+ ```
576
+
480
577
  "Find Cooking channels with US-heavy mobile audiences":
481
578
  ```bash
482
579
  # Use the recommender for the topic, then narrow with structured filters / SQL on the IDs.
@@ -13,6 +13,8 @@ description: |
13
13
 
14
14
  Save-intent variants ("save a campaign of …", "create the report …", "make a TL report for …") trigger auto-save; everything else previews. Off-taxonomy keywords ("crypto / Web3"), brand-exclusion logic ("not pitched to X"), demographic floors ("US audience ≥30%"), TPP/MSN scoping, and competitive-pitch shapes are all this skill's job — not the general `tl-cli:tl` data-analyst skill.
15
15
 
16
+ **Post-save refinements default to ASKING** — when the user's follow-up arrives after a successful save AND the topic overlaps with the prior save (same brand / niche / report type), the skill MUST surface the choice between updating the existing report, saving a separate variant, or treating as a fresh save. Refinement vocabulary (*instead*, *change*, *add*, *limit*, *only*, *filter*, *make it X*, etc.) strengthens the trigger but its absence does NOT bypass it — topic overlap alone is enough to ask. Do NOT auto-create a new report on every refinement-shaped prompt in a session. **Note**: the CLI edit endpoint can only patch campaign-level fields (title, description, columns, widgets, etc.); FilterSet changes (keywords, filters, demographics, cross-references) cannot be updated in place — those route to "save as a new variant" instead. See "Editing a saved report" in the body for the routing decision table + mechanics.
17
+
16
18
  **Skip this skill** for:
17
19
  - counts, metrics, trends, single-record show-by-ID lookups, raw exploratory queries, or analytical questions that aren't shaped as "give me a list" → route to `tl-cli:tl`.
18
20
  - **explicit intent to import a list of identifiers into a report — existing or new.** The routing test is the **user's import intent**, NOT the mere presence of a list. A user can paste 50 channel URLs and want analysis, comparison, similar-channel discovery, or filtered lookup — those still belong here (or in `tl-cli:tl`), not in tl-import. They can also paste 50 URLs and want exactly those channels to land in a report as-given — that is import, route to `tl-cli:tl-import`. The deciding question: *"Would the user be satisfied if the listed entities simply ended up as the report's contents exactly as-given, no transformation?"* If yes → import intent → `tl-cli:tl-import`. If they expect filtering, analysis, similarity expansion, or any other transformation on top of the list → it's not import, keep it here.
@@ -213,6 +215,93 @@ Same architecture, different intent. The prompt is exploratory; the policy says
213
215
 
214
216
  If the user replies *"yes save it"* or *"save"* → run the save step (resolve a portable temp path → write → verify → invoke `tl reports create --config-file <that-exact-path> --yes`; see Save-or-preview policy step 1+2 for the full mechanics) using the **same config that's already in working memory**. Don't re-run Phases 1–4. The follow-up reply is just the takeaways + saved-report link.
215
217
 
218
+ ### Editing a saved report (post-save refinement flow)
219
+
220
+ When the user's follow-up after a successful save is a **refinement** of the report we just created — *"change X to Y"*, *"use Z instead"*, *"add an A column"*, *"sort by last published"*, *"rename the report"* — the right response depends on **what** the user wants to change. The CLI edit endpoint accepts only a narrow set of fields; FilterSet changes need a different path.
221
+
222
+ **What `tl reports update` CAN edit today** (campaign-level fields only):
223
+
224
+ - `title`, `description`, `report_type`, `type`
225
+ - `columns`, `widgets`, `histogram_bucket_size`
226
+ - `emoji`, `display_mode`
227
+ - `owner`, `subscribers`, `link`
228
+ - `webhook_url`, `notifications_on`, `message_template`
229
+
230
+ **What `tl reports update` does NOT edit** (the backend explicitly rejects these — `CliReportEditView` docstring: *"Filterset edits are not supported here"*):
231
+
232
+ - `filterset` and any of its fields (`keywords`, `keyword_groups`, `channels`, `brands`, `start_date`, `end_date`, `days_ago`, `msn_channels_only`, `creator_countries`, etc.)
233
+ - `filters_json` (the catch-all containing `publish_status`, `ad_publish_status`, etc.)
234
+ - `cross_references`
235
+
236
+ The backend's `_reject_unknown_fields` validation is **atomic** — if the update payload contains any unsupported key (e.g. `filterset`), the WHOLE request returns HTTP 400, including any legitimate field edits in the same payload. The skill must therefore send ONLY editable fields in the patch, never the full working-memory config.
237
+
238
+ **Routing decision** — what kind of refinement the user is asking for:
239
+
240
+ | User wants to change | Editable via `tl reports update`? | Skill's action |
241
+ |---|---|---|
242
+ | Title, description, emoji, display mode | ✅ Yes | Update in place via the mechanics below |
243
+ | Columns (add/remove/reorder) | ✅ Yes | Update in place |
244
+ | Widgets, histogram bucket size | ✅ Yes | Update in place |
245
+ | Subscribers, webhook, notifications | ✅ Yes | Update in place |
246
+ | **Any filter field** — keywords, brands, channels, language, date range, MSN flag, demographics, cross_references, filters_json | ❌ **No** | **Cannot update in place.** Tell the user: *"The CLI edit endpoint can't patch FilterSet fields today (server-side limitation — `CliReportEditView`'s filterset path isn't wired up). I can save this as a new variant of the report instead — same shape with the filter change applied — and link it back to the original. OK to proceed?"* On confirmation: run Phase 1–4 with the prior config as the starting point + the user's filter delta, save as a NEW report. NOT an edit.
247
+
248
+ **Recognition** — treat the follow-up as an edit candidate based on these signals:
249
+
250
+ 1. The most recent terminal action in this session was a successful `tl reports create` invocation. The resulting `report_id` and the full config it wrote are in working memory.
251
+ 2. The new prompt's topic overlaps with the prior save — same report type, same primary brand / niche / channel set, same competitive frame. (If the new prompt opens a fundamentally different topic, it's a new save.)
252
+ 3. The new prompt contains a **refinement signal** — broadly defined. Refinement signals include:
253
+ - **Refinement vocabulary**: *instead*, *change*, *swap*, *drop*, *add*, *tighter*, *broader*, *narrower*, *without*, *except*, *now with*, *but with*, *use … instead of …*
254
+ - **Filter / sort modifiers**: *filter*, *limit*, *only*, *remove*, *replace*, *include*, *exclude*, *sort by [field]*
255
+ - **"Make it X" framings**: *make it [country]-only*, *make it [category]-only*, *make it [demographic-shape]*
256
+ - **Partial-filter prompts** that name a single filter axis without naming a new topic: *"with AdSpot price < $2K"* after a save on the same niche
257
+
258
+ **Decision rule** (not binary — ask when in doubt):
259
+
260
+ | 1 (prior save) | 2 (topic overlap) | 3 (refinement signal) | Action |
261
+ |---|---|---|---|
262
+ | ✓ | ✓ | strongly fires | Post-save trigger fires (Follow-Up Interactions). Ask update vs. variant; default-highlight **update**. |
263
+ | ✓ | ✓ | ambiguous OR absent | **Still ask** — topic overlap alone is reason enough to surface the choice. Default-highlight update; user can override to "save a separate variant" or "it's a new report." |
264
+ | ✓ | ✗ | n/a | Different topic → treat as new save; no clarifier needed. |
265
+ | ✗ | n/a | n/a | No prior save in working memory → standard preview/save flow; nothing to edit. |
266
+
267
+ The failure mode the skill is preventing: silent auto-create on every refinement prompt, producing N duplicate reports instead of one updated report. A clarifier is one ignorable line if the user wanted a new variant anyway; the cost of getting it wrong is the duplicate.
268
+
269
+ **Mechanics — when the user confirms "update the existing one" AND the change is in the editable-field whitelist above:**
270
+
271
+ 1. **Source the current config from working memory.** Recognition criterion 1 requires the prior save happened in this session, so the full config the skill emitted is still in working memory. **The CLI does NOT today expose a `tl reports show` / refetch command** — if for any reason the prior config is NOT in working memory (session was cleared, or the user is editing a report from a different session by ID), STOP and ask the user to (a) confirm the `report_id` and (b) describe the specific change. Do NOT guess at the current config.
272
+ 2. **Compose the patch — ONLY editable fields.** Build a JSON object containing **only the fields the user explicitly asked to change**, drawn exclusively from the editable-field whitelist (title, description, columns, widgets, histogram_bucket_size, emoji, display_mode, subscribers, webhook_url, notifications_on, message_template, report_type, type, owner, link). **Do NOT send the full working-memory config** — it contains `filterset`, `cross_references`, `filters_json`, and other create-only fields that the backend's `_reject_unknown_fields` validation will atomically 400 even if the user's actual edit is to a legitimate field like `columns`.
273
+ 3. **Resolve a portable temp path** for the patch JSON (same mechanics as save — `python -c "import tempfile, os; print(...)"`). Hard-coding `/tmp/` fails on Windows.
274
+ 4. **Write the patch and verify** — write the JSON to the resolved path, then `test -f <path>` before invoking the CLI.
275
+ 5. **Invoke the update** — `tl reports update <report_id> "$(cat <that-exact-path>)"`. The `update` command's second positional argument is a JSON object of fields to update (not a `--config-file` flag — that flag is only on `tl reports create`, not `update`). Passing inline as `'<json>'` breaks on apostrophes in brand names; the safe shape is `"$(cat <path>)"` against the verified temp file.
276
+ 6. **Reply** with the updated report's URL + a one-line summary of what changed (*"changed: columns added Y; description rewritten"*). Same wording rules as the save-success message — say "TL report" not "Campaign", say "report #N" not "Campaign #N".
277
+
278
+ **Patch shape — what the JSON should and shouldn't contain:**
279
+
280
+ ```json
281
+ // ✅ Correct: only editable fields the user changed
282
+ {
283
+ "columns": { "channel_name": true, "subscribers": true, "last_published": true, "outreach_email": true },
284
+ "description": "Updated description text"
285
+ }
286
+
287
+ // ❌ Wrong: merged full config — `filterset` and `cross_references` will atomically 400 the request
288
+ {
289
+ "title": "...", "description": "...", "filterset": {...}, "cross_references": [...], "columns": {...}
290
+ }
291
+ ```
292
+
293
+ **Anti-patterns to avoid** (real failure shape pinned here — agent has been observed producing four saves in eight minutes for what should have been one create + three updates; subsequent verification of the CLI edit endpoint surfaced additional constraints):
294
+
295
+ - ❌ Running Phase 1–4 from scratch on a *non-filter* refinement (column/title/widget change). The phases trust working memory; rerunning them composes a parallel config rather than patching the existing one.
296
+ - ❌ Calling `tl reports create` instead of `tl reports update` after a save when the new prompt is a column/widget/title refinement. Pick by intent shape, not by reflex.
297
+ - ❌ Sending the full working-memory config as the `tl reports update` payload. The endpoint's `_reject_unknown_fields` validation is atomic — including `filterset`, `cross_references`, or `filters_json` in the payload 400s the WHOLE request, even if the user's actual edit was to a legitimate field like `columns`. Send ONLY editable fields.
298
+ - ❌ Attempting to patch any FilterSet field (keywords, brands, channels, filters_json, msn_channels_only, demographics, date ranges, cross_references, etc.) via `tl reports update`. The backend explicitly rejects these — `CliReportEditView` docstring: *"Filterset edits are not supported here."* For FilterSet changes, route the user to "save as a new variant" (run Phase 1–4 with the prior config as starting point + the user's delta, save as a NEW report). Do NOT fabricate a working `tl reports update` call with a filterset payload.
299
+ - ❌ Asserting that ALL editing is impossible (the opposite over-correction). Campaign-level fields — title, description, columns, widgets, histogram_bucket_size, emoji, display_mode, subscribers, webhook, notifications — ARE editable. Don't tell the user *"the CLI can't edit anything on a saved report"*; the truth is *"the CLI can edit these fields, not these others."*
300
+ - ❌ Inventing a `tl reports show` command to refetch config. That command does not exist today; if config isn't in working memory, ask the user, don't fabricate a fetch step.
301
+ - ❌ Passing the patch via a `--config-file` flag on `tl reports update`. That flag exists only on `tl reports create`. For `update`, the JSON goes as the second positional argument.
302
+
303
+ When in doubt about whether a follow-up is an edit, a new variant, or a fresh save, ask. The clarifier is one ignorable line if the user wanted a new variant anyway; the cost of getting it wrong is a duplicate report or a 400 the user has to debug.
304
+
216
305
  What changes between save-mode and preview-mode:
217
306
 
218
307
  | | Save (explicit intent) | Preview (default) |
@@ -1641,6 +1730,7 @@ Every phase has explicit conditions where it must pause and ask the user, rather
1641
1730
  | **3** | No columns provided AND no clear intent | "I'll use [type]'s default set unless you want a different focus (outreach / discovery / sponsorship-pitch)" |
1642
1731
  | **4** | Aggregation/widget preferences need confirmation | "Default widgets for this report type are [list]; want to add/remove anything?" |
1643
1732
  | **4** | Final JSON-shape validation surfaced unresolved issues | "Can't ship config because [reason]. Fix [thing]?" |
1733
+ | **Post-save** | New prompt arrives after a successful save AND the topic overlaps with the prior save (same brand / niche / report type). Refinement signals strengthen the trigger but their absence does NOT bypass it when topic overlap is strong — refinement signals include vocabulary (*instead*, *change*, *swap*, *drop*, *add*, *tighter*, *broader*, *narrower*, *without*, *except*, *now with*, *but with*), filter/sort modifiers (*filter*, *limit*, *only*, *remove*, *replace*, *include*, *exclude*, *sort by …*), "make it X" framings, or partial-filter prompts that name a single filter axis without naming a new topic. | *"Looks like a refinement of the report you just created (#N — `<title>`). Update it in place, or save a separate variant?"* — default-highlight the update option; user can override. See "Editing a saved report" subsection above for the full mechanics. |
1644
1734
 
1645
1735
  Skills that follow up are skills users trust. Silent assumptions are silent regressions.
1646
1736
 
@@ -1,3 +1,3 @@
1
1
  """ThoughtLeaders CLI — query sponsorship data, channels, brands, and intelligence."""
2
2
 
3
- __version__ = "0.6.38"
3
+ __version__ = "0.6.40"
@@ -0,0 +1,130 @@
1
+ """Auth CLI commands: tl auth login/logout/status."""
2
+
3
+ import time
4
+
5
+ import typer
6
+ from rich.console import Console
7
+ from rich.prompt import Prompt
8
+
9
+ from tl_cli.auth.finalize import finalize_signup
10
+ from tl_cli.auth.login import login_browser, login_device_code
11
+ from tl_cli.auth.token_store import KIND_API_KEY, StoredTokens, clear_tokens, load_tokens, save_tokens
12
+
13
+ app = typer.Typer(help="Authentication commands")
14
+ console = Console(stderr=True)
15
+
16
+
17
+ @app.command("login", help="Log in to ThoughtLeaders.")
18
+ def login_cmd() -> None:
19
+ """Log in to ThoughtLeaders.
20
+
21
+ The default flow opens a browser on this machine for OAuth2 (Auth0).
22
+ A device-code flow is available for headless environments, and a
23
+ pre-issued API key can be configured for CI/scripts.
24
+ """
25
+ console.print("[bold]How would you like to authenticate?[/bold]")
26
+ console.print(
27
+ " [cyan]1[/cyan] — OAuth2 in a browser on this machine "
28
+ "[dim](default — opens a URL in the local browser)[/dim]"
29
+ )
30
+ console.print(" [cyan]2[/cyan] — Device code (use a browser on another device)")
31
+ console.print(" [cyan]3[/cyan] — API key (paste a pre-issued key; for CI / non-interactive use)")
32
+ console.print()
33
+ choice = Prompt.ask("Choose", choices=["1", "2", "3"], default="1", console=console)
34
+
35
+ if choice == "3":
36
+ _login_api_key()
37
+ return
38
+
39
+ if choice == "2":
40
+ login_device_code()
41
+ else:
42
+ login_browser()
43
+
44
+ finalize_signup()
45
+
46
+
47
+ def _login_api_key() -> None:
48
+ """Store a user-supplied API key as the active credential.
49
+
50
+ No browser, no finalize call — the server has already issued this key
51
+ against an existing user/organization. The stored record is tagged
52
+ `kind=api_key` so the HTTP client sends `X-TL-Auth: API-KEY` on every
53
+ request. We immediately call /whoami to (a) verify the key is valid and
54
+ (b) capture the owning user's email for `tl auth status` output.
55
+ """
56
+ # Imported here to avoid pulling httpx/keyring into the module-level
57
+ # import graph when callers just want `tl auth logout` / `status`.
58
+ from tl_cli.client.errors import ApiError
59
+ from tl_cli.client.http import get_client
60
+
61
+ key = Prompt.ask("Paste your API key", console=console, password=True).strip()
62
+ if not key:
63
+ console.print("[red]No key provided.[/red]")
64
+ raise typer.Exit(1)
65
+
66
+ save_tokens(
67
+ StoredTokens(
68
+ access_token=key,
69
+ refresh_token=None,
70
+ expires_at=time.time() + 10 * 365 * 24 * 3600,
71
+ email=None,
72
+ kind=KIND_API_KEY,
73
+ )
74
+ )
75
+
76
+ client = get_client()
77
+ try:
78
+ data = client.get("/whoami")
79
+ except ApiError as e:
80
+ clear_tokens()
81
+ console.print(f"[red]API key rejected:[/red] {e.detail}")
82
+ raise typer.Exit(1)
83
+ finally:
84
+ client.close()
85
+
86
+ email = (data.get("user") or {}).get("email")
87
+ if not email:
88
+ clear_tokens()
89
+ console.print(
90
+ "[red]API key accepted but the server returned no email for the owning user.[/red] "
91
+ "This usually means the user record is incomplete — contact support."
92
+ )
93
+ raise typer.Exit(1)
94
+
95
+ save_tokens(
96
+ StoredTokens(
97
+ access_token=key,
98
+ refresh_token=None,
99
+ expires_at=time.time() + 10 * 365 * 24 * 3600,
100
+ email=email,
101
+ kind=KIND_API_KEY,
102
+ )
103
+ )
104
+ console.print(f"[green]API key stored.[/green] Authenticated as: {email}")
105
+
106
+
107
+ @app.command("logout")
108
+ def logout_cmd() -> None:
109
+ """Clear stored authentication tokens."""
110
+ clear_tokens()
111
+ console.print("[green]Logged out successfully.[/green]")
112
+
113
+
114
+ @app.command("status")
115
+ def status_cmd() -> None:
116
+ """Show current authentication status."""
117
+ tokens = load_tokens()
118
+ if not tokens:
119
+ console.print("[yellow]Not logged in.[/yellow] Run: tl auth login")
120
+ raise SystemExit(2)
121
+
122
+ if tokens.is_expired:
123
+ console.print(f"[yellow]Token expired.[/yellow] Logged in as: {tokens.email or 'unknown'}")
124
+ console.print("Run: tl auth login")
125
+ raise SystemExit(2)
126
+
127
+ if tokens.is_api_key:
128
+ console.print("[green]Authenticated[/green] via API key.")
129
+ else:
130
+ console.print(f"[green]Authenticated[/green] as: {tokens.email or 'unknown'}")
@@ -14,26 +14,44 @@ SERVICE_NAME = "tl-cli"
14
14
  FALLBACK_FILE = "credentials.json"
15
15
 
16
16
 
17
+ KIND_BEARER = "bearer"
18
+ KIND_API_KEY = "api_key"
19
+
20
+
17
21
  @dataclass
18
22
  class StoredTokens:
19
- """Tokens stored in the keychain."""
23
+ """Auth credentials stored in the keychain.
24
+
25
+ `kind` distinguishes the OAuth2/Auth0 access-token flow ("bearer") from
26
+ a long-lived API key flow ("api_key"). API keys don't expire client-side
27
+ and have no refresh token — `refresh_token` and `expires_at` stay unset
28
+ in that case.
29
+ """
20
30
 
21
31
  access_token: str
22
32
  refresh_token: str | None
23
- expires_at: float # Unix timestamp
33
+ expires_at: float # Unix timestamp; 0 for API keys
24
34
  email: str | None = None
35
+ kind: str = KIND_BEARER
25
36
 
26
37
  @property
27
38
  def is_expired(self) -> bool:
39
+ if self.kind == KIND_API_KEY:
40
+ return False
28
41
  # 5-minute buffer before actual expiry
29
42
  return time.time() > (self.expires_at - 300)
30
43
 
44
+ @property
45
+ def is_api_key(self) -> bool:
46
+ return self.kind == KIND_API_KEY
47
+
31
48
  def to_json(self) -> str:
32
49
  return json.dumps({
33
50
  "access_token": self.access_token,
34
51
  "refresh_token": self.refresh_token,
35
52
  "expires_at": self.expires_at,
36
53
  "email": self.email,
54
+ "kind": self.kind,
37
55
  })
38
56
 
39
57
  @classmethod
@@ -42,8 +60,9 @@ class StoredTokens:
42
60
  return cls(
43
61
  access_token=parsed["access_token"],
44
62
  refresh_token=parsed.get("refresh_token"),
45
- expires_at=parsed["expires_at"],
63
+ expires_at=parsed.get("expires_at") or 0,
46
64
  email=parsed.get("email"),
65
+ kind=parsed.get("kind", KIND_BEARER),
47
66
  )
48
67
 
49
68
 
@@ -67,15 +67,24 @@ class TLClient:
67
67
  return response.json()
68
68
 
69
69
  def _auth_headers(self) -> dict[str, str]:
70
- """Get authorization headers from API key or stored tokens."""
71
- # API key takes priority (for CI/scripts)
70
+ """Get authorization headers from API key env var or stored credentials."""
71
+ # API key env var takes priority (for CI/scripts)
72
72
  if self._config.api_key:
73
- return {"Authorization": f"Bearer {self._config.api_key}"}
73
+ return {
74
+ "Authorization": f"Bearer {self._config.api_key}",
75
+ "X-TL-Auth": "API-KEY",
76
+ }
74
77
 
75
78
  tokens = load_tokens()
76
79
  if not tokens:
77
80
  raise ApiError(401, "Not authenticated. Run: tl auth login")
78
81
 
82
+ if tokens.is_api_key:
83
+ return {
84
+ "Authorization": f"Bearer {tokens.access_token}",
85
+ "X-TL-Auth": "API-KEY",
86
+ }
87
+
79
88
  if tokens.is_expired and tokens.refresh_token:
80
89
  tokens = refresh_access_token(tokens.refresh_token)
81
90
 
@@ -1,5 +1,6 @@
1
1
  """tl brands — Brand detail and sponsorship history."""
2
2
 
3
+ import json as _json
3
4
  import urllib.parse
4
5
 
5
6
  import typer
@@ -164,6 +165,39 @@ def history_stats_cmd(
164
165
  client.close()
165
166
 
166
167
 
168
+ @app.command("find")
169
+ def find_cmd(
170
+ query: str = typer.Argument(..., help="Brand name, slug, domain, or keyword"),
171
+ ) -> None:
172
+ """Resolve a string to a single brand and print {id, name} as JSON.
173
+
174
+ 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.
177
+
178
+ Examples:
179
+ tl brands find Nike
180
+ tl brands find nike.com
181
+ tl brands find https://www.nike.com/
182
+ tl brands find 21416
183
+ """
184
+ client = get_client()
185
+ try:
186
+ data = client.get("/brands/find", params={"q": query})
187
+ results = data.get("results", [])
188
+ record = results[0] if results else {}
189
+ print(_json.dumps({"id": record.get("id"), "name": record.get("name")}, ensure_ascii=False))
190
+ except ApiError as e:
191
+ if e.status_code == 400 and isinstance(e.raw, dict) and e.raw.get("candidates"):
192
+ err = Console(stderr=True)
193
+ err.print(f"[yellow]{e.detail}[/yellow]")
194
+ print(_json.dumps({"error": e.detail, "candidates": e.raw["candidates"]}, ensure_ascii=False))
195
+ raise typer.Exit(1)
196
+ handle_api_error(e)
197
+ finally:
198
+ client.close()
199
+
200
+
167
201
  SIMILAR_COLUMNS = ["score", "brand_id", "brand_name", "website", "mbn"]
168
202
  SIMILAR_COLUMN_CONFIG = {
169
203
  "score": {"justify": "right"},