thoughtleaders-cli 0.8.0__tar.gz → 0.8.1__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 (127) hide show
  1. {thoughtleaders_cli-0.8.0 → thoughtleaders_cli-0.8.1}/.claude-plugin/plugin.json +1 -1
  2. {thoughtleaders_cli-0.8.0 → thoughtleaders_cli-0.8.1}/PKG-INFO +1 -1
  3. {thoughtleaders_cli-0.8.0 → thoughtleaders_cli-0.8.1}/pyproject.toml +1 -1
  4. {thoughtleaders_cli-0.8.0 → thoughtleaders_cli-0.8.1}/src/tl_cli/__init__.py +1 -1
  5. {thoughtleaders_cli-0.8.0 → thoughtleaders_cli-0.8.1}/src/tl_cli/commands/db.py +39 -1
  6. {thoughtleaders_cli-0.8.0 → thoughtleaders_cli-0.8.1}/src/tl_cli/commands/schema.py +9 -0
  7. thoughtleaders_cli-0.8.1/src/tl_cli/query_history.py +94 -0
  8. thoughtleaders_cli-0.8.1/tests/test_db_repeat_warning.py +121 -0
  9. {thoughtleaders_cli-0.8.0 → thoughtleaders_cli-0.8.1}/uv.lock +1 -1
  10. {thoughtleaders_cli-0.8.0 → thoughtleaders_cli-0.8.1}/.claude-plugin/marketplace.json +0 -0
  11. {thoughtleaders_cli-0.8.0 → thoughtleaders_cli-0.8.1}/.github/dependabot.yml +0 -0
  12. {thoughtleaders_cli-0.8.0 → thoughtleaders_cli-0.8.1}/.github/workflows/ci.yml +0 -0
  13. {thoughtleaders_cli-0.8.0 → thoughtleaders_cli-0.8.1}/.github/workflows/cli-integration.yml +0 -0
  14. {thoughtleaders_cli-0.8.0 → thoughtleaders_cli-0.8.1}/.github/workflows/python-publish.yml +0 -0
  15. {thoughtleaders_cli-0.8.0 → thoughtleaders_cli-0.8.1}/.gitignore +0 -0
  16. {thoughtleaders_cli-0.8.0 → thoughtleaders_cli-0.8.1}/AGENTS.md +0 -0
  17. {thoughtleaders_cli-0.8.0 → thoughtleaders_cli-0.8.1}/API.md +0 -0
  18. {thoughtleaders_cli-0.8.0 → thoughtleaders_cli-0.8.1}/CLAUDE.md +0 -0
  19. {thoughtleaders_cli-0.8.0 → thoughtleaders_cli-0.8.1}/LICENSE +0 -0
  20. {thoughtleaders_cli-0.8.0 → thoughtleaders_cli-0.8.1}/README.md +0 -0
  21. {thoughtleaders_cli-0.8.0 → thoughtleaders_cli-0.8.1}/agents/tl-analyst.md +0 -0
  22. {thoughtleaders_cli-0.8.0 → thoughtleaders_cli-0.8.1}/agents/youtube-comment-classifier.md +0 -0
  23. {thoughtleaders_cli-0.8.0 → thoughtleaders_cli-0.8.1}/hooks/hooks.json +0 -0
  24. {thoughtleaders_cli-0.8.0 → thoughtleaders_cli-0.8.1}/hooks/scripts/load-tl-skill.mjs +0 -0
  25. {thoughtleaders_cli-0.8.0 → thoughtleaders_cli-0.8.1}/hooks/scripts/post-usage.sh +0 -0
  26. {thoughtleaders_cli-0.8.0 → thoughtleaders_cli-0.8.1}/hooks/scripts/pre-check.sh +0 -0
  27. {thoughtleaders_cli-0.8.0 → thoughtleaders_cli-0.8.1}/skills/tl/SKILL.md +0 -0
  28. {thoughtleaders_cli-0.8.0 → thoughtleaders_cli-0.8.1}/skills/tl/references/business-glossary.md +0 -0
  29. {thoughtleaders_cli-0.8.0 → thoughtleaders_cli-0.8.1}/skills/tl/references/elasticsearch-schema.md +0 -0
  30. {thoughtleaders_cli-0.8.0 → thoughtleaders_cli-0.8.1}/skills/tl/references/firebolt-schema.md +0 -0
  31. {thoughtleaders_cli-0.8.0 → thoughtleaders_cli-0.8.1}/skills/tl/references/postgres-schema.md +0 -0
  32. {thoughtleaders_cli-0.8.0 → thoughtleaders_cli-0.8.1}/skills/tl-channel-authenticity/.gitignore +0 -0
  33. {thoughtleaders_cli-0.8.0 → thoughtleaders_cli-0.8.1}/skills/tl-channel-authenticity/SKILL.md +0 -0
  34. {thoughtleaders_cli-0.8.0 → thoughtleaders_cli-0.8.1}/skills/tl-channel-authenticity/references/comment-patterns.md +0 -0
  35. {thoughtleaders_cli-0.8.0 → thoughtleaders_cli-0.8.1}/skills/tl-channel-authenticity/references/peer-cohort.md +0 -0
  36. {thoughtleaders_cli-0.8.0 → thoughtleaders_cli-0.8.1}/skills/tl-channel-authenticity/references/red-flags.md +0 -0
  37. {thoughtleaders_cli-0.8.0 → thoughtleaders_cli-0.8.1}/skills/tl-channel-authenticity/references/scoring.md +0 -0
  38. {thoughtleaders_cli-0.8.0 → thoughtleaders_cli-0.8.1}/skills/tl-channel-authenticity/scripts/_io_utf8.py +0 -0
  39. {thoughtleaders_cli-0.8.0 → thoughtleaders_cli-0.8.1}/skills/tl-channel-authenticity/scripts/analyze_channel.py +0 -0
  40. {thoughtleaders_cli-0.8.0 → thoughtleaders_cli-0.8.1}/skills/tl-channel-authenticity/scripts/anomaly_detector.py +0 -0
  41. {thoughtleaders_cli-0.8.0 → thoughtleaders_cli-0.8.1}/skills/tl-channel-authenticity/scripts/comment_analyzer.py +0 -0
  42. {thoughtleaders_cli-0.8.0 → thoughtleaders_cli-0.8.1}/skills/tl-channel-authenticity/scripts/comment_scraper.py +0 -0
  43. {thoughtleaders_cli-0.8.0 → thoughtleaders_cli-0.8.1}/skills/tl-channel-authenticity/scripts/engagement_ratios.py +0 -0
  44. {thoughtleaders_cli-0.8.0 → thoughtleaders_cli-0.8.1}/skills/tl-channel-authenticity/scripts/peer_cohort.py +0 -0
  45. {thoughtleaders_cli-0.8.0 → thoughtleaders_cli-0.8.1}/skills/tl-channel-authenticity/scripts/report.py +0 -0
  46. {thoughtleaders_cli-0.8.0 → thoughtleaders_cli-0.8.1}/skills/tl-channel-authenticity/scripts/resolve_channel.py +0 -0
  47. {thoughtleaders_cli-0.8.0 → thoughtleaders_cli-0.8.1}/skills/tl-channel-authenticity/scripts/score.py +0 -0
  48. {thoughtleaders_cli-0.8.0 → thoughtleaders_cli-0.8.1}/skills/tl-channel-authenticity/scripts/tl_cli.py +0 -0
  49. {thoughtleaders_cli-0.8.0 → thoughtleaders_cli-0.8.1}/skills/tl-channel-authenticity/scripts/video_integrity.py +0 -0
  50. {thoughtleaders_cli-0.8.0 → thoughtleaders_cli-0.8.1}/skills/tl-channel-authenticity/scripts/view_curves.py +0 -0
  51. {thoughtleaders_cli-0.8.0 → thoughtleaders_cli-0.8.1}/skills/tl-keyword-research/SKILL.md +0 -0
  52. {thoughtleaders_cli-0.8.0 → thoughtleaders_cli-0.8.1}/skills/tl-keyword-research/references/context-classifier.md +0 -0
  53. {thoughtleaders_cli-0.8.0 → thoughtleaders_cli-0.8.1}/skills/tl-keyword-research/scripts/fetch_context.py +0 -0
  54. {thoughtleaders_cli-0.8.0 → thoughtleaders_cli-0.8.1}/skills/tl-keyword-research/scripts/probe.py +0 -0
  55. {thoughtleaders_cli-0.8.0 → thoughtleaders_cli-0.8.1}/skills/tl-keyword-research/scripts/search_channels.py +0 -0
  56. {thoughtleaders_cli-0.8.0 → thoughtleaders_cli-0.8.1}/skills/tl-save-report/SKILL.md +0 -0
  57. {thoughtleaders_cli-0.8.0 → thoughtleaders_cli-0.8.1}/skills/tl-save-report/references/columns_brands.md +0 -0
  58. {thoughtleaders_cli-0.8.0 → thoughtleaders_cli-0.8.1}/skills/tl-save-report/references/columns_channels.md +0 -0
  59. {thoughtleaders_cli-0.8.0 → thoughtleaders_cli-0.8.1}/skills/tl-save-report/references/columns_content.md +0 -0
  60. {thoughtleaders_cli-0.8.0 → thoughtleaders_cli-0.8.1}/skills/tl-save-report/references/columns_sponsorships.md +0 -0
  61. {thoughtleaders_cli-0.8.0 → thoughtleaders_cli-0.8.1}/skills/tl-save-report/references/intelligence_filterset_schema.json +0 -0
  62. {thoughtleaders_cli-0.8.0 → thoughtleaders_cli-0.8.1}/skills/tl-save-report/references/intelligence_widget_schema.json +0 -0
  63. {thoughtleaders_cli-0.8.0 → thoughtleaders_cli-0.8.1}/skills/tl-save-report/references/report_glossary.md +0 -0
  64. {thoughtleaders_cli-0.8.0 → thoughtleaders_cli-0.8.1}/skills/tl-save-report/references/sortable_columns.json +0 -0
  65. {thoughtleaders_cli-0.8.0 → thoughtleaders_cli-0.8.1}/skills/tl-save-report/references/sponsorship_filterset_schema.json +0 -0
  66. {thoughtleaders_cli-0.8.0 → thoughtleaders_cli-0.8.1}/skills/tl-save-report/references/sponsorship_widget_schema.json +0 -0
  67. {thoughtleaders_cli-0.8.0 → thoughtleaders_cli-0.8.1}/skills/tl-save-report/references/widgets.md +0 -0
  68. {thoughtleaders_cli-0.8.0 → thoughtleaders_cli-0.8.1}/skills/tl-top-partnerships/SKILL.md +0 -0
  69. {thoughtleaders_cli-0.8.0 → thoughtleaders_cli-0.8.1}/skills/tl-top-partnerships/scripts/top_partnerships.py +0 -0
  70. {thoughtleaders_cli-0.8.0 → thoughtleaders_cli-0.8.1}/skills/tl-views-guarantee/SKILL.md +0 -0
  71. {thoughtleaders_cli-0.8.0 → thoughtleaders_cli-0.8.1}/skills/tl-views-guarantee/scripts/vg.py +0 -0
  72. {thoughtleaders_cli-0.8.0 → thoughtleaders_cli-0.8.1}/src/tl_cli/_completions.py +0 -0
  73. {thoughtleaders_cli-0.8.0 → thoughtleaders_cli-0.8.1}/src/tl_cli/_typer_utils.py +0 -0
  74. {thoughtleaders_cli-0.8.0 → thoughtleaders_cli-0.8.1}/src/tl_cli/auth/__init__.py +0 -0
  75. {thoughtleaders_cli-0.8.0 → thoughtleaders_cli-0.8.1}/src/tl_cli/auth/commands.py +0 -0
  76. {thoughtleaders_cli-0.8.0 → thoughtleaders_cli-0.8.1}/src/tl_cli/auth/login.py +0 -0
  77. {thoughtleaders_cli-0.8.0 → thoughtleaders_cli-0.8.1}/src/tl_cli/auth/pkce.py +0 -0
  78. {thoughtleaders_cli-0.8.0 → thoughtleaders_cli-0.8.1}/src/tl_cli/auth/token_store.py +0 -0
  79. {thoughtleaders_cli-0.8.0 → thoughtleaders_cli-0.8.1}/src/tl_cli/client/__init__.py +0 -0
  80. {thoughtleaders_cli-0.8.0 → thoughtleaders_cli-0.8.1}/src/tl_cli/client/errors.py +0 -0
  81. {thoughtleaders_cli-0.8.0 → thoughtleaders_cli-0.8.1}/src/tl_cli/client/http.py +0 -0
  82. {thoughtleaders_cli-0.8.0 → thoughtleaders_cli-0.8.1}/src/tl_cli/commands/__init__.py +0 -0
  83. {thoughtleaders_cli-0.8.0 → thoughtleaders_cli-0.8.1}/src/tl_cli/commands/_comments_common.py +0 -0
  84. {thoughtleaders_cli-0.8.0 → thoughtleaders_cli-0.8.1}/src/tl_cli/commands/balance.py +0 -0
  85. {thoughtleaders_cli-0.8.0 → thoughtleaders_cli-0.8.1}/src/tl_cli/commands/brands.py +0 -0
  86. {thoughtleaders_cli-0.8.0 → thoughtleaders_cli-0.8.1}/src/tl_cli/commands/bulk_import.py +0 -0
  87. {thoughtleaders_cli-0.8.0 → thoughtleaders_cli-0.8.1}/src/tl_cli/commands/changelog.py +0 -0
  88. {thoughtleaders_cli-0.8.0 → thoughtleaders_cli-0.8.1}/src/tl_cli/commands/channels.py +0 -0
  89. {thoughtleaders_cli-0.8.0 → thoughtleaders_cli-0.8.1}/src/tl_cli/commands/credits.py +0 -0
  90. {thoughtleaders_cli-0.8.0 → thoughtleaders_cli-0.8.1}/src/tl_cli/commands/deals.py +0 -0
  91. {thoughtleaders_cli-0.8.0 → thoughtleaders_cli-0.8.1}/src/tl_cli/commands/describe.py +0 -0
  92. {thoughtleaders_cli-0.8.0 → thoughtleaders_cli-0.8.1}/src/tl_cli/commands/doctor.py +0 -0
  93. {thoughtleaders_cli-0.8.0 → thoughtleaders_cli-0.8.1}/src/tl_cli/commands/matches.py +0 -0
  94. {thoughtleaders_cli-0.8.0 → thoughtleaders_cli-0.8.1}/src/tl_cli/commands/proposals.py +0 -0
  95. {thoughtleaders_cli-0.8.0 → thoughtleaders_cli-0.8.1}/src/tl_cli/commands/recommender.py +0 -0
  96. {thoughtleaders_cli-0.8.0 → thoughtleaders_cli-0.8.1}/src/tl_cli/commands/reports.py +0 -0
  97. {thoughtleaders_cli-0.8.0 → thoughtleaders_cli-0.8.1}/src/tl_cli/commands/setup.py +0 -0
  98. {thoughtleaders_cli-0.8.0 → thoughtleaders_cli-0.8.1}/src/tl_cli/commands/snapshots.py +0 -0
  99. {thoughtleaders_cli-0.8.0 → thoughtleaders_cli-0.8.1}/src/tl_cli/commands/sponsorships.py +0 -0
  100. {thoughtleaders_cli-0.8.0 → thoughtleaders_cli-0.8.1}/src/tl_cli/commands/uploads.py +0 -0
  101. {thoughtleaders_cli-0.8.0 → thoughtleaders_cli-0.8.1}/src/tl_cli/commands/whoami.py +0 -0
  102. {thoughtleaders_cli-0.8.0 → thoughtleaders_cli-0.8.1}/src/tl_cli/config.py +0 -0
  103. {thoughtleaders_cli-0.8.0 → thoughtleaders_cli-0.8.1}/src/tl_cli/filters.py +0 -0
  104. {thoughtleaders_cli-0.8.0 → thoughtleaders_cli-0.8.1}/src/tl_cli/hints.py +0 -0
  105. {thoughtleaders_cli-0.8.0 → thoughtleaders_cli-0.8.1}/src/tl_cli/main.py +0 -0
  106. {thoughtleaders_cli-0.8.0 → thoughtleaders_cli-0.8.1}/src/tl_cli/output/__init__.py +0 -0
  107. {thoughtleaders_cli-0.8.0 → thoughtleaders_cli-0.8.1}/src/tl_cli/output/formatter.py +0 -0
  108. {thoughtleaders_cli-0.8.0 → thoughtleaders_cli-0.8.1}/src/tl_cli/self_update.py +0 -0
  109. {thoughtleaders_cli-0.8.0 → thoughtleaders_cli-0.8.1}/tests/__init__.py +0 -0
  110. {thoughtleaders_cli-0.8.0 → thoughtleaders_cli-0.8.1}/tests/test_auth.py +0 -0
  111. {thoughtleaders_cli-0.8.0 → thoughtleaders_cli-0.8.1}/tests/test_brands_winner_channels.py +0 -0
  112. {thoughtleaders_cli-0.8.0 → thoughtleaders_cli-0.8.1}/tests/test_channels_lookalike.py +0 -0
  113. {thoughtleaders_cli-0.8.0 → thoughtleaders_cli-0.8.1}/tests/test_describe.py +0 -0
  114. {thoughtleaders_cli-0.8.0 → thoughtleaders_cli-0.8.1}/tests/test_filters.py +0 -0
  115. {thoughtleaders_cli-0.8.0 → thoughtleaders_cli-0.8.1}/tests/test_http_auth.py +0 -0
  116. {thoughtleaders_cli-0.8.0 → thoughtleaders_cli-0.8.1}/tests/test_output.py +0 -0
  117. {thoughtleaders_cli-0.8.0 → thoughtleaders_cli-0.8.1}/tests/test_reports.py +0 -0
  118. {thoughtleaders_cli-0.8.0 → thoughtleaders_cli-0.8.1}/tests/test_setup.py +0 -0
  119. {thoughtleaders_cli-0.8.0 → thoughtleaders_cli-0.8.1}/tests/test_sponsorships.py +0 -0
  120. {thoughtleaders_cli-0.8.0 → thoughtleaders_cli-0.8.1}/tests_cli/AGENTS.md +0 -0
  121. {thoughtleaders_cli-0.8.0 → thoughtleaders_cli-0.8.1}/tests_cli/conftest.py +0 -0
  122. {thoughtleaders_cli-0.8.0 → thoughtleaders_cli-0.8.1}/tests_cli/test_balance.py +0 -0
  123. {thoughtleaders_cli-0.8.0 → thoughtleaders_cli-0.8.1}/tests_cli/test_db_es.py +0 -0
  124. {thoughtleaders_cli-0.8.0 → thoughtleaders_cli-0.8.1}/tests_cli/test_db_fb.py +0 -0
  125. {thoughtleaders_cli-0.8.0 → thoughtleaders_cli-0.8.1}/tests_cli/test_db_pg.py +0 -0
  126. {thoughtleaders_cli-0.8.0 → thoughtleaders_cli-0.8.1}/tests_cli/test_schema.py +0 -0
  127. {thoughtleaders_cli-0.8.0 → thoughtleaders_cli-0.8.1}/tests_cli/test_whoami.py +0 -0
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tl-cli",
3
- "version": "0.8.0",
3
+ "version": "0.8.1",
4
4
  "description": "ThoughtLeaders CLI — query sponsorship deals, channels, brands, uploads, and intelligence from the terminal",
