thoughtleaders-cli 0.9.4__tar.gz → 0.9.8__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 (163) hide show
  1. {thoughtleaders_cli-0.9.4 → thoughtleaders_cli-0.9.8}/.claude-plugin/plugin.json +1 -1
  2. {thoughtleaders_cli-0.9.4 → thoughtleaders_cli-0.9.8}/PKG-INFO +2 -2
  3. {thoughtleaders_cli-0.9.4 → thoughtleaders_cli-0.9.8}/hooks/scripts/post-usage.sh +1 -1
  4. {thoughtleaders_cli-0.9.4 → thoughtleaders_cli-0.9.8}/pyproject.toml +1 -1
  5. {thoughtleaders_cli-0.9.4 → thoughtleaders_cli-0.9.8}/skills/tl/SKILL.md +63 -1
  6. {thoughtleaders_cli-0.9.4 → thoughtleaders_cli-0.9.8}/skills/tl/references/postgres-schema.md +9 -4
  7. {thoughtleaders_cli-0.9.4 → thoughtleaders_cli-0.9.8}/src/tl_cli/__init__.py +1 -1
  8. thoughtleaders_cli-0.9.8/src/tl_cli/client/errors.py +122 -0
  9. {thoughtleaders_cli-0.9.4 → thoughtleaders_cli-0.9.8}/src/tl_cli/client/http.py +2 -2
  10. {thoughtleaders_cli-0.9.4 → thoughtleaders_cli-0.9.8}/src/tl_cli/commands/balance.py +12 -1
  11. thoughtleaders_cli-0.9.8/src/tl_cli/commands/memory.py +149 -0
  12. {thoughtleaders_cli-0.9.4 → thoughtleaders_cli-0.9.8}/src/tl_cli/commands/reports.py +106 -0
  13. {thoughtleaders_cli-0.9.4 → thoughtleaders_cli-0.9.8}/src/tl_cli/commands/whoami.py +25 -0
  14. {thoughtleaders_cli-0.9.4 → thoughtleaders_cli-0.9.8}/src/tl_cli/main.py +2 -0
  15. {thoughtleaders_cli-0.9.4 → thoughtleaders_cli-0.9.8}/src/tl_cli/output/formatter.py +54 -17
  16. thoughtleaders_cli-0.9.8/tests/test_memory.py +213 -0
  17. {thoughtleaders_cli-0.9.4 → thoughtleaders_cli-0.9.8}/tests/test_output.py +46 -0
  18. thoughtleaders_cli-0.9.8/tests/test_quota_refusal_message.py +68 -0
  19. thoughtleaders_cli-0.9.8/tests/test_refusal_rendering.py +109 -0
  20. {thoughtleaders_cli-0.9.4 → thoughtleaders_cli-0.9.8}/tests/test_reports.py +51 -0
  21. thoughtleaders_cli-0.9.8/tests/test_whoami_newsletter.py +77 -0
  22. thoughtleaders_cli-0.9.4/src/tl_cli/client/errors.py +0 -84
  23. {thoughtleaders_cli-0.9.4 → thoughtleaders_cli-0.9.8}/.claude-plugin/marketplace.json +0 -0
  24. {thoughtleaders_cli-0.9.4 → thoughtleaders_cli-0.9.8}/.github/dependabot.yml +0 -0
  25. {thoughtleaders_cli-0.9.4 → thoughtleaders_cli-0.9.8}/.github/workflows/ci.yml +0 -0
  26. {thoughtleaders_cli-0.9.4 → thoughtleaders_cli-0.9.8}/.github/workflows/cli-integration.yml +0 -0
  27. {thoughtleaders_cli-0.9.4 → thoughtleaders_cli-0.9.8}/.github/workflows/python-publish.yml +0 -0
  28. {thoughtleaders_cli-0.9.4 → thoughtleaders_cli-0.9.8}/.gitignore +0 -0
  29. {thoughtleaders_cli-0.9.4 → thoughtleaders_cli-0.9.8}/AGENTS.md +0 -0
  30. {thoughtleaders_cli-0.9.4 → thoughtleaders_cli-0.9.8}/API.md +0 -0
  31. {thoughtleaders_cli-0.9.4 → thoughtleaders_cli-0.9.8}/CLAUDE.md +0 -0
  32. {thoughtleaders_cli-0.9.4 → thoughtleaders_cli-0.9.8}/LICENSE +0 -0
  33. {thoughtleaders_cli-0.9.4 → thoughtleaders_cli-0.9.8}/README.md +0 -0
  34. {thoughtleaders_cli-0.9.4 → thoughtleaders_cli-0.9.8}/agents/keyword-context-classifier.md +0 -0
  35. {thoughtleaders_cli-0.9.4 → thoughtleaders_cli-0.9.8}/agents/keyword-entity-resolver.md +0 -0
  36. {thoughtleaders_cli-0.9.4 → thoughtleaders_cli-0.9.8}/agents/keyword-relevance-validator.md +0 -0
  37. {thoughtleaders_cli-0.9.4 → thoughtleaders_cli-0.9.8}/agents/tl-analyst.md +0 -0
  38. {thoughtleaders_cli-0.9.4 → thoughtleaders_cli-0.9.8}/agents/youtube-comment-classifier.md +0 -0
  39. {thoughtleaders_cli-0.9.4 → thoughtleaders_cli-0.9.8}/hooks/hooks.json +0 -0
  40. {thoughtleaders_cli-0.9.4 → thoughtleaders_cli-0.9.8}/hooks/scripts/load-tl-skill.mjs +0 -0
  41. {thoughtleaders_cli-0.9.4 → thoughtleaders_cli-0.9.8}/hooks/scripts/pre-check.sh +0 -0
  42. {thoughtleaders_cli-0.9.4 → thoughtleaders_cli-0.9.8}/skills/tl/references/business-glossary.md +0 -0
  43. {thoughtleaders_cli-0.9.4 → thoughtleaders_cli-0.9.8}/skills/tl/references/elasticsearch-schema.md +0 -0
  44. {thoughtleaders_cli-0.9.4 → thoughtleaders_cli-0.9.8}/skills/tl/references/firebolt-schema.md +0 -0
  45. {thoughtleaders_cli-0.9.4 → thoughtleaders_cli-0.9.8}/skills/tl-channel-authenticity/.gitignore +0 -0
  46. {thoughtleaders_cli-0.9.4 → thoughtleaders_cli-0.9.8}/skills/tl-channel-authenticity/SKILL.md +0 -0
  47. {thoughtleaders_cli-0.9.4 → thoughtleaders_cli-0.9.8}/skills/tl-channel-authenticity/references/comment-patterns.md +0 -0
  48. {thoughtleaders_cli-0.9.4 → thoughtleaders_cli-0.9.8}/skills/tl-channel-authenticity/references/peer-cohort.md +0 -0
  49. {thoughtleaders_cli-0.9.4 → thoughtleaders_cli-0.9.8}/skills/tl-channel-authenticity/references/red-flags.md +0 -0
  50. {thoughtleaders_cli-0.9.4 → thoughtleaders_cli-0.9.8}/skills/tl-channel-authenticity/references/scoring.md +0 -0
  51. {thoughtleaders_cli-0.9.4 → thoughtleaders_cli-0.9.8}/skills/tl-channel-authenticity/scripts/_io_utf8.py +0 -0
  52. {thoughtleaders_cli-0.9.4 → thoughtleaders_cli-0.9.8}/skills/tl-channel-authenticity/scripts/analyze_channel.py +0 -0
  53. {thoughtleaders_cli-0.9.4 → thoughtleaders_cli-0.9.8}/skills/tl-channel-authenticity/scripts/anomaly_detector.py +0 -0
  54. {thoughtleaders_cli-0.9.4 → thoughtleaders_cli-0.9.8}/skills/tl-channel-authenticity/scripts/comment_analyzer.py +0 -0
  55. {thoughtleaders_cli-0.9.4 → thoughtleaders_cli-0.9.8}/skills/tl-channel-authenticity/scripts/comment_scraper.py +0 -0
  56. {thoughtleaders_cli-0.9.4 → thoughtleaders_cli-0.9.8}/skills/tl-channel-authenticity/scripts/engagement_ratios.py +0 -0
  57. {thoughtleaders_cli-0.9.4 → thoughtleaders_cli-0.9.8}/skills/tl-channel-authenticity/scripts/peer_cohort.py +0 -0
  58. {thoughtleaders_cli-0.9.4 → thoughtleaders_cli-0.9.8}/skills/tl-channel-authenticity/scripts/report.py +0 -0
  59. {thoughtleaders_cli-0.9.4 → thoughtleaders_cli-0.9.8}/skills/tl-channel-authenticity/scripts/resolve_channel.py +0 -0
  60. {thoughtleaders_cli-0.9.4 → thoughtleaders_cli-0.9.8}/skills/tl-channel-authenticity/scripts/score.py +0 -0
  61. {thoughtleaders_cli-0.9.4 → thoughtleaders_cli-0.9.8}/skills/tl-channel-authenticity/scripts/tl_cli.py +0 -0
  62. {thoughtleaders_cli-0.9.4 → thoughtleaders_cli-0.9.8}/skills/tl-channel-authenticity/scripts/video_integrity.py +0 -0
  63. {thoughtleaders_cli-0.9.4 → thoughtleaders_cli-0.9.8}/skills/tl-channel-authenticity/scripts/view_curves.py +0 -0
  64. {thoughtleaders_cli-0.9.4 → thoughtleaders_cli-0.9.8}/skills/tl-create-workflow/SKILL.md +0 -0
  65. {thoughtleaders_cli-0.9.4 → thoughtleaders_cli-0.9.8}/skills/tl-create-workflow/references/creating-in-app.md +0 -0
  66. {thoughtleaders_cli-0.9.4 → thoughtleaders_cli-0.9.8}/skills/tl-create-workflow/references/methodology.md +0 -0
  67. {thoughtleaders_cli-0.9.4 → thoughtleaders_cli-0.9.8}/skills/tl-create-workflow/references/pitfalls.md +0 -0
  68. {thoughtleaders_cli-0.9.4 → thoughtleaders_cli-0.9.8}/skills/tl-create-workflow/references/workflow-model.md +0 -0
  69. {thoughtleaders_cli-0.9.4 → thoughtleaders_cli-0.9.8}/skills/tl-keyword-research/SKILL.md +0 -0
  70. {thoughtleaders_cli-0.9.4 → thoughtleaders_cli-0.9.8}/skills/tl-keyword-research/references/elasticsearch-content-search.md +0 -0
  71. {thoughtleaders_cli-0.9.4 → thoughtleaders_cli-0.9.8}/skills/tl-keyword-research/references/help.md +0 -0
  72. {thoughtleaders_cli-0.9.4 → thoughtleaders_cli-0.9.8}/skills/tl-keyword-research/scripts/build_report.py +0 -0
  73. {thoughtleaders_cli-0.9.4 → thoughtleaders_cli-0.9.8}/skills/tl-keyword-research/scripts/expand_entities.py +0 -0
  74. {thoughtleaders_cli-0.9.4 → thoughtleaders_cli-0.9.8}/skills/tl-keyword-research/scripts/fetch_context.py +0 -0
  75. {thoughtleaders_cli-0.9.4 → thoughtleaders_cli-0.9.8}/skills/tl-keyword-research/scripts/probe.py +0 -0
  76. {thoughtleaders_cli-0.9.4 → thoughtleaders_cli-0.9.8}/skills/tl-keyword-research/scripts/search_channels.py +0 -0
  77. {thoughtleaders_cli-0.9.4 → thoughtleaders_cli-0.9.8}/skills/tl-keyword-research/scripts/search_videos.py +0 -0
  78. {thoughtleaders_cli-0.9.4 → thoughtleaders_cli-0.9.8}/skills/tl-keyword-research/scripts/select_keywords.py +0 -0
  79. {thoughtleaders_cli-0.9.4 → thoughtleaders_cli-0.9.8}/skills/tl-save-report/SKILL.md +0 -0
  80. {thoughtleaders_cli-0.9.4 → thoughtleaders_cli-0.9.8}/skills/tl-save-report/references/columns_brands.md +0 -0
  81. {thoughtleaders_cli-0.9.4 → thoughtleaders_cli-0.9.8}/skills/tl-save-report/references/columns_channels.md +0 -0
  82. {thoughtleaders_cli-0.9.4 → thoughtleaders_cli-0.9.8}/skills/tl-save-report/references/columns_content.md +0 -0
  83. {thoughtleaders_cli-0.9.4 → thoughtleaders_cli-0.9.8}/skills/tl-save-report/references/columns_sponsorships.md +0 -0
  84. {thoughtleaders_cli-0.9.4 → thoughtleaders_cli-0.9.8}/skills/tl-save-report/references/intelligence_filterset_schema.json +0 -0
  85. {thoughtleaders_cli-0.9.4 → thoughtleaders_cli-0.9.8}/skills/tl-save-report/references/intelligence_widget_schema.json +0 -0
  86. {thoughtleaders_cli-0.9.4 → thoughtleaders_cli-0.9.8}/skills/tl-save-report/references/report_glossary.md +0 -0
  87. {thoughtleaders_cli-0.9.4 → thoughtleaders_cli-0.9.8}/skills/tl-save-report/references/sortable_columns.json +0 -0
  88. {thoughtleaders_cli-0.9.4 → thoughtleaders_cli-0.9.8}/skills/tl-save-report/references/sponsorship_filterset_schema.json +0 -0
  89. {thoughtleaders_cli-0.9.4 → thoughtleaders_cli-0.9.8}/skills/tl-save-report/references/sponsorship_widget_schema.json +0 -0
  90. {thoughtleaders_cli-0.9.4 → thoughtleaders_cli-0.9.8}/skills/tl-save-report/references/widgets.md +0 -0
  91. {thoughtleaders_cli-0.9.4 → thoughtleaders_cli-0.9.8}/skills/tl-top-partnerships/SKILL.md +0 -0
  92. {thoughtleaders_cli-0.9.4 → thoughtleaders_cli-0.9.8}/skills/tl-top-partnerships/scripts/top_partnerships.py +0 -0
  93. {thoughtleaders_cli-0.9.4 → thoughtleaders_cli-0.9.8}/skills/tl-views-guarantee/SKILL.md +0 -0
  94. {thoughtleaders_cli-0.9.4 → thoughtleaders_cli-0.9.8}/skills/tl-views-guarantee/scripts/vg.py +0 -0
  95. {thoughtleaders_cli-0.9.4 → thoughtleaders_cli-0.9.8}/src/tl_cli/_completions.py +0 -0
  96. {thoughtleaders_cli-0.9.4 → thoughtleaders_cli-0.9.8}/src/tl_cli/_typer_utils.py +0 -0
  97. {thoughtleaders_cli-0.9.4 → thoughtleaders_cli-0.9.8}/src/tl_cli/auth/__init__.py +0 -0
  98. {thoughtleaders_cli-0.9.4 → thoughtleaders_cli-0.9.8}/src/tl_cli/auth/commands.py +0 -0
  99. {thoughtleaders_cli-0.9.4 → thoughtleaders_cli-0.9.8}/src/tl_cli/auth/login.py +0 -0
  100. {thoughtleaders_cli-0.9.4 → thoughtleaders_cli-0.9.8}/src/tl_cli/auth/pkce.py +0 -0
  101. {thoughtleaders_cli-0.9.4 → thoughtleaders_cli-0.9.8}/src/tl_cli/auth/token_store.py +0 -0
  102. {thoughtleaders_cli-0.9.4 → thoughtleaders_cli-0.9.8}/src/tl_cli/client/__init__.py +0 -0
  103. {thoughtleaders_cli-0.9.4 → thoughtleaders_cli-0.9.8}/src/tl_cli/commands/__init__.py +0 -0
  104. {thoughtleaders_cli-0.9.4 → thoughtleaders_cli-0.9.8}/src/tl_cli/commands/_comments_common.py +0 -0
  105. {thoughtleaders_cli-0.9.4 → thoughtleaders_cli-0.9.8}/src/tl_cli/commands/brands.py +0 -0
  106. {thoughtleaders_cli-0.9.4 → thoughtleaders_cli-0.9.8}/src/tl_cli/commands/bulk_import.py +0 -0
  107. {thoughtleaders_cli-0.9.4 → thoughtleaders_cli-0.9.8}/src/tl_cli/commands/changelog.py +0 -0
  108. {thoughtleaders_cli-0.9.4 → thoughtleaders_cli-0.9.8}/src/tl_cli/commands/channels.py +0 -0
  109. {thoughtleaders_cli-0.9.4 → thoughtleaders_cli-0.9.8}/src/tl_cli/commands/credits.py +0 -0
  110. {thoughtleaders_cli-0.9.4 → thoughtleaders_cli-0.9.8}/src/tl_cli/commands/db.py +0 -0
  111. {thoughtleaders_cli-0.9.4 → thoughtleaders_cli-0.9.8}/src/tl_cli/commands/deals.py +0 -0
  112. {thoughtleaders_cli-0.9.4 → thoughtleaders_cli-0.9.8}/src/tl_cli/commands/describe.py +0 -0
  113. {thoughtleaders_cli-0.9.4 → thoughtleaders_cli-0.9.8}/src/tl_cli/commands/doctor.py +0 -0
  114. {thoughtleaders_cli-0.9.4 → thoughtleaders_cli-0.9.8}/src/tl_cli/commands/matches.py +0 -0
  115. {thoughtleaders_cli-0.9.4 → thoughtleaders_cli-0.9.8}/src/tl_cli/commands/profiles.py +0 -0
  116. {thoughtleaders_cli-0.9.4 → thoughtleaders_cli-0.9.8}/src/tl_cli/commands/proposals.py +0 -0
  117. {thoughtleaders_cli-0.9.4 → thoughtleaders_cli-0.9.8}/src/tl_cli/commands/recommender.py +0 -0
  118. {thoughtleaders_cli-0.9.4 → thoughtleaders_cli-0.9.8}/src/tl_cli/commands/schema.py +0 -0
  119. {thoughtleaders_cli-0.9.4 → thoughtleaders_cli-0.9.8}/src/tl_cli/commands/setup.py +0 -0
  120. {thoughtleaders_cli-0.9.4 → thoughtleaders_cli-0.9.8}/src/tl_cli/commands/skills.py +0 -0
  121. {thoughtleaders_cli-0.9.4 → thoughtleaders_cli-0.9.8}/src/tl_cli/commands/snapshots.py +0 -0
  122. {thoughtleaders_cli-0.9.4 → thoughtleaders_cli-0.9.8}/src/tl_cli/commands/sponsorships.py +0 -0
  123. {thoughtleaders_cli-0.9.4 → thoughtleaders_cli-0.9.8}/src/tl_cli/commands/uploads.py +0 -0
  124. {thoughtleaders_cli-0.9.4 → thoughtleaders_cli-0.9.8}/src/tl_cli/commands/workflows.py +0 -0
  125. {thoughtleaders_cli-0.9.4 → thoughtleaders_cli-0.9.8}/src/tl_cli/config.py +0 -0
  126. {thoughtleaders_cli-0.9.4 → thoughtleaders_cli-0.9.8}/src/tl_cli/filters.py +0 -0
  127. {thoughtleaders_cli-0.9.4 → thoughtleaders_cli-0.9.8}/src/tl_cli/hints.py +0 -0
  128. {thoughtleaders_cli-0.9.4 → thoughtleaders_cli-0.9.8}/src/tl_cli/output/__init__.py +0 -0
  129. {thoughtleaders_cli-0.9.4 → thoughtleaders_cli-0.9.8}/src/tl_cli/query_history.py +0 -0
  130. {thoughtleaders_cli-0.9.4 → thoughtleaders_cli-0.9.8}/src/tl_cli/self_update.py +0 -0
  131. {thoughtleaders_cli-0.9.4 → thoughtleaders_cli-0.9.8}/src/tl_cli/skill_registry.py +0 -0
  132. {thoughtleaders_cli-0.9.4 → thoughtleaders_cli-0.9.8}/tests/__init__.py +0 -0
  133. {thoughtleaders_cli-0.9.4 → thoughtleaders_cli-0.9.8}/tests/test_auth.py +0 -0
  134. {thoughtleaders_cli-0.9.4 → thoughtleaders_cli-0.9.8}/tests/test_brands_winner_channels.py +0 -0
  135. {thoughtleaders_cli-0.9.4 → thoughtleaders_cli-0.9.8}/tests/test_build_report.py +0 -0
  136. {thoughtleaders_cli-0.9.4 → thoughtleaders_cli-0.9.8}/tests/test_channels_lookalike.py +0 -0
  137. {thoughtleaders_cli-0.9.4 → thoughtleaders_cli-0.9.8}/tests/test_db_repeat_warning.py +0 -0
  138. {thoughtleaders_cli-0.9.4 → thoughtleaders_cli-0.9.8}/tests/test_describe.py +0 -0
  139. {thoughtleaders_cli-0.9.4 → thoughtleaders_cli-0.9.8}/tests/test_error_hints.py +0 -0
  140. {thoughtleaders_cli-0.9.4 → thoughtleaders_cli-0.9.8}/tests/test_expand_entities.py +0 -0
  141. {thoughtleaders_cli-0.9.4 → thoughtleaders_cli-0.9.8}/tests/test_filters.py +0 -0
  142. {thoughtleaders_cli-0.9.4 → thoughtleaders_cli-0.9.8}/tests/test_http_auth.py +0 -0
  143. {thoughtleaders_cli-0.9.4 → thoughtleaders_cli-0.9.8}/tests/test_probe.py +0 -0
  144. {thoughtleaders_cli-0.9.4 → thoughtleaders_cli-0.9.8}/tests/test_profiles.py +0 -0
  145. {thoughtleaders_cli-0.9.4 → thoughtleaders_cli-0.9.8}/tests/test_scripts_executable.py +0 -0
  146. {thoughtleaders_cli-0.9.4 → thoughtleaders_cli-0.9.8}/tests/test_search_channels.py +0 -0
  147. {thoughtleaders_cli-0.9.4 → thoughtleaders_cli-0.9.8}/tests/test_search_videos.py +0 -0
  148. {thoughtleaders_cli-0.9.4 → thoughtleaders_cli-0.9.8}/tests/test_select_keywords.py +0 -0
  149. {thoughtleaders_cli-0.9.4 → thoughtleaders_cli-0.9.8}/tests/test_setup.py +0 -0
  150. {thoughtleaders_cli-0.9.4 → thoughtleaders_cli-0.9.8}/tests/test_setup_marker_respect.py +0 -0
  151. {thoughtleaders_cli-0.9.4 → thoughtleaders_cli-0.9.8}/tests/test_skill_registry.py +0 -0
  152. {thoughtleaders_cli-0.9.4 → thoughtleaders_cli-0.9.8}/tests/test_skills_command.py +0 -0
  153. {thoughtleaders_cli-0.9.4 → thoughtleaders_cli-0.9.8}/tests/test_sponsorships.py +0 -0
  154. {thoughtleaders_cli-0.9.4 → thoughtleaders_cli-0.9.8}/tests/test_workflows.py +0 -0
  155. {thoughtleaders_cli-0.9.4 → thoughtleaders_cli-0.9.8}/tests_cli/AGENTS.md +0 -0
  156. {thoughtleaders_cli-0.9.4 → thoughtleaders_cli-0.9.8}/tests_cli/conftest.py +0 -0
  157. {thoughtleaders_cli-0.9.4 → thoughtleaders_cli-0.9.8}/tests_cli/test_balance.py +0 -0
  158. {thoughtleaders_cli-0.9.4 → thoughtleaders_cli-0.9.8}/tests_cli/test_db_es.py +0 -0
  159. {thoughtleaders_cli-0.9.4 → thoughtleaders_cli-0.9.8}/tests_cli/test_db_fb.py +0 -0
  160. {thoughtleaders_cli-0.9.4 → thoughtleaders_cli-0.9.8}/tests_cli/test_db_pg.py +0 -0
  161. {thoughtleaders_cli-0.9.4 → thoughtleaders_cli-0.9.8}/tests_cli/test_schema.py +0 -0
  162. {thoughtleaders_cli-0.9.4 → thoughtleaders_cli-0.9.8}/tests_cli/test_whoami.py +0 -0
  163. {thoughtleaders_cli-0.9.4 → thoughtleaders_cli-0.9.8}/uv.lock +0 -0
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tl-cli",
3
- "version": "0.9.4",
3
+ "version": "0.9.8",
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
- Metadata-Version: 2.4
1
+ Metadata-Version: 2.5
2
2
  Name: thoughtleaders-cli
