thoughtleaders-cli 0.6.24__tar.gz → 0.6.25__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (93) hide show
  1. {thoughtleaders_cli-0.6.24 → thoughtleaders_cli-0.6.25}/.claude-plugin/plugin.json +1 -1
  2. {thoughtleaders_cli-0.6.24 → thoughtleaders_cli-0.6.25}/PKG-INFO +1 -1
  3. {thoughtleaders_cli-0.6.24 → thoughtleaders_cli-0.6.25}/pyproject.toml +1 -1
  4. thoughtleaders_cli-0.6.25/skills/bulk-import/SKILL.md +90 -0
  5. {thoughtleaders_cli-0.6.24 → thoughtleaders_cli-0.6.25}/skills/tl/references/postgres-schema.md +11 -0
  6. {thoughtleaders_cli-0.6.24 → thoughtleaders_cli-0.6.25}/skills/tl-report-builder/SKILL.md +2 -0
  7. {thoughtleaders_cli-0.6.24 → thoughtleaders_cli-0.6.25}/src/tl_cli/__init__.py +1 -1
  8. thoughtleaders_cli-0.6.25/src/tl_cli/commands/bulk_import.py +120 -0
  9. {thoughtleaders_cli-0.6.24 → thoughtleaders_cli-0.6.25}/src/tl_cli/main.py +5 -0
  10. {thoughtleaders_cli-0.6.24 → thoughtleaders_cli-0.6.25}/.claude-plugin/marketplace.json +0 -0
  11. {thoughtleaders_cli-0.6.24 → thoughtleaders_cli-0.6.25}/.github/workflows/python-publish.yml +0 -0
  12. {thoughtleaders_cli-0.6.24 → thoughtleaders_cli-0.6.25}/.gitignore +0 -0
  13. {thoughtleaders_cli-0.6.24 → thoughtleaders_cli-0.6.25}/AGENTS.md +0 -0
  14. {thoughtleaders_cli-0.6.24 → thoughtleaders_cli-0.6.25}/CLAUDE.md +0 -0
  15. {thoughtleaders_cli-0.6.24 → thoughtleaders_cli-0.6.25}/LICENSE +0 -0
  16. {thoughtleaders_cli-0.6.24 → thoughtleaders_cli-0.6.25}/README.md +0 -0
  17. {thoughtleaders_cli-0.6.24 → thoughtleaders_cli-0.6.25}/agents/tl-analyst.md +0 -0
  18. {thoughtleaders_cli-0.6.24 → thoughtleaders_cli-0.6.25}/commands/tl-balance.md +0 -0
  19. {thoughtleaders_cli-0.6.24 → thoughtleaders_cli-0.6.25}/commands/tl-reports.md +0 -0
  20. {thoughtleaders_cli-0.6.24 → thoughtleaders_cli-0.6.25}/commands/tl-sponsorships.md +0 -0
  21. {thoughtleaders_cli-0.6.24 → thoughtleaders_cli-0.6.25}/commands/tl.md +0 -0
  22. {thoughtleaders_cli-0.6.24 → thoughtleaders_cli-0.6.25}/docs/architecture.md +0 -0
  23. {thoughtleaders_cli-0.6.24 → thoughtleaders_cli-0.6.25}/hooks/hooks.json +0 -0
  24. {thoughtleaders_cli-0.6.24 → thoughtleaders_cli-0.6.25}/hooks/scripts/post-usage.sh +0 -0
  25. {thoughtleaders_cli-0.6.24 → thoughtleaders_cli-0.6.25}/hooks/scripts/pre-check.sh +0 -0
  26. {thoughtleaders_cli-0.6.24 → thoughtleaders_cli-0.6.25}/skills/tl/SKILL.md +0 -0
  27. {thoughtleaders_cli-0.6.24 → thoughtleaders_cli-0.6.25}/skills/tl/references/business-glossary.md +0 -0
  28. {thoughtleaders_cli-0.6.24 → thoughtleaders_cli-0.6.25}/skills/tl/references/elasticsearch-schema.md +0 -0
  29. {thoughtleaders_cli-0.6.24 → thoughtleaders_cli-0.6.25}/skills/tl/references/firebolt-schema.md +0 -0
  30. {thoughtleaders_cli-0.6.24 → thoughtleaders_cli-0.6.25}/skills/tl-report-builder/examples/e2e_findings.md +0 -0
  31. {thoughtleaders_cli-0.6.24 → thoughtleaders_cli-0.6.25}/skills/tl-report-builder/examples/golden_queries.md +0 -0
  32. {thoughtleaders_cli-0.6.24 → thoughtleaders_cli-0.6.25}/skills/tl-report-builder/references/columns_brands.md +0 -0
  33. {thoughtleaders_cli-0.6.24 → thoughtleaders_cli-0.6.25}/skills/tl-report-builder/references/columns_channels.md +0 -0
  34. {thoughtleaders_cli-0.6.24 → thoughtleaders_cli-0.6.25}/skills/tl-report-builder/references/columns_content.md +0 -0
  35. {thoughtleaders_cli-0.6.24 → thoughtleaders_cli-0.6.25}/skills/tl-report-builder/references/columns_sponsorships.md +0 -0
  36. {thoughtleaders_cli-0.6.24 → thoughtleaders_cli-0.6.25}/skills/tl-report-builder/references/intelligence_filterset_schema.json +0 -0
  37. {thoughtleaders_cli-0.6.24 → thoughtleaders_cli-0.6.25}/skills/tl-report-builder/references/intelligence_widget_schema.json +0 -0
  38. {thoughtleaders_cli-0.6.24 → thoughtleaders_cli-0.6.25}/skills/tl-report-builder/references/report_glossary.md +0 -0
  39. {thoughtleaders_cli-0.6.24 → thoughtleaders_cli-0.6.25}/skills/tl-report-builder/references/sortable_columns.json +0 -0
  40. {thoughtleaders_cli-0.6.24 → thoughtleaders_cli-0.6.25}/skills/tl-report-builder/references/sponsorship_filterset_schema.json +0 -0
  41. {thoughtleaders_cli-0.6.24 → thoughtleaders_cli-0.6.25}/skills/tl-report-builder/references/sponsorship_widget_schema.json +0 -0
  42. {thoughtleaders_cli-0.6.24 → thoughtleaders_cli-0.6.25}/skills/tl-report-builder/references/widgets.md +0 -0
  43. {thoughtleaders_cli-0.6.24 → thoughtleaders_cli-0.6.25}/skills/tl-report-builder/tools/column_builder.md +0 -0
  44. {thoughtleaders_cli-0.6.24 → thoughtleaders_cli-0.6.25}/skills/tl-report-builder/tools/database_query.md +0 -0
  45. {thoughtleaders_cli-0.6.24 → thoughtleaders_cli-0.6.25}/skills/tl-report-builder/tools/keyword_research.md +0 -0
  46. {thoughtleaders_cli-0.6.24 → thoughtleaders_cli-0.6.25}/skills/tl-report-builder/tools/name_resolver.md +0 -0
  47. {thoughtleaders_cli-0.6.24 → thoughtleaders_cli-0.6.25}/skills/tl-report-builder/tools/sample_judge.md +0 -0
  48. {thoughtleaders_cli-0.6.24 → thoughtleaders_cli-0.6.25}/skills/tl-report-builder/tools/similar_channels.md +0 -0
  49. {thoughtleaders_cli-0.6.24 → thoughtleaders_cli-0.6.25}/skills/tl-report-builder/tools/topic_matcher.md +0 -0
  50. {thoughtleaders_cli-0.6.24 → thoughtleaders_cli-0.6.25}/skills/tl-report-builder/tools/widget_builder.md +0 -0
  51. {thoughtleaders_cli-0.6.24 → thoughtleaders_cli-0.6.25}/src/tl_cli/_completions.py +0 -0
  52. {thoughtleaders_cli-0.6.24 → thoughtleaders_cli-0.6.25}/src/tl_cli/auth/__init__.py +0 -0
  53. {thoughtleaders_cli-0.6.24 → thoughtleaders_cli-0.6.25}/src/tl_cli/auth/commands.py +0 -0
  54. {thoughtleaders_cli-0.6.24 → thoughtleaders_cli-0.6.25}/src/tl_cli/auth/login.py +0 -0
  55. {thoughtleaders_cli-0.6.24 → thoughtleaders_cli-0.6.25}/src/tl_cli/auth/pkce.py +0 -0
  56. {thoughtleaders_cli-0.6.24 → thoughtleaders_cli-0.6.25}/src/tl_cli/auth/token_store.py +0 -0
  57. {thoughtleaders_cli-0.6.24 → thoughtleaders_cli-0.6.25}/src/tl_cli/client/__init__.py +0 -0
  58. {thoughtleaders_cli-0.6.24 → thoughtleaders_cli-0.6.25}/src/tl_cli/client/errors.py +0 -0
  59. {thoughtleaders_cli-0.6.24 → thoughtleaders_cli-0.6.25}/src/tl_cli/client/http.py +0 -0
  60. {thoughtleaders_cli-0.6.24 → thoughtleaders_cli-0.6.25}/src/tl_cli/commands/__init__.py +0 -0
  61. {thoughtleaders_cli-0.6.24 → thoughtleaders_cli-0.6.25}/src/tl_cli/commands/_comments_common.py +0 -0
  62. {thoughtleaders_cli-0.6.24 → thoughtleaders_cli-0.6.25}/src/tl_cli/commands/ask.py +0 -0
  63. {thoughtleaders_cli-0.6.24 → thoughtleaders_cli-0.6.25}/src/tl_cli/commands/balance.py +0 -0
  64. {thoughtleaders_cli-0.6.24 → thoughtleaders_cli-0.6.25}/src/tl_cli/commands/brands.py +0 -0
  65. {thoughtleaders_cli-0.6.24 → thoughtleaders_cli-0.6.25}/src/tl_cli/commands/changelog.py +0 -0
  66. {thoughtleaders_cli-0.6.24 → thoughtleaders_cli-0.6.25}/src/tl_cli/commands/channels.py +0 -0
  67. {thoughtleaders_cli-0.6.24 → thoughtleaders_cli-0.6.25}/src/tl_cli/commands/db.py +0 -0
  68. {thoughtleaders_cli-0.6.24 → thoughtleaders_cli-0.6.25}/src/tl_cli/commands/deals.py +0 -0
  69. {thoughtleaders_cli-0.6.24 → thoughtleaders_cli-0.6.25}/src/tl_cli/commands/describe.py +0 -0
  70. {thoughtleaders_cli-0.6.24 → thoughtleaders_cli-0.6.25}/src/tl_cli/commands/doctor.py +0 -0
  71. {thoughtleaders_cli-0.6.24 → thoughtleaders_cli-0.6.25}/src/tl_cli/commands/matches.py +0 -0
  72. {thoughtleaders_cli-0.6.24 → thoughtleaders_cli-0.6.25}/src/tl_cli/commands/proposals.py +0 -0
  73. {thoughtleaders_cli-0.6.24 → thoughtleaders_cli-0.6.25}/src/tl_cli/commands/recommender.py +0 -0
  74. {thoughtleaders_cli-0.6.24 → thoughtleaders_cli-0.6.25}/src/tl_cli/commands/reports.py +0 -0
  75. {thoughtleaders_cli-0.6.24 → thoughtleaders_cli-0.6.25}/src/tl_cli/commands/schema.py +0 -0
  76. {thoughtleaders_cli-0.6.24 → thoughtleaders_cli-0.6.25}/src/tl_cli/commands/setup.py +0 -0
  77. {thoughtleaders_cli-0.6.24 → thoughtleaders_cli-0.6.25}/src/tl_cli/commands/snapshots.py +0 -0
  78. {thoughtleaders_cli-0.6.24 → thoughtleaders_cli-0.6.25}/src/tl_cli/commands/sponsorships.py +0 -0
  79. {thoughtleaders_cli-0.6.24 → thoughtleaders_cli-0.6.25}/src/tl_cli/commands/uploads.py +0 -0
  80. {thoughtleaders_cli-0.6.24 → thoughtleaders_cli-0.6.25}/src/tl_cli/commands/whoami.py +0 -0
  81. {thoughtleaders_cli-0.6.24 → thoughtleaders_cli-0.6.25}/src/tl_cli/config.py +0 -0
  82. {thoughtleaders_cli-0.6.24 → thoughtleaders_cli-0.6.25}/src/tl_cli/filters.py +0 -0
  83. {thoughtleaders_cli-0.6.24 → thoughtleaders_cli-0.6.25}/src/tl_cli/hints.py +0 -0
  84. {thoughtleaders_cli-0.6.24 → thoughtleaders_cli-0.6.25}/src/tl_cli/output/__init__.py +0 -0
  85. {thoughtleaders_cli-0.6.24 → thoughtleaders_cli-0.6.25}/src/tl_cli/output/formatter.py +0 -0
  86. {thoughtleaders_cli-0.6.24 → thoughtleaders_cli-0.6.25}/src/tl_cli/self_update.py +0 -0
  87. {thoughtleaders_cli-0.6.24 → thoughtleaders_cli-0.6.25}/tests/__init__.py +0 -0
  88. {thoughtleaders_cli-0.6.24 → thoughtleaders_cli-0.6.25}/tests/test_auth.py +0 -0
  89. {thoughtleaders_cli-0.6.24 → thoughtleaders_cli-0.6.25}/tests/test_filters.py +0 -0
  90. {thoughtleaders_cli-0.6.24 → thoughtleaders_cli-0.6.25}/tests/test_output.py +0 -0
  91. {thoughtleaders_cli-0.6.24 → thoughtleaders_cli-0.6.25}/tests/test_reports.py +0 -0
  92. {thoughtleaders_cli-0.6.24 → thoughtleaders_cli-0.6.25}/tests/test_sponsorships.py +0 -0
  93. {thoughtleaders_cli-0.6.24 → thoughtleaders_cli-0.6.25}/uv.lock +0 -0
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tl-cli",
3
- "version": "0.6.24",
3
+ "version": "0.6.25",
4
4
  "description": "ThoughtLeaders CLI — query sponsorship deals, channels, brands, uploads, and intelligence from the terminal",
