thoughtleaders-cli 0.6.23__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.23 → thoughtleaders_cli-0.6.25}/.claude-plugin/plugin.json +1 -1
  2. {thoughtleaders_cli-0.6.23 → thoughtleaders_cli-0.6.25}/PKG-INFO +1 -1
  3. {thoughtleaders_cli-0.6.23 → 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.23 → thoughtleaders_cli-0.6.25}/skills/tl/references/postgres-schema.md +12 -0
  6. {thoughtleaders_cli-0.6.23 → thoughtleaders_cli-0.6.25}/skills/tl-report-builder/SKILL.md +19 -5
  7. {thoughtleaders_cli-0.6.23 → thoughtleaders_cli-0.6.25}/skills/tl-report-builder/tools/topic_matcher.md +17 -1
  8. {thoughtleaders_cli-0.6.23 → thoughtleaders_cli-0.6.25}/src/tl_cli/__init__.py +1 -1
  9. thoughtleaders_cli-0.6.25/src/tl_cli/commands/bulk_import.py +120 -0
  10. {thoughtleaders_cli-0.6.23 → thoughtleaders_cli-0.6.25}/src/tl_cli/main.py +5 -0
  11. {thoughtleaders_cli-0.6.23 → thoughtleaders_cli-0.6.25}/.claude-plugin/marketplace.json +0 -0
  12. {thoughtleaders_cli-0.6.23 → thoughtleaders_cli-0.6.25}/.github/workflows/python-publish.yml +0 -0
  13. {thoughtleaders_cli-0.6.23 → thoughtleaders_cli-0.6.25}/.gitignore +0 -0
  14. {thoughtleaders_cli-0.6.23 → thoughtleaders_cli-0.6.25}/AGENTS.md +0 -0
  15. {thoughtleaders_cli-0.6.23 → thoughtleaders_cli-0.6.25}/CLAUDE.md +0 -0
  16. {thoughtleaders_cli-0.6.23 → thoughtleaders_cli-0.6.25}/LICENSE +0 -0
  17. {thoughtleaders_cli-0.6.23 → thoughtleaders_cli-0.6.25}/README.md +0 -0
  18. {thoughtleaders_cli-0.6.23 → thoughtleaders_cli-0.6.25}/agents/tl-analyst.md +0 -0
  19. {thoughtleaders_cli-0.6.23 → thoughtleaders_cli-0.6.25}/commands/tl-balance.md +0 -0
  20. {thoughtleaders_cli-0.6.23 → thoughtleaders_cli-0.6.25}/commands/tl-reports.md +0 -0
  21. {thoughtleaders_cli-0.6.23 → thoughtleaders_cli-0.6.25}/commands/tl-sponsorships.md +0 -0
  22. {thoughtleaders_cli-0.6.23 → thoughtleaders_cli-0.6.25}/commands/tl.md +0 -0
  23. {thoughtleaders_cli-0.6.23 → thoughtleaders_cli-0.6.25}/docs/architecture.md +0 -0
  24. {thoughtleaders_cli-0.6.23 → thoughtleaders_cli-0.6.25}/hooks/hooks.json +0 -0
  25. {thoughtleaders_cli-0.6.23 → thoughtleaders_cli-0.6.25}/hooks/scripts/post-usage.sh +0 -0
  26. {thoughtleaders_cli-0.6.23 → thoughtleaders_cli-0.6.25}/hooks/scripts/pre-check.sh +0 -0
  27. {thoughtleaders_cli-0.6.23 → thoughtleaders_cli-0.6.25}/skills/tl/SKILL.md +0 -0
  28. {thoughtleaders_cli-0.6.23 → thoughtleaders_cli-0.6.25}/skills/tl/references/business-glossary.md +0 -0
  29. {thoughtleaders_cli-0.6.23 → thoughtleaders_cli-0.6.25}/skills/tl/references/elasticsearch-schema.md +0 -0
  30. {thoughtleaders_cli-0.6.23 → thoughtleaders_cli-0.6.25}/skills/tl/references/firebolt-schema.md +0 -0
  31. {thoughtleaders_cli-0.6.23 → thoughtleaders_cli-0.6.25}/skills/tl-report-builder/examples/e2e_findings.md +0 -0
  32. {thoughtleaders_cli-0.6.23 → thoughtleaders_cli-0.6.25}/skills/tl-report-builder/examples/golden_queries.md +0 -0
  33. {thoughtleaders_cli-0.6.23 → thoughtleaders_cli-0.6.25}/skills/tl-report-builder/references/columns_brands.md +0 -0
  34. {thoughtleaders_cli-0.6.23 → thoughtleaders_cli-0.6.25}/skills/tl-report-builder/references/columns_channels.md +0 -0
  35. {thoughtleaders_cli-0.6.23 → thoughtleaders_cli-0.6.25}/skills/tl-report-builder/references/columns_content.md +0 -0
  36. {thoughtleaders_cli-0.6.23 → thoughtleaders_cli-0.6.25}/skills/tl-report-builder/references/columns_sponsorships.md +0 -0
  37. {thoughtleaders_cli-0.6.23 → thoughtleaders_cli-0.6.25}/skills/tl-report-builder/references/intelligence_filterset_schema.json +0 -0
  38. {thoughtleaders_cli-0.6.23 → thoughtleaders_cli-0.6.25}/skills/tl-report-builder/references/intelligence_widget_schema.json +0 -0
  39. {thoughtleaders_cli-0.6.23 → thoughtleaders_cli-0.6.25}/skills/tl-report-builder/references/report_glossary.md +0 -0
  40. {thoughtleaders_cli-0.6.23 → thoughtleaders_cli-0.6.25}/skills/tl-report-builder/references/sortable_columns.json +0 -0
  41. {thoughtleaders_cli-0.6.23 → thoughtleaders_cli-0.6.25}/skills/tl-report-builder/references/sponsorship_filterset_schema.json +0 -0
  42. {thoughtleaders_cli-0.6.23 → thoughtleaders_cli-0.6.25}/skills/tl-report-builder/references/sponsorship_widget_schema.json +0 -0
  43. {thoughtleaders_cli-0.6.23 → thoughtleaders_cli-0.6.25}/skills/tl-report-builder/references/widgets.md +0 -0
  44. {thoughtleaders_cli-0.6.23 → thoughtleaders_cli-0.6.25}/skills/tl-report-builder/tools/column_builder.md +0 -0
  45. {thoughtleaders_cli-0.6.23 → thoughtleaders_cli-0.6.25}/skills/tl-report-builder/tools/database_query.md +0 -0
  46. {thoughtleaders_cli-0.6.23 → thoughtleaders_cli-0.6.25}/skills/tl-report-builder/tools/keyword_research.md +0 -0
  47. {thoughtleaders_cli-0.6.23 → thoughtleaders_cli-0.6.25}/skills/tl-report-builder/tools/name_resolver.md +0 -0
  48. {thoughtleaders_cli-0.6.23 → thoughtleaders_cli-0.6.25}/skills/tl-report-builder/tools/sample_judge.md +0 -0
  49. {thoughtleaders_cli-0.6.23 → thoughtleaders_cli-0.6.25}/skills/tl-report-builder/tools/similar_channels.md +0 -0
  50. {thoughtleaders_cli-0.6.23 → thoughtleaders_cli-0.6.25}/skills/tl-report-builder/tools/widget_builder.md +0 -0
  51. {thoughtleaders_cli-0.6.23 → thoughtleaders_cli-0.6.25}/src/tl_cli/_completions.py +0 -0
  52. {thoughtleaders_cli-0.6.23 → thoughtleaders_cli-0.6.25}/src/tl_cli/auth/__init__.py +0 -0
  53. {thoughtleaders_cli-0.6.23 → thoughtleaders_cli-0.6.25}/src/tl_cli/auth/commands.py +0 -0
  54. {thoughtleaders_cli-0.6.23 → thoughtleaders_cli-0.6.25}/src/tl_cli/auth/login.py +0 -0
  55. {thoughtleaders_cli-0.6.23 → thoughtleaders_cli-0.6.25}/src/tl_cli/auth/pkce.py +0 -0
  56. {thoughtleaders_cli-0.6.23 → thoughtleaders_cli-0.6.25}/src/tl_cli/auth/token_store.py +0 -0
  57. {thoughtleaders_cli-0.6.23 → thoughtleaders_cli-0.6.25}/src/tl_cli/client/__init__.py +0 -0
  58. {thoughtleaders_cli-0.6.23 → thoughtleaders_cli-0.6.25}/src/tl_cli/client/errors.py +0 -0
  59. {thoughtleaders_cli-0.6.23 → thoughtleaders_cli-0.6.25}/src/tl_cli/client/http.py +0 -0
  60. {thoughtleaders_cli-0.6.23 → thoughtleaders_cli-0.6.25}/src/tl_cli/commands/__init__.py +0 -0
  61. {thoughtleaders_cli-0.6.23 → thoughtleaders_cli-0.6.25}/src/tl_cli/commands/_comments_common.py +0 -0
  62. {thoughtleaders_cli-0.6.23 → thoughtleaders_cli-0.6.25}/src/tl_cli/commands/ask.py +0 -0
  63. {thoughtleaders_cli-0.6.23 → thoughtleaders_cli-0.6.25}/src/tl_cli/commands/balance.py +0 -0
  64. {thoughtleaders_cli-0.6.23 → thoughtleaders_cli-0.6.25}/src/tl_cli/commands/brands.py +0 -0
  65. {thoughtleaders_cli-0.6.23 → thoughtleaders_cli-0.6.25}/src/tl_cli/commands/changelog.py +0 -0
  66. {thoughtleaders_cli-0.6.23 → thoughtleaders_cli-0.6.25}/src/tl_cli/commands/channels.py +0 -0
  67. {thoughtleaders_cli-0.6.23 → thoughtleaders_cli-0.6.25}/src/tl_cli/commands/db.py +0 -0
  68. {thoughtleaders_cli-0.6.23 → thoughtleaders_cli-0.6.25}/src/tl_cli/commands/deals.py +0 -0
  69. {thoughtleaders_cli-0.6.23 → thoughtleaders_cli-0.6.25}/src/tl_cli/commands/describe.py +0 -0
  70. {thoughtleaders_cli-0.6.23 → thoughtleaders_cli-0.6.25}/src/tl_cli/commands/doctor.py +0 -0
  71. {thoughtleaders_cli-0.6.23 → thoughtleaders_cli-0.6.25}/src/tl_cli/commands/matches.py +0 -0
  72. {thoughtleaders_cli-0.6.23 → thoughtleaders_cli-0.6.25}/src/tl_cli/commands/proposals.py +0 -0
  73. {thoughtleaders_cli-0.6.23 → thoughtleaders_cli-0.6.25}/src/tl_cli/commands/recommender.py +0 -0
  74. {thoughtleaders_cli-0.6.23 → thoughtleaders_cli-0.6.25}/src/tl_cli/commands/reports.py +0 -0
  75. {thoughtleaders_cli-0.6.23 → thoughtleaders_cli-0.6.25}/src/tl_cli/commands/schema.py +0 -0
  76. {thoughtleaders_cli-0.6.23 → thoughtleaders_cli-0.6.25}/src/tl_cli/commands/setup.py +0 -0
  77. {thoughtleaders_cli-0.6.23 → thoughtleaders_cli-0.6.25}/src/tl_cli/commands/snapshots.py +0 -0
  78. {thoughtleaders_cli-0.6.23 → thoughtleaders_cli-0.6.25}/src/tl_cli/commands/sponsorships.py +0 -0
  79. {thoughtleaders_cli-0.6.23 → thoughtleaders_cli-0.6.25}/src/tl_cli/commands/uploads.py +0 -0
  80. {thoughtleaders_cli-0.6.23 → thoughtleaders_cli-0.6.25}/src/tl_cli/commands/whoami.py +0 -0
  81. {thoughtleaders_cli-0.6.23 → thoughtleaders_cli-0.6.25}/src/tl_cli/config.py +0 -0
  82. {thoughtleaders_cli-0.6.23 → thoughtleaders_cli-0.6.25}/src/tl_cli/filters.py +0 -0
  83. {thoughtleaders_cli-0.6.23 → thoughtleaders_cli-0.6.25}/src/tl_cli/hints.py +0 -0
  84. {thoughtleaders_cli-0.6.23 → thoughtleaders_cli-0.6.25}/src/tl_cli/output/__init__.py +0 -0
  85. {thoughtleaders_cli-0.6.23 → thoughtleaders_cli-0.6.25}/src/tl_cli/output/formatter.py +0 -0
  86. {thoughtleaders_cli-0.6.23 → thoughtleaders_cli-0.6.25}/src/tl_cli/self_update.py +0 -0
  87. {thoughtleaders_cli-0.6.23 → thoughtleaders_cli-0.6.25}/tests/__init__.py +0 -0
  88. {thoughtleaders_cli-0.6.23 → thoughtleaders_cli-0.6.25}/tests/test_auth.py +0 -0
  89. {thoughtleaders_cli-0.6.23 → thoughtleaders_cli-0.6.25}/tests/test_filters.py +0 -0
  90. {thoughtleaders_cli-0.6.23 → thoughtleaders_cli-0.6.25}/tests/test_output.py +0 -0
  91. {thoughtleaders_cli-0.6.23 → thoughtleaders_cli-0.6.25}/tests/test_reports.py +0 -0
  92. {thoughtleaders_cli-0.6.23 → thoughtleaders_cli-0.6.25}/tests/test_sponsorships.py +0 -0
  93. {thoughtleaders_cli-0.6.23 → thoughtleaders_cli-0.6.25}/uv.lock +0 -0
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tl-cli",
3
- "version": "0.6.23",
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.23
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.23"
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).
@@ -270,6 +281,7 @@ Common hallucinations the agent has tried in real runs (each wasted a round-trip
270
281
  Cited regression markers from real runs:
271
282
  - AI/marketing channels run: tried `thoughtleaders_topic` (singular — table doesn't exist), then `WHERE is_active = TRUE`. Three round-trips before consulting `information_schema`.
272
283
  - Travel/digital-nomad run: tried `SELECT id, name, type, parent_id FROM thoughtleaders_topics WHERE name ILIKE ANY(...)`.
284
+ - **Name-pattern WHERE-clause loop (general pattern)**: when the user's niche has no obvious curated topic, agents have run progressively broader name-pattern `WHERE` queries against this table — typically two or three rounds of `WHERE name ILIKE '%<term1>%' OR name ILIKE '%<term2>%' OR ...`, sometimes interleaved with an `information_schema.columns` inspection between them — each returning zero rows. The correct path is one canonical fetch (above) + the matcher's `summary.no_match: true` verdict for the off-taxonomy case. **A zero-row canonical fetch (no WHERE clause) is a data-plane failure, NOT off-taxonomy** — surface the failure rather than silently falling through to keyword_research.
273
285
 
274
286
  If a query against this table errors with *"column '\<X\>' does not exist"*, that's the regression marker — go back to the verbatim fetch above.
275
287
 
@@ -321,11 +321,16 @@ USER_QUERY
321
321
  │ • Type-8: count_sponsorships, sum_price (axis branches on │
322
322
  │ publish_status — send_date for proposals, purchase_date for sold)│
323
323
  │ • histogram_bucket_size set per date range │
324
+ │ • Generate report_title + report_description from final config │
325
+ │ (must happen BEFORE validation — both fields are mandatory │
326
+ │ on save and the validation step below checks for them) │
324
327
  │ • PERFORM FINAL JSON-SHAPE VALIDATION of the campaign config: │
325
328
  │ – All Phase 2 + Phase 3 + Phase 4 outputs compose validly │
326
329
  │ – API-contract pre-check (type=2 DYNAMIC, valid report_type, │
327
- │ non-empty columns, sort references an emitted column)
328
- Generate report_title + report_description from final config
330
+ │ non-empty columns, sort references an emitted column,
331
+ report_title ≤60 chars non-empty, report_description
332
+ │ 1–3 sentences non-empty — both mandatory on save; CLI │
333
+ │ rejects with HTTP 400 if either is missing) │
329
334
  │ • Compose key takeaway insights │
330
335
  │ │
331
336
  │ ↘ FOLLOW-UP TRIGGERS: │
@@ -474,7 +479,13 @@ Each tool fires only when its criteria are explicitly met (no automatic / specul
474
479
  ### T1 — `tools/topic_matcher.md`
475
480
  **Fires when**: `ReportType ∈ {1, 2, 3}` AND USER_QUERY mentions a topic concept that could plausibly map to a curated topic in `thoughtleaders_topics`.
476
481
  **Skipped when**: `ReportType == 8` (sponsorships don't use topic matching at the SQL level) OR USER_QUERY is purely an entity-name lookup ("emails for these channels").
477
- **How to fetch the live topics**: see the `tl-cli:tl` skill's Postgres-schema reference [`tl/references/postgres-schema.md` → `thoughtleaders_topics`](../tl/references/postgres-schema.md#thoughtleaders_topics-curated-topic-taxonomy). That's the canonical home for the fetch query, column list, and "do not guess" regression markers. Don't restate the SQL here.
482
+ **How to fetch the live topics**: use the canonical fetch SQL documented at [`tl/references/postgres-schema.md` → `thoughtleaders_topics` → Fetch query](../tl/references/postgres-schema.md#fetch-query-canonical--use-verbatim). Single query, no `WHERE` clause; table has <20 rows so client-side filtering after the full fetch is free.
483
+
484
+ **Agent-behaviour rules** (encoded in [`tools/topic_matcher.md`](tools/topic_matcher.md); regression markers catalogued in the schema reference's "Cited regression markers" list):
485
+
486
+ - Don't push name-pattern `WHERE` clauses into the fetch query — agents have burnt credits + round-trips on this in multiple real runs.
487
+ - Don't run `information_schema.columns` to inspect the table.
488
+ - **Empty fetch ≠ off-taxonomy.** A zero-row result from the canonical (no-`WHERE`) fetch is a data-plane failure — surface it rather than silently falling through to T2. Off-taxonomy is when the fetch returns rows but the matcher emits `summary.no_match: true`.
478
489
  **Output**: per-topic verdicts (strong/weak/none) + summary. If `summary.strong_matches` non-empty, the topic's curated `keywords[]` array drives the FilterSet's `keywords` field (with per-position `content_fields` set via `keyword_content_fields_map` when a keyword targets a non-default match surface). Phase 2 may also emit the matched topic IDs directly via the FilterSet's `topics` field — both paths are valid; pick by intent.
479
490
 
480
491
  **Narrow-first FilterSet assembly (mandatory — applies to topic-strong + keyword_research paths both)**: Phase 2c MUST assemble the FilterSet with the **narrowest viable shape first**, then validate. Expand only if the count is below the type's narrow threshold. The two narrowing levers, **ranked by impact on noisy-niche / multilingual runs**:
@@ -1468,7 +1479,9 @@ Phase 4 is the terminal phase. It picks widgets, performs FINAL JSON-shape valid
1468
1479
  ### Process
1469
1480
 
1470
1481
  1. **Pick widgets via `tools/widget_builder.md`.** Inject `REPORT_TYPE`, `FILTERSET`, `COLUMNS`, `ROUTING_METADATA`, and the matching widget schema (`references/intelligence_widget_schema.json` for types 1/2/3; `references/sponsorship_widget_schema.json` for type 8). The builder emits `{ widgets, histogram_bucket_size, _widget_metadata }`. **The selection rule is: emit only widgets that add value to the user's original prompt.** A widget earns its slot if it answers a question the user implicitly cares about (intent), surfaces a metric tied to a filter the user named (niche), or shows a trend over the date scope they specified. Don't pad to hit 6 — emit fewer (down to 4) if the extras don't answer something. The builder handles type-8 axis branching and intent-driven swaps per the schema's `_tl_intent_overrides`.
1471
- 2. **FINAL JSON-shape validation pass.** Verify the composed config:
1482
+ 2. **Generate `report_title` and `report_description`** from the FilterSet + the user's original NL request. Title ≤ 60 chars; description 1–3 sentences summarizing intent + key filters. **Do this BEFORE step 3's validation pass** both fields are mandatory on save, so the validation in step 3 needs to see them populated.
1483
+ 3. **FINAL JSON-shape validation pass.** Verify the composed config:
1484
+ - **`report_title` is a non-empty string ≤ 60 chars AND `report_description` is a non-empty 1–3 sentences.** Both fields are **MANDATORY** on `tl reports create` — the CLI rejects with HTTP 400 `Missing required field: report_title` (or `report_description`) if either is missing. If step 2 (title/description generation) hasn't run yet, run it FIRST, then come back to this check. Verbatim regression marker (real run, LATAM cooking 2026-05-11): saved config omitted `report_title`; first `tl reports create --config-file <path> --yes` returned `Error (400): Missing required field: report_title` and the agent had to edit the transport file and retry. **Fail closed at this validation step rather than discovering the missing field at save time** — a save-side 400 wastes a CLI round-trip and a credit charge.
1472
1485
  - Every field in `filterset` exists in the schema and matches its declared type.
1473
1486
  - Every column in `columns` is in the type's column file.
1474
1487
  - Every aggregator in `widgets` is in the matching catalog (intelligence for 1/2/3, sponsorship for 8).
@@ -1477,7 +1490,6 @@ Phase 4 is the terminal phase. It picks widgets, performs FINAL JSON-shape valid
1477
1490
  - When `cross_references` is present, `report_type ∈ {1, 3}`.
1478
1491
  - When `filters_json.similar_to_channels` is present, no overlapping `keywords` / `topics` fields.
1479
1492
  - `type = 2` (DYNAMIC) and `report_type ∈ {1, 2, 3, 8}` — Campaign-model contract for the API endpoint.
1480
- 3. **Generate `report_title` and `report_description`** from the FilterSet + the user's original NL request. Title ≤ 60 chars; description 1–3 sentences summarizing intent + key filters.
1481
1493
  4. **Compose key takeaway insights** — see "Takeaway-composition rules" below. These are the headline observations the user reads in the Phase 4 message. The `_validation` block from Phase 2 carries through here — narrow-result notes, sample_judge reasoning, and validation_concerns are all surfaced as takeaways.
1482
1494
  5. **Emit the final deliverable.**
1483
1495
 
@@ -1596,6 +1608,8 @@ Render as Markdown links in the table cell — *not* the bare ID, *not* the YouT
1596
1608
 
1597
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.
1598
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
+
1599
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.
1600
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.
1601
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.
@@ -26,7 +26,23 @@ The orchestration injects two values:
26
26
 
27
27
  ### How to fetch the topics
28
28
 
29
- The fetch query, the column list, and the negative-column regression markers all live in the canonical Postgres-schema reference in the `tl-cli:tl` skill: **[`tl/references/postgres-schema.md` → `thoughtleaders_topics`](../../tl/references/postgres-schema.md#thoughtleaders_topics-curated-topic-taxonomy)**. Schema-shaped facts belong in that reference, not in tool text. Use the verbatim fetch query documented there. **Do not restate or paraphrase the schema here.** If you find yourself about to type `SELECT … FROM thoughtleaders_topics …` from memory, stop and consult the reference file instead. This tool's job is to score topics against the user query; the schema reference's job is to say what the underlying table looks like.
29
+ Use the canonical fetch SQL from the schema reference: **[`tl/references/postgres-schema.md` → `thoughtleaders_topics` → Fetch query](../../tl/references/postgres-schema.md#fetch-query-canonical--use-verbatim)**. The table has fewer than 20 rows; client-side filtering after the full fetch is free **filter the results in your head, not in SQL.** Column catalogue and "do not exist" markers live in the same reference; consult it when you need column-level facts.
30
+
31
+ **Agent-behaviour rules** (these are agent-side, not schema-shaped — the failure modes pinned here are catalogued in the schema reference's "Cited regression markers" list):
32
+
33
+ - ❌ Don't push a name-pattern `WHERE` clause into the query (e.g. `WHERE name ILIKE '%crypto%' OR name ILIKE '%web3%' OR ...`). Whatever the user said, the right path is fetch-all → match in your head.
34
+ - ❌ Don't run `information_schema.columns` to inspect the table. If you need column names, read the schema reference linked above.
35
+ - ❌ Don't retry the canonical fetch with broader patterns or different fields when the matcher reads the fetched rows and emits `summary.no_match: true` — that's off-taxonomy. Fall through to keyword_research (T2).
36
+
37
+ **Interpreting the fetch result:**
38
+
39
+ | Fetch result | Meaning | Next step |
40
+ |---|---|---|
41
+ | Non-empty, matcher emits ≥1 `strong` / `weak` verdict | Curated match found | Use the matched topic's `keywords[]` array in the FilterSet (topic-strong path) |
42
+ | Non-empty, matcher emits all `none` verdicts (`summary.no_match: true`) | **Off-taxonomy** — niche has no curated topic | Fall through to keyword_research (T2) |
43
+ | **Empty (zero rows returned)** | **Data-plane failure or empty taxonomy — NOT off-taxonomy.** The canonical fetch has no `WHERE` clause; an empty result means either the table is empty, the database returned an error, or the request was truncated. | Surface the failure rather than silently falling through to T2. If a re-fetch also returns empty, escalate to the user — silently bypassing curated topic matching on a real data-plane failure would mask the bug. |
44
+
45
+ The "Cited regression markers" section in the schema reference catalogues the anti-pattern shapes that have occurred in practice. Read it when you recognise the failure-mode shape in your own output.
30
46
 
31
47
  ---
32
48
 
@@ -1,3 +1,3 @@
1
1
  """ThoughtLeaders CLI — query sponsorship data, channels, brands, and intelligence."""
2
2
 
3
- __version__ = "0.6.23"
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