5
5
  "author": {
6
6
  "name": "ThoughtLeaders",
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: thoughtleaders-cli
3
- Version: 0.8.0
3
+ Version: 0.8.1
4
4
  Summary: ThoughtLeaders CLI — query sponsorship data, channels, brands, and intelligence
5
5
  Project-URL: Homepage, https://thoughtleaders.io
6
6
  Project-URL: Repository, https://github.com/ThoughtLeaders-io/thoughtleaders-cli
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "thoughtleaders-cli"
7
- version = "0.8.0"
7
+ version = "0.8.1"
8
8
  description = "ThoughtLeaders CLI — query sponsorship data, channels, brands, and intelligence"
9
9
  readme = "README.md"
10
10
  license = "MIT"
@@ -1,3 +1,3 @@
1
1
  """ThoughtLeaders CLI — query sponsorship data, channels, brands, and intelligence."""
2
2
 
3
- __version__ = "0.8.0"
3
+ __version__ = "0.8.1"
@@ -4,14 +4,23 @@ import json
4
4
  import sys
5
5
 
6
6
  import typer
7
+ from rich.console import Console
7
8
  from tl_cli._typer_utils import AlphaSortedTyperGroup
8
9
 
9
10
  from tl_cli.client.errors import ApiError, handle_api_error
10
11
  from tl_cli.client.http import get_client
11
12
  from tl_cli.output.formatter import detect_format, output, output_pricing_estimate
13
+ from tl_cli.query_history import query_hash, record_and_check
12
14
 
13
15
  app = typer.Typer(cls=AlphaSortedTyperGroup, help="Raw read-only queries against PostgreSQL, Firebolt, or Elasticsearch (full-access only)")
14
16
 
17
+ _err = Console(stderr=True)
18
+
19
+ _NO_REPEAT_WARNING_OPTION = typer.Option(
20
+ False, "--no-repeat-warning",
21
+ help="Suppress the warning shown when the same query is re-run repeatedly (also: TL_NO_REPEAT_WARNING=1).",
22
+ )
23
+
15
24
 
16
25
  def _read_query(query: str | None) -> str:
17
26
  if query is not None and query != "-":
@@ -21,6 +30,24 @@ def _read_query(query: str | None) -> str:
21
30
  return sys.stdin.read()
22
31
 
23
32
 
33
+ def _warn_if_repeat(engine: str, query: str, pricing: bool, suppressed: bool) -> None:
34
+ """Nudge (on stderr) when this exact query keeps being re-run.
35
+
36
+ Detection is local and best-effort; the query always executes.
37
+ """
38
+ if suppressed:
39
+ return
40
+ count = record_and_check(query_hash(engine, query, pricing))
41
+ if count is None:
42
+ return
43
+ _err.print(
44
+ f"[yellow]Repeat query:[/yellow] this exact query has now run {count} times "
45
+ "in the last 5 minutes. Each run is billed and the results are unlikely to "
46
+ "differ — reuse the earlier output (e.g. save it with `--json > file.json`). "
47
+ "Pass --no-repeat-warning if the repeats are deliberate."
48
+ )
49
+
50
+
24
51
  def _run(path: str, body: dict, fmt: str, title: str, pricing: bool = False) -> None:
25
52
  client = get_client()
26
53
  try:
@@ -53,16 +80,23 @@ def pg_cmd(
53
80
  False, "--pricing",
54
81
  help="Estimate the query's credit cost via EXPLAIN without running it (flat 1 credit).",
55
82
  ),
83
+ no_repeat_warning: bool = _NO_REPEAT_WARNING_OPTION,
56
84
  ) -> None:
