thoughtleaders-cli 0.6.38__tar.gz → 0.6.39__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.
- {thoughtleaders_cli-0.6.38 → thoughtleaders_cli-0.6.39}/.claude-plugin/plugin.json +1 -1
- {thoughtleaders_cli-0.6.38 → thoughtleaders_cli-0.6.39}/PKG-INFO +1 -1
- {thoughtleaders_cli-0.6.38 → thoughtleaders_cli-0.6.39}/pyproject.toml +1 -1
- {thoughtleaders_cli-0.6.38 → thoughtleaders_cli-0.6.39}/skills/tl/SKILL.md +129 -32
- {thoughtleaders_cli-0.6.38 → thoughtleaders_cli-0.6.39}/skills/tl-report-builder/SKILL.md +90 -0
- {thoughtleaders_cli-0.6.38 → thoughtleaders_cli-0.6.39}/src/tl_cli/__init__.py +1 -1
- thoughtleaders_cli-0.6.39/src/tl_cli/auth/commands.py +130 -0
- {thoughtleaders_cli-0.6.38 → thoughtleaders_cli-0.6.39}/src/tl_cli/auth/token_store.py +22 -3
- {thoughtleaders_cli-0.6.38 → thoughtleaders_cli-0.6.39}/src/tl_cli/client/http.py +12 -3
- {thoughtleaders_cli-0.6.38 → thoughtleaders_cli-0.6.39}/src/tl_cli/commands/brands.py +34 -0
- {thoughtleaders_cli-0.6.38 → thoughtleaders_cli-0.6.39}/src/tl_cli/commands/channels.py +51 -0
- {thoughtleaders_cli-0.6.38 → thoughtleaders_cli-0.6.39}/src/tl_cli/commands/sponsorships.py +49 -3
- thoughtleaders_cli-0.6.39/tests/test_auth.py +78 -0
- thoughtleaders_cli-0.6.39/tests/test_http_auth.py +62 -0
- thoughtleaders_cli-0.6.38/src/tl_cli/auth/commands.py +0 -57
- thoughtleaders_cli-0.6.38/tests/test_auth.py +0 -45
- {thoughtleaders_cli-0.6.38 → thoughtleaders_cli-0.6.39}/.claude-plugin/marketplace.json +0 -0
- {thoughtleaders_cli-0.6.38 → thoughtleaders_cli-0.6.39}/.github/workflows/python-publish.yml +0 -0
- {thoughtleaders_cli-0.6.38 → thoughtleaders_cli-0.6.39}/.gitignore +0 -0
- {thoughtleaders_cli-0.6.38 → thoughtleaders_cli-0.6.39}/AGENTS.md +0 -0
- {thoughtleaders_cli-0.6.38 → thoughtleaders_cli-0.6.39}/CLAUDE.md +0 -0
- {thoughtleaders_cli-0.6.38 → thoughtleaders_cli-0.6.39}/LICENSE +0 -0
- {thoughtleaders_cli-0.6.38 → thoughtleaders_cli-0.6.39}/README.md +0 -0
- {thoughtleaders_cli-0.6.38 → thoughtleaders_cli-0.6.39}/agents/tl-analyst.md +0 -0
- {thoughtleaders_cli-0.6.38 → thoughtleaders_cli-0.6.39}/docs/architecture.md +0 -0
- {thoughtleaders_cli-0.6.38 → thoughtleaders_cli-0.6.39}/hooks/hooks.json +0 -0
- {thoughtleaders_cli-0.6.38 → thoughtleaders_cli-0.6.39}/hooks/scripts/load-tl-skill.mjs +0 -0
- {thoughtleaders_cli-0.6.38 → thoughtleaders_cli-0.6.39}/hooks/scripts/post-usage.sh +0 -0
- {thoughtleaders_cli-0.6.38 → thoughtleaders_cli-0.6.39}/hooks/scripts/pre-check.sh +0 -0
- {thoughtleaders_cli-0.6.38 → thoughtleaders_cli-0.6.39}/skills/tl/references/business-glossary.md +0 -0
- {thoughtleaders_cli-0.6.38 → thoughtleaders_cli-0.6.39}/skills/tl/references/elasticsearch-schema.md +0 -0
- {thoughtleaders_cli-0.6.38 → thoughtleaders_cli-0.6.39}/skills/tl/references/firebolt-schema.md +0 -0
- {thoughtleaders_cli-0.6.38 → thoughtleaders_cli-0.6.39}/skills/tl/references/postgres-schema.md +0 -0
- {thoughtleaders_cli-0.6.38 → thoughtleaders_cli-0.6.39}/skills/tl-import/SKILL.md +0 -0
- {thoughtleaders_cli-0.6.38 → thoughtleaders_cli-0.6.39}/skills/tl-report-builder/examples/e2e_findings.md +0 -0
- {thoughtleaders_cli-0.6.38 → thoughtleaders_cli-0.6.39}/skills/tl-report-builder/examples/golden_queries.md +0 -0
- {thoughtleaders_cli-0.6.38 → thoughtleaders_cli-0.6.39}/skills/tl-report-builder/references/columns_brands.md +0 -0
- {thoughtleaders_cli-0.6.38 → thoughtleaders_cli-0.6.39}/skills/tl-report-builder/references/columns_channels.md +0 -0
- {thoughtleaders_cli-0.6.38 → thoughtleaders_cli-0.6.39}/skills/tl-report-builder/references/columns_content.md +0 -0
- {thoughtleaders_cli-0.6.38 → thoughtleaders_cli-0.6.39}/skills/tl-report-builder/references/columns_sponsorships.md +0 -0
- {thoughtleaders_cli-0.6.38 → thoughtleaders_cli-0.6.39}/skills/tl-report-builder/references/intelligence_filterset_schema.json +0 -0
- {thoughtleaders_cli-0.6.38 → thoughtleaders_cli-0.6.39}/skills/tl-report-builder/references/intelligence_widget_schema.json +0 -0
- {thoughtleaders_cli-0.6.38 → thoughtleaders_cli-0.6.39}/skills/tl-report-builder/references/report_glossary.md +0 -0
- {thoughtleaders_cli-0.6.38 → thoughtleaders_cli-0.6.39}/skills/tl-report-builder/references/sortable_columns.json +0 -0
- {thoughtleaders_cli-0.6.38 → thoughtleaders_cli-0.6.39}/skills/tl-report-builder/references/sponsorship_filterset_schema.json +0 -0
- {thoughtleaders_cli-0.6.38 → thoughtleaders_cli-0.6.39}/skills/tl-report-builder/references/sponsorship_widget_schema.json +0 -0
- {thoughtleaders_cli-0.6.38 → thoughtleaders_cli-0.6.39}/skills/tl-report-builder/references/widgets.md +0 -0
- {thoughtleaders_cli-0.6.38 → thoughtleaders_cli-0.6.39}/skills/tl-report-builder/tools/column_builder.md +0 -0
- {thoughtleaders_cli-0.6.38 → thoughtleaders_cli-0.6.39}/skills/tl-report-builder/tools/database_query.md +0 -0
- {thoughtleaders_cli-0.6.38 → thoughtleaders_cli-0.6.39}/skills/tl-report-builder/tools/keyword_research.md +0 -0
- {thoughtleaders_cli-0.6.38 → thoughtleaders_cli-0.6.39}/skills/tl-report-builder/tools/name_resolver.md +0 -0
- {thoughtleaders_cli-0.6.38 → thoughtleaders_cli-0.6.39}/skills/tl-report-builder/tools/sample_judge.md +0 -0
- {thoughtleaders_cli-0.6.38 → thoughtleaders_cli-0.6.39}/skills/tl-report-builder/tools/similar_channels.md +0 -0
- {thoughtleaders_cli-0.6.38 → thoughtleaders_cli-0.6.39}/skills/tl-report-builder/tools/topic_matcher.md +0 -0
- {thoughtleaders_cli-0.6.38 → thoughtleaders_cli-0.6.39}/skills/tl-report-builder/tools/widget_builder.md +0 -0
- {thoughtleaders_cli-0.6.38 → thoughtleaders_cli-0.6.39}/src/tl_cli/_completions.py +0 -0
- {thoughtleaders_cli-0.6.38 → thoughtleaders_cli-0.6.39}/src/tl_cli/auth/__init__.py +0 -0
- {thoughtleaders_cli-0.6.38 → thoughtleaders_cli-0.6.39}/src/tl_cli/auth/finalize.py +0 -0
- {thoughtleaders_cli-0.6.38 → thoughtleaders_cli-0.6.39}/src/tl_cli/auth/login.py +0 -0
- {thoughtleaders_cli-0.6.38 → thoughtleaders_cli-0.6.39}/src/tl_cli/auth/pkce.py +0 -0
- {thoughtleaders_cli-0.6.38 → thoughtleaders_cli-0.6.39}/src/tl_cli/client/__init__.py +0 -0
- {thoughtleaders_cli-0.6.38 → thoughtleaders_cli-0.6.39}/src/tl_cli/client/errors.py +0 -0
- {thoughtleaders_cli-0.6.38 → thoughtleaders_cli-0.6.39}/src/tl_cli/commands/__init__.py +0 -0
- {thoughtleaders_cli-0.6.38 → thoughtleaders_cli-0.6.39}/src/tl_cli/commands/_comments_common.py +0 -0
- {thoughtleaders_cli-0.6.38 → thoughtleaders_cli-0.6.39}/src/tl_cli/commands/ask.py +0 -0
- {thoughtleaders_cli-0.6.38 → thoughtleaders_cli-0.6.39}/src/tl_cli/commands/balance.py +0 -0
- {thoughtleaders_cli-0.6.38 → thoughtleaders_cli-0.6.39}/src/tl_cli/commands/bulk_import.py +0 -0
- {thoughtleaders_cli-0.6.38 → thoughtleaders_cli-0.6.39}/src/tl_cli/commands/changelog.py +0 -0
- {thoughtleaders_cli-0.6.38 → thoughtleaders_cli-0.6.39}/src/tl_cli/commands/credits.py +0 -0
- {thoughtleaders_cli-0.6.38 → thoughtleaders_cli-0.6.39}/src/tl_cli/commands/db.py +0 -0
- {thoughtleaders_cli-0.6.38 → thoughtleaders_cli-0.6.39}/src/tl_cli/commands/deals.py +0 -0
- {thoughtleaders_cli-0.6.38 → thoughtleaders_cli-0.6.39}/src/tl_cli/commands/describe.py +0 -0
- {thoughtleaders_cli-0.6.38 → thoughtleaders_cli-0.6.39}/src/tl_cli/commands/doctor.py +0 -0
- {thoughtleaders_cli-0.6.38 → thoughtleaders_cli-0.6.39}/src/tl_cli/commands/matches.py +0 -0
- {thoughtleaders_cli-0.6.38 → thoughtleaders_cli-0.6.39}/src/tl_cli/commands/proposals.py +0 -0
- {thoughtleaders_cli-0.6.38 → thoughtleaders_cli-0.6.39}/src/tl_cli/commands/recommender.py +0 -0
- {thoughtleaders_cli-0.6.38 → thoughtleaders_cli-0.6.39}/src/tl_cli/commands/reports.py +0 -0
- {thoughtleaders_cli-0.6.38 → thoughtleaders_cli-0.6.39}/src/tl_cli/commands/schema.py +0 -0
- {thoughtleaders_cli-0.6.38 → thoughtleaders_cli-0.6.39}/src/tl_cli/commands/setup.py +0 -0
- {thoughtleaders_cli-0.6.38 → thoughtleaders_cli-0.6.39}/src/tl_cli/commands/snapshots.py +0 -0
- {thoughtleaders_cli-0.6.38 → thoughtleaders_cli-0.6.39}/src/tl_cli/commands/uploads.py +0 -0
- {thoughtleaders_cli-0.6.38 → thoughtleaders_cli-0.6.39}/src/tl_cli/commands/whoami.py +0 -0
- {thoughtleaders_cli-0.6.38 → thoughtleaders_cli-0.6.39}/src/tl_cli/config.py +0 -0
- {thoughtleaders_cli-0.6.38 → thoughtleaders_cli-0.6.39}/src/tl_cli/filters.py +0 -0
- {thoughtleaders_cli-0.6.38 → thoughtleaders_cli-0.6.39}/src/tl_cli/hints.py +0 -0
- {thoughtleaders_cli-0.6.38 → thoughtleaders_cli-0.6.39}/src/tl_cli/main.py +0 -0
- {thoughtleaders_cli-0.6.38 → thoughtleaders_cli-0.6.39}/src/tl_cli/output/__init__.py +0 -0
- {thoughtleaders_cli-0.6.38 → thoughtleaders_cli-0.6.39}/src/tl_cli/output/formatter.py +0 -0
- {thoughtleaders_cli-0.6.38 → thoughtleaders_cli-0.6.39}/src/tl_cli/self_update.py +0 -0
- {thoughtleaders_cli-0.6.38 → thoughtleaders_cli-0.6.39}/tests/__init__.py +0 -0
- {thoughtleaders_cli-0.6.38 → thoughtleaders_cli-0.6.39}/tests/test_filters.py +0 -0
- {thoughtleaders_cli-0.6.38 → thoughtleaders_cli-0.6.39}/tests/test_output.py +0 -0
- {thoughtleaders_cli-0.6.38 → thoughtleaders_cli-0.6.39}/tests/test_reports.py +0 -0
- {thoughtleaders_cli-0.6.38 → thoughtleaders_cli-0.6.39}/tests/test_sponsorships.py +0 -0
- {thoughtleaders_cli-0.6.38 → thoughtleaders_cli-0.6.39}/uv.lock +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: thoughtleaders-cli
|
|
3
|
-
Version: 0.6.
|
|
3
|
+
Version: 0.6.39
|
|
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
|
|
@@ -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
|
|
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 (
|
|
169
|
-
tl channels
|
|
170
|
-
tl channels
|
|
171
|
-
tl channels
|
|
172
|
-
tl
|
|
173
|
-
tl brands
|
|
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
|
|
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
|
|
178
|
-
tl recommender tags [query] # List similarity tag names — categories, demographics, formats
|
|
179
|
-
tl recommender top-channels "<tag>" # Top channels loaded on a similarity tag (
|
|
180
|
-
tl recommender top-profiles "<tag>" # Top brand profiles loaded on a similarity tag
|
|
181
|
-
tl recommender top-brands "<tag>" # Top brands (deduped from profiles) loaded on a similarity tag
|
|
182
|
-
tl recommender inspect-channel <ref> # Show a channel's similarity-profile breakdown (
|
|
183
|
-
tl recommender inspect-brand <ref> # Show a brand profile's ideal similarity-profile breakdown (
|
|
184
|
-
tl recommender similar-to-profile <id> # Channels closest to a brand profile's ideal profile (
|
|
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
|
|
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
|
|
191
|
-
tl <entity> comment-edit <comment-id> "msg" # Edit own comment (author or superuser
|
|
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
|
|
|
@@ -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
|
-
"""
|
|
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
|
|
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
|
|
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 {
|
|
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"},
|