thoughtleaders-cli 0.9.7__tar.gz → 0.9.9__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 (165) hide show
  1. {thoughtleaders_cli-0.9.7 → thoughtleaders_cli-0.9.9}/.claude-plugin/plugin.json +1 -1
  2. thoughtleaders_cli-0.9.9/.github/workflows/ci.yml +66 -0
  3. {thoughtleaders_cli-0.9.7 → thoughtleaders_cli-0.9.9}/PKG-INFO +21 -2
  4. {thoughtleaders_cli-0.9.7 → thoughtleaders_cli-0.9.9}/README.md +19 -0
  5. {thoughtleaders_cli-0.9.7 → thoughtleaders_cli-0.9.9}/hooks/scripts/post-usage.sh +1 -1
  6. {thoughtleaders_cli-0.9.7 → thoughtleaders_cli-0.9.9}/pyproject.toml +1 -1
  7. {thoughtleaders_cli-0.9.7 → thoughtleaders_cli-0.9.9}/skills/tl/SKILL.md +1 -1
  8. {thoughtleaders_cli-0.9.7 → thoughtleaders_cli-0.9.9}/skills/tl/references/postgres-schema.md +6 -3
  9. {thoughtleaders_cli-0.9.7 → thoughtleaders_cli-0.9.9}/src/tl_cli/__init__.py +1 -1
  10. thoughtleaders_cli-0.9.9/src/tl_cli/__main__.py +13 -0
  11. thoughtleaders_cli-0.9.9/src/tl_cli/client/errors.py +122 -0
  12. {thoughtleaders_cli-0.9.7 → thoughtleaders_cli-0.9.9}/src/tl_cli/commands/balance.py +12 -1
  13. {thoughtleaders_cli-0.9.7 → thoughtleaders_cli-0.9.9}/src/tl_cli/commands/setup.py +71 -0
  14. {thoughtleaders_cli-0.9.7 → thoughtleaders_cli-0.9.9}/src/tl_cli/output/formatter.py +36 -11
  15. thoughtleaders_cli-0.9.9/tests/test_quota_refusal_message.py +68 -0
  16. thoughtleaders_cli-0.9.9/tests/test_refusal_rendering.py +109 -0
  17. thoughtleaders_cli-0.9.7/.github/workflows/ci.yml +0 -38
  18. thoughtleaders_cli-0.9.7/src/tl_cli/client/errors.py +0 -84
  19. {thoughtleaders_cli-0.9.7 → thoughtleaders_cli-0.9.9}/.claude-plugin/marketplace.json +0 -0
  20. {thoughtleaders_cli-0.9.7 → thoughtleaders_cli-0.9.9}/.github/dependabot.yml +0 -0
  21. {thoughtleaders_cli-0.9.7 → thoughtleaders_cli-0.9.9}/.github/workflows/cli-integration.yml +0 -0
  22. {thoughtleaders_cli-0.9.7 → thoughtleaders_cli-0.9.9}/.github/workflows/python-publish.yml +0 -0
  23. {thoughtleaders_cli-0.9.7 → thoughtleaders_cli-0.9.9}/.gitignore +0 -0
  24. {thoughtleaders_cli-0.9.7 → thoughtleaders_cli-0.9.9}/AGENTS.md +0 -0
  25. {thoughtleaders_cli-0.9.7 → thoughtleaders_cli-0.9.9}/API.md +0 -0
  26. {thoughtleaders_cli-0.9.7 → thoughtleaders_cli-0.9.9}/CLAUDE.md +0 -0
  27. {thoughtleaders_cli-0.9.7 → thoughtleaders_cli-0.9.9}/LICENSE +0 -0
  28. {thoughtleaders_cli-0.9.7 → thoughtleaders_cli-0.9.9}/agents/keyword-context-classifier.md +0 -0
  29. {thoughtleaders_cli-0.9.7 → thoughtleaders_cli-0.9.9}/agents/keyword-entity-resolver.md +0 -0
  30. {thoughtleaders_cli-0.9.7 → thoughtleaders_cli-0.9.9}/agents/keyword-relevance-validator.md +0 -0
  31. {thoughtleaders_cli-0.9.7 → thoughtleaders_cli-0.9.9}/agents/tl-analyst.md +0 -0
  32. {thoughtleaders_cli-0.9.7 → thoughtleaders_cli-0.9.9}/agents/youtube-comment-classifier.md +0 -0
  33. {thoughtleaders_cli-0.9.7 → thoughtleaders_cli-0.9.9}/hooks/hooks.json +0 -0
  34. {thoughtleaders_cli-0.9.7 → thoughtleaders_cli-0.9.9}/hooks/scripts/load-tl-skill.mjs +0 -0
  35. {thoughtleaders_cli-0.9.7 → thoughtleaders_cli-0.9.9}/hooks/scripts/pre-check.sh +0 -0
  36. {thoughtleaders_cli-0.9.7 → thoughtleaders_cli-0.9.9}/skills/tl/references/business-glossary.md +0 -0
  37. {thoughtleaders_cli-0.9.7 → thoughtleaders_cli-0.9.9}/skills/tl/references/elasticsearch-schema.md +0 -0
  38. {thoughtleaders_cli-0.9.7 → thoughtleaders_cli-0.9.9}/skills/tl/references/firebolt-schema.md +0 -0
  39. {thoughtleaders_cli-0.9.7 → thoughtleaders_cli-0.9.9}/skills/tl-channel-authenticity/.gitignore +0 -0
  40. {thoughtleaders_cli-0.9.7 → thoughtleaders_cli-0.9.9}/skills/tl-channel-authenticity/SKILL.md +0 -0
  41. {thoughtleaders_cli-0.9.7 → thoughtleaders_cli-0.9.9}/skills/tl-channel-authenticity/references/comment-patterns.md +0 -0
  42. {thoughtleaders_cli-0.9.7 → thoughtleaders_cli-0.9.9}/skills/tl-channel-authenticity/references/peer-cohort.md +0 -0
  43. {thoughtleaders_cli-0.9.7 → thoughtleaders_cli-0.9.9}/skills/tl-channel-authenticity/references/red-flags.md +0 -0
  44. {thoughtleaders_cli-0.9.7 → thoughtleaders_cli-0.9.9}/skills/tl-channel-authenticity/references/scoring.md +0 -0
  45. {thoughtleaders_cli-0.9.7 → thoughtleaders_cli-0.9.9}/skills/tl-channel-authenticity/scripts/_io_utf8.py +0 -0
  46. {thoughtleaders_cli-0.9.7 → thoughtleaders_cli-0.9.9}/skills/tl-channel-authenticity/scripts/analyze_channel.py +0 -0
  47. {thoughtleaders_cli-0.9.7 → thoughtleaders_cli-0.9.9}/skills/tl-channel-authenticity/scripts/anomaly_detector.py +0 -0
  48. {thoughtleaders_cli-0.9.7 → thoughtleaders_cli-0.9.9}/skills/tl-channel-authenticity/scripts/comment_analyzer.py +0 -0
  49. {thoughtleaders_cli-0.9.7 → thoughtleaders_cli-0.9.9}/skills/tl-channel-authenticity/scripts/comment_scraper.py +0 -0
  50. {thoughtleaders_cli-0.9.7 → thoughtleaders_cli-0.9.9}/skills/tl-channel-authenticity/scripts/engagement_ratios.py +0 -0
  51. {thoughtleaders_cli-0.9.7 → thoughtleaders_cli-0.9.9}/skills/tl-channel-authenticity/scripts/peer_cohort.py +0 -0
  52. {thoughtleaders_cli-0.9.7 → thoughtleaders_cli-0.9.9}/skills/tl-channel-authenticity/scripts/report.py +0 -0
  53. {thoughtleaders_cli-0.9.7 → thoughtleaders_cli-0.9.9}/skills/tl-channel-authenticity/scripts/resolve_channel.py +0 -0
  54. {thoughtleaders_cli-0.9.7 → thoughtleaders_cli-0.9.9}/skills/tl-channel-authenticity/scripts/score.py +0 -0
  55. {thoughtleaders_cli-0.9.7 → thoughtleaders_cli-0.9.9}/skills/tl-channel-authenticity/scripts/tl_cli.py +0 -0
  56. {thoughtleaders_cli-0.9.7 → thoughtleaders_cli-0.9.9}/skills/tl-channel-authenticity/scripts/video_integrity.py +0 -0
  57. {thoughtleaders_cli-0.9.7 → thoughtleaders_cli-0.9.9}/skills/tl-channel-authenticity/scripts/view_curves.py +0 -0
  58. {thoughtleaders_cli-0.9.7 → thoughtleaders_cli-0.9.9}/skills/tl-create-workflow/SKILL.md +0 -0
  59. {thoughtleaders_cli-0.9.7 → thoughtleaders_cli-0.9.9}/skills/tl-create-workflow/references/creating-in-app.md +0 -0
  60. {thoughtleaders_cli-0.9.7 → thoughtleaders_cli-0.9.9}/skills/tl-create-workflow/references/methodology.md +0 -0
  61. {thoughtleaders_cli-0.9.7 → thoughtleaders_cli-0.9.9}/skills/tl-create-workflow/references/pitfalls.md +0 -0
  62. {thoughtleaders_cli-0.9.7 → thoughtleaders_cli-0.9.9}/skills/tl-create-workflow/references/workflow-model.md +0 -0
  63. {thoughtleaders_cli-0.9.7 → thoughtleaders_cli-0.9.9}/skills/tl-keyword-research/SKILL.md +0 -0
  64. {thoughtleaders_cli-0.9.7 → thoughtleaders_cli-0.9.9}/skills/tl-keyword-research/references/elasticsearch-content-search.md +0 -0
  65. {thoughtleaders_cli-0.9.7 → thoughtleaders_cli-0.9.9}/skills/tl-keyword-research/references/help.md +0 -0
  66. {thoughtleaders_cli-0.9.7 → thoughtleaders_cli-0.9.9}/skills/tl-keyword-research/scripts/build_report.py +0 -0
  67. {thoughtleaders_cli-0.9.7 → thoughtleaders_cli-0.9.9}/skills/tl-keyword-research/scripts/expand_entities.py +0 -0
  68. {thoughtleaders_cli-0.9.7 → thoughtleaders_cli-0.9.9}/skills/tl-keyword-research/scripts/fetch_context.py +0 -0
  69. {thoughtleaders_cli-0.9.7 → thoughtleaders_cli-0.9.9}/skills/tl-keyword-research/scripts/probe.py +0 -0
  70. {thoughtleaders_cli-0.9.7 → thoughtleaders_cli-0.9.9}/skills/tl-keyword-research/scripts/search_channels.py +0 -0
  71. {thoughtleaders_cli-0.9.7 → thoughtleaders_cli-0.9.9}/skills/tl-keyword-research/scripts/search_videos.py +0 -0
  72. {thoughtleaders_cli-0.9.7 → thoughtleaders_cli-0.9.9}/skills/tl-keyword-research/scripts/select_keywords.py +0 -0
  73. {thoughtleaders_cli-0.9.7 → thoughtleaders_cli-0.9.9}/skills/tl-save-report/SKILL.md +0 -0
  74. {thoughtleaders_cli-0.9.7 → thoughtleaders_cli-0.9.9}/skills/tl-save-report/references/columns_brands.md +0 -0
  75. {thoughtleaders_cli-0.9.7 → thoughtleaders_cli-0.9.9}/skills/tl-save-report/references/columns_channels.md +0 -0
  76. {thoughtleaders_cli-0.9.7 → thoughtleaders_cli-0.9.9}/skills/tl-save-report/references/columns_content.md +0 -0
  77. {thoughtleaders_cli-0.9.7 → thoughtleaders_cli-0.9.9}/skills/tl-save-report/references/columns_sponsorships.md +0 -0
  78. {thoughtleaders_cli-0.9.7 → thoughtleaders_cli-0.9.9}/skills/tl-save-report/references/intelligence_filterset_schema.json +0 -0
  79. {thoughtleaders_cli-0.9.7 → thoughtleaders_cli-0.9.9}/skills/tl-save-report/references/intelligence_widget_schema.json +0 -0
  80. {thoughtleaders_cli-0.9.7 → thoughtleaders_cli-0.9.9}/skills/tl-save-report/references/report_glossary.md +0 -0
  81. {thoughtleaders_cli-0.9.7 → thoughtleaders_cli-0.9.9}/skills/tl-save-report/references/sortable_columns.json +0 -0
  82. {thoughtleaders_cli-0.9.7 → thoughtleaders_cli-0.9.9}/skills/tl-save-report/references/sponsorship_filterset_schema.json +0 -0
  83. {thoughtleaders_cli-0.9.7 → thoughtleaders_cli-0.9.9}/skills/tl-save-report/references/sponsorship_widget_schema.json +0 -0
  84. {thoughtleaders_cli-0.9.7 → thoughtleaders_cli-0.9.9}/skills/tl-save-report/references/widgets.md +0 -0
  85. {thoughtleaders_cli-0.9.7 → thoughtleaders_cli-0.9.9}/skills/tl-top-partnerships/SKILL.md +0 -0
  86. {thoughtleaders_cli-0.9.7 → thoughtleaders_cli-0.9.9}/skills/tl-top-partnerships/scripts/top_partnerships.py +0 -0
  87. {thoughtleaders_cli-0.9.7 → thoughtleaders_cli-0.9.9}/skills/tl-views-guarantee/SKILL.md +0 -0
  88. {thoughtleaders_cli-0.9.7 → thoughtleaders_cli-0.9.9}/skills/tl-views-guarantee/scripts/vg.py +0 -0
  89. {thoughtleaders_cli-0.9.7 → thoughtleaders_cli-0.9.9}/src/tl_cli/_completions.py +0 -0
  90. {thoughtleaders_cli-0.9.7 → thoughtleaders_cli-0.9.9}/src/tl_cli/_typer_utils.py +0 -0
  91. {thoughtleaders_cli-0.9.7 → thoughtleaders_cli-0.9.9}/src/tl_cli/auth/__init__.py +0 -0
  92. {thoughtleaders_cli-0.9.7 → thoughtleaders_cli-0.9.9}/src/tl_cli/auth/commands.py +0 -0
  93. {thoughtleaders_cli-0.9.7 → thoughtleaders_cli-0.9.9}/src/tl_cli/auth/login.py +0 -0
  94. {thoughtleaders_cli-0.9.7 → thoughtleaders_cli-0.9.9}/src/tl_cli/auth/pkce.py +0 -0
  95. {thoughtleaders_cli-0.9.7 → thoughtleaders_cli-0.9.9}/src/tl_cli/auth/token_store.py +0 -0
  96. {thoughtleaders_cli-0.9.7 → thoughtleaders_cli-0.9.9}/src/tl_cli/client/__init__.py +0 -0
  97. {thoughtleaders_cli-0.9.7 → thoughtleaders_cli-0.9.9}/src/tl_cli/client/http.py +0 -0
  98. {thoughtleaders_cli-0.9.7 → thoughtleaders_cli-0.9.9}/src/tl_cli/commands/__init__.py +0 -0
  99. {thoughtleaders_cli-0.9.7 → thoughtleaders_cli-0.9.9}/src/tl_cli/commands/_comments_common.py +0 -0
  100. {thoughtleaders_cli-0.9.7 → thoughtleaders_cli-0.9.9}/src/tl_cli/commands/brands.py +0 -0
  101. {thoughtleaders_cli-0.9.7 → thoughtleaders_cli-0.9.9}/src/tl_cli/commands/bulk_import.py +0 -0
  102. {thoughtleaders_cli-0.9.7 → thoughtleaders_cli-0.9.9}/src/tl_cli/commands/changelog.py +0 -0
  103. {thoughtleaders_cli-0.9.7 → thoughtleaders_cli-0.9.9}/src/tl_cli/commands/channels.py +0 -0
  104. {thoughtleaders_cli-0.9.7 → thoughtleaders_cli-0.9.9}/src/tl_cli/commands/credits.py +0 -0
  105. {thoughtleaders_cli-0.9.7 → thoughtleaders_cli-0.9.9}/src/tl_cli/commands/db.py +0 -0
  106. {thoughtleaders_cli-0.9.7 → thoughtleaders_cli-0.9.9}/src/tl_cli/commands/deals.py +0 -0
  107. {thoughtleaders_cli-0.9.7 → thoughtleaders_cli-0.9.9}/src/tl_cli/commands/describe.py +0 -0
  108. {thoughtleaders_cli-0.9.7 → thoughtleaders_cli-0.9.9}/src/tl_cli/commands/doctor.py +0 -0
  109. {thoughtleaders_cli-0.9.7 → thoughtleaders_cli-0.9.9}/src/tl_cli/commands/matches.py +0 -0
  110. {thoughtleaders_cli-0.9.7 → thoughtleaders_cli-0.9.9}/src/tl_cli/commands/memory.py +0 -0
  111. {thoughtleaders_cli-0.9.7 → thoughtleaders_cli-0.9.9}/src/tl_cli/commands/profiles.py +0 -0
  112. {thoughtleaders_cli-0.9.7 → thoughtleaders_cli-0.9.9}/src/tl_cli/commands/proposals.py +0 -0
  113. {thoughtleaders_cli-0.9.7 → thoughtleaders_cli-0.9.9}/src/tl_cli/commands/recommender.py +0 -0
  114. {thoughtleaders_cli-0.9.7 → thoughtleaders_cli-0.9.9}/src/tl_cli/commands/reports.py +0 -0
  115. {thoughtleaders_cli-0.9.7 → thoughtleaders_cli-0.9.9}/src/tl_cli/commands/schema.py +0 -0
  116. {thoughtleaders_cli-0.9.7 → thoughtleaders_cli-0.9.9}/src/tl_cli/commands/skills.py +0 -0
  117. {thoughtleaders_cli-0.9.7 → thoughtleaders_cli-0.9.9}/src/tl_cli/commands/snapshots.py +0 -0
  118. {thoughtleaders_cli-0.9.7 → thoughtleaders_cli-0.9.9}/src/tl_cli/commands/sponsorships.py +0 -0
  119. {thoughtleaders_cli-0.9.7 → thoughtleaders_cli-0.9.9}/src/tl_cli/commands/uploads.py +0 -0
  120. {thoughtleaders_cli-0.9.7 → thoughtleaders_cli-0.9.9}/src/tl_cli/commands/whoami.py +0 -0
  121. {thoughtleaders_cli-0.9.7 → thoughtleaders_cli-0.9.9}/src/tl_cli/commands/workflows.py +0 -0
  122. {thoughtleaders_cli-0.9.7 → thoughtleaders_cli-0.9.9}/src/tl_cli/config.py +0 -0
  123. {thoughtleaders_cli-0.9.7 → thoughtleaders_cli-0.9.9}/src/tl_cli/filters.py +0 -0
  124. {thoughtleaders_cli-0.9.7 → thoughtleaders_cli-0.9.9}/src/tl_cli/hints.py +0 -0
  125. {thoughtleaders_cli-0.9.7 → thoughtleaders_cli-0.9.9}/src/tl_cli/main.py +0 -0
  126. {thoughtleaders_cli-0.9.7 → thoughtleaders_cli-0.9.9}/src/tl_cli/output/__init__.py +0 -0
  127. {thoughtleaders_cli-0.9.7 → thoughtleaders_cli-0.9.9}/src/tl_cli/query_history.py +0 -0
  128. {thoughtleaders_cli-0.9.7 → thoughtleaders_cli-0.9.9}/src/tl_cli/self_update.py +0 -0
  129. {thoughtleaders_cli-0.9.7 → thoughtleaders_cli-0.9.9}/src/tl_cli/skill_registry.py +0 -0
  130. {thoughtleaders_cli-0.9.7 → thoughtleaders_cli-0.9.9}/tests/__init__.py +0 -0
  131. {thoughtleaders_cli-0.9.7 → thoughtleaders_cli-0.9.9}/tests/test_auth.py +0 -0
  132. {thoughtleaders_cli-0.9.7 → thoughtleaders_cli-0.9.9}/tests/test_brands_winner_channels.py +0 -0
  133. {thoughtleaders_cli-0.9.7 → thoughtleaders_cli-0.9.9}/tests/test_build_report.py +0 -0
  134. {thoughtleaders_cli-0.9.7 → thoughtleaders_cli-0.9.9}/tests/test_channels_lookalike.py +0 -0
  135. {thoughtleaders_cli-0.9.7 → thoughtleaders_cli-0.9.9}/tests/test_db_repeat_warning.py +0 -0
  136. {thoughtleaders_cli-0.9.7 → thoughtleaders_cli-0.9.9}/tests/test_describe.py +0 -0
  137. {thoughtleaders_cli-0.9.7 → thoughtleaders_cli-0.9.9}/tests/test_error_hints.py +0 -0
  138. {thoughtleaders_cli-0.9.7 → thoughtleaders_cli-0.9.9}/tests/test_expand_entities.py +0 -0
  139. {thoughtleaders_cli-0.9.7 → thoughtleaders_cli-0.9.9}/tests/test_filters.py +0 -0
  140. {thoughtleaders_cli-0.9.7 → thoughtleaders_cli-0.9.9}/tests/test_http_auth.py +0 -0
  141. {thoughtleaders_cli-0.9.7 → thoughtleaders_cli-0.9.9}/tests/test_memory.py +0 -0
  142. {thoughtleaders_cli-0.9.7 → thoughtleaders_cli-0.9.9}/tests/test_output.py +0 -0
  143. {thoughtleaders_cli-0.9.7 → thoughtleaders_cli-0.9.9}/tests/test_probe.py +0 -0
  144. {thoughtleaders_cli-0.9.7 → thoughtleaders_cli-0.9.9}/tests/test_profiles.py +0 -0
  145. {thoughtleaders_cli-0.9.7 → thoughtleaders_cli-0.9.9}/tests/test_reports.py +0 -0
  146. {thoughtleaders_cli-0.9.7 → thoughtleaders_cli-0.9.9}/tests/test_scripts_executable.py +0 -0
  147. {thoughtleaders_cli-0.9.7 → thoughtleaders_cli-0.9.9}/tests/test_search_channels.py +0 -0
  148. {thoughtleaders_cli-0.9.7 → thoughtleaders_cli-0.9.9}/tests/test_search_videos.py +0 -0
  149. {thoughtleaders_cli-0.9.7 → thoughtleaders_cli-0.9.9}/tests/test_select_keywords.py +0 -0
  150. {thoughtleaders_cli-0.9.7 → thoughtleaders_cli-0.9.9}/tests/test_setup.py +0 -0
  151. {thoughtleaders_cli-0.9.7 → thoughtleaders_cli-0.9.9}/tests/test_setup_marker_respect.py +0 -0
  152. {thoughtleaders_cli-0.9.7 → thoughtleaders_cli-0.9.9}/tests/test_skill_registry.py +0 -0
  153. {thoughtleaders_cli-0.9.7 → thoughtleaders_cli-0.9.9}/tests/test_skills_command.py +0 -0
  154. {thoughtleaders_cli-0.9.7 → thoughtleaders_cli-0.9.9}/tests/test_sponsorships.py +0 -0
  155. {thoughtleaders_cli-0.9.7 → thoughtleaders_cli-0.9.9}/tests/test_whoami_newsletter.py +0 -0
  156. {thoughtleaders_cli-0.9.7 → thoughtleaders_cli-0.9.9}/tests/test_workflows.py +0 -0
  157. {thoughtleaders_cli-0.9.7 → thoughtleaders_cli-0.9.9}/tests_cli/AGENTS.md +0 -0
  158. {thoughtleaders_cli-0.9.7 → thoughtleaders_cli-0.9.9}/tests_cli/conftest.py +0 -0
  159. {thoughtleaders_cli-0.9.7 → thoughtleaders_cli-0.9.9}/tests_cli/test_balance.py +0 -0
  160. {thoughtleaders_cli-0.9.7 → thoughtleaders_cli-0.9.9}/tests_cli/test_db_es.py +0 -0
  161. {thoughtleaders_cli-0.9.7 → thoughtleaders_cli-0.9.9}/tests_cli/test_db_fb.py +0 -0
  162. {thoughtleaders_cli-0.9.7 → thoughtleaders_cli-0.9.9}/tests_cli/test_db_pg.py +0 -0
  163. {thoughtleaders_cli-0.9.7 → thoughtleaders_cli-0.9.9}/tests_cli/test_schema.py +0 -0
  164. {thoughtleaders_cli-0.9.7 → thoughtleaders_cli-0.9.9}/tests_cli/test_whoami.py +0 -0
  165. {thoughtleaders_cli-0.9.7 → thoughtleaders_cli-0.9.9}/uv.lock +0 -0
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tl-cli",
3
- "version": "0.9.7",
3
+ "version": "0.9.9",
4
4
  "description": "ThoughtLeaders CLI — query sponsorship deals, channels, brands, uploads, and intelligence from the terminal",
