thoughtleaders-cli 0.6.25__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.
Files changed (96) hide show
  1. {thoughtleaders_cli-0.6.25 → thoughtleaders_cli-0.6.26}/.claude-plugin/plugin.json +1 -1
  2. {thoughtleaders_cli-0.6.25 → thoughtleaders_cli-0.6.26}/PKG-INFO +1 -1
  3. {thoughtleaders_cli-0.6.25 → thoughtleaders_cli-0.6.26}/pyproject.toml +1 -1
  4. {thoughtleaders_cli-0.6.25 → thoughtleaders_cli-0.6.26}/skills/tl/SKILL.md +5 -2
  5. thoughtleaders_cli-0.6.26/skills/tl-import/SKILL.md +180 -0
  6. {thoughtleaders_cli-0.6.25 → thoughtleaders_cli-0.6.26}/src/tl_cli/__init__.py +1 -1
  7. {thoughtleaders_cli-0.6.25 → thoughtleaders_cli-0.6.26}/src/tl_cli/auth/commands.py +9 -1
  8. thoughtleaders_cli-0.6.26/src/tl_cli/auth/finalize.py +88 -0
  9. {thoughtleaders_cli-0.6.25 → thoughtleaders_cli-0.6.26}/src/tl_cli/client/errors.py +2 -1
  10. {thoughtleaders_cli-0.6.25 → thoughtleaders_cli-0.6.26}/src/tl_cli/commands/balance.py +13 -0
  11. thoughtleaders_cli-0.6.26/src/tl_cli/commands/credits.py +184 -0
  12. {thoughtleaders_cli-0.6.25 → thoughtleaders_cli-0.6.26}/src/tl_cli/commands/whoami.py +18 -6
  13. {thoughtleaders_cli-0.6.25 → thoughtleaders_cli-0.6.26}/src/tl_cli/main.py +2 -0
  14. {thoughtleaders_cli-0.6.25 → thoughtleaders_cli-0.6.26}/src/tl_cli/self_update.py +42 -2
  15. thoughtleaders_cli-0.6.25/skills/bulk-import/SKILL.md +0 -90
  16. {thoughtleaders_cli-0.6.25 → thoughtleaders_cli-0.6.26}/.claude-plugin/marketplace.json +0 -0
  17. {thoughtleaders_cli-0.6.25 → thoughtleaders_cli-0.6.26}/.github/workflows/python-publish.yml +0 -0
  18. {thoughtleaders_cli-0.6.25 → thoughtleaders_cli-0.6.26}/.gitignore +0 -0
  19. {thoughtleaders_cli-0.6.25 → thoughtleaders_cli-0.6.26}/AGENTS.md +0 -0
  20. {thoughtleaders_cli-0.6.25 → thoughtleaders_cli-0.6.26}/CLAUDE.md +0 -0
  21. {thoughtleaders_cli-0.6.25 → thoughtleaders_cli-0.6.26}/LICENSE +0 -0
  22. {thoughtleaders_cli-0.6.25 → thoughtleaders_cli-0.6.26}/README.md +0 -0
  23. {thoughtleaders_cli-0.6.25 → thoughtleaders_cli-0.6.26}/agents/tl-analyst.md +0 -0
  24. {thoughtleaders_cli-0.6.25 → thoughtleaders_cli-0.6.26}/commands/tl-balance.md +0 -0
  25. {thoughtleaders_cli-0.6.25 → thoughtleaders_cli-0.6.26}/commands/tl-reports.md +0 -0
  26. {thoughtleaders_cli-0.6.25 → thoughtleaders_cli-0.6.26}/commands/tl-sponsorships.md +0 -0
  27. {thoughtleaders_cli-0.6.25 → thoughtleaders_cli-0.6.26}/commands/tl.md +0 -0
  28. {thoughtleaders_cli-0.6.25 → thoughtleaders_cli-0.6.26}/docs/architecture.md +0 -0
  29. {thoughtleaders_cli-0.6.25 → thoughtleaders_cli-0.6.26}/hooks/hooks.json +0 -0
  30. {thoughtleaders_cli-0.6.25 → thoughtleaders_cli-0.6.26}/hooks/scripts/post-usage.sh +0 -0
  31. {thoughtleaders_cli-0.6.25 → thoughtleaders_cli-0.6.26}/hooks/scripts/pre-check.sh +0 -0
  32. {thoughtleaders_cli-0.6.25 → thoughtleaders_cli-0.6.26}/skills/tl/references/business-glossary.md +0 -0
  33. {thoughtleaders_cli-0.6.25 → thoughtleaders_cli-0.6.26}/skills/tl/references/elasticsearch-schema.md +0 -0
  34. {thoughtleaders_cli-0.6.25 → thoughtleaders_cli-0.6.26}/skills/tl/references/firebolt-schema.md +0 -0
  35. {thoughtleaders_cli-0.6.25 → thoughtleaders_cli-0.6.26}/skills/tl/references/postgres-schema.md +0 -0
  36. {thoughtleaders_cli-0.6.25 → thoughtleaders_cli-0.6.26}/skills/tl-report-builder/SKILL.md +0 -0
  37. {thoughtleaders_cli-0.6.25 → thoughtleaders_cli-0.6.26}/skills/tl-report-builder/examples/e2e_findings.md +0 -0
  38. {thoughtleaders_cli-0.6.25 → thoughtleaders_cli-0.6.26}/skills/tl-report-builder/examples/golden_queries.md +0 -0
  39. {thoughtleaders_cli-0.6.25 → thoughtleaders_cli-0.6.26}/skills/tl-report-builder/references/columns_brands.md +0 -0
  40. {thoughtleaders_cli-0.6.25 → thoughtleaders_cli-0.6.26}/skills/tl-report-builder/references/columns_channels.md +0 -0
  41. {thoughtleaders_cli-0.6.25 → thoughtleaders_cli-0.6.26}/skills/tl-report-builder/references/columns_content.md +0 -0
  42. {thoughtleaders_cli-0.6.25 → thoughtleaders_cli-0.6.26}/skills/tl-report-builder/references/columns_sponsorships.md +0 -0
  43. {thoughtleaders_cli-0.6.25 → thoughtleaders_cli-0.6.26}/skills/tl-report-builder/references/intelligence_filterset_schema.json +0 -0
  44. {thoughtleaders_cli-0.6.25 → thoughtleaders_cli-0.6.26}/skills/tl-report-builder/references/intelligence_widget_schema.json +0 -0
  45. {thoughtleaders_cli-0.6.25 → thoughtleaders_cli-0.6.26}/skills/tl-report-builder/references/report_glossary.md +0 -0
  46. {thoughtleaders_cli-0.6.25 → thoughtleaders_cli-0.6.26}/skills/tl-report-builder/references/sortable_columns.json +0 -0
  47. {thoughtleaders_cli-0.6.25 → thoughtleaders_cli-0.6.26}/skills/tl-report-builder/references/sponsorship_filterset_schema.json +0 -0
  48. {thoughtleaders_cli-0.6.25 → thoughtleaders_cli-0.6.26}/skills/tl-report-builder/references/sponsorship_widget_schema.json +0 -0
  49. {thoughtleaders_cli-0.6.25 → thoughtleaders_cli-0.6.26}/skills/tl-report-builder/references/widgets.md +0 -0
  50. {thoughtleaders_cli-0.6.25 → thoughtleaders_cli-0.6.26}/skills/tl-report-builder/tools/column_builder.md +0 -0
  51. {thoughtleaders_cli-0.6.25 → thoughtleaders_cli-0.6.26}/skills/tl-report-builder/tools/database_query.md +0 -0
  52. {thoughtleaders_cli-0.6.25 → thoughtleaders_cli-0.6.26}/skills/tl-report-builder/tools/keyword_research.md +0 -0
  53. {thoughtleaders_cli-0.6.25 → thoughtleaders_cli-0.6.26}/skills/tl-report-builder/tools/name_resolver.md +0 -0
  54. {thoughtleaders_cli-0.6.25 → thoughtleaders_cli-0.6.26}/skills/tl-report-builder/tools/sample_judge.md +0 -0
  55. {thoughtleaders_cli-0.6.25 → thoughtleaders_cli-0.6.26}/skills/tl-report-builder/tools/similar_channels.md +0 -0
  56. {thoughtleaders_cli-0.6.25 → thoughtleaders_cli-0.6.26}/skills/tl-report-builder/tools/topic_matcher.md +0 -0
  57. {thoughtleaders_cli-0.6.25 → thoughtleaders_cli-0.6.26}/skills/tl-report-builder/tools/widget_builder.md +0 -0
  58. {thoughtleaders_cli-0.6.25 → thoughtleaders_cli-0.6.26}/src/tl_cli/_completions.py +0 -0
  59. {thoughtleaders_cli-0.6.25 → thoughtleaders_cli-0.6.26}/src/tl_cli/auth/__init__.py +0 -0
  60. {thoughtleaders_cli-0.6.25 → thoughtleaders_cli-0.6.26}/src/tl_cli/auth/login.py +0 -0
  61. {thoughtleaders_cli-0.6.25 → thoughtleaders_cli-0.6.26}/src/tl_cli/auth/pkce.py +0 -0
  62. {thoughtleaders_cli-0.6.25 → thoughtleaders_cli-0.6.26}/src/tl_cli/auth/token_store.py +0 -0
  63. {thoughtleaders_cli-0.6.25 → thoughtleaders_cli-0.6.26}/src/tl_cli/client/__init__.py +0 -0
  64. {thoughtleaders_cli-0.6.25 → thoughtleaders_cli-0.6.26}/src/tl_cli/client/http.py +0 -0
  65. {thoughtleaders_cli-0.6.25 → thoughtleaders_cli-0.6.26}/src/tl_cli/commands/__init__.py +0 -0
  66. {thoughtleaders_cli-0.6.25 → thoughtleaders_cli-0.6.26}/src/tl_cli/commands/_comments_common.py +0 -0
  67. {thoughtleaders_cli-0.6.25 → thoughtleaders_cli-0.6.26}/src/tl_cli/commands/ask.py +0 -0
  68. {thoughtleaders_cli-0.6.25 → thoughtleaders_cli-0.6.26}/src/tl_cli/commands/brands.py +0 -0
  69. {thoughtleaders_cli-0.6.25 → thoughtleaders_cli-0.6.26}/src/tl_cli/commands/bulk_import.py +0 -0
  70. {thoughtleaders_cli-0.6.25 → thoughtleaders_cli-0.6.26}/src/tl_cli/commands/changelog.py +0 -0
  71. {thoughtleaders_cli-0.6.25 → thoughtleaders_cli-0.6.26}/src/tl_cli/commands/channels.py +0 -0
  72. {thoughtleaders_cli-0.6.25 → thoughtleaders_cli-0.6.26}/src/tl_cli/commands/db.py +0 -0
  73. {thoughtleaders_cli-0.6.25 → thoughtleaders_cli-0.6.26}/src/tl_cli/commands/deals.py +0 -0
  74. {thoughtleaders_cli-0.6.25 → thoughtleaders_cli-0.6.26}/src/tl_cli/commands/describe.py +0 -0
  75. {thoughtleaders_cli-0.6.25 → thoughtleaders_cli-0.6.26}/src/tl_cli/commands/doctor.py +0 -0
  76. {thoughtleaders_cli-0.6.25 → thoughtleaders_cli-0.6.26}/src/tl_cli/commands/matches.py +0 -0
  77. {thoughtleaders_cli-0.6.25 → thoughtleaders_cli-0.6.26}/src/tl_cli/commands/proposals.py +0 -0
  78. {thoughtleaders_cli-0.6.25 → thoughtleaders_cli-0.6.26}/src/tl_cli/commands/recommender.py +0 -0
  79. {thoughtleaders_cli-0.6.25 → thoughtleaders_cli-0.6.26}/src/tl_cli/commands/reports.py +0 -0
  80. {thoughtleaders_cli-0.6.25 → thoughtleaders_cli-0.6.26}/src/tl_cli/commands/schema.py +0 -0
  81. {thoughtleaders_cli-0.6.25 → thoughtleaders_cli-0.6.26}/src/tl_cli/commands/setup.py +0 -0
  82. {thoughtleaders_cli-0.6.25 → thoughtleaders_cli-0.6.26}/src/tl_cli/commands/snapshots.py +0 -0
  83. {thoughtleaders_cli-0.6.25 → thoughtleaders_cli-0.6.26}/src/tl_cli/commands/sponsorships.py +0 -0
  84. {thoughtleaders_cli-0.6.25 → thoughtleaders_cli-0.6.26}/src/tl_cli/commands/uploads.py +0 -0
  85. {thoughtleaders_cli-0.6.25 → thoughtleaders_cli-0.6.26}/src/tl_cli/config.py +0 -0
  86. {thoughtleaders_cli-0.6.25 → thoughtleaders_cli-0.6.26}/src/tl_cli/filters.py +0 -0
  87. {thoughtleaders_cli-0.6.25 → thoughtleaders_cli-0.6.26}/src/tl_cli/hints.py +0 -0
  88. {thoughtleaders_cli-0.6.25 → thoughtleaders_cli-0.6.26}/src/tl_cli/output/__init__.py +0 -0
  89. {thoughtleaders_cli-0.6.25 → thoughtleaders_cli-0.6.26}/src/tl_cli/output/formatter.py +0 -0
  90. {thoughtleaders_cli-0.6.25 → thoughtleaders_cli-0.6.26}/tests/__init__.py +0 -0
  91. {thoughtleaders_cli-0.6.25 → thoughtleaders_cli-0.6.26}/tests/test_auth.py +0 -0
  92. {thoughtleaders_cli-0.6.25 → thoughtleaders_cli-0.6.26}/tests/test_filters.py +0 -0
  93. {thoughtleaders_cli-0.6.25 → thoughtleaders_cli-0.6.26}/tests/test_output.py +0 -0
  94. {thoughtleaders_cli-0.6.25 → thoughtleaders_cli-0.6.26}/tests/test_reports.py +0 -0
  95. {thoughtleaders_cli-0.6.25 → thoughtleaders_cli-0.6.26}/tests/test_sponsorships.py +0 -0
  96. {thoughtleaders_cli-0.6.25 → thoughtleaders_cli-0.6.26}/uv.lock +0 -0
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tl-cli",
3
- "version": "0.6.25",
3
+ "version": "0.6.26",
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.25
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
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "thoughtleaders-cli"
7
- version = "0.6.25"
7
+ version = "0.6.26"
8
8
  description = "ThoughtLeaders CLI — query sponsorship data, channels, brands, and intelligence"