57
85
  """Run a raw PostgreSQL SELECT query.
58
86
 
87
+ Column names follow the current schema (e.g. subscribers, projected_views,
88
+ scheduled_date — not the older reach / impression / send_date). Run
89
+ `tl schema pg <table>` to see the exact columns your role can query.
90
+
59
91
  Examples:
60
- tl db pg "SELECT id, name FROM thoughtleaders_brand LIMIT 10 OFFSET 0"
92
+ tl db pg "SELECT channel_name, subscribers, projected_views FROM thoughtleaders_channel ORDER BY subscribers DESC LIMIT 10"
93
+ tl db pg "SELECT id, scheduled_date, publish_status FROM thoughtleaders_adlink LIMIT 20"
61
94
  cat query.sql | tl db pg -
62
95
  tl db pg "SELECT * FROM thoughtleaders_channel LIMIT 100" --pricing
63
96
  """
64
97
  fmt = detect_format(json_output, csv_output, md_output, toon_output)
65
98
  sql = _read_query(query)
99
+ _warn_if_repeat("pg", sql, pricing, no_repeat_warning)
66
100
  body: dict = {"query": sql}
67
101
  if pricing:
68
102
  body["pricing"] = True
@@ -80,6 +114,7 @@ def fb_cmd(
80
114
  False, "--pricing",
81
115
  help="Estimate the query's credit cost without running it (flat 1 credit).",
82
116
  ),