5
5
  "author": {
6
6
  "name": "ThoughtLeaders",
@@ -0,0 +1,66 @@
1
+ name: CI
2
+
3
+ # Fast, hermetic checks on every push to main and every PR. These run the
4
+ # mocked unit suite under tests/ — no live API, no credits, fully
5
+ # deterministic. The live CLI tests live in cli-integration.yml.
6
+
7
+ on:
8
+ push:
9
+ branches: [main]
10
+ pull_request:
11
+
12
+ permissions:
13
+ contents: read
14
+
15
+ concurrency:
16
+ group: ci-${{ github.workflow }}-${{ github.ref }}
17
+ cancel-in-progress: true
18
+
19
+ jobs:
20
+ unit-tests:
21
+ name: Unit tests (mocked) · py${{ matrix.python-version }}
22
+ runs-on: ubuntu-latest
23
+ strategy:
24
+ fail-fast: false
25
+ matrix:
26
+ # Matches `requires-python = ">=3.12"`.
27
+ python-version: ["3.12", "3.13", "3.14"]
28
+ steps:
29
+ - uses: actions/checkout@v7
30
+ - uses: astral-sh/setup-uv@v7
31
+ with:
32
+ enable-cache: true
33
+ - name: Sync locked dependencies
34
+ run: uv sync --frozen --python ${{ matrix.python-version }}
35
+ - name: Run unit tests
36
+ # tests/ mock the HTTP client (CliRunner + fake get_client), so there
37
+ # is no network, no API key, and no credit spend.
38
+ run: uv run --python ${{ matrix.python-version }} --with pytest pytest tests/ -v
39
+
40
+ windows-launcher:
41
+ name: Windows launcher (tl setup windows)
42
+ # `tl setup windows` writes a tl.cmd that runs the CLI through the signed
43
+ # python.exe, so the unsigned pipx stub can't be blocked by Smart App
44
+ # Control. This job proves the wrapper is written and actually launches the
45
+ # CLI. NOTE: GitHub runners don't have SAC in enforcement, so this verifies
46
+ # the mechanism, not the block itself — real SAC validation needs a SAC/WDAC
47
+ # machine. Hermetic: a uv project install is a dev install, so the CLI's
48
+ # auto-updater no-ops (no network).
49
+ runs-on: windows-latest
50
+ steps:
51
+ - uses: actions/checkout@v7
52
+ - uses: astral-sh/setup-uv@v7
53
+ with:
54
+ enable-cache: true
55
+ - name: Sync locked dependencies
56
+ run: uv sync --frozen
57
+ - name: Install the launcher wrapper
58
+ run: uv run tl setup windows
59
+ - name: Wrapper exists and launches the CLI through python
60
+ shell: pwsh
61
+ run: |
62
+ $cmd = "$env:LOCALAPPDATA\Programs\tl-cli\tl.cmd"
63
+ if (-not (Test-Path $cmd)) { throw "wrapper not written at $cmd" }
64
+ Get-Content $cmd
65
+ & $cmd --version
66
+ if ($LASTEXITCODE -ne 0) { throw "wrapper failed to launch the CLI (exit $LASTEXITCODE)" }
@@ -1,6 +1,6 @@
1
- Metadata-Version: 2.4
1
+ Metadata-Version: 2.5
2
2
  Name: thoughtleaders-cli
3
- Version: 0.9.7
3
+ Version: 0.9.9
4
4
  Summary: ThoughtLeaders CLI — query sponsorship data, channels, brands, and intelligence
5
5
  Project-URL: Homepage, https://thoughtleaders.io
6
6
  Project-URL: Repository, https://github.com/ThoughtLeaders-io/thoughtleaders-cli
@@ -92,6 +92,25 @@ uv tool install thoughtleaders-cli
92
92
  pip install thoughtleaders-cli
93
93
  ```
94
94
 
95
+ #### Windows with Smart App Control / WDAC
96
+
97
+ The pipx/uv install above creates an **unsigned** launcher stub that Windows
98
+ Smart App Control (and enforced WDAC policies) block — `tl` fails to start with
99
+ a Code Integrity error. Fix it with:
100
+
101
+ ```powershell
102
+ tl setup windows
103
+ ```
104
+
105
+ That drops a small `tl.cmd` on your `PATH` (ahead of the blocked stub) which
106
+ launches the CLI through the already-signed `python.exe`, so it runs cleanly
107
+ under SAC. If `tl` is *already* blocked and won't start, bootstrap it through
108
+ the signed interpreter directly, then reopen your terminal:
109
+
110
+ ```powershell
111
+ & "$env:USERPROFILE\pipx\venvs\thoughtleaders-cli\Scripts\python.exe" -m tl_cli setup windows
112
+ ```
113
+
95
114
  Then set up:
96
115
  ```bash
97
116
  tl auth login # authenticate with ThoughtLeaders (OAuth2 browser flow, device code, or API key)
@@ -64,6 +64,25 @@ uv tool install thoughtleaders-cli
64
64
  pip install thoughtleaders-cli
65
65
  ```
66
66
 
67
+ #### Windows with Smart App Control / WDAC
68
+
69
+ The pipx/uv install above creates an **unsigned** launcher stub that Windows
70
+ Smart App Control (and enforced WDAC policies) block — `tl` fails to start with
71
+ a Code Integrity error. Fix it with:
72
+
73
+ ```powershell
74
+ tl setup windows
75
+ ```
76
+
77
+ That drops a small `tl.cmd` on your `PATH` (ahead of the blocked stub) which
78
+ launches the CLI through the already-signed `python.exe`, so it runs cleanly
79
+ under SAC. If `tl` is *already* blocked and won't start, bootstrap it through
80
+ the signed interpreter directly, then reopen your terminal:
81
+
82
+ ```powershell
83
+ & "$env:USERPROFILE\pipx\venvs\thoughtleaders-cli\Scripts\python.exe" -m tl_cli setup windows
84
+ ```
85
+
67
86
  Then set up:
68
87
  ```bash
69
88
  tl auth login # authenticate with ThoughtLeaders (OAuth2 browser flow, device code, or API key)
@@ -11,7 +11,7 @@ fi
11
11
 
12
12
  # Check for 402 (insufficient credits)
13
13
  if [[ "$OUTPUT" =~ "402" ]] || [[ "$OUTPUT" =~ "Insufficient credits" ]]; then
14
- echo "WARN: Credits exhausted. Deposit more at https://app.thoughtleaders.io/settings/billing" >&2
14
+ echo "WARN: Credits exhausted. Deposit more at https://app.thoughtleaders.io/billing" >&2
15
15
  exit 0
16
16
  fi
17
17
 
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "thoughtleaders-cli"
7
- version = "0.9.7"
7
+ version = "0.9.9"
8
8
  description = "ThoughtLeaders CLI — query sponsorship data, channels, brands, and intelligence"
9
9
  readme = "README.md"
10
10
  license = "MIT"
@@ -61,7 +61,7 @@ Retry after 5 seconds if the server returns a "connection denied" or a "server e
61
61
 
62
62
  Where possible reference sponsorships, brands, channel by numeric IDs.
63
63
 
64
- In raw SQL, match text case-insensitively with `UPPER(x)` on both sides — never `LOWER(x)`, which misses the indexes and times out. See `references/postgres-schema.md`.
64
+ In raw SQL, match text case-insensitively with `UPPER(x)` on both sides — never `LOWER(x)`, which misses the indexes and times out. This includes channel names (`channel_name`, `common_name`), not just handles and URLs. See `references/postgres-schema.md`.
65
65
 
66
66
  ## Data Model & Terminology
67
67
 
@@ -8,7 +8,7 @@ Accepted SQL:
8
8
  - **SELECT only**, single statement. No DDL/DML/transactions/SET/COPY/MERGE.
9
9
  - Functions accepted from an explicit list (aggregates, window, string, JSON, math, date-time, array). Catalog-resolving casts (`::regclass`, `::regprocedure`, …) are not accepted.
10
10
  - `LIMIT` and `OFFSET` are optional. Omit them and the server fills in `LIMIT 50 OFFSET 0`. Explicit `LIMIT` must be an integer literal ≤ 10,000. Explicit `OFFSET` ≥ 10,000 is rejected with HTTP 403 (`OFFSET_TOO_DEEP`); paginate with the response's `next_offset`/breadcrumbs instead of jumping deep.
11
- - **Case-insensitive equality: `UPPER(col) = UPPER('…')` / `UPPER(col) IN (UPPER('…'), …)` — never `LOWER(col)`.** The functional indexes on this database are built on `UPPER(…)` (e.g. channel `url`, `common_name`); a `LOWER(col)` predicate can't use them, seq-scans the 1.3M-row channel table, and dies on statement timeout (504). For substring search use `ILIKE '%…%'` on the bare column instead — that's served by trigram indexes where present (`channel_name`, `slug`).
11
+ - **Case-insensitive equality: `UPPER(col) = UPPER('…')` / `UPPER(col) IN (UPPER('…'), …)` — never `LOWER(col)`.** The case-insensitive functional indexes on this database are built on `UPPER(…)` on channel `channel_name`, `common_name` and `url`, among others. A `LOWER(col)` predicate can't use them: it seq-scans the 1.3M-row channel table (~20s+) and often dies on statement timeout (504). This holds for channel **names** exactly as it does for handles and URLs — see *Finding a single channel or brand ID* below for the bulk-lookup form. For substring search use `ILIKE '%…%'` on the **bare** column instead — that's served by trigram indexes where present (channel `channel_name`, `slug`; brand `name`, `website`). `LOWER(col) ILIKE '%…%'` is both redundant (`ILIKE` is already case-insensitive) and slow (the trigram index is on the bare column).
12
12
 
13
13
  ## Core Tables
14
14
 
@@ -262,16 +262,19 @@ thoughtleaders_adlink
262
262
 
263
263
  As a special case, the `tl channels find` and `tl brands find` commands accept a name of the channel / brand (be sure to properly quote them for the shell) and will return the respective ID. Use this instead of constructing SQL for this particular case. The commands will return a list of possible choices
264
264
 
265
- For bulk handle/name lookups in SQL (e.g. resolving a list of YouTube handles against `common_name`), compare case-insensitively with `UPPER(…)` on both sides — that's the form the functional indexes serve:
265
+ For bulk handle/name lookups in SQL (e.g. resolving a list of YouTube handles against `common_name`, or a list of channel names against `channel_name`), compare case-insensitively with `UPPER(…)` on both sides — that's the form the functional indexes serve:
266
266
 
267
267
  ```sql
268
268
  SELECT id, channel_name, common_name
269
269
  FROM thoughtleaders_channel
270
270
  WHERE UPPER(common_name) IN (UPPER('@TheInfographicsShow'), UPPER('@DrewDirksen'))
271
+ OR UPPER(channel_name) IN (UPPER('The Infographics Show'), UPPER('Drew Dirksen'))
271
272
  LIMIT 100 OFFSET 0
272
273
  ```
273
274
 
274
- `LOWER(common_name)` (or `LOWER()` on any indexed column) cannot use those indexes it seq-scans and times out.
275
+ Wrap **both** sides in `UPPER()`. `UPPER(col) IN ('lowercase literal', )` matches nothing, since the indexed side is upper-cased.
276
+
277
+ `LOWER(channel_name)`, `LOWER(common_name)` — or `LOWER()` on any indexed column — cannot use those indexes. Every such predicate is a full sequential scan of 1.3M channel rows: ~20s when it survives, a 504 statement timeout when it doesn't. One `OR LOWER(col) IN (…)` branch is enough to force the scan even when every other branch in the `WHERE` is indexed, so do not mix the two forms.
275
278
 
276
279
  ### Common Join Paths
277
280
 
@@ -1,3 +1,3 @@
1
1
  """ThoughtLeaders CLI — query sponsorship data, channels, brands, and intelligence."""
2
2
 
3
- __version__ = "0.9.7"
3
+ __version__ = "0.9.9"
@@ -0,0 +1,13 @@
1
+ """Module entry point for the CLI.
2
+
3
+ Enables `python -m tl_cli` and serves as the single entry script for the
4
+ PyInstaller-frozen standalone build (see packaging/tl.spec). Invoking the CLI
5
+ through the signed `python.exe` / signed frozen exe is what keeps it runnable
6
+ under Windows Smart App Control, which blocks the unsigned launcher stub that
7
+ pipx/uv generate per install.
8
+ """
9
+
10
+ from tl_cli.main import cli
11
+
12
+ if __name__ == "__main__":
13
+ cli()
@@ -0,0 +1,122 @@
1
+ """User-friendly error handling for API responses."""
2
+
3
+ import json
4
+ import sys
5
+ import traceback
6
+
7
+ from rich.console import Console
8
+
9
+ err = Console(stderr=True)
10
+
11
+
12
+ class ApiError(Exception):
13
+ """Raised when the API returns a non-success status."""
14
+
15
+ def __init__(self, status_code: int, detail: str, raw: dict | None = None, url: str | None = None, response_text: str | None = None):
16
+ self.status_code = status_code
17
+ self.detail = detail
18
+ self.raw = raw
19
+ self.url = url
20
+ self.response_text = response_text
21
+ super().__init__(f"HTTP {status_code}: {detail}")
22
+
23
+
24
+ def _print_debug(error: ApiError) -> None:
25
+ """Print detailed debug info for an API error."""
26
+ from tl_cli.config import debug
27
+
28
+ if not debug:
29
+ return
30
+ err.print(f"\n[dim]--- debug ---[/dim]")
31
+ if error.url:
32
+ err.print(f"[dim]URL: {error.url}[/dim]")
33
+ err.print(f"[dim]HTTP {error.status_code}: {error.detail}[/dim]")
34
+ if error.response_text:
35
+ err.print(f"[dim]Response body:[/dim]")
36
+ err.print(f"[dim]{error.response_text}[/dim]")
37
+ err.print(f"[dim]Traceback:[/dim]")
38
+ err.print(f"[dim]{''.join(traceback.format_exception(error))}[/dim]")
39
+
40
+
41
+ def _split_hint(error: ApiError) -> tuple[str, str | None]:
42
+ """Separate the server's `hint` from `detail`.
43
+
44
+ The server sends the remediation both concatenated into `detail` (for
45
+ older clients) and as a separate `hint` key — render it on its own line
46
+ so it can't get lost in the error. Used by every branch: a hint on a
47
+ billing refusal (402/403/429) is just as actionable as one on a 400.
48
+ """
49
+ detail = error.detail or ""
50
+ hint = (error.raw or {}).get("hint") if isinstance(error.raw, dict) else None
51
+ if isinstance(hint, str) and hint:
52
+ if detail.endswith(hint):
53
+ detail = detail[: -len(hint)].rstrip()
54
+ return detail, hint
55
+ return detail, None
56
+
57
+
58
+ def _print_hint(hint: str | None) -> None:
59
+ if hint:
60
+ err.print(f"[bold yellow]Hint:[/bold yellow] [yellow]{hint}[/yellow]")
61
+
62
+
63
+ def handle_api_error(error: ApiError) -> None:
64
+ """Print a user-friendly error message and exit with the right code."""
65
+ detail, hint = _split_hint(error)
66
+ if error.status_code == 401:
67
+ err.print("[red]Authentication required.[/red] Run: tl auth login")
68
+ _print_debug(error)
69
+ sys.exit(2)
70
+ elif error.status_code == 402:
71
+ # The server composes the whole story into `detail` — a prepaid org is
72
+ # told to deposit, a subscription org that it can also just wait for
73
+ # the next top-up — and only the server knows which org this is. The
74
+ # old fixed copy overwrote that with the same top-up pitch for
75
+ # everyone. The `tl credits buy` line stays: it is the CLI-native way
76
+ # to act on the refusal, which the server's sentence can't know about.
77
+ if detail:
78
+ err.print(f"[red]{detail}[/red]")
79
+ else:
80
+ err.print("[red]Insufficient credits.[/red]")
81
+ err.print("Or visit: https://app.thoughtleaders.io/billing")
82
+ _print_hint(hint)
83
+ err.print("Top up with: [bold]tl credits buy --amount-usd 10[/bold]")
84
+ _print_debug(error)
85
+ sys.exit(4)
86
+ elif error.status_code == 403:
87
+ # Verbatim, with no upsell line of our own: a billing 403's detail
88
+ # already names the paid plan (UPGRADE_MESSAGE and friends), and the
89
+ # rest of the 403s — "Superuser only", permission errors — are not
90
+ # plan problems, so "your plan may not include this" was misdirection
91
+ # exactly where the user needed the real reason.
92
+ err.print(f"[red]Access denied:[/red] {detail}")
93
+ _print_hint(hint)
94
+ _print_debug(error)
95
+ sys.exit(1)
96
+ elif error.status_code == 404:
97
+ err.print(f"[yellow]Not found:[/yellow] {detail}")
98
+ _print_hint(hint)
99
+ _print_debug(error)
100
+ sys.exit(1)
101
+ elif error.status_code == 429:
102
+ # Both quota gates refuse with 429 and compose the whole explanation
103
+ # into `detail` — which cap was hit, how much of it is used, and when it
104
+ # frees up. Collapsing that to a flat "rate limited" line drops the only
105
+ # thing that tells the user whether to wait, buy credits, or ask for a
106
+ # seat. An edge/WAF 429 carries no detail and keeps the generic wording.
107
+ if detail:
108
+ err.print(f"[yellow]{detail}[/yellow]")
109
+ else:
110
+ err.print("[yellow]Rate limited.[/yellow] Please wait and try again.")
111
+ _print_hint(hint)
112
+ _print_debug(error)
113
+ sys.exit(3)
114
+ elif error.status_code >= 500:
115
+ err.print(f"[red]Server error ({error.status_code}):[/red] {error.detail}")
116
+ _print_debug(error)
117
+ sys.exit(3)
118
+ else:
119
+ err.print(f"[red]Error ({error.status_code}):[/red] {detail}")
120
+ _print_hint(hint)
121
+ _print_debug(error)
122
+ sys.exit(1)
@@ -53,6 +53,17 @@ def balance(
53
53
  if allow_overage:
54
54
  console.print("[dim]Overage: enabled[/dim]")
55
55
 
56
+ # The server's own read on the caller's allowance — the session-quota
57
+ # percentage, or the seatless explanation. This is the same sentence
58
+ # the refusal path uses, so a seatless member learns why from
59
+ # `tl balance` instead of only from their first refused call (it used
60
+ # to be visible solely under --json).
61
+ usage = data.get("credit_usage") or {}
62
+ usage_message = usage.get("message")
63
+ if isinstance(usage_message, str) and usage_message:
64
+ style = "yellow" if usage.get("warn") else "dim"
65
+ console.print(f"[{style}]{usage_message}[/{style}]")
66
+
56
67
  # Top-up hint when running low. Threshold matches the hook warning
57
68
  # that nudges the user before they hit 0.
58
69
  try:
@@ -63,7 +74,7 @@ def balance(
63
74
  console.print(
64
75
  "[yellow]Running low.[/yellow] Top up with: "
65
76
  "[bold]tl credits buy --amount-usd 10[/bold] "
66
- "(or https://app.thoughtleaders.io/billing/cli)"
77
+ "(or https://app.thoughtleaders.io/billing)"
67
78
  )
68
79
 
69
80
  recent = data.get("recent_usage", [])
@@ -756,3 +756,74 @@ def setup_codex(
756
756
  json_output=json_output,
757
757
  toon_output=toon_output,
758
758
  )
759
+
760
+
761
+ def _prepend_user_path(new_dir: str) -> bool:
762
+ """Prepend ``new_dir`` to the persistent per-user PATH.
763
+
764
+ Writes HKCU\\Environment and broadcasts the change so new processes pick it
765
+ up without a sign-out. Returns True if PATH was changed, False if new_dir
766
+ was already present. Windows-only.
767
+ """
768
+ import ctypes
769
+ import winreg
770
+
771
+ with winreg.OpenKey(winreg.HKEY_CURRENT_USER, "Environment", 0, winreg.KEY_READ | winreg.KEY_WRITE) as key:
772
+ try:
773
+ current, _ = winreg.QueryValueEx(key, "Path")
774
+ except FileNotFoundError:
775
+ current = ""
776
+ parts = [p for p in current.split(os.pathsep) if p]
777
+ target = os.path.normcase(os.path.normpath(new_dir))
778
+ if any(os.path.normcase(os.path.normpath(p)) == target for p in parts):
779
+ return False
780
+ updated = os.pathsep.join([new_dir, *parts]) if parts else new_dir
781
+ winreg.SetValueEx(key, "Path", 0, winreg.REG_EXPAND_SZ, updated)
782
+
783
+ # Tell running shells the environment changed (best-effort).
784
+ ctypes.windll.user32.SendMessageTimeoutW(0xFFFF, 0x1A, 0, "Environment", 0x2, 5000, None)
785
+ return True
786
+
787
+
788
+ @app.command("windows")
789
+ def setup_windows(
790
+ force: bool = typer.Option(False, "--force", help="Overwrite an existing tl.cmd wrapper"),
791
+ ) -> None:
792
+ """Make `tl` runnable under Windows Smart App Control (Windows only).
793
+
794
+ pipx/uv expose `tl` through an *unsigned* launcher stub that Smart App
795
+ Control (and enforced WDAC policies) block. This writes a small `tl.cmd`
796
+ that runs the CLI through the already-signed `python.exe` instead, and
797
+ puts it ahead of the blocked stub on your PATH.
798
+
799
+ Run once. On a machine where `tl` is already blocked, bootstrap it with
800
+ the venv's signed python directly:
801
+
802
+ <pipx-venv>\\Scripts\\python.exe -m tl_cli setup windows
803
+
804
+ Examples:
805
+ tl setup windows
806
+ """
807
+ if sys.platform != "win32":
808
+ console.print("[yellow]`tl setup windows` only applies on Windows — nothing to do here.[/yellow]")
809
+ return
810
+
811
+ python_exe = Path(sys.executable)
812
+ wrapper_dir = Path(os.environ.get("LOCALAPPDATA", Path.home())) / "Programs" / "tl-cli"
813
+ wrapper = wrapper_dir / "tl.cmd"
814
+
815
+ if wrapper.exists() and not force:
816
+ console.print(f"[yellow]Wrapper already exists:[/yellow] {wrapper}\nRe-run with --force to regenerate it.")
817
+ return
818
+
819
+ wrapper_dir.mkdir(parents=True, exist_ok=True)
820
+ # Absolute path to the signed interpreter bypasses PATH so the launch can't
821
+ # resolve back to the blocked stub. %* forwards all arguments unchanged.
822
+ wrapper.write_text(f'@echo off\r\n"{python_exe}" -m tl_cli %*\r\n', encoding="ascii", newline="")
823
+
824
+ console.print(f"[green]✓[/green] Wrote signed-launcher wrapper: {wrapper}")
825
+ if _prepend_user_path(str(wrapper_dir)):
826
+ console.print(f"[green]✓[/green] Added to your user PATH (ahead of the blocked stub): {wrapper_dir}")
827
+ else:
828
+ console.print(f"[dim]Already on your user PATH: {wrapper_dir}[/dim]")
829
+ console.print("[dim]Open a new terminal, then run:[/dim] tl whoami")
@@ -95,12 +95,14 @@ def output(
95
95
  # Banner still goes to stderr so it surfaces even when stdout is
96
96
  # piped through `jq` or redirected to a file.
97
97
  _print_quota_notice(data)
98
+ _print_upgrade_notice(data)
98
99
  _print_server_warnings(data)
99
100
  return
100
101
 
101
102
  if not results:
102
103
  err_console.print("[dim]No results found.[/dim]")
103
104
  _print_quota_notice(data)
105
+ _print_upgrade_notice(data)
104
106
  _print_server_warnings(data)
105
107
  _print_usage(usage)
106
108
  return
@@ -121,6 +123,7 @@ def output(
121
123
 
122
124
  _print_pagination_notice(data)
123
125
  _print_quota_notice(data)
126
+ _print_upgrade_notice(data)
124
127
  _print_server_warnings(data)
125
128
  _print_usage(usage)
126
129
  _print_breadcrumbs(breadcrumbs)
@@ -523,17 +526,17 @@ def _print_server_warnings(data: dict) -> None:
523
526
 
524
527
 
525
528
  def _print_quota_notice(data: dict) -> None:
526
- """Print a banner when the server signals a billing-quota refusal or
527
- truncation on a raw-DB call.
528
-
529
- The server emits `_billing_quota_exhausted` on the response envelope
530
- in two cases: (a) the query was refused outright before execution
531
- because the org has already used its expensive-query allowance for
532
- the window; (b) the query ran but the result list was truncated
533
- because the remaining expensive-row allowance was smaller than the
534
- natural row count. Either way the response carries an empty or
535
- short ``results`` array and the caller needs to know *why* and
536
- *when they can retry*.
529
+ """Print a banner when the server signals a billing-quota truncation
530
+ on a raw-DB call.
531
+
532
+ The query ran, but the result list was cut to the org's remaining
533
+ premium-row allowance for the window so ``results`` is short of what
534
+ the query would naturally return, and the caller needs to know *why*
535
+ and *when the rest becomes reachable*.
536
+
537
+ An outright refusal never lands here: it is a 429 carrying the server's
538
+ detail, rendered by `client.errors.handle_api_error`. A short result set
539
+ and a refused query must not look alike.
537
540
  """
538
541
  if not data.get("_billing_quota_exhausted"):
539
542
  return
@@ -555,6 +558,28 @@ def _print_quota_notice(data: dict) -> None:
555
558
  )
556
559
 
557
560
 
561
+ def _print_upgrade_notice(data: dict) -> None:
562
+ """Print a banner when the server withheld premium fields from a
563
+ successful response (``_upgrade_required``).
564
+
565
+ The rows are real, but transcript / brand-mention / demographic fields
566
+ were omitted for the caller's plan — without this line a free-tier user
567
+ reads the gaps as "no data exists". The sentence is the server's own
568
+ (UPGRADE_MESSAGE), the same one the web app's field locks show, so the
569
+ wording stays composed in exactly one place.
570
+ """
571
+ notice = data.get("_upgrade_required")
572
+ if not isinstance(notice, dict):
573
+ return
574
+ message = notice.get("message") or "Some fields are available on paid plans."
575
+ err_console.print(f"[bold yellow]⚠ {message}[/bold yellow]")
576
+ fields = notice.get("fields") or []
577
+ if fields:
578
+ err_console.print(
579
+ f"[yellow] withheld field(s): {', '.join(str(f) for f in fields)}[/yellow]"
580
+ )
581
+
582
+
558
583
  def output_pricing_estimate(data: dict, fmt: str) -> None:
559
584
  """Render the `--pricing` dry-run estimate for `tl db pg|fb|es`.
560
585
 
@@ -0,0 +1,68 @@
1
+ """Tests for what a 429 quota refusal actually tells the user.
2
+
3
+ Both server-side quota gates refuse with 429 and put the entire remedy into
4
+ `detail`: which cap was hit, how much of it is spent, and when it frees up. The
5
+ user's next action differs per gate — wait out a rolling window, buy credits,
6
+ or ask an org admin for a seat — so a generic "rate limited" line is not a
7
+ smaller version of the message, it is the wrong message.
8
+ """
9
+
10
+ import pytest
11
+
12
+ from tl_cli.client.errors import ApiError, handle_api_error
13
+
14
+
15
+ def _render(error: ApiError, capsys) -> str:
16
+ with pytest.raises(SystemExit) as exc:
17
+ handle_api_error(error)
18
+ assert exc.value.code == 3
19
+ return " ".join(capsys.readouterr().err.split()) # undo console wrapping
20
+
21
+
22
+ class TestQuotaRefusalMessage:
23
+ def test_premium_data_quota_detail_reaches_the_user(self, capsys):
24
+ detail = (
25
+ "Premium data quota reached: your organization has used 100 of 100 "
26
+ "premium-data rows in the last 24h. Wait ~3.2h until the oldest call "
27
+ "rolls off the window (2026-08-17T14:02:11+00:00)."
28
+ )
29
+ out = _render(
30
+ ApiError(429, detail, raw={"detail": detail, "code": "quota_exhausted"}),
31
+ capsys,
32
+ )
33
+ assert "100 of 100 premium-data rows" in out
34
+ assert "Wait ~3.2h" in out
35
+
36
+ def test_per_user_credit_quota_detail_reaches_the_user(self, capsys):
37
+ detail = (
38
+ "Per-user CLI credit quota reached: 1236.32 of 1200.00 credits used this "
39
+ "session (5h window). Wait ~5.0h until your session resets "
40
+ "(2026-08-17T16:21:13+00:00); you can then spend up to 1200.00 credits "
41
+ "again. Or buy credits to continue without waiting: "
42
+ "https://app.thoughtleaders.io/billing"
43
+ )
44
+ out = _render(
45
+ ApiError(429, detail, raw={"detail": detail, "code": "quota_exhausted"}),
46
+ capsys,
47
+ )
48
+ assert "1236.32 of 1200.00 credits" in out
49
+ # The remedy is the point: without it the user cannot tell that buying
50
+ # credits skips the wait.
51
+ assert "buy credits" in out
52
+
53
+ def test_seatless_refusal_names_the_remedy(self, capsys):
54
+ detail = (
55
+ "No CLI seat is assigned to your user in this organization. Ask an "
56
+ "organization owner or admin to assign you a seat: "
57
+ "https://app.thoughtleaders.io/billing"
58
+ )
59
+ out = _render(
60
+ ApiError(429, detail, raw={"detail": detail, "code": "quota_exhausted"}),
61
+ capsys,
62
+ )
63
+ assert "assign you a seat" in out
64
+
65
+ def test_bare_429_keeps_the_generic_wording(self, capsys):
66
+ # An edge/WAF rate limit carries no detail; there is nothing to explain.
67
+ out = _render(ApiError(429, "", raw=None), capsys)
68
+ assert "Rate limited." in out