thoughtleaders-cli 0.6.24__tar.gz → 0.6.26__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.24 → thoughtleaders_cli-0.6.26}/.claude-plugin/plugin.json +1 -1
- {thoughtleaders_cli-0.6.24 → thoughtleaders_cli-0.6.26}/PKG-INFO +1 -1
- {thoughtleaders_cli-0.6.24 → thoughtleaders_cli-0.6.26}/pyproject.toml +1 -1
- {thoughtleaders_cli-0.6.24 → thoughtleaders_cli-0.6.26}/skills/tl/SKILL.md +5 -2
- {thoughtleaders_cli-0.6.24 → thoughtleaders_cli-0.6.26}/skills/tl/references/postgres-schema.md +11 -0
- thoughtleaders_cli-0.6.26/skills/tl-import/SKILL.md +180 -0
- {thoughtleaders_cli-0.6.24 → thoughtleaders_cli-0.6.26}/skills/tl-report-builder/SKILL.md +2 -0
- {thoughtleaders_cli-0.6.24 → thoughtleaders_cli-0.6.26}/src/tl_cli/__init__.py +1 -1
- {thoughtleaders_cli-0.6.24 → thoughtleaders_cli-0.6.26}/src/tl_cli/auth/commands.py +9 -1
- thoughtleaders_cli-0.6.26/src/tl_cli/auth/finalize.py +88 -0
- {thoughtleaders_cli-0.6.24 → thoughtleaders_cli-0.6.26}/src/tl_cli/client/errors.py +2 -1
- {thoughtleaders_cli-0.6.24 → thoughtleaders_cli-0.6.26}/src/tl_cli/commands/balance.py +13 -0
- thoughtleaders_cli-0.6.26/src/tl_cli/commands/bulk_import.py +120 -0
- thoughtleaders_cli-0.6.26/src/tl_cli/commands/credits.py +184 -0
- {thoughtleaders_cli-0.6.24 → thoughtleaders_cli-0.6.26}/src/tl_cli/commands/whoami.py +18 -6
- {thoughtleaders_cli-0.6.24 → thoughtleaders_cli-0.6.26}/src/tl_cli/main.py +7 -0
- {thoughtleaders_cli-0.6.24 → thoughtleaders_cli-0.6.26}/src/tl_cli/self_update.py +42 -2
- {thoughtleaders_cli-0.6.24 → thoughtleaders_cli-0.6.26}/.claude-plugin/marketplace.json +0 -0
- {thoughtleaders_cli-0.6.24 → thoughtleaders_cli-0.6.26}/.github/workflows/python-publish.yml +0 -0
- {thoughtleaders_cli-0.6.24 → thoughtleaders_cli-0.6.26}/.gitignore +0 -0
- {thoughtleaders_cli-0.6.24 → thoughtleaders_cli-0.6.26}/AGENTS.md +0 -0
- {thoughtleaders_cli-0.6.24 → thoughtleaders_cli-0.6.26}/CLAUDE.md +0 -0
- {thoughtleaders_cli-0.6.24 → thoughtleaders_cli-0.6.26}/LICENSE +0 -0
- {thoughtleaders_cli-0.6.24 → thoughtleaders_cli-0.6.26}/README.md +0 -0
- {thoughtleaders_cli-0.6.24 → thoughtleaders_cli-0.6.26}/agents/tl-analyst.md +0 -0
- {thoughtleaders_cli-0.6.24 → thoughtleaders_cli-0.6.26}/commands/tl-balance.md +0 -0
- {thoughtleaders_cli-0.6.24 → thoughtleaders_cli-0.6.26}/commands/tl-reports.md +0 -0
- {thoughtleaders_cli-0.6.24 → thoughtleaders_cli-0.6.26}/commands/tl-sponsorships.md +0 -0
- {thoughtleaders_cli-0.6.24 → thoughtleaders_cli-0.6.26}/commands/tl.md +0 -0
- {thoughtleaders_cli-0.6.24 → thoughtleaders_cli-0.6.26}/docs/architecture.md +0 -0
- {thoughtleaders_cli-0.6.24 → thoughtleaders_cli-0.6.26}/hooks/hooks.json +0 -0
- {thoughtleaders_cli-0.6.24 → thoughtleaders_cli-0.6.26}/hooks/scripts/post-usage.sh +0 -0
- {thoughtleaders_cli-0.6.24 → thoughtleaders_cli-0.6.26}/hooks/scripts/pre-check.sh +0 -0
- {thoughtleaders_cli-0.6.24 → thoughtleaders_cli-0.6.26}/skills/tl/references/business-glossary.md +0 -0
- {thoughtleaders_cli-0.6.24 → thoughtleaders_cli-0.6.26}/skills/tl/references/elasticsearch-schema.md +0 -0
- {thoughtleaders_cli-0.6.24 → thoughtleaders_cli-0.6.26}/skills/tl/references/firebolt-schema.md +0 -0
- {thoughtleaders_cli-0.6.24 → thoughtleaders_cli-0.6.26}/skills/tl-report-builder/examples/e2e_findings.md +0 -0
- {thoughtleaders_cli-0.6.24 → thoughtleaders_cli-0.6.26}/skills/tl-report-builder/examples/golden_queries.md +0 -0
- {thoughtleaders_cli-0.6.24 → thoughtleaders_cli-0.6.26}/skills/tl-report-builder/references/columns_brands.md +0 -0
- {thoughtleaders_cli-0.6.24 → thoughtleaders_cli-0.6.26}/skills/tl-report-builder/references/columns_channels.md +0 -0
- {thoughtleaders_cli-0.6.24 → thoughtleaders_cli-0.6.26}/skills/tl-report-builder/references/columns_content.md +0 -0
- {thoughtleaders_cli-0.6.24 → thoughtleaders_cli-0.6.26}/skills/tl-report-builder/references/columns_sponsorships.md +0 -0
- {thoughtleaders_cli-0.6.24 → thoughtleaders_cli-0.6.26}/skills/tl-report-builder/references/intelligence_filterset_schema.json +0 -0
- {thoughtleaders_cli-0.6.24 → thoughtleaders_cli-0.6.26}/skills/tl-report-builder/references/intelligence_widget_schema.json +0 -0
- {thoughtleaders_cli-0.6.24 → thoughtleaders_cli-0.6.26}/skills/tl-report-builder/references/report_glossary.md +0 -0
- {thoughtleaders_cli-0.6.24 → thoughtleaders_cli-0.6.26}/skills/tl-report-builder/references/sortable_columns.json +0 -0
- {thoughtleaders_cli-0.6.24 → thoughtleaders_cli-0.6.26}/skills/tl-report-builder/references/sponsorship_filterset_schema.json +0 -0
- {thoughtleaders_cli-0.6.24 → thoughtleaders_cli-0.6.26}/skills/tl-report-builder/references/sponsorship_widget_schema.json +0 -0
- {thoughtleaders_cli-0.6.24 → thoughtleaders_cli-0.6.26}/skills/tl-report-builder/references/widgets.md +0 -0
- {thoughtleaders_cli-0.6.24 → thoughtleaders_cli-0.6.26}/skills/tl-report-builder/tools/column_builder.md +0 -0
- {thoughtleaders_cli-0.6.24 → thoughtleaders_cli-0.6.26}/skills/tl-report-builder/tools/database_query.md +0 -0
- {thoughtleaders_cli-0.6.24 → thoughtleaders_cli-0.6.26}/skills/tl-report-builder/tools/keyword_research.md +0 -0
- {thoughtleaders_cli-0.6.24 → thoughtleaders_cli-0.6.26}/skills/tl-report-builder/tools/name_resolver.md +0 -0
- {thoughtleaders_cli-0.6.24 → thoughtleaders_cli-0.6.26}/skills/tl-report-builder/tools/sample_judge.md +0 -0
- {thoughtleaders_cli-0.6.24 → thoughtleaders_cli-0.6.26}/skills/tl-report-builder/tools/similar_channels.md +0 -0
- {thoughtleaders_cli-0.6.24 → thoughtleaders_cli-0.6.26}/skills/tl-report-builder/tools/topic_matcher.md +0 -0
- {thoughtleaders_cli-0.6.24 → thoughtleaders_cli-0.6.26}/skills/tl-report-builder/tools/widget_builder.md +0 -0
- {thoughtleaders_cli-0.6.24 → thoughtleaders_cli-0.6.26}/src/tl_cli/_completions.py +0 -0
- {thoughtleaders_cli-0.6.24 → thoughtleaders_cli-0.6.26}/src/tl_cli/auth/__init__.py +0 -0
- {thoughtleaders_cli-0.6.24 → thoughtleaders_cli-0.6.26}/src/tl_cli/auth/login.py +0 -0
- {thoughtleaders_cli-0.6.24 → thoughtleaders_cli-0.6.26}/src/tl_cli/auth/pkce.py +0 -0
- {thoughtleaders_cli-0.6.24 → thoughtleaders_cli-0.6.26}/src/tl_cli/auth/token_store.py +0 -0
- {thoughtleaders_cli-0.6.24 → thoughtleaders_cli-0.6.26}/src/tl_cli/client/__init__.py +0 -0
- {thoughtleaders_cli-0.6.24 → thoughtleaders_cli-0.6.26}/src/tl_cli/client/http.py +0 -0
- {thoughtleaders_cli-0.6.24 → thoughtleaders_cli-0.6.26}/src/tl_cli/commands/__init__.py +0 -0
- {thoughtleaders_cli-0.6.24 → thoughtleaders_cli-0.6.26}/src/tl_cli/commands/_comments_common.py +0 -0
- {thoughtleaders_cli-0.6.24 → thoughtleaders_cli-0.6.26}/src/tl_cli/commands/ask.py +0 -0
- {thoughtleaders_cli-0.6.24 → thoughtleaders_cli-0.6.26}/src/tl_cli/commands/brands.py +0 -0
- {thoughtleaders_cli-0.6.24 → thoughtleaders_cli-0.6.26}/src/tl_cli/commands/changelog.py +0 -0
- {thoughtleaders_cli-0.6.24 → thoughtleaders_cli-0.6.26}/src/tl_cli/commands/channels.py +0 -0
- {thoughtleaders_cli-0.6.24 → thoughtleaders_cli-0.6.26}/src/tl_cli/commands/db.py +0 -0
- {thoughtleaders_cli-0.6.24 → thoughtleaders_cli-0.6.26}/src/tl_cli/commands/deals.py +0 -0
- {thoughtleaders_cli-0.6.24 → thoughtleaders_cli-0.6.26}/src/tl_cli/commands/describe.py +0 -0
- {thoughtleaders_cli-0.6.24 → thoughtleaders_cli-0.6.26}/src/tl_cli/commands/doctor.py +0 -0
- {thoughtleaders_cli-0.6.24 → thoughtleaders_cli-0.6.26}/src/tl_cli/commands/matches.py +0 -0
- {thoughtleaders_cli-0.6.24 → thoughtleaders_cli-0.6.26}/src/tl_cli/commands/proposals.py +0 -0
- {thoughtleaders_cli-0.6.24 → thoughtleaders_cli-0.6.26}/src/tl_cli/commands/recommender.py +0 -0
- {thoughtleaders_cli-0.6.24 → thoughtleaders_cli-0.6.26}/src/tl_cli/commands/reports.py +0 -0
- {thoughtleaders_cli-0.6.24 → thoughtleaders_cli-0.6.26}/src/tl_cli/commands/schema.py +0 -0
- {thoughtleaders_cli-0.6.24 → thoughtleaders_cli-0.6.26}/src/tl_cli/commands/setup.py +0 -0
- {thoughtleaders_cli-0.6.24 → thoughtleaders_cli-0.6.26}/src/tl_cli/commands/snapshots.py +0 -0
- {thoughtleaders_cli-0.6.24 → thoughtleaders_cli-0.6.26}/src/tl_cli/commands/sponsorships.py +0 -0
- {thoughtleaders_cli-0.6.24 → thoughtleaders_cli-0.6.26}/src/tl_cli/commands/uploads.py +0 -0
- {thoughtleaders_cli-0.6.24 → thoughtleaders_cli-0.6.26}/src/tl_cli/config.py +0 -0
- {thoughtleaders_cli-0.6.24 → thoughtleaders_cli-0.6.26}/src/tl_cli/filters.py +0 -0
- {thoughtleaders_cli-0.6.24 → thoughtleaders_cli-0.6.26}/src/tl_cli/hints.py +0 -0
- {thoughtleaders_cli-0.6.24 → thoughtleaders_cli-0.6.26}/src/tl_cli/output/__init__.py +0 -0
- {thoughtleaders_cli-0.6.24 → thoughtleaders_cli-0.6.26}/src/tl_cli/output/formatter.py +0 -0
- {thoughtleaders_cli-0.6.24 → thoughtleaders_cli-0.6.26}/tests/__init__.py +0 -0
- {thoughtleaders_cli-0.6.24 → thoughtleaders_cli-0.6.26}/tests/test_auth.py +0 -0
- {thoughtleaders_cli-0.6.24 → thoughtleaders_cli-0.6.26}/tests/test_filters.py +0 -0
- {thoughtleaders_cli-0.6.24 → thoughtleaders_cli-0.6.26}/tests/test_output.py +0 -0
- {thoughtleaders_cli-0.6.24 → thoughtleaders_cli-0.6.26}/tests/test_reports.py +0 -0
- {thoughtleaders_cli-0.6.24 → thoughtleaders_cli-0.6.26}/tests/test_sponsorships.py +0 -0
- {thoughtleaders_cli-0.6.24 → thoughtleaders_cli-0.6.26}/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.26
|
|
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
|
|
@@ -92,7 +92,7 @@ Other key concepts:
|
|
|
92
92
|
- **`purchase_date`** — when the sponsorship was purchased (i.e. when the deal was made); These make up bookings.
|
|
93
93
|
- **`send_date`** — the date the video is/was expected to be published (scheduled)
|
|
94
94
|
- **`publish_date`** — the date the video was actually published; These make up live ads.
|
|
95
|
-
- **Credits** — every data query costs credits; use `tl describe` to see rates
|
|
95
|
+
- **Credits** — every data query costs credits; use `tl describe` to see rates. Top up with `tl credits buy --amount-usd N` (free; opens a browser checkout). New accounts get a starter balance on first `tl auth login`; the rate is shown by `tl credits pricing`.
|
|
96
96
|
|
|
97
97
|
Users see data scoped by their organization and plan:
|
|
98
98
|
- **Media buyers** see sponsorships where their org is the brand. They see `price` but never `cost`.
|
|
@@ -345,7 +345,10 @@ tl schema pg <table> # PostgreSQL schema for a SINGLE table (f
|
|
|
345
345
|
tl schema fb # Live Firebolt tables and column types for `tl db fb` (free) — both tables
|
|
346
346
|
tl schema fb <table> # Firebolt schema for a SINGLE table (free) — `article_metrics` or `channel_metrics`
|
|
347
347
|
tl schema es # Elasticsearch document shape for `tl db es` (free)
|
|
348
|
-
tl balance --json # Credit balance (free)
|
|
348
|
+
tl balance --json # Credit balance + recent usage (free)
|
|
349
|
+
tl credits pricing # Current usd-per-credit rate (free, no auth)
|
|
350
|
+
tl credits buy --amount-usd 10 # Start a top-up; opens browser checkout (free)
|
|
351
|
+
tl credits history # Recent top-ups for the caller's org (free)
|
|
349
352
|
tl whoami # Current user, org, brands (free)
|
|
350
353
|
tl auth status # Auth check (free)
|
|
351
354
|
tl changelog # Release notes — current version, or current..latest if behind (free)
|
{thoughtleaders_cli-0.6.24 → thoughtleaders_cli-0.6.26}/skills/tl/references/postgres-schema.md
RENAMED
|
@@ -207,6 +207,17 @@ A channel can have multiple adspots (different sellers: talent manager, direct,
|
|
|
207
207
|
| `description` | text | LLM-generated description of the channel. Sometimes useful as a regex-target for thematic filtering when the integer category is too coarse (e.g. filtering "Technology" cat 15 down to actual tech reviewers via keywords like `tech|gadget|review|software`). |
|
|
208
208
|
| `evergreenness` | float | Cached evergreen score |
|
|
209
209
|
|
|
210
|
+
#### Hallucination shapes to avoid
|
|
211
|
+
|
|
212
|
+
When composing `SELECT ... FROM thoughtleaders_channel ...`, do not improvise column names from semantic intuition — consult the column table above. Failed guesses return *"column '\<name\>' does not exist"* and cost a round-trip. Recurring shapes:
|
|
213
|
+
|
|
214
|
+
- ❌ **Suffix/qualifier variants of date columns** (e.g. an `_max` / `latest_` / `_date` form when the canonical column has neither). Date columns above use bare names.
|
|
215
|
+
- ❌ **Platform-name-prefixed ID forms** (e.g. a platform-name prefix when the canonical column uses a neutral `external_` prefix). See the column table for the actual ID column.
|
|
216
|
+
- ❌ **Bare-noun forms without the table-prefix** (e.g. `name` instead of `channel_name`). This table prefixes its display fields with `channel_` to avoid SQL keyword collisions and ambiguity in joins.
|
|
217
|
+
- ❌ **User-facing-term forms used as SQL column names** (the user-facing word is sometimes different from the SQL column name; consult [business-glossary](business-glossary.md) for the canonical mapping when the two diverge).
|
|
218
|
+
|
|
219
|
+
When the canonical column you need isn't obvious from the table above, consult the column table first. Do **not** rely on a 400 to correct you, and do **not** fall back to `information_schema.columns` as the recovery path — that's a regression marker too.
|
|
220
|
+
|
|
210
221
|
#### `content_category` Constants
|
|
211
222
|
|
|
212
223
|
Source of truth: `thoughtleaders.taxonomies.ContentCategory` (Django `IntEnum` in the main `thoughtleaders` repo).
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: tl-import
|
|
3
|
+
description: Bulk-add or exclude a list of channels, brands, uploads (videos), or sponsorships against a ThoughtLeaders report (campaign). Superuser-only. Use when a request asks to import / add / exclude a batch of identifiers against a specific report ID — phrasings like "import these channels into report 1234", "add brands to campaign 5678", "exclude these channels from report Z", "bulk-add these videos to report X".
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# tl-import
|
|
7
|
+
|
|
8
|
+
Wraps the `tl bulk-import` command — submits a list of identifiers against a report, polls until done, and renders a per-row result table.
|
|
9
|
+
|
|
10
|
+
## When to use
|
|
11
|
+
|
|
12
|
+
Trigger on requests like:
|
|
13
|
+
|
|
14
|
+
- "Import @mkbhd, @veritasium into report 1234"
|
|
15
|
+
- "Add these brands to campaign 5678"
|
|
16
|
+
- "Bulk-add this list of channels to report 999"
|
|
17
|
+
- "Exclude these channels from report Z"
|
|
18
|
+
- "Add these videos to report X"
|
|
19
|
+
|
|
20
|
+
Single-identifier requests still work (the command accepts one). The reason to keep this skill separate from other report-edit flows: it's the only path that auto-creates channels from YouTube URLs / handles, and brands from website domains.
|
|
21
|
+
|
|
22
|
+
## Inputs to gather
|
|
23
|
+
|
|
24
|
+
Before running, confirm:
|
|
25
|
+
|
|
26
|
+
1. **Report ID** (`--campaign` / `-c`) — required. If the user pastes a TL URL (e.g. `https://app.thoughtleaders.io/#/thoughtleaders?campaign=23859&...`), the integer after `campaign=` is the ID.
|
|
27
|
+
2. **Entity type** — one of `channels` / `brands` / `articles` / `sponsorships`. Infer from context, but translate user-facing vocabulary:
|
|
28
|
+
- YouTube URLs / handles / `UC…` IDs → `channels`
|
|
29
|
+
- Domains / brand slugs → `brands`
|
|
30
|
+
- "videos" / "uploads" / video URLs / video IDs → `articles` *(the CLI calls them uploads in `tl uploads list`, but `bulk-import` expects `articles` — same concept, legacy naming)*
|
|
31
|
+
- "adlinks" / "deals" / "sponsorships" / numeric AdLink IDs → `sponsorships`
|
|
32
|
+
3. **Identifiers** — the list. Accepted shapes per entity:
|
|
33
|
+
- **channels**: numeric DB IDs, YouTube channel IDs (`UC…`), `@handles`, full YouTube URLs (`/@…`, `/channel/UC…`, `/user/…`)
|
|
34
|
+
- **brands**: numeric IDs, slugs, websites / domains (`example.com`)
|
|
35
|
+
- **articles** (uploads): video IDs or video URLs
|
|
36
|
+
- **sponsorships** (adlinks): numeric AdLink IDs only
|
|
37
|
+
4. **Include vs exclude** — default is include (add to the report). Pass `--exclude` only if the user explicitly wants to remove from the report.
|
|
38
|
+
|
|
39
|
+
## How to invoke
|
|
40
|
+
|
|
41
|
+
The command reads identifiers from a file (`--ids-file`) or stdin:
|
|
42
|
+
|
|
43
|
+
```bash
|
|
44
|
+
# small list — stdin
|
|
45
|
+
echo '@mkbhd
|
|
46
|
+
@veritasium
|
|
47
|
+
@lemmino' | tl bulk-import channels --campaign 1234
|
|
48
|
+
|
|
49
|
+
# larger list — file
|
|
50
|
+
tl bulk-import channels --campaign 1234 --ids-file ./channels.txt
|
|
51
|
+
|
|
52
|
+
# exclusion
|
|
53
|
+
tl bulk-import brands --campaign 5678 -f ./brands.txt --exclude
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
Short flags: `-c` for `--campaign`, `-f` for `--ids-file`.
|
|
57
|
+
|
|
58
|
+
## Output: the `inputs` envelope
|
|
59
|
+
|
|
60
|
+
`tl bulk-import` returns a JSON envelope. Use the **`inputs`** array as the source of truth for what to render — it has one row per submitted identifier, in input order, with everything you need to classify and display.
|
|
61
|
+
|
|
62
|
+
```json
|
|
63
|
+
{
|
|
64
|
+
"task_id": "...",
|
|
65
|
+
"mode": "include",
|
|
66
|
+
"inputs": [
|
|
67
|
+
{"input": "@mkbhd", "resolved_id": 4587, "reason": "Success", "newly_created": false},
|
|
68
|
+
{"input": "@veritasium", "resolved_id": 1209, "reason": "Duplicate", "newly_created": false},
|
|
69
|
+
{"input": "@OfficialSaharTV", "resolved_id": 1328906, "reason": "Success", "newly_created": true},
|
|
70
|
+
{"input": "https://bad-url", "resolved_id": null, "reason": "Not found", "newly_created": false}
|
|
71
|
+
],
|
|
72
|
+
"success_ids": [4587, 1328906],
|
|
73
|
+
"newly_created_ids": [1328906],
|
|
74
|
+
"failed_ids": [...],
|
|
75
|
+
...
|
|
76
|
+
}
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
Each `inputs` row's `input` field echoes the raw identifier the user submitted (unchanged). `resolved_id` is the entity ID it matched/created, or `null` for failures. `reason` and `newly_created` drive the row's display label below.
|
|
80
|
+
|
|
81
|
+
**`mode` echoes back the operation mode** (`"include"` or `"exclude"`). You need this for labelling because the semantics flip:
|
|
82
|
+
|
|
83
|
+
- include + Success = identifier was just added to the report
|
|
84
|
+
- exclude + Success = identifier was just removed from the report
|
|
85
|
+
- include + Duplicate = identifier was already in the report (no-op)
|
|
86
|
+
- exclude + Duplicate = identifier was already excluded (no-op)
|
|
87
|
+
|
|
88
|
+
Don't use `success_ids` / `failed_ids` for display — they lose input mapping and miss the include/exclude direction. `inputs` is the canonical surface.
|
|
89
|
+
|
|
90
|
+
## Classify each row
|
|
91
|
+
|
|
92
|
+
| `reason` | `newly_created` | `mode` | Icon | Label |
|
|
93
|
+
|---|---|---|---|---|
|
|
94
|
+
| `Success` | `true` | `include` | 🆕 | Created in TL |
|
|
95
|
+
| `Success` | `true` | `exclude` | ⚠️ | Created in TL — unexpected for exclude, verify report state |
|
|
96
|
+
| `Success` | `false` | `include` | ✅ | Added |
|
|
97
|
+
| `Success` | `false` | `exclude` | ✂️ | Excluded |
|
|
98
|
+
| `Duplicate` | any | `include` | ↺ | Already in report |
|
|
99
|
+
| `Duplicate` | any | `exclude` | ↺ | Already excluded |
|
|
100
|
+
| `Not found` | any | any | ❌ | Not found |
|
|
101
|
+
| `Cannot parse` | any | any | ❌ | Bad format |
|
|
102
|
+
| `Multiple matches found` | any | any | ❌ | Ambiguous (multiple matches) |
|
|
103
|
+
| `Limit exceeded` | any | any | ❌ | Auto-create cap hit |
|
|
104
|
+
| starts with `Error:` | any | any | ❌ | Error (show reason verbatim) |
|
|
105
|
+
| anything else | any | any | ❌ | Failed (show reason verbatim) |
|
|
106
|
+
|
|
107
|
+
For 🆕 rows: mention that enrichment (subscriber stats, AI description, demographics for channels; metadata for brands) is queued and will populate over the next few minutes — these entities just entered the database.
|
|
108
|
+
|
|
109
|
+
For ⚠️ rows: if an exclude import returns `newly_created: true`, treat it as unexpected. Tell the user the channel was created but does not appear to have been excluded — ask them to verify the report and re-submit the exclude against the returned `resolved_id` if needed.
|
|
110
|
+
|
|
111
|
+
## Display
|
|
112
|
+
|
|
113
|
+
Per-row markdown table. **Headline first** with the gain count, then the table.
|
|
114
|
+
|
|
115
|
+
For include mode:
|
|
116
|
+
|
|
117
|
+
```markdown
|
|
118
|
+
**Bulk-import to report 23859 — done.** Report gained **2** rows; **1** was already there; **1** failed.
|
|
119
|
+
|
|
120
|
+
| # | Status | Input | ID | Reason |
|
|
121
|
+
|---|---|---|---|---|
|
|
122
|
+
| 1 | ✅ Added | `@mkbhd` | 4587 | Success |
|
|
123
|
+
| 2 | ↺ Already in report | `@veritasium` | 1209 | Duplicate |
|
|
124
|
+
| 3 | 🆕 Created in TL | `@OfficialSaharTV` | 1328906 | Success — enrichment queued |
|
|
125
|
+
| 4 | ❌ Not found | `https://bad-url` | — | Not found |
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
For exclude mode, headline uses "lost" wording:
|
|
129
|
+
|
|
130
|
+
```markdown
|
|
131
|
+
**Bulk-import (exclude) to report 23859 — done.** Report lost **N** rows; **M** were already excluded.
|
|
132
|
+
```
|
|
133
|
+
|
|
134
|
+
Display rules:
|
|
135
|
+
|
|
136
|
+
- **Use the user's raw `input` value** in the Input column (it's `inputs[i].input` — the raw submitted string, unchanged).
|
|
137
|
+
- **Omit any column that's uniformly empty** — for sponsorships, the "Input" and "ID" columns are usually identical (both numeric); the Reason column carries the signal.
|
|
138
|
+
- **Small imports (≤30 rows):** render the full table.
|
|
139
|
+
- **Large imports (>30 rows):** lead with a summary table of bucket counts; render the per-row table only for **non-bulk-success rows** — i.e. omit the dominant happy-path bucket, which is ✅ Added in include mode and ✂️ Excluded in exclude mode. The rows the user cares about (already-present, newly-created, failed, unexpected) all stay. Offer to dump the omitted rows on request.
|
|
140
|
+
|
|
141
|
+
Summary table (include mode example):
|
|
142
|
+
|
|
143
|
+
```markdown
|
|
144
|
+
| Bucket | Count |
|
|
145
|
+
|---|---|
|
|
146
|
+
| ✅ Added | 142 |
|
|
147
|
+
| ↺ Already in report | 7 |
|
|
148
|
+
| 🆕 Created in TL | 3 |
|
|
149
|
+
| ❌ Failed | 2 |
|
|
150
|
+
| **Total submitted** | **154** |
|
|
151
|
+
```
|
|
152
|
+
|
|
153
|
+
Summary table (exclude mode example):
|
|
154
|
+
|
|
155
|
+
```markdown
|
|
156
|
+
| Bucket | Count |
|
|
157
|
+
|---|---|
|
|
158
|
+
| ✂️ Excluded | 142 |
|
|
159
|
+
| ↺ Already excluded | 7 |
|
|
160
|
+
| ❌ Failed | 2 |
|
|
161
|
+
| **Total submitted** | **151** |
|
|
162
|
+
```
|
|
163
|
+
|
|
164
|
+
- **Never look up entity names** with extra `tl channels show <id>` / `tl brands show <id>` calls just to populate a "Name" column — those are metered. The user's input column is enough for them to identify each row. If the user explicitly asks "what are these channels called?", then look them up.
|
|
165
|
+
|
|
166
|
+
## Errors at the command level (before the per-row results)
|
|
167
|
+
|
|
168
|
+
These are envelope-level failures, distinct from per-row `reason` values:
|
|
169
|
+
|
|
170
|
+
- **403** → caller isn't a superuser. Stop and tell the user; this command is gated.
|
|
171
|
+
- **400** → bad input shape (missing field, unknown entity, all-empty identifiers). Show the `detail` verbatim.
|
|
172
|
+
- **402** → out of credits. Tell the user to top up.
|
|
173
|
+
- **Connection failed** → transient network issue. Retry once; if it persists, surface to the user.
|
|
174
|
+
|
|
175
|
+
## What this skill does NOT do
|
|
176
|
+
|
|
177
|
+
- Doesn't create reports — that's `tl-report-builder`.
|
|
178
|
+
- Doesn't change report metadata (title, description, columns, filters).
|
|
179
|
+
- Doesn't validate identifiers ahead of time — submit and let the per-row `reason` tell the user which ones failed. Pre-checking with `tl channels show` / etc. is wasteful (metered) and adds latency.
|
|
180
|
+
- Doesn't sweep duplicates from the user's input list — submit them as-is. The response will mark the second occurrence as `Duplicate`, which is more informative than silently deduping.
|
|
@@ -1608,6 +1608,8 @@ Render as Markdown links in the table cell — *not* the bare ID, *not* the YouT
|
|
|
1608
1608
|
|
|
1609
1609
|
If the slug is missing or empty for a row, fall back to the ID-based path the platform exposes (e.g. `https://app.thoughtleaders.io/youtube/id-<channel_id>`); never fall back to the YouTube URL — that takes the user *away* from TL. The Phase 2 sample query must include the slug column alongside the rendered fields, otherwise the table can't link properly.
|
|
1610
1610
|
|
|
1611
|
+
**Sample-row enrichment column names — read from the canonical schema, do NOT improvise.** When the rendered table needs columns beyond what the initial ES sample returned (typically a slug for the hyperlink and a "last published" date), look up the column names in [`tl/references/postgres-schema.md` → `thoughtleaders_channel`](../tl/references/postgres-schema.md#thoughtleaders_channel-youtube-channels) before composing the PG query. Agents have improvised semantically-plausible column names from intuition (date-shape variants, platform-name-prefixed ID forms, bare-noun forms without table prefix, user-facing-term forms), hit a 400 with *"column '\<name\>' does not exist"*, then run an `information_schema.columns` fishing query to recover — a wasted round-trip that the canonical column catalogue eliminates. **If you find yourself about to write a `SELECT ... FROM thoughtleaders_channel WHERE ...` query and you're not sure of a column name, consult the schema reference first** — do not guess and rely on the 400 to correct you, and do not fall back to `information_schema.columns` as the recovery path. See the schema reference's "Hallucination shapes to avoid" subsection for the recurring guess patterns.
|
|
1612
|
+
|
|
1611
1613
|
21. **No side-channel deliverables.** The skill produces exactly two output shapes: (a) a saved TL Campaign + a campaign URL (save mode), or (b) an in-chat preview with the sample-rows table + takeaways + save tail (preview mode). It does NOT write CSVs, Markdown reports, or any other "data dump" file to disk as a deliverable. A real run for FRÉ Skincare wrote a CSV to `<temp>\fre-skincare-shortlist.csv` and pointed the user at it as the "full list" — that's a fabricated alternative deliverable that bypasses the TL report-creation flow. If the user wants more than the preview shows, the answer is "save it as a campaign and run it" — not "I'll dump CSV". The only filesystem write the skill is allowed to make is the `<system-temp>/tl-report-builder-<slug>.json` transport file used in step 1 of the save mechanics, and even that is a transport (deleted whenever) — never a deliverable.
|
|
1612
1614
|
22. **Phases 1–4 always run; the skill never short-circuits to a chat-only data answer.** When the skill is invoked, the output is **always** a Campaign (save mode) or a Phase-4 preview (preview mode). Bypassing Phase 1–4 to produce a verification table, an analyst summary, a list cross-check, or any other "I'll just answer this directly in chat" deliverable is a regression bug. Real example to internalise: a prompt of *"Brands sponsoring Linus Tech Tips in the past 6 months: dbrand, Private Internet Access, Squarespace, Vessi, Secretlab, UGREEN, Odoo, Dell, Razer, Saily"* should route through Phase 1 → Type 2 brands report scoped to channel 1788 + last 180 days → Phases 2/3/4 → preview with the user's seed brands as a starting filter and the takeaways calling out *"your seed list is accurate but incomplete — TL data shows 60 distinct sponsors over 131 videos; top missing are War Thunder (7), Boot.dev (6), DeleteMe (6)…"*. Instead, a recent run produced exactly that analytical content **as a free-floating markdown table in chat** — no FilterSet emitted, no columns picked, no widgets, no save option. The analytical insight is welcome as a takeaway; it is **not** a substitute for the report. If you find yourself replying with a markdown table directly, ask: am I about to ship a Phase-4 preview, or am I bypassing the phases? The answer must always be the former.
|
|
1613
1615
|
23. **No ad-hoc data-engineering pipelines.** The skill does NOT write Python consolidation scripts, multi-stage CSV merge tools, dedupe scripts, false-positive filters as standalone files, or any other custom data pipeline as part of producing the deliverable. The data plane is fixed: `tl db pg` (PG), `tl db es` (ES), `tl db fb` (Firebolt). Phase 2 issues queries against these directly to compose a FilterSet and validate it; that's the entire data-side surface. A real aviation/non-MSN run produced this anti-pattern: the agent issued five separate PG queries each writing a CSV (`/tmp/aviation_by_name.csv`, `/tmp/aviation_desc.csv`, `/tmp/aviation_desc2.csv`, `/tmp/aviation_desc3.csv`, `/tmp/aviation_pilot_desc.csv`), wrote a `consolidate_aviation.py` script to merge + dedupe + filter false positives, hit a Windows-vs-Linux `/tmp/` path mismatch, debugged it with `cygpath`, eventually rewrote the script to use `%LOCALAPPDATA%\Temp`, then produced `aviation_consolidated.csv` as the "full list". **None of this is the skill's job.** The right shape: one ES query with `terms` / `bool.should` filters covering the niche keywords + the `creator_countries` filter + `msn_channels_only: false` + `is_active: true` → get count + sample → emit the FilterSet → preview. If the skill's narration is starting to read like a data engineer's bash session ("Run consolidation script", "Try /tmp path resolution", "Resolve /tmp via cygpath", "Find where /tmp files actually are"), stop — the skill has gone off the rails. Restart from Phase 1 with a single composed query.
|
|
@@ -4,6 +4,7 @@ import typer
|
|
|
4
4
|
from rich.console import Console
|
|
5
5
|
from rich.prompt import Prompt
|
|
6
6
|
|
|
7
|
+
from tl_cli.auth.finalize import finalize_signup
|
|
7
8
|
from tl_cli.auth.login import login_browser, login_device_code
|
|
8
9
|
from tl_cli.auth.token_store import clear_tokens, load_tokens
|
|
9
10
|
|
|
@@ -13,7 +14,12 @@ console = Console(stderr=True)
|
|
|
13
14
|
|
|
14
15
|
@app.command("login", help="Log in to ThoughtLeaders.")
|
|
15
16
|
def login_cmd() -> None:
|
|
16
|
-
"""Log in to ThoughtLeaders.
|
|
17
|
+
"""Log in to ThoughtLeaders.
|
|
18
|
+
|
|
19
|
+
After Auth0 returns, the CLI calls the server's finalize endpoint.
|
|
20
|
+
First-time users are prompted for a persona (Media Buyer or Creator)
|
|
21
|
+
and the account + starter credits are created on the server side.
|
|
22
|
+
"""
|
|
17
23
|
console.print("[bold]How would you like to authenticate?[/bold]")
|
|
18
24
|
console.print(" [cyan]1[/cyan] — Browser on this machine (default)")
|
|
19
25
|
console.print(" [cyan]2[/cyan] — Device code (use a browser on another device)")
|
|
@@ -25,6 +31,8 @@ def login_cmd() -> None:
|
|
|
25
31
|
else:
|
|
26
32
|
login_browser()
|
|
27
33
|
|
|
34
|
+
finalize_signup()
|
|
35
|
+
|
|
28
36
|
|
|
29
37
|
@app.command("logout")
|
|
30
38
|
def logout_cmd() -> None:
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
"""Server-side signup finalize, called once per login.
|
|
2
|
+
|
|
3
|
+
After Auth0 returns a valid token the CLI asks the server whether an
|
|
4
|
+
account exists for the email. If yes, this is a no-op. If no, the CLI
|
|
5
|
+
prompts for a persona (Media Buyer or Creator) and POSTs it back; the
|
|
6
|
+
server creates the User, Organization, Profile and CreditAccount.
|
|
7
|
+
|
|
8
|
+
Errors here never abort login — the user can always retry the prompt
|
|
9
|
+
on the next call. We do print a clear message so it's obvious whether
|
|
10
|
+
the account is fully set up.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import typer
|
|
16
|
+
from rich.console import Console
|
|
17
|
+
from rich.prompt import Prompt
|
|
18
|
+
|
|
19
|
+
from tl_cli.client.errors import ApiError
|
|
20
|
+
from tl_cli.client.http import get_client
|
|
21
|
+
|
|
22
|
+
console = Console(stderr=True)
|
|
23
|
+
|
|
24
|
+
PERSONA_LABEL_TO_KEY = {
|
|
25
|
+
"Media Buyer": "media_buyer",
|
|
26
|
+
"Creator": "creator",
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def finalize_signup() -> None:
|
|
31
|
+
"""POST /auth/finalize, prompting for persona if the server asks for one."""
|
|
32
|
+
client = get_client()
|
|
33
|
+
try:
|
|
34
|
+
# First call: no body. Server tells us whether persona is required.
|
|
35
|
+
try:
|
|
36
|
+
result = client.post("/auth/finalize", {})
|
|
37
|
+
except ApiError as exc:
|
|
38
|
+
if exc.status_code == 400 and isinstance(exc.raw, dict) and exc.raw.get("code") == "persona_required":
|
|
39
|
+
result = _prompt_and_finalize(client, exc.raw.get("allowed_personas") or [])
|
|
40
|
+
elif exc.status_code == 404:
|
|
41
|
+
# Server predates this endpoint — silently skip; legacy
|
|
42
|
+
# accounts already exist and don't need provisioning.
|
|
43
|
+
return
|
|
44
|
+
else:
|
|
45
|
+
console.print(f"[yellow]Could not finalize signup: {exc.detail}[/yellow]")
|
|
46
|
+
return
|
|
47
|
+
|
|
48
|
+
if result.get("created"):
|
|
49
|
+
org = result.get("organization", {})
|
|
50
|
+
console.print(
|
|
51
|
+
f"[green]Account created for {org.get('name', 'your organization')}.[/green] "
|
|
52
|
+
"Run [bold]tl balance[/bold] to see your starter credits."
|
|
53
|
+
)
|
|
54
|
+
finally:
|
|
55
|
+
client.close()
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _prompt_and_finalize(client, allowed: list[str]) -> dict:
|
|
59
|
+
"""Prompt the user for a persona, then retry /auth/finalize."""
|
|
60
|
+
console.print()
|
|
61
|
+
console.print("[bold]Welcome to ThoughtLeaders![/bold] We need one more detail to set up your account.")
|
|
62
|
+
console.print(" [cyan]1[/cyan] — Media Buyer (brands and agencies buying sponsorships)")
|
|
63
|
+
console.print(" [cyan]2[/cyan] — Creator (channels selling sponsorships)")
|
|
64
|
+
|
|
65
|
+
persona_key: str | None = None
|
|
66
|
+
while persona_key is None:
|
|
67
|
+
choice = Prompt.ask("I am a", choices=["1", "2"], default="1", console=console)
|
|
68
|
+
candidate = "media_buyer" if choice == "1" else "creator"
|
|
69
|
+
if allowed and candidate not in allowed:
|
|
70
|
+
console.print(f"[yellow]Server rejects persona '{candidate}'. Allowed: {', '.join(allowed)}.[/yellow]")
|
|
71
|
+
continue
|
|
72
|
+
persona_key = candidate
|
|
73
|
+
|
|
74
|
+
org_name = Prompt.ask(
|
|
75
|
+
"Organization name (optional, leave blank to use your email)",
|
|
76
|
+
default="",
|
|
77
|
+
console=console,
|
|
78
|
+
).strip()
|
|
79
|
+
|
|
80
|
+
body = {"persona": persona_key}
|
|
81
|
+
if org_name:
|
|
82
|
+
body["organization_name"] = org_name
|
|
83
|
+
|
|
84
|
+
try:
|
|
85
|
+
return client.post("/auth/finalize", body)
|
|
86
|
+
except ApiError as exc:
|
|
87
|
+
console.print(f"[red]Signup failed:[/red] {exc.detail}")
|
|
88
|
+
raise typer.Exit(1)
|
|
@@ -46,7 +46,8 @@ def handle_api_error(error: ApiError) -> None:
|
|
|
46
46
|
sys.exit(2)
|
|
47
47
|
elif error.status_code == 402:
|
|
48
48
|
err.print("[red]Insufficient credits.[/red]")
|
|
49
|
-
err.print("
|
|
49
|
+
err.print("Top up with: [bold]tl credits buy --amount-usd 10[/bold]")
|
|
50
|
+
err.print("Or visit: https://app.thoughtleaders.io/billing/cli")
|
|
50
51
|
_print_debug(error)
|
|
51
52
|
sys.exit(4)
|
|
52
53
|
elif error.status_code == 403:
|
|
@@ -46,6 +46,19 @@ def balance(
|
|
|
46
46
|
if allow_overage:
|
|
47
47
|
console.print("[dim]Overage: enabled[/dim]")
|
|
48
48
|
|
|
49
|
+
# Top-up hint when running low. Threshold matches the hook warning
|
|
50
|
+
# that nudges the user before they hit 0.
|
|
51
|
+
try:
|
|
52
|
+
balance_decimal = float(balance_val)
|
|
53
|
+
except (TypeError, ValueError):
|
|
54
|
+
balance_decimal = None
|
|
55
|
+
if balance_decimal is not None and balance_decimal < 500:
|
|
56
|
+
console.print(
|
|
57
|
+
"[yellow]Running low.[/yellow] Top up with: "
|
|
58
|
+
"[bold]tl credits buy --amount-usd 10[/bold] "
|
|
59
|
+
"(or https://app.thoughtleaders.io/billing/cli)"
|
|
60
|
+
)
|
|
61
|
+
|
|
49
62
|
recent = data.get("recent_usage", [])
|
|
50
63
|
if recent:
|
|
51
64
|
table = Table(title="Recent Usage")
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
"""tl bulk-import - bulk-add or exclude entities from a report.
|
|
2
|
+
|
|
3
|
+
Superuser-only on the server side. Submits a list of identifiers
|
|
4
|
+
(channels / brands / articles / sponsorships) against a target report
|
|
5
|
+
and polls until the import completes.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import json
|
|
9
|
+
import sys
|
|
10
|
+
import time
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
|
|
13
|
+
import typer
|
|
14
|
+
from rich.console import Console
|
|
15
|
+
|
|
16
|
+
from tl_cli.client.errors import ApiError, handle_api_error
|
|
17
|
+
from tl_cli.client.http import get_client
|
|
18
|
+
|
|
19
|
+
err = Console(stderr=True)
|
|
20
|
+
|
|
21
|
+
POLL_INTERVAL_SEC = 2
|
|
22
|
+
POLL_TIMEOUT_SEC = 600
|
|
23
|
+
VALID_ENTITIES = ("channels", "brands", "articles", "sponsorships")
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _read_ids(ids_file: str | None) -> list[str]:
|
|
27
|
+
if ids_file:
|
|
28
|
+
text = Path(ids_file).read_text()
|
|
29
|
+
elif not sys.stdin.isatty():
|
|
30
|
+
text = sys.stdin.read()
|
|
31
|
+
else:
|
|
32
|
+
err.print("[red]Provide --ids-file or pipe identifiers via stdin.[/red]")
|
|
33
|
+
raise typer.Exit(2)
|
|
34
|
+
ids = [line.strip() for line in text.splitlines() if line.strip()]
|
|
35
|
+
if not ids:
|
|
36
|
+
err.print("[red]No identifiers found.[/red]")
|
|
37
|
+
raise typer.Exit(2)
|
|
38
|
+
return ids
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _poll_until_done(client, task_id: str) -> dict:
|
|
42
|
+
deadline = time.time() + POLL_TIMEOUT_SEC
|
|
43
|
+
with err.status(f"[bold blue]Importing... (task {task_id})[/bold blue]"):
|
|
44
|
+
while time.time() < deadline:
|
|
45
|
+
time.sleep(POLL_INTERVAL_SEC)
|
|
46
|
+
data = client.get(f"/bulk-import/poll/{task_id}")
|
|
47
|
+
if data.get("finished"):
|
|
48
|
+
if data.get("error"):
|
|
49
|
+
err.print(f"[red]Import failed: {data.get('error')}[/red]")
|
|
50
|
+
raise typer.Exit(1)
|
|
51
|
+
return data.get("end_result") or {}
|
|
52
|
+
err.print(f"[red]Polling timed out after {POLL_TIMEOUT_SEC}s. Task still running: {task_id}[/red]")
|
|
53
|
+
raise typer.Exit(3)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def bulk_import_command(
|
|
57
|
+
entity: str = typer.Argument(..., help=f"Entity type: one of {', '.join(VALID_ENTITIES)}"),
|
|
58
|
+
campaign: int = typer.Option(..., "--campaign", "-c", help="Target report ID"),
|
|
59
|
+
ids_file: str | None = typer.Option(None, "--ids-file", "-f", help="Path to file with one identifier per line. Omit to read from stdin."),
|
|
60
|
+
exclude: bool = typer.Option(False, "--exclude", help="Mark these identifiers as excluded from the report instead of included"),
|
|
61
|
+
json_output: bool = typer.Option(False, "--json", help="JSON output (default)"),
|
|
62
|
+
) -> None:
|
|
63
|
+
"""Bulk-import entities into a report.
|
|
64
|
+
|
|
65
|
+
Accepts a list of identifiers per entity:
|
|
66
|
+
channels -> numeric IDs, YouTube channel IDs (UC...), @handles, full URLs
|
|
67
|
+
brands -> numeric IDs, slugs, websites/domains
|
|
68
|
+
articles -> video IDs or URLs
|
|
69
|
+
sponsorships -> AdLink IDs (numeric)
|
|
70
|
+
|
|
71
|
+
Submits the list and polls until the import completes. Channels/brands
|
|
72
|
+
that aren't already on file get auto-created from YouTube / their
|
|
73
|
+
website. Enrichment (metadata, AI description, demographics) is queued
|
|
74
|
+
and lands a few minutes after the import returns.
|
|
75
|
+
|
|
76
|
+
Examples:
|
|
77
|
+
tl bulk-import channels --campaign 23859 --ids-file ./channels.txt
|
|
78
|
+
echo "@mkbhd" | tl bulk-import channels -c 23859
|
|
79
|
+
tl bulk-import brands -c 23859 -f ./brands.txt --exclude
|
|
80
|
+
|
|
81
|
+
Requires superuser permission - non-superusers get a 403.
|
|
82
|
+
"""
|
|
83
|
+
if entity not in VALID_ENTITIES:
|
|
84
|
+
err.print(f"[red]entity must be one of: {', '.join(VALID_ENTITIES)}[/red]")
|
|
85
|
+
raise typer.Exit(2)
|
|
86
|
+
|
|
87
|
+
ids = _read_ids(ids_file)
|
|
88
|
+
|
|
89
|
+
body = {
|
|
90
|
+
"campaign_id": campaign,
|
|
91
|
+
"entity": entity,
|
|
92
|
+
"entity_ids": ids,
|
|
93
|
+
"include": not exclude,
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
err.print(f"[dim]Submitting {len(ids)} {entity} to report {campaign} (include={not exclude})...[/dim]")
|
|
97
|
+
|
|
98
|
+
client = get_client()
|
|
99
|
+
try:
|
|
100
|
+
submit = client.post("/bulk-import", json_body=body)
|
|
101
|
+
except ApiError as e:
|
|
102
|
+
handle_api_error(e)
|
|
103
|
+
raise typer.Exit(1)
|
|
104
|
+
|
|
105
|
+
task_id = submit.get("task_id")
|
|
106
|
+
if not task_id:
|
|
107
|
+
err.print(f"[red]No task_id in submit response: {submit}[/red]")
|
|
108
|
+
raise typer.Exit(1)
|
|
109
|
+
|
|
110
|
+
try:
|
|
111
|
+
result = _poll_until_done(client, task_id)
|
|
112
|
+
finally:
|
|
113
|
+
client.close()
|
|
114
|
+
|
|
115
|
+
output = {"task_id": task_id, **result}
|
|
116
|
+
if json_output or not sys.stdout.isatty():
|
|
117
|
+
json.dump(output, sys.stdout, indent=2)
|
|
118
|
+
sys.stdout.write("\n")
|
|
119
|
+
else:
|
|
120
|
+
Console().print_json(json.dumps(output))
|
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
"""tl credits — buy credits and view top-up history.
|
|
2
|
+
|
|
3
|
+
`tl credits buy --amount-usd N` calls the server to start a top-up,
|
|
4
|
+
opens the resulting web checkout URL in the user's browser, and polls
|
|
5
|
+
the balance until it changes (or the user gives up).
|
|
6
|
+
|
|
7
|
+
`tl credits history` lists recent top-ups for the caller's org.
|
|
8
|
+
|
|
9
|
+
`tl credits pricing` shows the current usd-per-credit rate. Use this to
|
|
10
|
+
sanity-check what `--amount-usd N` will buy before paying.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import json
|
|
16
|
+
import time
|
|
17
|
+
import webbrowser
|
|
18
|
+
from decimal import Decimal, InvalidOperation
|
|
19
|
+
|
|
20
|
+
import typer
|
|
21
|
+
from rich.console import Console
|
|
22
|
+
from rich.table import Table
|
|
23
|
+
|
|
24
|
+
from tl_cli.client.errors import ApiError, handle_api_error
|
|
25
|
+
from tl_cli.client.http import get_client
|
|
26
|
+
from tl_cli.output.formatter import detect_format
|
|
27
|
+
|
|
28
|
+
app = typer.Typer(help="Buy credits and view top-up history (free)")
|
|
29
|
+
console = Console()
|
|
30
|
+
err = Console(stderr=True)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@app.command("pricing")
|
|
34
|
+
def pricing_cmd(
|
|
35
|
+
json_output: bool = typer.Option(False, "--json", help="JSON output"),
|
|
36
|
+
) -> None:
|
|
37
|
+
"""Show the credit-to-USD rate, minimum purchase, and starter balance.
|
|
38
|
+
|
|
39
|
+
Free — no authentication required.
|
|
40
|
+
"""
|
|
41
|
+
client = get_client()
|
|
42
|
+
try:
|
|
43
|
+
data = client.get("/pricing")
|
|
44
|
+
except ApiError as e:
|
|
45
|
+
handle_api_error(e)
|
|
46
|
+
return
|
|
47
|
+
finally:
|
|
48
|
+
client.close()
|
|
49
|
+
|
|
50
|
+
if json_output:
|
|
51
|
+
print(json.dumps(data, indent=2, default=str))
|
|
52
|
+
return
|
|
53
|
+
|
|
54
|
+
console.print(f"\n[bold]Rate:[/bold] ${data['usd_per_credit']} per credit ({data.get('currency', 'USD')})")
|
|
55
|
+
console.print(f"[bold]Minimum top-up:[/bold] ${data['min_purchase_usd']}")
|
|
56
|
+
console.print(f"[bold]Starter balance:[/bold] {data['starter_balance']} credits")
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
@app.command("buy")
|
|
60
|
+
def buy_cmd(
|
|
61
|
+
amount_usd: str = typer.Option(..., "--amount-usd", help="Amount to top up, in USD."),
|
|
62
|
+
no_browser: bool = typer.Option(False, "--no-browser", help="Don't open a browser, just print the checkout URL."),
|
|
63
|
+
poll: bool = typer.Option(True, "--poll/--no-poll", help="Poll balance after opening the checkout page."),
|
|
64
|
+
) -> None:
|
|
65
|
+
"""Start a credit top-up.
|
|
66
|
+
|
|
67
|
+
Calls the server to create a pending purchase, opens the web checkout
|
|
68
|
+
URL, and (by default) polls `tl balance` until the credits land or you
|
|
69
|
+
Ctrl-C out.
|
|
70
|
+
"""
|
|
71
|
+
try:
|
|
72
|
+
Decimal(amount_usd)
|
|
73
|
+
except (InvalidOperation, ValueError):
|
|
74
|
+
err.print(f"[red]Invalid amount:[/red] {amount_usd}")
|
|
75
|
+
raise typer.Exit(1)
|
|
76
|
+
|
|
77
|
+
client = get_client()
|
|
78
|
+
try:
|
|
79
|
+
try:
|
|
80
|
+
initial = client.get("/balance")
|
|
81
|
+
initial_balance = Decimal(str(initial.get("balance", 0)))
|
|
82
|
+
except ApiError:
|
|
83
|
+
# Pricing fetch may work even if balance fails; still attempt the purchase.
|
|
84
|
+
initial_balance = None
|
|
85
|
+
|
|
86
|
+
try:
|
|
87
|
+
result = client.post("/top-up", {"usd_amount": amount_usd})
|
|
88
|
+
except ApiError as e:
|
|
89
|
+
handle_api_error(e)
|
|
90
|
+
return
|
|
91
|
+
finally:
|
|
92
|
+
client.close()
|
|
93
|
+
|
|
94
|
+
checkout_url = result.get("checkout_url")
|
|
95
|
+
credits = result.get("credits")
|
|
96
|
+
console.print(
|
|
97
|
+
f"\n[bold]Started top-up:[/bold] ${result['usd_amount']} → {credits} credits"
|
|
98
|
+
)
|
|
99
|
+
console.print(f"Checkout: {checkout_url}")
|
|
100
|
+
|
|
101
|
+
if checkout_url and not no_browser:
|
|
102
|
+
try:
|
|
103
|
+
webbrowser.open(checkout_url)
|
|
104
|
+
except Exception:
|
|
105
|
+
pass
|
|
106
|
+
|
|
107
|
+
if not poll or initial_balance is None:
|
|
108
|
+
console.print("[dim]Run `tl balance` to confirm once payment completes.[/dim]")
|
|
109
|
+
return
|
|
110
|
+
|
|
111
|
+
_poll_for_credit(initial_balance, expected_increment=Decimal(str(credits)))
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def _poll_for_credit(initial_balance: Decimal, expected_increment: Decimal) -> None:
|
|
115
|
+
"""Poll the balance endpoint until it goes up. Bounded so the CLI
|
|
116
|
+
eventually returns to the prompt instead of hanging forever.
|
|
117
|
+
"""
|
|
118
|
+
console.print("[dim]Waiting for payment confirmation (up to 10 minutes; Ctrl-C to stop)…[/dim]")
|
|
119
|
+
deadline = time.time() + 600
|
|
120
|
+
last_balance = initial_balance
|
|
121
|
+
try:
|
|
122
|
+
while time.time() < deadline:
|
|
123
|
+
time.sleep(5)
|
|
124
|
+
client = get_client()
|
|
125
|
+
try:
|
|
126
|
+
data = client.get("/balance")
|
|
127
|
+
except ApiError:
|
|
128
|
+
client.close()
|
|
129
|
+
continue
|
|
130
|
+
client.close()
|
|
131
|
+
new_balance = Decimal(str(data.get("balance", 0)))
|
|
132
|
+
if new_balance >= initial_balance + expected_increment:
|
|
133
|
+
console.print(f"[green]Payment confirmed.[/green] New balance: [cyan]{new_balance}[/cyan] credits")
|
|
134
|
+
return
|
|
135
|
+
if new_balance != last_balance:
|
|
136
|
+
console.print(f"[dim]Balance updated: {new_balance} credits[/dim]")
|
|
137
|
+
last_balance = new_balance
|
|
138
|
+
except KeyboardInterrupt:
|
|
139
|
+
console.print("\n[yellow]Stopped polling.[/yellow] Run `tl balance` later to check.")
|
|
140
|
+
return
|
|
141
|
+
console.print("[yellow]Timed out waiting for payment.[/yellow] Run `tl balance` later to check.")
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
@app.command("history")
|
|
145
|
+
def history_cmd(
|
|
146
|
+
limit: int = typer.Option(25, "--limit", help="Max rows to show"),
|
|
147
|
+
offset: int = typer.Option(0, "--offset", help="Offset for pagination"),
|
|
148
|
+
json_output: bool = typer.Option(False, "--json", help="JSON output"),
|
|
149
|
+
) -> None:
|
|
150
|
+
"""Show recent credit top-ups for your organization (free)."""
|
|
151
|
+
fmt = detect_format(json_output, False, False, False)
|
|
152
|
+
client = get_client()
|
|
153
|
+
try:
|
|
154
|
+
data = client.get("/credit-purchases", params={"limit": limit, "offset": offset})
|
|
155
|
+
except ApiError as e:
|
|
156
|
+
handle_api_error(e)
|
|
157
|
+
return
|
|
158
|
+
finally:
|
|
159
|
+
client.close()
|
|
160
|
+
|
|
161
|
+
if fmt == "json":
|
|
162
|
+
print(json.dumps(data, indent=2, default=str))
|
|
163
|
+
return
|
|
164
|
+
|
|
165
|
+
rows = data.get("results", [])
|
|
166
|
+
if not rows:
|
|
167
|
+
console.print("[dim]No credit purchases yet. Run `tl credits buy --amount-usd N`.[/dim]")
|
|
168
|
+
return
|
|
169
|
+
|
|
170
|
+
table = Table(title=f"Credit purchases ({data.get('total', len(rows))} total)")
|
|
171
|
+
table.add_column("Date")
|
|
172
|
+
table.add_column("USD", justify="right")
|
|
173
|
+
table.add_column("Credits", justify="right")
|
|
174
|
+
table.add_column("Status")
|
|
175
|
+
table.add_column("Invoice")
|
|
176
|
+
for row in rows:
|
|
177
|
+
table.add_row(
|
|
178
|
+
row.get("created_at", "")[:19].replace("T", " "),
|
|
179
|
+
row.get("usd_amount", ""),
|
|
180
|
+
row.get("credits", ""),
|
|
181
|
+
row.get("status", ""),
|
|
182
|
+
row.get("green_invoice_document_id") or "—",
|
|
183
|
+
)
|
|
184
|
+
console.print(table)
|
|
@@ -26,10 +26,13 @@ def _render_whoami(data: dict) -> None:
|
|
|
26
26
|
|
|
27
27
|
# --- User ---
|
|
28
28
|
name = f"{user.get('first_name', '')} {user.get('last_name', '')}".strip()
|
|
29
|
+
email = user.get("email", "")
|
|
29
30
|
title = Text()
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
title.append(f"
|
|
31
|
+
if name and email:
|
|
32
|
+
title.append(f'"{name}"', style="bold cyan")
|
|
33
|
+
title.append(f" <{email}>", style="dim")
|
|
34
|
+
else:
|
|
35
|
+
title.append(f"<{email}>" if email else name, style="bold cyan")
|
|
33
36
|
|
|
34
37
|
flags = profile.get("flags", [])
|
|
35
38
|
persona = profile.get("persona")
|
|
@@ -61,6 +64,10 @@ def _render_whoami(data: dict) -> None:
|
|
|
61
64
|
if org.get("is_managed_services"):
|
|
62
65
|
org_lines.append("Managed services", style="magenta")
|
|
63
66
|
org_lines.append("\n")
|
|
67
|
+
if "credits_balance" in org:
|
|
68
|
+
org_lines.append("Credits: ", style="dim")
|
|
69
|
+
org_lines.append(f"{org['credits_balance']:g}", style="cyan")
|
|
70
|
+
org_lines.append("\n")
|
|
64
71
|
start = org.get("contract_start_date")
|
|
65
72
|
end = org.get("contract_end_date")
|
|
66
73
|
if start or end:
|
|
@@ -118,9 +125,12 @@ def _render_whoami_md(data: dict) -> None:
|
|
|
118
125
|
brands = data.get("brands", [])
|
|
119
126
|
|
|
120
127
|
name = f"{user.get('first_name', '')} {user.get('last_name', '')}".strip()
|
|
121
|
-
|
|
122
|
-
if name:
|
|
123
|
-
|
|
128
|
+
email = user.get("email", "")
|
|
129
|
+
if name and email:
|
|
130
|
+
header = f'"{name}" <{email}>'
|
|
131
|
+
else:
|
|
132
|
+
header = f"<{email}>" if email else name
|
|
133
|
+
print(f"# {header}\n")
|
|
124
134
|
persona = profile.get("persona")
|
|
125
135
|
if persona:
|
|
126
136
|
print(f"- **Persona:** {persona}")
|
|
@@ -136,6 +146,8 @@ def _render_whoami_md(data: dict) -> None:
|
|
|
136
146
|
print(f"- **Plan:** {plan}")
|
|
137
147
|
if org.get("is_managed_services"):
|
|
138
148
|
print("- **Managed services:** yes")
|
|
149
|
+
if "credits_balance" in org:
|
|
150
|
+
print(f"- **Credits:** {org['credits_balance']:g}")
|
|
139
151
|
start = org.get("contract_start_date")
|
|
140
152
|
end = org.get("contract_end_date")
|
|
141
153
|
if start or end:
|
|
@@ -16,6 +16,8 @@ from tl_cli.commands.ask import app as ask_app
|
|
|
16
16
|
from tl_cli.commands.balance import app as balance_app
|
|
17
17
|
from tl_cli.commands.changelog import changelog_command
|
|
18
18
|
from tl_cli.commands.brands import app as brands_app
|
|
19
|
+
from tl_cli.commands.bulk_import import bulk_import_command
|
|
20
|
+
from tl_cli.commands.credits import app as credits_app
|
|
19
21
|
from tl_cli.commands.channels import app as channels_app
|
|
20
22
|
from tl_cli.commands.db import app as db_app
|
|
21
23
|
from tl_cli.commands.deals import app as deals_app
|
|
@@ -93,12 +95,17 @@ app.add_typer(brands_app, name="brands")
|
|
|
93
95
|
app.add_typer(recommender_app, name="recommender")
|
|
94
96
|
app.add_typer(snapshots_app, name="snapshots")
|
|
95
97
|
app.add_typer(reports_app, name="reports")
|
|
98
|
+
# Direct command (not a sub-Typer) so `tl bulk-import <entity> --campaign <id>`
|
|
99
|
+
# parses ENTITY as the positional and --campaign as a command option, instead
|
|
100
|
+
# of Typer treating `--campaign` as a group-level flag that has to come first.
|
|
101
|
+
app.command(name="bulk-import")(bulk_import_command)
|
|
96
102
|
app.add_typer(db_app, name="db")
|
|
97
103
|
|
|
98
104
|
# Discoverability
|
|
99
105
|
app.add_typer(describe_app, name="describe")
|
|
100
106
|
app.add_typer(schema_app, name="schema")
|
|
101
107
|
app.add_typer(balance_app, name="balance")
|
|
108
|
+
app.add_typer(credits_app, name="credits")
|
|
102
109
|
app.add_typer(doctor_app, name="doctor")
|
|
103
110
|
app.add_typer(whoami_app, name="whoami")
|
|
104
111
|
|
|
@@ -115,12 +115,52 @@ def _run_upgrade(method: str, latest: str) -> None:
|
|
|
115
115
|
f"[tl-cli] upgrading {__version__} → {latest} via {method}…",
|
|
116
116
|
file=sys.stderr,
|
|
117
117
|
)
|
|
118
|
+
# Capture output so a noisy traceback from a broken upgrader (seen on
|
|
119
|
+
# Windows pipx shims that lose track of their own module) doesn't get
|
|
120
|
+
# dumped into the user's shell — we surface it deliberately on failure
|
|
121
|
+
# alongside an actionable next-step message.
|
|
118
122
|
try:
|
|
119
|
-
result = subprocess.run(cmd, check=False, timeout=60)
|
|
120
|
-
except (OSError, subprocess.TimeoutExpired):
|
|
123
|
+
result = subprocess.run(cmd, check=False, timeout=60, capture_output=True, text=True)
|
|
124
|
+
except (OSError, subprocess.TimeoutExpired) as exc:
|
|
125
|
+
print(
|
|
126
|
+
f"[tl-cli] could not run {method}: {exc}\n"
|
|
127
|
+
f"[tl-cli] upgrade manually with:\n {' '.join(cmd)}",
|
|
128
|
+
file=sys.stderr,
|
|
129
|
+
)
|
|
121
130
|
return
|
|
122
131
|
if result.returncode == 0:
|
|
123
132
|
_resync_integrations()
|
|
133
|
+
return
|
|
134
|
+
_report_upgrade_failure(method, cmd, result)
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def _report_upgrade_failure(method: str, cmd: list[str], result: subprocess.CompletedProcess) -> None:
|
|
138
|
+
"""Print a user-friendly failure message after a non-zero upgrader exit.
|
|
139
|
+
|
|
140
|
+
On Windows in particular, `pipx.exe` can be a broken shim that errors
|
|
141
|
+
with `ModuleNotFoundError: No module named 'pipx'`. We detect that and
|
|
142
|
+
give a targeted hint instead of just echoing the traceback.
|
|
143
|
+
"""
|
|
144
|
+
combined_err = (result.stderr or '') + (result.stdout or '')
|
|
145
|
+
print(
|
|
146
|
+
f"[tl-cli] automatic upgrade failed (exit {result.returncode}).",
|
|
147
|
+
file=sys.stderr,
|
|
148
|
+
)
|
|
149
|
+
if "No module named 'pipx'" in combined_err:
|
|
150
|
+
print(
|
|
151
|
+
"[tl-cli] Your pipx install appears broken (its launcher can't find the pipx module).\n"
|
|
152
|
+
"[tl-cli] Reinstall pipx from your system Python, then rerun the upgrade.\n"
|
|
153
|
+
"[tl-cli] Or switch to uv: uv tool install --force " + cmd[-1],
|
|
154
|
+
file=sys.stderr,
|
|
155
|
+
)
|
|
156
|
+
else:
|
|
157
|
+
print(
|
|
158
|
+
f"[tl-cli] To upgrade manually, run:\n {' '.join(cmd)}",
|
|
159
|
+
file=sys.stderr,
|
|
160
|
+
)
|
|
161
|
+
if combined_err.strip():
|
|
162
|
+
print("[tl-cli] Upgrader output:", file=sys.stderr)
|
|
163
|
+
sys.stderr.write(combined_err if combined_err.endswith('\n') else combined_err + '\n')
|
|
124
164
|
|
|
125
165
|
|
|
126
166
|
def _resync_integrations() -> None:
|
|
File without changes
|
{thoughtleaders_cli-0.6.24 → thoughtleaders_cli-0.6.26}/.github/workflows/python-publish.yml
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{thoughtleaders_cli-0.6.24 → thoughtleaders_cli-0.6.26}/skills/tl/references/business-glossary.md
RENAMED
|
File without changes
|
{thoughtleaders_cli-0.6.24 → thoughtleaders_cli-0.6.26}/skills/tl/references/elasticsearch-schema.md
RENAMED
|
File without changes
|
{thoughtleaders_cli-0.6.24 → thoughtleaders_cli-0.6.26}/skills/tl/references/firebolt-schema.md
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{thoughtleaders_cli-0.6.24 → thoughtleaders_cli-0.6.26}/src/tl_cli/commands/_comments_common.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|