5
5
  "author": {
6
6
  "name": "ThoughtLeaders",
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: thoughtleaders-cli
3
- Version: 0.6.24
3
+ Version: 0.6.25
4
4
  Summary: ThoughtLeaders CLI — query sponsorship data, channels, brands, and intelligence
5
5
  Project-URL: Homepage, https://thoughtleaders.io
6
6
  Project-URL: Repository, https://github.com/ThoughtLeaders-io/thoughtleaders-cli
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "thoughtleaders-cli"
7
- version = "0.6.24"
7
+ version = "0.6.25"
8
8
  description = "ThoughtLeaders CLI — query sponsorship data, channels, brands, and intelligence"
9
9
  readme = "README.md"
10
10
  license = "MIT"
@@ -0,0 +1,90 @@
1
+ ---
2
+ name: bulk-import
3
+ description: Bulk-add or exclude a list of channels, brands, articles, or sponsorships from 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".
4
+ ---
5
+
6
+ # Bulk Import
7
+
8
+ Wraps `tl bulk-import` — submits a list of identifiers against a report and polls until the import finishes. Reports which entities landed and which were skipped or newly created.
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
+
19
+ If a single identifier is asked for, `tl bulk-import` still works (it 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.
20
+
21
+ ## Inputs to gather
22
+
23
+ Before running the command, confirm:
24
+
25
+ 1. **Report ID** (`--campaign`) — required. If the user pastes a TL URL (e.g. `https://app.thoughtleaders.io/#/thoughtleaders?campaign=23859&...`), the integer after `campaign=` is the ID.
26
+ 2. **Entity type** — one of `channels`, `brands`, `articles`, `sponsorships`. Infer from context:
27
+ - YouTube URLs / handles / `UC…` IDs → `channels`
28
+ - Domains / brand slugs → `brands`
29
+ - Video URLs / IDs → `articles`
30
+ - AdLink integer IDs → `sponsorships`
31
+ 3. **Identifiers** — the actual list. Accepted shapes per entity:
32
+ - **channels**: numeric DB IDs, YouTube channel IDs (`UC…`), `@handles`, full YouTube URLs (`/@…`, `/channel/UC…`, `/user/…`)
33
+ - **brands**: numeric IDs, slugs, websites/domains (`example.com`)
34
+ - **articles**: video IDs or video URLs
35
+ - **sponsorships**: AdLink IDs (numeric only)
36
+ 4. **Include vs exclude** — default is include (add to the report). Pass `--exclude` only if the user explicitly wants to remove from the report.
37
+
38
+ ## How to invoke
39
+
40
+ The command reads identifiers from a file (`--ids-file`) or stdin. For lists of more than a handful, write to a temp file:
41
+
42
+ ```bash
43
+ # small list — stdin
44
+ echo '@mkbhd
45
+ @veritasium
46
+ @lemmino' | tl bulk-import channels --campaign 1234
47
+
48
+ # larger list — file
49
+ tl bulk-import channels --campaign 1234 --ids-file ./channels.txt
50
+
51
+ # exclusion
52
+ tl bulk-import brands --campaign 5678 -f ./brands.txt --exclude
53
+ ```
54
+
55
+ Short flags: `-c` for `--campaign`, `-f` for `--ids-file`.
56
+
57
+ ## Output
58
+
59
+ JSON envelope on stdout:
60
+
61
+ ```json
62
+ {
63
+ "task_id": "...",
64
+ "success_ids": [<int>, ...],
65
+ "success_ids_count": <int>,
66
+ "failed_ids": [...],
67
+ "failed_ids_count": <int>,
68
+ "newly_created_ids": [<int>, ...],
69
+ "not_created_channels_count": <int>
70
+ }
71
+ ```
72
+
73
+ Surface to the user:
74
+
75
+ - **`success_ids_count`** — how many identifiers landed in the report.
76
+ - **`newly_created_ids`** — channels/brands that didn't exist before and were created by this import. Mention that enrichment (subscriber stats, AI description, demographics for channels; logo/website metadata for brands) is queued and will populate over the next few minutes.
77
+ - **`failed_ids` / `not_created_channels_count`** — anything that couldn't be resolved or created. Show them so the user can fix and retry.
78
+
79
+ ## Errors
80
+
81
+ - **403** → caller isn't a superuser. Stop and tell the user; this skill is gated.
82
+ - **400** → bad input. Show the `detail` verbatim (usually missing field, unknown entity, or all-empty identifiers).
83
+ - **402** → out of credits. Tell the user to top up.
84
+ - **Connection failed** → transient network issue. Retry once; if it persists, ask the user.
85
+
86
+ ## What this skill does NOT do
87
+
88
+ - Doesn't create reports — that's a separate skill (`tl-report-builder`).
89
+ - Doesn't change report metadata (title, description, columns, filters).
90
+ - Doesn't validate identifiers ahead of time — let `tl bulk-import` do the lookup and report back which ones failed. Pre-checking via `tl channels show` is wasteful.
@@ -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).
@@ -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.
@@ -1,3 +1,3 @@
1
1
  """ThoughtLeaders CLI — query sponsorship data, channels, brands, and intelligence."""
2
2
 
3
- __version__ = "0.6.24"
3
+ __version__ = "0.6.25"
@@ -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))
@@ -16,6 +16,7 @@ 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
19
20
  from tl_cli.commands.channels import app as channels_app
20
21
  from tl_cli.commands.db import app as db_app
21
22
  from tl_cli.commands.deals import app as deals_app
@@ -93,6 +94,10 @@ app.add_typer(brands_app, name="brands")
93
94
  app.add_typer(recommender_app, name="recommender")
94
95
  app.add_typer(snapshots_app, name="snapshots")
95
96
  app.add_typer(reports_app, name="reports")
97
+ # Direct command (not a sub-Typer) so `tl bulk-import <entity> --campaign <id>`
98
+ # parses ENTITY as the positional and --campaign as a command option, instead
99
+ # of Typer treating `--campaign` as a group-level flag that has to come first.
100
+ app.command(name="bulk-import")(bulk_import_command)
96
101
  app.add_typer(db_app, name="db")
97
102
 
98
103
  # Discoverability