9
9
  readme = "README.md"
10
10
  license = "MIT"
@@ -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)
@@ -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.
@@ -1,3 +1,3 @@
1
1
  """ThoughtLeaders CLI — query sponsorship data, channels, brands, and intelligence."""
2
2
 
3
- __version__ = "0.6.25"
3
+ __version__ = "0.6.26"
@@ -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("Deposit more at: https://app.thoughtleaders.io/settings/billing")
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,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
- title.append(name or user.get("email", ""), style="bold cyan")
31
- if name:
32
- title.append(f" {user.get('email', '')}", style="dim")
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
- print(f"# {name or user.get('email', '')}\n")
122
- if name:
123
- print(f"- **Email:** {user.get('email', '')}")
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:
@@ -17,6 +17,7 @@ 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
19
  from tl_cli.commands.bulk_import import bulk_import_command
20
+ from tl_cli.commands.credits import app as credits_app
20
21
  from tl_cli.commands.channels import app as channels_app
21
22
  from tl_cli.commands.db import app as db_app
22
23
  from tl_cli.commands.deals import app as deals_app
@@ -104,6 +105,7 @@ app.add_typer(db_app, name="db")
104
105
  app.add_typer(describe_app, name="describe")
105
106
  app.add_typer(schema_app, name="schema")
106
107
  app.add_typer(balance_app, name="balance")
108
+ app.add_typer(credits_app, name="credits")
107
109
  app.add_typer(doctor_app, name="doctor")
108
110
  app.add_typer(whoami_app, name="whoami")
109
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:
@@ -1,90 +0,0 @@
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.