117
+ no_repeat_warning: bool = _NO_REPEAT_WARNING_OPTION,
83
118
  ) -> None:
84
119
  """Run a raw Firebolt SELECT query.
85
120
 
@@ -91,6 +126,7 @@ def fb_cmd(
91
126
  """
92
127
  fmt = detect_format(json_output, csv_output, md_output, toon_output)
93
128
  sql = _read_query(query)
129
+ _warn_if_repeat("fb", sql, pricing, no_repeat_warning)
94
130
  body: dict = {"query": sql}
95
131
  if pricing:
96
132
  body["pricing"] = True
@@ -112,6 +148,7 @@ def es_cmd(
112
148
  False, "--highlight",
113
149
  help="Keep ES highlight fragments in each result row. Only meaningful when the query body includes a `highlight` clause; otherwise no-op.",
114
150
  ),
151
+ no_repeat_warning: bool = _NO_REPEAT_WARNING_OPTION,
115
152
  ) -> None:
116
153
  """Run a raw Elasticsearch search query.
117
154
 
@@ -129,6 +166,7 @@ def es_cmd(
129
166
  except json.JSONDecodeError as exc:
130
167
  raise typer.BadParameter(f"Query is not valid JSON: {exc}") from exc
131
168
 
169
+ _warn_if_repeat("es", raw, pricing, no_repeat_warning)
132
170
  body: dict = {"query": body_query}
133
171
  if pricing:
134
172
  body["pricing"] = True
@@ -75,6 +75,15 @@ def _try_render_yaml_tree(content: str) -> bool:
75
75
  if fcomment:
76
76
  line.append(f' — {fcomment}', style='dim')
77
77
  tree.add(line)
78
+
79
+ # Curated "common traps" for this table (old → new field names, join
80
+ # hints), surfaced as a sub-branch so it's visible during discovery.
81
+ common_traps = fields.get('__common_traps')
82
+ if isinstance(common_traps, list) and common_traps:
83
+ traps_branch = tree.add(Text('common traps', style='bold red'))
84
+ for trap in common_traps:
85
+ traps_branch.add(Text(str(trap), style='dim'))
86
+
78
87
  console.print(tree)
79
88
  console.print()
80
89
 
@@ -0,0 +1,94 @@
1
+ """Local repeat-query detection for `tl db` commands.
2
+
3
+ Agents sometimes re-run the exact same raw query in a tight loop — losing
4
+ the result, or polling data that will not change — and every run is billed.
5
+ This module keeps a small local log of recent query hashes and decides when
6
+ a repeat warning is due. Everything is best-effort by design: state lives
7
+ in a JSON file next to the CLI config, concurrent runs may race it, and any
8
+ I/O or parse failure resets it — a heuristic warning must never break a
9
+ query.
10
+ """
11
+
12
+ import hashlib
13
+ import json
14
+ import os
15
+ import time
16
+
17
+ from tl_cli.config import CONFIG_DIR
18
+
19
+ HISTORY_FILE = CONFIG_DIR / "recent_queries.json"
20
+
21
+ # A repeat is the same normalized query seen REPEAT_THRESHOLD+ times inside
22
+ # WINDOW_S. Warnings re-arm after WARN_INTERVAL_S so a long-running loop is
23
+ # nudged about once a minute rather than on every single run.
24
+ WINDOW_S = 300
25
+ REPEAT_THRESHOLD = 3
26
+ WARN_INTERVAL_S = 60
27
+
28
+ ENV_SUPPRESS = "TL_NO_REPEAT_WARNING"
29
+
30
+
31
+ def query_hash(engine: str, query: str, pricing: bool = False) -> str:
32
+ """Stable digest for an (engine, query) pair.
33
+
34
+ Whitespace runs and letter case don't change what a query does, so they
35
+ don't change the digest. The engine and the --pricing flag do: the same
36
+ text against pg vs fb, or a dry-run vs a real run, are different actions.
37
+ """
38
+ normalized = " ".join(query.split()).strip().rstrip(";").lower()
39
+ basis = f"{engine}\x00{'pricing' if pricing else 'run'}\x00{normalized}"
40
+ return hashlib.sha256(basis.encode("utf-8")).hexdigest()
41
+
42
+
43
+ def record_and_check(digest: str) -> int | None:
44
+ """Record one run of ``digest``; return the run count when a warning is due.
45
+
46
+ Returns None below the threshold, while a warning from the last
47
+ WARN_INTERVAL_S is still fresh, or when TL_NO_REPEAT_WARNING is set.
48
+ """
49
+ if os.environ.get(ENV_SUPPRESS):
50
+ return None
51
+ now = time.time()
52
+ state = _load()
53
+ entry = state.get(digest)
54
+ entry = entry if isinstance(entry, dict) else {}
55
+ runs = _recent_runs(entry, now)
56
+ runs.append(now)
57
+ warned_at = entry.get("warned_at")
58
+ warning_fresh = isinstance(warned_at, int | float) and now - warned_at < WARN_INTERVAL_S
59
+ due = len(runs) >= REPEAT_THRESHOLD and not warning_fresh
60
+ state[digest] = {"runs": runs, "warned_at": now if due else warned_at}
61
+ _save(state, now)
62
+ return len(runs) if due else None
63
+
64
+
65
+ def _recent_runs(entry: dict, now: float) -> list[float]:
66
+ raw = entry.get("runs")
67
+ if not isinstance(raw, list):
68
+ return []
69
+ return [t for t in raw if isinstance(t, int | float) and now - t < WINDOW_S]
70
+
71
+
72
+ def _load() -> dict:
73
+ try:
74
+ with open(HISTORY_FILE, encoding="utf-8") as f:
75
+ data = json.load(f)
76
+ return data if isinstance(data, dict) else {}
77
+ except (OSError, ValueError):
78
+ return {}
79
+
80
+
81
+ def _save(state: dict, now: float) -> None:
82
+ pruned = {}
83
+ for digest, entry in state.items():
84
+ if not isinstance(entry, dict):
85
+ continue
86
+ runs = _recent_runs(entry, now)
87
+ if runs:
88
+ pruned[digest] = {"runs": runs, "warned_at": entry.get("warned_at")}
89
+ try:
90
+ HISTORY_FILE.parent.mkdir(parents=True, exist_ok=True)
91
+ with open(HISTORY_FILE, "w", encoding="utf-8") as f:
92
+ json.dump(pruned, f)
93
+ except OSError:
94
+ pass
@@ -0,0 +1,121 @@
1
+ """Tests for the local repeat-query warning (`tl db` commands)."""
2
+
3
+ import json
4
+
5
+ from tl_cli import query_history
6
+ from tl_cli.commands import db
7
+ from tl_cli.query_history import query_hash, record_and_check
8
+
9
+
10
+ def _use_tmp_history(monkeypatch, tmp_path):
11
+ monkeypatch.setattr(query_history, "HISTORY_FILE", tmp_path / "recent_queries.json")
12
+ monkeypatch.delenv(query_history.ENV_SUPPRESS, raising=False)
13
+
14
+
15
+ class TestQueryHash:
16
+ def test_whitespace_and_case_collapse(self):
17
+ assert query_hash("pg", "SELECT 1\n FROM x;") == query_hash("pg", "select 1 from x")
18
+
19
+ def test_engines_do_not_collide(self):
20
+ assert query_hash("pg", "SELECT 1") != query_hash("fb", "SELECT 1")
21
+
22
+ def test_pricing_dry_run_is_distinct(self):
23
+ assert query_hash("pg", "SELECT 1") != query_hash("pg", "SELECT 1", pricing=True)
24
+
25
+ def test_different_literals_are_different_queries(self):
26
+ # Pagination (OFFSET 0/100/200) must never look like a repeat.
27
+ assert query_hash("pg", "SELECT 1 OFFSET 0") != query_hash("pg", "SELECT 1 OFFSET 100")
28
+
29
+
30
+ class TestRecordAndCheck:
31
+ def test_warns_on_third_run_only(self, monkeypatch, tmp_path):
32
+ _use_tmp_history(monkeypatch, tmp_path)
33
+ digest = query_hash("pg", "SELECT 1")
34
+ assert record_and_check(digest) is None
35
+ assert record_and_check(digest) is None
36
+ assert record_and_check(digest) == 3
37
+
38
+ def test_warn_once_per_interval(self, monkeypatch, tmp_path):
39
+ _use_tmp_history(monkeypatch, tmp_path)
40
+ digest = query_hash("pg", "SELECT 1")
41
+ for _ in range(3):
42
+ record_and_check(digest)
43
+ # 4th and 5th runs arrive while the warning is still fresh.
44
+ assert record_and_check(digest) is None
45
+ assert record_and_check(digest) is None
46
+
47
+ def test_rewarns_after_interval_elapses(self, monkeypatch, tmp_path):
48
+ _use_tmp_history(monkeypatch, tmp_path)
49
+ digest = query_hash("pg", "SELECT 1")
50
+ now = 1_000_000.0
51
+ monkeypatch.setattr(query_history.time, "time", lambda: now)
52
+ for _ in range(4):
53
+ record_and_check(digest)
54
+ monkeypatch.setattr(
55
+ query_history.time, "time", lambda: now + query_history.WARN_INTERVAL_S + 1
56
+ )
57
+ assert record_and_check(digest) == 5
58
+
59
+ def test_runs_age_out_of_window(self, monkeypatch, tmp_path):
60
+ _use_tmp_history(monkeypatch, tmp_path)
61
+ digest = query_hash("pg", "SELECT 1")
62
+ now = 1_000_000.0
63
+ monkeypatch.setattr(query_history.time, "time", lambda: now)
64
+ record_and_check(digest)
65
+ record_and_check(digest)
66
+ monkeypatch.setattr(
67
+ query_history.time, "time", lambda: now + query_history.WINDOW_S + 1
68
+ )
69
+ # Both earlier runs expired: this is a fresh run 1, not run 3.
70
+ assert record_and_check(digest) is None
71
+
72
+ def test_env_var_suppresses(self, monkeypatch, tmp_path):
73
+ _use_tmp_history(monkeypatch, tmp_path)
74
+ monkeypatch.setenv(query_history.ENV_SUPPRESS, "1")
75
+ digest = query_hash("pg", "SELECT 1")
76
+ for _ in range(5):
77
+ assert record_and_check(digest) is None
78
+
79
+ def test_corrupt_state_file_is_tolerated(self, monkeypatch, tmp_path):
80
+ _use_tmp_history(monkeypatch, tmp_path)
81
+ (tmp_path / "recent_queries.json").write_text("{not json", encoding="utf-8")
82
+ digest = query_hash("pg", "SELECT 1")
83
+ assert record_and_check(digest) is None
84
+ # State was reset and is valid JSON again.
85
+ state = json.loads((tmp_path / "recent_queries.json").read_text(encoding="utf-8"))
86
+ assert digest in state
87
+
88
+ def test_other_hashes_pruned_when_stale(self, monkeypatch, tmp_path):
89
+ _use_tmp_history(monkeypatch, tmp_path)
90
+ now = 1_000_000.0
91
+ monkeypatch.setattr(query_history.time, "time", lambda: now)
92
+ record_and_check(query_hash("pg", "SELECT 1"))
93
+ monkeypatch.setattr(
94
+ query_history.time, "time", lambda: now + query_history.WINDOW_S + 1
95
+ )
96
+ record_and_check(query_hash("pg", "SELECT 2"))
97
+ state = json.loads((tmp_path / "recent_queries.json").read_text(encoding="utf-8"))
98
+ assert list(state) == [query_hash("pg", "SELECT 2")]
99
+
100
+
101
+ class TestWarnIfRepeat:
102
+ def test_prints_warning_on_third_run(self, monkeypatch, tmp_path, capsys):
103
+ _use_tmp_history(monkeypatch, tmp_path)
104
+ for _ in range(3):
105
+ db._warn_if_repeat("pg", "SELECT 1", False, False)
106
+ err = capsys.readouterr().err
107
+ assert "Repeat query" in err
108
+ assert "3 times" in err
109
+ assert "--no-repeat-warning" in err
110
+
111
+ def test_flag_suppresses(self, monkeypatch, tmp_path, capsys):
112
+ _use_tmp_history(monkeypatch, tmp_path)
113
+ for _ in range(5):
114
+ db._warn_if_repeat("pg", "SELECT 1", False, True)
115
+ assert "Repeat query" not in capsys.readouterr().err
116
+
117
+ def test_silent_below_threshold(self, monkeypatch, tmp_path, capsys):
118
+ _use_tmp_history(monkeypatch, tmp_path)
119
+ db._warn_if_repeat("pg", "SELECT 1", False, False)
120
+ db._warn_if_repeat("pg", "SELECT 2", False, False)
121
+ assert "Repeat query" not in capsys.readouterr().err
@@ -434,7 +434,7 @@ wheels = [
434
434
 
435
435
  [[package]]
436
436
  name = "thoughtleaders-cli"
437
- version = "0.7.12"
437
+ version = "0.8.1"
438
438
  source = { editable = "." }
439
439
  dependencies = [
440
440
  { name = "authlib" },