3
- Version: 0.9.4
3
+ Version: 0.9.8
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
@@ -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.4"
7
+ version = "0.9.8"
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
 
@@ -217,6 +217,8 @@ tl snapshots channel <id> # Channel metrics over time (Firebolt-bac
217
217
  tl snapshots video <id> --channel <id> # Video view curve (--channel required!)
218
218
  tl reports # List saved reports
219
219
  tl reports run <id> # Run a saved report
220
+ tl reports link <id> --source <id> --entity <e> [--type include|exclude] [--source-side included|excluded] # Link another report in as a live entity source
221
+ tl reports unlink <id> --source <id> [--entity <e>] [--type include|exclude] # Remove report links (all from that source unless narrowed)
220
222
  tl <entity> comment-list <id> # List comments on a sponsorship/channel/brand/upload
221
223
  tl <entity> comment-add <id> "msg" # Add a comment
222
224
  tl <entity> comment-edit <comment-id> "msg" # Edit own comment (author or superuser)
@@ -261,6 +263,66 @@ clear. This is currently the only editable profile field; anything else returns
261
263
  the editable surface. Use it for account-handling notes meant for the internal team — it is
262
264
  never shown to the customer.
263
265
 
266
+ **Report linking.** `tl reports link` attaches another report to a report as a **live**
267
+ include/exclude entity source — the target report's results are filtered by the source
268
+ report's entities (channels, brands, articles, or sponsorships) and stay in sync as the
269
+ source changes. This is the "include/exclude entities from another report" feature the
270
+ web UI offers, and it differs from `tl bulk-import`, which copies a **frozen snapshot**
271
+ of IDs. `--type` defaults to `include`; `--source-side` defaults to `included` (pass
272
+ `excluded` to pull the source report's excluded entities instead of its results).
273
+ Requires owning the target report (or full access), and the source report must be
274
+ visible to you. Linking a report to itself, an exact duplicate link, or an unknown key
275
+ comes back as a 400.
276
+
277
+ `tl reports unlink` removes links: with only `--source`, every link pulling from that
278
+ source report is removed; add `--entity` and/or `--type` to remove just the matching
279
+ one. No matching link comes back as a 404, never a silent no-op.
280
+
281
+ ```bash
282
+ tl reports link 1234 --source 5678 --entity channels # include source's channels
283
+ tl reports link 1234 --source 5678 --entity brands --type exclude # exclude source's brands
284
+ tl reports unlink 1234 --source 5678 # remove all links from 5678
285
+ tl reports unlink 1234 --source 5678 --entity brands --type exclude # remove one specific link
286
+ ```
287
+
288
+ ### Profile memory
289
+
290
+ Profile memory is the free-text summary of who a user is and what they want from
291
+ sponsorships. The onboarding interview writes it first; `tl memory` keeps it current
292
+ afterwards. **This is the standard way to update user data** — prefer it over any
293
+ direct database write.
294
+
295
+ ```bash
296
+ tl memory show # Your memory + the preferences derived from it (free)
297
+ tl memory show --profile-id <id> # Someone else's memory (full-access only) (free)
298
+ tl memory add "<fact>" # Fold one new fact into the existing memory (free)
299
+ tl memory set "<blob>" # Replace the memory wholesale (free)
300
+ tl memory set --from-file <path> # Same, reading the text from a file (free)
301
+ ```
302
+
303
+ Examples:
304
+ ```bash
305
+ tl memory show --profile-id 8871
306
+ tl memory add "Stopped doing finance ads. Now targeting 18-24 in the US."
307
+ tl memory set --from-file ./memory.txt
308
+ ```
309
+
310
+ `add` is the verb to reach for almost every time: the new fact is folded into the
311
+ existing memory, keeping what's still true and replacing only what the fact
312
+ contradicts. Never read the memory, edit it yourself and write it back with `set` when
313
+ `add` would do — one `add` per fact is the whole interface. `add` takes noticeably
314
+ longer than the other two; that is expected, so let it finish rather than retrying,
315
+ because a repeated `add` can land twice. A fact is one statement, not a document:
316
+ anything past 4,000 characters is rejected, so split a long update into several `add`
317
+ calls rather than sending it as one.
318
+
319
+ `set` replaces the memory wholesale and, unlike `add`, accepts a short replacement for
320
+ a long memory — treat it as the repair hatch for when a merge has damaged a memory,
321
+ not as a routine update path. Blank replacement text is refused rather than erasing the
322
+ memory, and text beyond 50,000 characters is not kept. `add` and `set` always write to
323
+ the **caller's own** profile regardless of permissions; only `show --profile-id` can
324
+ reach someone else's, and that's full-access only.
325
+
264
326
  ### Creating and vetting sponsorships
265
327
 
266
328
  This is the end-to-end workflow for proposing a sponsorship, then moving it through the funnel as the two sides respond. Three create commands plus `tl sponsorships update` cover every state transition the CLI exposes.
@@ -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
 
@@ -25,6 +25,7 @@ The profile table is tightly coupled with the brand table for media buyers, so m
25
25
  > - ❌ `youtube_id` (on channel) — use `external_channel_id`.
26
26
  > - ❌ `msn_join_date` (on channel) — use `media_selling_network_join_date`.
27
27
  > - ❌ `mbn_join_date` (on profile) — use `media_buying_network_join_date`.
28
+ > - ❌ `url` — renamed to `urls` (array) by migration 0010 (2026-07-16, "AdLink.url becomes urls"). A sponsorship can carry several URLs now; use `urls` — e.g. `urls[1]` for the first entry, or `array_to_string(urls, ', ')` to flatten.
28
29
 
29
30
  #### Key Columns for the thoughtleaders_adlink table
30
31
 
@@ -58,6 +59,7 @@ The profile table is tightly coupled with the brand table for media buyers, so m
58
59
  | `performance_grade` | int | Performance rating (see business-glossary) |
59
60
  | `article_id` | varchar | Compound `<channel_id>:<youtube_id>` — links to ES `_id` and ES `id` field |
60
61
  | `created_where` | varchar | What mechanism / software / agent created the record |
62
+ | `urls` | varchar(600)[] (array) | Destination/reference URLs for the sponsorship. ⚠️ Renamed from the old singular `url` column by migration 0010 (2026-07-16) — a sponsorship can carry several URLs now. Never NULL (empty array `{}` when none set). Use `urls[1]` for "the" URL when a single value is needed, or `array_to_string(urls, ', ')` to flatten for display. |
61
63
 
62
64
  #### `publish_status` Constants
63
65
 
@@ -204,7 +206,7 @@ A channel can have multiple adspots (different sellers: talent manager, direct,
204
206
 
205
207
  When composing `SELECT ... FROM thoughtleaders_channel ...`, do not improvise column names from semantic intuition — consult the output of `tl schema pg thoughtleaders_channel` or the column table above. The `tl schema` command is authoritative. Failed guesses return *"column '\<name\>' does not exist"* and cost a round-trip. Recurring problems:
206
208
 
207
- - Subscriber count is in the field named `subscribers`
209
+ - `reach` this column does NOT exist on Postgres. Use `subscribers`. (The Elasticsearch channel doc uses `reach` as a legacy alias for the same value — see [elasticsearch-schema.md](elasticsearch-schema.md) — but Postgres never had that name; don't carry the ES field name over when switching to a `tl db pg` query.)
208
210
  - Projected views is in the field named `projected_views`
209
211
  - ❌ **Suffix/qualifier variants of date columns** (e.g. an `_max` / `latest_` / `_date` form when the canonical column has neither). Date columns use bare names.
210
212
  - ❌ **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.
@@ -260,16 +262,19 @@ thoughtleaders_adlink
260
262
 
261
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
262
264
 
263
- 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:
264
266
 
265
267
  ```sql
266
268
  SELECT id, channel_name, common_name
267
269
  FROM thoughtleaders_channel
268
270
  WHERE UPPER(common_name) IN (UPPER('@TheInfographicsShow'), UPPER('@DrewDirksen'))
271
+ OR UPPER(channel_name) IN (UPPER('The Infographics Show'), UPPER('Drew Dirksen'))
269
272
  LIMIT 100 OFFSET 0
270
273
  ```
271
274
 
272
- `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.
273
278
 
274
279
  ### Common Join Paths
275
280
 
@@ -1,3 +1,3 @@
1
1
  """ThoughtLeaders CLI — query sponsorship data, channels, brands, and intelligence."""
2
2
 
3
- __version__ = "0.9.4"
3
+ __version__ = "0.9.8"
@@ -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)
@@ -26,8 +26,8 @@ class TLClient:
26
26
  def get(self, path: str, params: dict | None = None, timeout: float | None = None) -> dict:
27
27
  return self._request("GET", path, params=params, timeout=timeout)
28
28
 
29
- def post(self, path: str, json_body: dict | None = None) -> dict:
30
- return self._request("POST", path, json_body=json_body)
29
+ def post(self, path: str, json_body: dict | None = None, timeout: float | None = None) -> dict:
30
+ return self._request("POST", path, json_body=json_body, timeout=timeout)
31
31
 
32
32
  def patch(self, path: str, json_body: dict | None = None) -> dict:
33
33
  return self._request("PATCH", path, json_body=json_body)
@@ -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", [])
@@ -0,0 +1,149 @@
1
+ """tl memory — read and update your profile memory.
2
+
3
+ Your profile memory is the free-text summary of who you are and what you want from
4
+ sponsorships. The onboarding interview writes it first; these commands are how it
5
+ stays current afterwards.
6
+ """
7
+
8
+ from pathlib import Path
9
+
10
+ import typer
11
+ from rich.console import Console
12
+ from rich.markup import escape as rich_escape
13
+
14
+ from tl_cli._typer_utils import AlphaSortedTyperGroup
15
+ from tl_cli.client.errors import ApiError, handle_api_error
16
+ from tl_cli.client.http import get_client
17
+ from tl_cli.output.formatter import detect_format, output_single
18
+
19
+ app = typer.Typer(cls=AlphaSortedTyperGroup, help="Your profile memory (show; add a fact; replace)")
20
+
21
+ # `add` folds the fact into the existing memory with a model call whose duration
22
+ # scales with how much memory there is to rewrite, so it can run far past the
23
+ # client's 30s default. `set` is a plain replace and needs no extra room.
24
+ _ADD_TIMEOUT_SECONDS = 180.0
25
+
26
+
27
+ @app.command("show")
28
+ def show_cmd(
29
+ profile_id: int | None = typer.Option(None, "--profile-id", help="Another profile's memory (full-access only)"),
30
+ json_output: bool = typer.Option(False, "--json", help="JSON output"),
31
+ toon_output: bool = typer.Option(False, "--toon", help="TOON output (token-efficient for LLMs)"),
32
+ ) -> None:
33
+ """Show your profile memory and the preferences derived from it.
34
+
35
+ Examples:
36
+ tl memory show
37
+ tl memory show --json
38
+ tl memory show --profile-id 8871
39
+ """
40
+ fmt = detect_format(json_output, False, False, toon_output)
41
+ params = {"profile_id": profile_id} if profile_id is not None else {}
42
+
43
+ client = get_client()
44
+ try:
45
+ data = client.get("/memory", params=params)
46
+ output_single(data, fmt)
47
+ except ApiError as e:
48
+ handle_api_error(e)
49
+ finally:
50
+ client.close()
51
+
52
+
53
+ @app.command("add")
54
+ def add_cmd(
55
+ fact: str = typer.Argument(..., help="One new fact about you"),
56
+ json_output: bool = typer.Option(False, "--json", help="JSON output"),
57
+ toon_output: bool = typer.Option(False, "--toon", help="TOON output (token-efficient for LLMs)"),
58
+ ) -> None:
59
+ """Fold one new fact into your profile memory.
60
+
61
+ The fact is merged into what's already there — everything still true is kept,
62
+ and only what the new fact contradicts is replaced. Always writes to your own
63
+ profile.
64
+
65
+ Examples:
66
+ tl memory add "We stopped doing finance sponsorships."
67
+ tl memory add "Now targeting 18-24 in the US."
68
+ """
69
+ fmt = detect_format(json_output, False, False, toon_output)
70
+ if not fact.strip():
71
+ Console(stderr=True).print("[red]Error:[/red] fact cannot be empty.")
72
+ raise typer.Exit(1)
73
+
74
+ client = get_client()
75
+ try:
76
+ data = client.post("/memory", json_body={"fact": fact.strip()}, timeout=_ADD_TIMEOUT_SECONDS)
77
+ output_single(data, fmt)
78
+ except ApiError as e:
79
+ handle_api_error(e)
80
+ finally:
81
+ client.close()
82
+
83
+
84
+ @app.command("set")
85
+ def set_cmd(
86
+ memory: str | None = typer.Argument(None, help="The full replacement text"),
87
+ from_file: Path | None = typer.Option(None, "--from-file", help="Read the replacement text from a file"),
88
+ json_output: bool = typer.Option(False, "--json", help="JSON output"),
89
+ toon_output: bool = typer.Option(False, "--toon", help="TOON output (token-efficient for LLMs)"),
90
+ ) -> None:
91
+ """Replace your profile memory wholesale.
92
+
93
+ The repair hatch for when a merge has gone wrong — unlike `add`, this is not
94
+ length-guarded, so it will happily replace a long blob with a short one.
95
+ Prefer `add` for ordinary updates.
96
+
97
+ Examples:
98
+ tl memory set "Runs a finance channel. Avoids crypto."
99
+ tl memory set --from-file ./memory.txt
100
+ """
101
+ fmt = detect_format(json_output, False, False, toon_output)
102
+ err = Console(stderr=True)
103
+
104
+ if memory is not None and from_file is not None:
105
+ err.print("[red]Error:[/red] pass either the replacement text or --from-file, not both.")
106
+ raise typer.Exit(1)
107
+ if memory is None and from_file is None:
108
+ err.print("[red]Error:[/red] pass the replacement text, or --from-file to read it from a file.")
109
+ raise typer.Exit(1)
110
+
111
+ if from_file is not None:
112
+ try:
113
+ # utf-8-sig, not utf-8: an editor that writes a byte-order mark leaves it
114
+ # as the first character of the text, where it is not whitespace, survives
115
+ # the strip below, and is stored invisibly at the head of the memory.
116
+ memory = from_file.read_text(encoding="utf-8-sig")
117
+ # A non-text file raises UnicodeDecodeError, a ValueError rather than an
118
+ # OSError. The path and the error text are escaped because a bracket in
119
+ # either would abort on a markup error and lose the message itself.
120
+ except (OSError, UnicodeDecodeError) as exc:
121
+ err.print(f"[red]Error:[/red] could not read {rich_escape(str(from_file))}: {rich_escape(str(exc))}")
122
+ raise typer.Exit(1)
123
+
124
+ # A blank replacement erases the whole memory, and the two ways to arrive at one
125
+ # — a stray empty argument, or a --from-file that was never written — are both
126
+ # mistakes rather than an intent to erase.
127
+ memory = (memory or "").strip()
128
+ if not memory:
129
+ source = f"{rich_escape(str(from_file))} is empty" if from_file is not None else "the replacement text is empty"
130
+ err.print(f"[red]Error:[/red] {source}; that would erase your whole memory.")
131
+ err.print("Pass the text you want stored, or use [bold]tl memory add[/bold] to change one thing at a time.")
132
+ raise typer.Exit(1)
133
+
134
+ client = get_client()
135
+ try:
136
+ data = client.post("/memory", json_body={"memory": memory})
137
+ output_single(data, fmt)
138
+ # A replacement past the size limit is kept only up to it. The stored length
139
+ # is in the output either way, but nothing there says text went missing.
140
+ stored = data.get("profile_memory")
141
+ if isinstance(stored, str) and len(stored) < len(memory):
142
+ err.print(
143
+ f"[bold yellow]Warning:[/bold yellow] [yellow]stored {len(stored):,} of "
144
+ f"{len(memory):,} characters — the rest was past the size limit.[/yellow]"
145
+ )
146
+ except ApiError as e:
147
+ handle_api_error(e)
148
+ finally:
149
+ client.close()
@@ -672,3 +672,109 @@ def update_report(
672
672
  handle_api_error(e)
673
673
  finally:
674
674
  client.close()
675
+
676
+
677
+ LINK_ENTITIES = ("channels", "brands", "articles", "sponsorships")
678
+ LINK_TYPES = ("include", "exclude")
679
+ LINK_SOURCE_SIDES = ("included", "excluded")
680
+
681
+
682
+ @app.command("link")
683
+ def link_report(
684
+ report_id: int = typer.Argument(..., help="Target report ID (the report being filtered)"),
685
+ source: int = typer.Option(..., "--source", "-s", help="Source report ID whose entities are pulled in"),
686
+ entity: str = typer.Option(..., "--entity", "-e", help=f"Entity type: one of {', '.join(LINK_ENTITIES)}"),
687
+ filter_type: str = typer.Option(
688
+ "include", "--type", "-t", help="How the source acts on the target: include or exclude"
689
+ ),
690
+ source_side: str = typer.Option(
691
+ "included",
692
+ "--source-side",
693
+ help="Pull the source report's results ('included', default) or its excluded entities ('excluded')",
694
+ ),
695
+ json_output: bool = typer.Option(False, "--json", help="JSON output"),
696
+ toon_output: bool = typer.Option(False, "--toon", help="TOON output (token-efficient for LLMs)"),
697
+ ) -> None:
698
+ """Link another report into this report as an include/exclude entity source.
699
+
700
+ The target report's results are then filtered by the source report's
701
+ entities of the given type — the same linking the web UI offers, kept
702
+ in sync automatically (unlike a bulk-import snapshot).
703
+
704
+ Examples:
705
+ tl reports link 1234 --source 5678 --entity channels --type include
706
+ tl reports link 1234 --source 5678 --entity brands --type exclude
707
+ """
708
+ if entity not in LINK_ENTITIES:
709
+ err.print(f"[red]--entity must be one of: {', '.join(LINK_ENTITIES)}[/red]")
710
+ raise typer.Exit(2)
711
+ if filter_type not in LINK_TYPES:
712
+ err.print(f"[red]--type must be one of: {', '.join(LINK_TYPES)}[/red]")
713
+ raise typer.Exit(2)
714
+ if source_side not in LINK_SOURCE_SIDES:
715
+ err.print(f"[red]--source-side must be one of: {', '.join(LINK_SOURCE_SIDES)}[/red]")
716
+ raise typer.Exit(2)
717
+
718
+ fmt = detect_format(json_output, False, False, toon_output)
719
+ client = get_client()
720
+ try:
721
+ data = client.post(
722
+ f"/reports/{report_id}/link",
723
+ json_body={
724
+ "source_report_id": source,
725
+ "entity": entity,
726
+ "filter_type": filter_type,
727
+ "campaign_entity_type": source_side,
728
+ },
729
+ )
730
+ output_single(data, fmt)
731
+ except ApiError as e:
732
+ handle_api_error(e)
733
+ finally:
734
+ client.close()
735
+
736
+
737
+ @app.command("unlink")
738
+ def unlink_report(
739
+ report_id: int = typer.Argument(..., help="Target report ID (the report holding the link)"),
740
+ source: int = typer.Option(..., "--source", "-s", help="Source report ID to unlink"),
741
+ entity: str | None = typer.Option(
742
+ None, "--entity", "-e", help=f"Only remove links of this entity type: one of {', '.join(LINK_ENTITIES)}"
743
+ ),
744
+ filter_type: str | None = typer.Option(
745
+ None, "--type", "-t", help="Only remove links of this type: include or exclude"
746
+ ),
747
+ json_output: bool = typer.Option(False, "--json", help="JSON output"),
748
+ toon_output: bool = typer.Option(False, "--toon", help="TOON output (token-efficient for LLMs)"),
749
+ ) -> None:
750
+ """Remove a report link created with `tl reports link`.
751
+
752
+ Without --entity/--type, removes every link pulling from the source
753
+ report. Narrow with --entity and/or --type to remove a specific one.
754
+
755
+ Examples:
756
+ tl reports unlink 1234 --source 5678
757
+ tl reports unlink 1234 --source 5678 --entity channels --type exclude
758
+ """
759
+ if entity is not None and entity not in LINK_ENTITIES:
760
+ err.print(f"[red]--entity must be one of: {', '.join(LINK_ENTITIES)}[/red]")
761
+ raise typer.Exit(2)
762
+ if filter_type is not None and filter_type not in LINK_TYPES:
763
+ err.print(f"[red]--type must be one of: {', '.join(LINK_TYPES)}[/red]")
764
+ raise typer.Exit(2)
765
+
766
+ body: dict = {"source_report_id": source}
767
+ if entity is not None:
768
+ body["entity"] = entity
769
+ if filter_type is not None:
770
+ body["filter_type"] = filter_type
771
+
772
+ fmt = detect_format(json_output, False, False, toon_output)
773
+ client = get_client()
774
+ try:
775
+ data = client.post(f"/reports/{report_id}/unlink", json_body=body)
776
+ output_single(data, fmt)
777
+ except ApiError as e:
778
+ handle_api_error(e)
779
+ finally:
780
+ client.close()
@@ -17,6 +17,18 @@ from tl_cli.output.formatter import detect_format
17
17
  app = typer.Typer(cls=AlphaSortedTyperGroup, help="Show current user, profile, org, and brands (free)")
18
18
 
19
19
 
20
+ def _newsletter_subscribed(data: dict) -> bool | None:
21
+ """Industry Weekly membership, or None when the response doesn't say.
22
+
23
+ The field is absent whenever membership can't be determined, so None means
24
+ unknown — callers must not render it as a "no".
25
+ """
26
+ newsletter = data.get("newsletter")
27
+ if not isinstance(newsletter, dict) or "subscribed" not in newsletter:
28
+ return None
29
+ return bool(newsletter["subscribed"])
30
+
31
+
20
32
  def _render_whoami(data: dict) -> None:
21
33
  """Rich-formatted whoami output."""
22
34
  console = Console()
@@ -53,6 +65,16 @@ def _render_whoami(data: dict) -> None:
53
65
  lines.append("\n")
54
66
  lines.append("Joined: ", style="dim")
55
67
  lines.append(user.get("date_joined", "")[:10])
68
+ # Unknown membership prints no row at all, rather than a "no" the reader
69
+ # would take as fact.
70
+ subscribed = _newsletter_subscribed(data)
71
+ if subscribed is not None:
72
+ lines.append("\n")
73
+ lines.append("Weekly: ", style="dim")
74
+ lines.append(
75
+ "subscribed" if subscribed else "not subscribed",
76
+ style="green" if subscribed else "yellow",
77
+ )
56
78
 
57
79
  console.print(Panel(lines, title=title, border_style="cyan"))
58
80
 
@@ -141,6 +163,9 @@ def _render_whoami_md(data: dict) -> None:
141
163
  print(f"- **Flags:** {', '.join(flags)}")
142
164
  print(f"- **Paid:** {'yes' if profile.get('is_paid') else 'no'}")
143
165
  print(f"- **Joined:** {user.get('date_joined', '')[:10]}")
166
+ subscribed = _newsletter_subscribed(data)
167
+ if subscribed is not None:
168
+ print(f"- **Industry Weekly:** {'subscribed' if subscribed else 'not subscribed'}")
144
169
 
145
170
  print(f"\n## Organization: {org.get('name', '')}\n")
146
171
  plan = org.get("plan")