thoughtleaders-cli 0.9.7__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.
- {thoughtleaders_cli-0.9.7 → thoughtleaders_cli-0.9.8}/.claude-plugin/plugin.json +1 -1
- {thoughtleaders_cli-0.9.7 → thoughtleaders_cli-0.9.8}/PKG-INFO +2 -2
- {thoughtleaders_cli-0.9.7 → thoughtleaders_cli-0.9.8}/hooks/scripts/post-usage.sh +1 -1
- {thoughtleaders_cli-0.9.7 → thoughtleaders_cli-0.9.8}/pyproject.toml +1 -1
- {thoughtleaders_cli-0.9.7 → thoughtleaders_cli-0.9.8}/skills/tl/SKILL.md +1 -1
- {thoughtleaders_cli-0.9.7 → thoughtleaders_cli-0.9.8}/skills/tl/references/postgres-schema.md +6 -3
- {thoughtleaders_cli-0.9.7 → thoughtleaders_cli-0.9.8}/src/tl_cli/__init__.py +1 -1
- thoughtleaders_cli-0.9.8/src/tl_cli/client/errors.py +122 -0
- {thoughtleaders_cli-0.9.7 → thoughtleaders_cli-0.9.8}/src/tl_cli/commands/balance.py +12 -1
- {thoughtleaders_cli-0.9.7 → thoughtleaders_cli-0.9.8}/src/tl_cli/output/formatter.py +36 -11
- thoughtleaders_cli-0.9.8/tests/test_quota_refusal_message.py +68 -0
- thoughtleaders_cli-0.9.8/tests/test_refusal_rendering.py +109 -0
- thoughtleaders_cli-0.9.7/src/tl_cli/client/errors.py +0 -84
- {thoughtleaders_cli-0.9.7 → thoughtleaders_cli-0.9.8}/.claude-plugin/marketplace.json +0 -0
- {thoughtleaders_cli-0.9.7 → thoughtleaders_cli-0.9.8}/.github/dependabot.yml +0 -0
- {thoughtleaders_cli-0.9.7 → thoughtleaders_cli-0.9.8}/.github/workflows/ci.yml +0 -0
- {thoughtleaders_cli-0.9.7 → thoughtleaders_cli-0.9.8}/.github/workflows/cli-integration.yml +0 -0
- {thoughtleaders_cli-0.9.7 → thoughtleaders_cli-0.9.8}/.github/workflows/python-publish.yml +0 -0
- {thoughtleaders_cli-0.9.7 → thoughtleaders_cli-0.9.8}/.gitignore +0 -0
- {thoughtleaders_cli-0.9.7 → thoughtleaders_cli-0.9.8}/AGENTS.md +0 -0
- {thoughtleaders_cli-0.9.7 → thoughtleaders_cli-0.9.8}/API.md +0 -0
- {thoughtleaders_cli-0.9.7 → thoughtleaders_cli-0.9.8}/CLAUDE.md +0 -0
- {thoughtleaders_cli-0.9.7 → thoughtleaders_cli-0.9.8}/LICENSE +0 -0
- {thoughtleaders_cli-0.9.7 → thoughtleaders_cli-0.9.8}/README.md +0 -0
- {thoughtleaders_cli-0.9.7 → thoughtleaders_cli-0.9.8}/agents/keyword-context-classifier.md +0 -0
- {thoughtleaders_cli-0.9.7 → thoughtleaders_cli-0.9.8}/agents/keyword-entity-resolver.md +0 -0
- {thoughtleaders_cli-0.9.7 → thoughtleaders_cli-0.9.8}/agents/keyword-relevance-validator.md +0 -0
- {thoughtleaders_cli-0.9.7 → thoughtleaders_cli-0.9.8}/agents/tl-analyst.md +0 -0
- {thoughtleaders_cli-0.9.7 → thoughtleaders_cli-0.9.8}/agents/youtube-comment-classifier.md +0 -0
- {thoughtleaders_cli-0.9.7 → thoughtleaders_cli-0.9.8}/hooks/hooks.json +0 -0
- {thoughtleaders_cli-0.9.7 → thoughtleaders_cli-0.9.8}/hooks/scripts/load-tl-skill.mjs +0 -0
- {thoughtleaders_cli-0.9.7 → thoughtleaders_cli-0.9.8}/hooks/scripts/pre-check.sh +0 -0
- {thoughtleaders_cli-0.9.7 → thoughtleaders_cli-0.9.8}/skills/tl/references/business-glossary.md +0 -0
- {thoughtleaders_cli-0.9.7 → thoughtleaders_cli-0.9.8}/skills/tl/references/elasticsearch-schema.md +0 -0
- {thoughtleaders_cli-0.9.7 → thoughtleaders_cli-0.9.8}/skills/tl/references/firebolt-schema.md +0 -0
- {thoughtleaders_cli-0.9.7 → thoughtleaders_cli-0.9.8}/skills/tl-channel-authenticity/.gitignore +0 -0
- {thoughtleaders_cli-0.9.7 → thoughtleaders_cli-0.9.8}/skills/tl-channel-authenticity/SKILL.md +0 -0
- {thoughtleaders_cli-0.9.7 → thoughtleaders_cli-0.9.8}/skills/tl-channel-authenticity/references/comment-patterns.md +0 -0
- {thoughtleaders_cli-0.9.7 → thoughtleaders_cli-0.9.8}/skills/tl-channel-authenticity/references/peer-cohort.md +0 -0
- {thoughtleaders_cli-0.9.7 → thoughtleaders_cli-0.9.8}/skills/tl-channel-authenticity/references/red-flags.md +0 -0
- {thoughtleaders_cli-0.9.7 → thoughtleaders_cli-0.9.8}/skills/tl-channel-authenticity/references/scoring.md +0 -0
- {thoughtleaders_cli-0.9.7 → thoughtleaders_cli-0.9.8}/skills/tl-channel-authenticity/scripts/_io_utf8.py +0 -0
- {thoughtleaders_cli-0.9.7 → thoughtleaders_cli-0.9.8}/skills/tl-channel-authenticity/scripts/analyze_channel.py +0 -0
- {thoughtleaders_cli-0.9.7 → thoughtleaders_cli-0.9.8}/skills/tl-channel-authenticity/scripts/anomaly_detector.py +0 -0
- {thoughtleaders_cli-0.9.7 → thoughtleaders_cli-0.9.8}/skills/tl-channel-authenticity/scripts/comment_analyzer.py +0 -0
- {thoughtleaders_cli-0.9.7 → thoughtleaders_cli-0.9.8}/skills/tl-channel-authenticity/scripts/comment_scraper.py +0 -0
- {thoughtleaders_cli-0.9.7 → thoughtleaders_cli-0.9.8}/skills/tl-channel-authenticity/scripts/engagement_ratios.py +0 -0
- {thoughtleaders_cli-0.9.7 → thoughtleaders_cli-0.9.8}/skills/tl-channel-authenticity/scripts/peer_cohort.py +0 -0
- {thoughtleaders_cli-0.9.7 → thoughtleaders_cli-0.9.8}/skills/tl-channel-authenticity/scripts/report.py +0 -0
- {thoughtleaders_cli-0.9.7 → thoughtleaders_cli-0.9.8}/skills/tl-channel-authenticity/scripts/resolve_channel.py +0 -0
- {thoughtleaders_cli-0.9.7 → thoughtleaders_cli-0.9.8}/skills/tl-channel-authenticity/scripts/score.py +0 -0
- {thoughtleaders_cli-0.9.7 → thoughtleaders_cli-0.9.8}/skills/tl-channel-authenticity/scripts/tl_cli.py +0 -0
- {thoughtleaders_cli-0.9.7 → thoughtleaders_cli-0.9.8}/skills/tl-channel-authenticity/scripts/video_integrity.py +0 -0
- {thoughtleaders_cli-0.9.7 → thoughtleaders_cli-0.9.8}/skills/tl-channel-authenticity/scripts/view_curves.py +0 -0
- {thoughtleaders_cli-0.9.7 → thoughtleaders_cli-0.9.8}/skills/tl-create-workflow/SKILL.md +0 -0
- {thoughtleaders_cli-0.9.7 → thoughtleaders_cli-0.9.8}/skills/tl-create-workflow/references/creating-in-app.md +0 -0
- {thoughtleaders_cli-0.9.7 → thoughtleaders_cli-0.9.8}/skills/tl-create-workflow/references/methodology.md +0 -0
- {thoughtleaders_cli-0.9.7 → thoughtleaders_cli-0.9.8}/skills/tl-create-workflow/references/pitfalls.md +0 -0
- {thoughtleaders_cli-0.9.7 → thoughtleaders_cli-0.9.8}/skills/tl-create-workflow/references/workflow-model.md +0 -0
- {thoughtleaders_cli-0.9.7 → thoughtleaders_cli-0.9.8}/skills/tl-keyword-research/SKILL.md +0 -0
- {thoughtleaders_cli-0.9.7 → thoughtleaders_cli-0.9.8}/skills/tl-keyword-research/references/elasticsearch-content-search.md +0 -0
- {thoughtleaders_cli-0.9.7 → thoughtleaders_cli-0.9.8}/skills/tl-keyword-research/references/help.md +0 -0
- {thoughtleaders_cli-0.9.7 → thoughtleaders_cli-0.9.8}/skills/tl-keyword-research/scripts/build_report.py +0 -0
- {thoughtleaders_cli-0.9.7 → thoughtleaders_cli-0.9.8}/skills/tl-keyword-research/scripts/expand_entities.py +0 -0
- {thoughtleaders_cli-0.9.7 → thoughtleaders_cli-0.9.8}/skills/tl-keyword-research/scripts/fetch_context.py +0 -0
- {thoughtleaders_cli-0.9.7 → thoughtleaders_cli-0.9.8}/skills/tl-keyword-research/scripts/probe.py +0 -0
- {thoughtleaders_cli-0.9.7 → thoughtleaders_cli-0.9.8}/skills/tl-keyword-research/scripts/search_channels.py +0 -0
- {thoughtleaders_cli-0.9.7 → thoughtleaders_cli-0.9.8}/skills/tl-keyword-research/scripts/search_videos.py +0 -0
- {thoughtleaders_cli-0.9.7 → thoughtleaders_cli-0.9.8}/skills/tl-keyword-research/scripts/select_keywords.py +0 -0
- {thoughtleaders_cli-0.9.7 → thoughtleaders_cli-0.9.8}/skills/tl-save-report/SKILL.md +0 -0
- {thoughtleaders_cli-0.9.7 → thoughtleaders_cli-0.9.8}/skills/tl-save-report/references/columns_brands.md +0 -0
- {thoughtleaders_cli-0.9.7 → thoughtleaders_cli-0.9.8}/skills/tl-save-report/references/columns_channels.md +0 -0
- {thoughtleaders_cli-0.9.7 → thoughtleaders_cli-0.9.8}/skills/tl-save-report/references/columns_content.md +0 -0
- {thoughtleaders_cli-0.9.7 → thoughtleaders_cli-0.9.8}/skills/tl-save-report/references/columns_sponsorships.md +0 -0
- {thoughtleaders_cli-0.9.7 → thoughtleaders_cli-0.9.8}/skills/tl-save-report/references/intelligence_filterset_schema.json +0 -0
- {thoughtleaders_cli-0.9.7 → thoughtleaders_cli-0.9.8}/skills/tl-save-report/references/intelligence_widget_schema.json +0 -0
- {thoughtleaders_cli-0.9.7 → thoughtleaders_cli-0.9.8}/skills/tl-save-report/references/report_glossary.md +0 -0
- {thoughtleaders_cli-0.9.7 → thoughtleaders_cli-0.9.8}/skills/tl-save-report/references/sortable_columns.json +0 -0
- {thoughtleaders_cli-0.9.7 → thoughtleaders_cli-0.9.8}/skills/tl-save-report/references/sponsorship_filterset_schema.json +0 -0
- {thoughtleaders_cli-0.9.7 → thoughtleaders_cli-0.9.8}/skills/tl-save-report/references/sponsorship_widget_schema.json +0 -0
- {thoughtleaders_cli-0.9.7 → thoughtleaders_cli-0.9.8}/skills/tl-save-report/references/widgets.md +0 -0
- {thoughtleaders_cli-0.9.7 → thoughtleaders_cli-0.9.8}/skills/tl-top-partnerships/SKILL.md +0 -0
- {thoughtleaders_cli-0.9.7 → thoughtleaders_cli-0.9.8}/skills/tl-top-partnerships/scripts/top_partnerships.py +0 -0
- {thoughtleaders_cli-0.9.7 → thoughtleaders_cli-0.9.8}/skills/tl-views-guarantee/SKILL.md +0 -0
- {thoughtleaders_cli-0.9.7 → thoughtleaders_cli-0.9.8}/skills/tl-views-guarantee/scripts/vg.py +0 -0
- {thoughtleaders_cli-0.9.7 → thoughtleaders_cli-0.9.8}/src/tl_cli/_completions.py +0 -0
- {thoughtleaders_cli-0.9.7 → thoughtleaders_cli-0.9.8}/src/tl_cli/_typer_utils.py +0 -0
- {thoughtleaders_cli-0.9.7 → thoughtleaders_cli-0.9.8}/src/tl_cli/auth/__init__.py +0 -0
- {thoughtleaders_cli-0.9.7 → thoughtleaders_cli-0.9.8}/src/tl_cli/auth/commands.py +0 -0
- {thoughtleaders_cli-0.9.7 → thoughtleaders_cli-0.9.8}/src/tl_cli/auth/login.py +0 -0
- {thoughtleaders_cli-0.9.7 → thoughtleaders_cli-0.9.8}/src/tl_cli/auth/pkce.py +0 -0
- {thoughtleaders_cli-0.9.7 → thoughtleaders_cli-0.9.8}/src/tl_cli/auth/token_store.py +0 -0
- {thoughtleaders_cli-0.9.7 → thoughtleaders_cli-0.9.8}/src/tl_cli/client/__init__.py +0 -0
- {thoughtleaders_cli-0.9.7 → thoughtleaders_cli-0.9.8}/src/tl_cli/client/http.py +0 -0
- {thoughtleaders_cli-0.9.7 → thoughtleaders_cli-0.9.8}/src/tl_cli/commands/__init__.py +0 -0
- {thoughtleaders_cli-0.9.7 → thoughtleaders_cli-0.9.8}/src/tl_cli/commands/_comments_common.py +0 -0
- {thoughtleaders_cli-0.9.7 → thoughtleaders_cli-0.9.8}/src/tl_cli/commands/brands.py +0 -0
- {thoughtleaders_cli-0.9.7 → thoughtleaders_cli-0.9.8}/src/tl_cli/commands/bulk_import.py +0 -0
- {thoughtleaders_cli-0.9.7 → thoughtleaders_cli-0.9.8}/src/tl_cli/commands/changelog.py +0 -0
- {thoughtleaders_cli-0.9.7 → thoughtleaders_cli-0.9.8}/src/tl_cli/commands/channels.py +0 -0
- {thoughtleaders_cli-0.9.7 → thoughtleaders_cli-0.9.8}/src/tl_cli/commands/credits.py +0 -0
- {thoughtleaders_cli-0.9.7 → thoughtleaders_cli-0.9.8}/src/tl_cli/commands/db.py +0 -0
- {thoughtleaders_cli-0.9.7 → thoughtleaders_cli-0.9.8}/src/tl_cli/commands/deals.py +0 -0
- {thoughtleaders_cli-0.9.7 → thoughtleaders_cli-0.9.8}/src/tl_cli/commands/describe.py +0 -0
- {thoughtleaders_cli-0.9.7 → thoughtleaders_cli-0.9.8}/src/tl_cli/commands/doctor.py +0 -0
- {thoughtleaders_cli-0.9.7 → thoughtleaders_cli-0.9.8}/src/tl_cli/commands/matches.py +0 -0
- {thoughtleaders_cli-0.9.7 → thoughtleaders_cli-0.9.8}/src/tl_cli/commands/memory.py +0 -0
- {thoughtleaders_cli-0.9.7 → thoughtleaders_cli-0.9.8}/src/tl_cli/commands/profiles.py +0 -0
- {thoughtleaders_cli-0.9.7 → thoughtleaders_cli-0.9.8}/src/tl_cli/commands/proposals.py +0 -0
- {thoughtleaders_cli-0.9.7 → thoughtleaders_cli-0.9.8}/src/tl_cli/commands/recommender.py +0 -0
- {thoughtleaders_cli-0.9.7 → thoughtleaders_cli-0.9.8}/src/tl_cli/commands/reports.py +0 -0
- {thoughtleaders_cli-0.9.7 → thoughtleaders_cli-0.9.8}/src/tl_cli/commands/schema.py +0 -0
- {thoughtleaders_cli-0.9.7 → thoughtleaders_cli-0.9.8}/src/tl_cli/commands/setup.py +0 -0
- {thoughtleaders_cli-0.9.7 → thoughtleaders_cli-0.9.8}/src/tl_cli/commands/skills.py +0 -0
- {thoughtleaders_cli-0.9.7 → thoughtleaders_cli-0.9.8}/src/tl_cli/commands/snapshots.py +0 -0
- {thoughtleaders_cli-0.9.7 → thoughtleaders_cli-0.9.8}/src/tl_cli/commands/sponsorships.py +0 -0
- {thoughtleaders_cli-0.9.7 → thoughtleaders_cli-0.9.8}/src/tl_cli/commands/uploads.py +0 -0
- {thoughtleaders_cli-0.9.7 → thoughtleaders_cli-0.9.8}/src/tl_cli/commands/whoami.py +0 -0
- {thoughtleaders_cli-0.9.7 → thoughtleaders_cli-0.9.8}/src/tl_cli/commands/workflows.py +0 -0
- {thoughtleaders_cli-0.9.7 → thoughtleaders_cli-0.9.8}/src/tl_cli/config.py +0 -0
- {thoughtleaders_cli-0.9.7 → thoughtleaders_cli-0.9.8}/src/tl_cli/filters.py +0 -0
- {thoughtleaders_cli-0.9.7 → thoughtleaders_cli-0.9.8}/src/tl_cli/hints.py +0 -0
- {thoughtleaders_cli-0.9.7 → thoughtleaders_cli-0.9.8}/src/tl_cli/main.py +0 -0
- {thoughtleaders_cli-0.9.7 → thoughtleaders_cli-0.9.8}/src/tl_cli/output/__init__.py +0 -0
- {thoughtleaders_cli-0.9.7 → thoughtleaders_cli-0.9.8}/src/tl_cli/query_history.py +0 -0
- {thoughtleaders_cli-0.9.7 → thoughtleaders_cli-0.9.8}/src/tl_cli/self_update.py +0 -0
- {thoughtleaders_cli-0.9.7 → thoughtleaders_cli-0.9.8}/src/tl_cli/skill_registry.py +0 -0
- {thoughtleaders_cli-0.9.7 → thoughtleaders_cli-0.9.8}/tests/__init__.py +0 -0
- {thoughtleaders_cli-0.9.7 → thoughtleaders_cli-0.9.8}/tests/test_auth.py +0 -0
- {thoughtleaders_cli-0.9.7 → thoughtleaders_cli-0.9.8}/tests/test_brands_winner_channels.py +0 -0
- {thoughtleaders_cli-0.9.7 → thoughtleaders_cli-0.9.8}/tests/test_build_report.py +0 -0
- {thoughtleaders_cli-0.9.7 → thoughtleaders_cli-0.9.8}/tests/test_channels_lookalike.py +0 -0
- {thoughtleaders_cli-0.9.7 → thoughtleaders_cli-0.9.8}/tests/test_db_repeat_warning.py +0 -0
- {thoughtleaders_cli-0.9.7 → thoughtleaders_cli-0.9.8}/tests/test_describe.py +0 -0
- {thoughtleaders_cli-0.9.7 → thoughtleaders_cli-0.9.8}/tests/test_error_hints.py +0 -0
- {thoughtleaders_cli-0.9.7 → thoughtleaders_cli-0.9.8}/tests/test_expand_entities.py +0 -0
- {thoughtleaders_cli-0.9.7 → thoughtleaders_cli-0.9.8}/tests/test_filters.py +0 -0
- {thoughtleaders_cli-0.9.7 → thoughtleaders_cli-0.9.8}/tests/test_http_auth.py +0 -0
- {thoughtleaders_cli-0.9.7 → thoughtleaders_cli-0.9.8}/tests/test_memory.py +0 -0
- {thoughtleaders_cli-0.9.7 → thoughtleaders_cli-0.9.8}/tests/test_output.py +0 -0
- {thoughtleaders_cli-0.9.7 → thoughtleaders_cli-0.9.8}/tests/test_probe.py +0 -0
- {thoughtleaders_cli-0.9.7 → thoughtleaders_cli-0.9.8}/tests/test_profiles.py +0 -0
- {thoughtleaders_cli-0.9.7 → thoughtleaders_cli-0.9.8}/tests/test_reports.py +0 -0
- {thoughtleaders_cli-0.9.7 → thoughtleaders_cli-0.9.8}/tests/test_scripts_executable.py +0 -0
- {thoughtleaders_cli-0.9.7 → thoughtleaders_cli-0.9.8}/tests/test_search_channels.py +0 -0
- {thoughtleaders_cli-0.9.7 → thoughtleaders_cli-0.9.8}/tests/test_search_videos.py +0 -0
- {thoughtleaders_cli-0.9.7 → thoughtleaders_cli-0.9.8}/tests/test_select_keywords.py +0 -0
- {thoughtleaders_cli-0.9.7 → thoughtleaders_cli-0.9.8}/tests/test_setup.py +0 -0
- {thoughtleaders_cli-0.9.7 → thoughtleaders_cli-0.9.8}/tests/test_setup_marker_respect.py +0 -0
- {thoughtleaders_cli-0.9.7 → thoughtleaders_cli-0.9.8}/tests/test_skill_registry.py +0 -0
- {thoughtleaders_cli-0.9.7 → thoughtleaders_cli-0.9.8}/tests/test_skills_command.py +0 -0
- {thoughtleaders_cli-0.9.7 → thoughtleaders_cli-0.9.8}/tests/test_sponsorships.py +0 -0
- {thoughtleaders_cli-0.9.7 → thoughtleaders_cli-0.9.8}/tests/test_whoami_newsletter.py +0 -0
- {thoughtleaders_cli-0.9.7 → thoughtleaders_cli-0.9.8}/tests/test_workflows.py +0 -0
- {thoughtleaders_cli-0.9.7 → thoughtleaders_cli-0.9.8}/tests_cli/AGENTS.md +0 -0
- {thoughtleaders_cli-0.9.7 → thoughtleaders_cli-0.9.8}/tests_cli/conftest.py +0 -0
- {thoughtleaders_cli-0.9.7 → thoughtleaders_cli-0.9.8}/tests_cli/test_balance.py +0 -0
- {thoughtleaders_cli-0.9.7 → thoughtleaders_cli-0.9.8}/tests_cli/test_db_es.py +0 -0
- {thoughtleaders_cli-0.9.7 → thoughtleaders_cli-0.9.8}/tests_cli/test_db_fb.py +0 -0
- {thoughtleaders_cli-0.9.7 → thoughtleaders_cli-0.9.8}/tests_cli/test_db_pg.py +0 -0
- {thoughtleaders_cli-0.9.7 → thoughtleaders_cli-0.9.8}/tests_cli/test_schema.py +0 -0
- {thoughtleaders_cli-0.9.7 → thoughtleaders_cli-0.9.8}/tests_cli/test_whoami.py +0 -0
- {thoughtleaders_cli-0.9.7 → thoughtleaders_cli-0.9.8}/uv.lock +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
Metadata-Version: 2.
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
2
|
Name: thoughtleaders-cli
|
|
3
|
-
Version: 0.9.
|
|
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/
|
|
14
|
+
echo "WARN: Credits exhausted. Deposit more at https://app.thoughtleaders.io/billing" >&2
|
|
15
15
|
exit 0
|
|
16
16
|
fi
|
|
17
17
|
|
|
@@ -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
|
|
{thoughtleaders_cli-0.9.7 → thoughtleaders_cli-0.9.8}/skills/tl/references/postgres-schema.md
RENAMED
|
@@ -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(…)`
|
|
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
|
-
`
|
|
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
|
|
|
@@ -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
|
|
77
|
+
"(or https://app.thoughtleaders.io/billing)"
|
|
67
78
|
)
|
|
68
79
|
|
|
69
80
|
recent = data.get("recent_usage", [])
|
|
@@ -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
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
The
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
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
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
"""Tests for how billing refusals and withheld fields reach the user.
|
|
2
|
+
|
|
3
|
+
The server composes every refusal's story into `detail` (and names the code
|
|
4
|
+
in `code`); the CLI's job is to deliver it verbatim, add the CLI-native way
|
|
5
|
+
to act on it, and never bolt on a diagnosis of its own. These tests pin the
|
|
6
|
+
three places that used to do otherwise: the 402 branch (fixed copy that
|
|
7
|
+
overwrote the server's prepaid/subscription distinction), the 403 branch
|
|
8
|
+
(a blanket "your plan may not include this" on every 403, superuser gates
|
|
9
|
+
included), and successful envelopes whose premium fields were silently
|
|
10
|
+
withheld (`_upgrade_required`).
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
import pytest
|
|
14
|
+
|
|
15
|
+
from tl_cli.client.errors import ApiError, handle_api_error
|
|
16
|
+
from tl_cli.output.formatter import output
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _render(error: ApiError, capsys, code: int) -> str:
|
|
20
|
+
with pytest.raises(SystemExit) as exc:
|
|
21
|
+
handle_api_error(error)
|
|
22
|
+
assert exc.value.code == code
|
|
23
|
+
return " ".join(capsys.readouterr().err.split()) # undo console wrapping
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class TestInsufficientCredits402:
|
|
27
|
+
def test_server_detail_reaches_the_user_verbatim(self, capsys):
|
|
28
|
+
# The subscription org's alternative — wait for the next top-up —
|
|
29
|
+
# exists only in the server's sentence. Fixed client copy erased it.
|
|
30
|
+
detail = (
|
|
31
|
+
"Your organization is out of CLI credits for this top-up period. "
|
|
32
|
+
"Buy credits at https://app.thoughtleaders.io/billing or wait for "
|
|
33
|
+
"the next top-up."
|
|
34
|
+
)
|
|
35
|
+
out = _render(
|
|
36
|
+
ApiError(402, detail, raw={"detail": detail, "code": "insufficient_credits"}),
|
|
37
|
+
capsys,
|
|
38
|
+
code=4,
|
|
39
|
+
)
|
|
40
|
+
assert "wait for the next top-up" in out
|
|
41
|
+
# The CLI-native affordance stays alongside the server's sentence.
|
|
42
|
+
assert "tl credits buy" in out
|
|
43
|
+
|
|
44
|
+
def test_empty_detail_falls_back_to_the_generic_copy(self, capsys):
|
|
45
|
+
out = _render(ApiError(402, "", raw=None), capsys, code=4)
|
|
46
|
+
assert "Insufficient credits." in out
|
|
47
|
+
assert "tl credits buy" in out
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class TestAccessDenied403:
|
|
51
|
+
def test_plan_gate_detail_is_verbatim_without_a_second_diagnosis(self, capsys):
|
|
52
|
+
detail = "Snapshots require a paid plan"
|
|
53
|
+
out = _render(
|
|
54
|
+
ApiError(403, detail, raw={"detail": detail, "code": "plan_required"}),
|
|
55
|
+
capsys,
|
|
56
|
+
code=1,
|
|
57
|
+
)
|
|
58
|
+
assert "Access denied: Snapshots require a paid plan" in out
|
|
59
|
+
# The detail already names the plan; a bolted-on suggestion would be
|
|
60
|
+
# redundant here and wrong on every non-billing 403.
|
|
61
|
+
assert "Your plan may not include" not in out
|
|
62
|
+
|
|
63
|
+
def test_superuser_gate_gets_no_plan_upsell(self, capsys):
|
|
64
|
+
out = _render(ApiError(403, "Superuser only", raw={"detail": "Superuser only"}), capsys, code=1)
|
|
65
|
+
assert "Access denied: Superuser only" in out
|
|
66
|
+
assert "plan" not in out.lower()
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
class TestHintsOnBillingStatuses:
|
|
70
|
+
# A hint on a refusal is just as actionable as one on a 400 — the old
|
|
71
|
+
# handler only rendered hints on the generic branch.
|
|
72
|
+
def test_hint_renders_on_403(self, capsys):
|
|
73
|
+
error = ApiError(403, "Query rejected: premium column(s)", raw={"hint": "Name the columns you want."})
|
|
74
|
+
out = _render(error, capsys, code=1)
|
|
75
|
+
assert "Hint: Name the columns you want." in out
|
|
76
|
+
|
|
77
|
+
def test_hint_renders_on_429(self, capsys):
|
|
78
|
+
error = ApiError(429, "Premium data quota reached", raw={"hint": "Assign another seat."})
|
|
79
|
+
out = _render(error, capsys, code=3)
|
|
80
|
+
assert "Hint: Assign another seat." in out
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
class TestUpgradeNoticeOnSuccess:
|
|
84
|
+
def test_withheld_fields_are_announced_on_stderr(self, capsys):
|
|
85
|
+
# ES attaches `_upgrade_required` to a 200 whose premium fields were
|
|
86
|
+
# stripped; silently missing fields read as "no data exists".
|
|
87
|
+
output(
|
|
88
|
+
{
|
|
89
|
+
"results": [{"id": 1, "title": "video"}],
|
|
90
|
+
"_upgrade_required": {
|
|
91
|
+
"fields": ["transcript", "sponsored_brand_mentions"],
|
|
92
|
+
"message": (
|
|
93
|
+
"Audience demographics, channel outreach emails, video "
|
|
94
|
+
"transcripts and brand mentions are available on paid plans."
|
|
95
|
+
),
|
|
96
|
+
},
|
|
97
|
+
},
|
|
98
|
+
fmt="json",
|
|
99
|
+
)
|
|
100
|
+
captured = capsys.readouterr()
|
|
101
|
+
err_out = " ".join(captured.err.split())
|
|
102
|
+
assert "available on paid plans" in err_out
|
|
103
|
+
assert "transcript, sponsored_brand_mentions" in err_out
|
|
104
|
+
# The notice is a banner, not data: stdout stays valid JSON.
|
|
105
|
+
assert "_upgrade_required" in captured.out
|
|
106
|
+
|
|
107
|
+
def test_absent_key_prints_nothing(self, capsys):
|
|
108
|
+
output({"results": [{"id": 1}]}, fmt="json")
|
|
109
|
+
assert "paid plans" not in capsys.readouterr().err
|
|
@@ -1,84 +0,0 @@
|
|
|
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 handle_api_error(error: ApiError) -> None:
|
|
42
|
-
"""Print a user-friendly error message and exit with the right code."""
|
|
43
|
-
if error.status_code == 401:
|
|
44
|
-
err.print("[red]Authentication required.[/red] Run: tl auth login")
|
|
45
|
-
_print_debug(error)
|
|
46
|
-
sys.exit(2)
|
|
47
|
-
elif error.status_code == 402:
|
|
48
|
-
err.print("[red]Insufficient credits.[/red]")
|
|
49
|
-
err.print("Top up with: [bold]tl credits buy --amount-usd 10[/bold]")
|
|
50
|
-
err.print("Or visit: https://app.thoughtleaders.io/billing/cli")
|
|
51
|
-
_print_debug(error)
|
|
52
|
-
sys.exit(4)
|
|
53
|
-
elif error.status_code == 403:
|
|
54
|
-
err.print(f"[red]Access denied:[/red] {error.detail}")
|
|
55
|
-
err.print("Your plan may not include access to this resource.")
|
|
56
|
-
_print_debug(error)
|
|
57
|
-
sys.exit(1)
|
|
58
|
-
elif error.status_code == 404:
|
|
59
|
-
err.print(f"[yellow]Not found:[/yellow] {error.detail}")
|
|
60
|
-
_print_debug(error)
|
|
61
|
-
sys.exit(1)
|
|
62
|
-
elif error.status_code == 429:
|
|
63
|
-
err.print("[yellow]Rate limited.[/yellow] Please wait and try again.")
|
|
64
|
-
_print_debug(error)
|
|
65
|
-
sys.exit(3)
|
|
66
|
-
elif error.status_code >= 500:
|
|
67
|
-
err.print(f"[red]Server error ({error.status_code}):[/red] {error.detail}")
|
|
68
|
-
_print_debug(error)
|
|
69
|
-
sys.exit(3)
|
|
70
|
-
else:
|
|
71
|
-
detail = error.detail or ""
|
|
72
|
-
hint = (error.raw or {}).get("hint") if isinstance(error.raw, dict) else None
|
|
73
|
-
if isinstance(hint, str) and hint:
|
|
74
|
-
# The server sends the remediation both concatenated into
|
|
75
|
-
# `detail` (for older clients) and as a separate `hint` key —
|
|
76
|
-
# render it on its own line so it can't get lost in the error.
|
|
77
|
-
if detail.endswith(hint):
|
|
78
|
-
detail = detail[: -len(hint)].rstrip()
|
|
79
|
-
err.print(f"[red]Error ({error.status_code}):[/red] {detail}")
|
|
80
|
-
err.print(f"[bold yellow]Hint:[/bold yellow] [yellow]{hint}[/yellow]")
|
|
81
|
-
else:
|
|
82
|
-
err.print(f"[red]Error ({error.status_code}):[/red] {detail}")
|
|
83
|
-
_print_debug(error)
|
|
84
|
-
sys.exit(1)
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{thoughtleaders_cli-0.9.7 → thoughtleaders_cli-0.9.8}/skills/tl/references/business-glossary.md
RENAMED
|
File without changes
|
{thoughtleaders_cli-0.9.7 → thoughtleaders_cli-0.9.8}/skills/tl/references/elasticsearch-schema.md
RENAMED
|
File without changes
|
{thoughtleaders_cli-0.9.7 → thoughtleaders_cli-0.9.8}/skills/tl/references/firebolt-schema.md
RENAMED
|
File without changes
|
{thoughtleaders_cli-0.9.7 → thoughtleaders_cli-0.9.8}/skills/tl-channel-authenticity/.gitignore
RENAMED
|
File without changes
|
{thoughtleaders_cli-0.9.7 → thoughtleaders_cli-0.9.8}/skills/tl-channel-authenticity/SKILL.md
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{thoughtleaders_cli-0.9.7 → thoughtleaders_cli-0.9.8}/skills/tl-keyword-research/references/help.md
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{thoughtleaders_cli-0.9.7 → thoughtleaders_cli-0.9.8}/skills/tl-keyword-research/scripts/probe.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{thoughtleaders_cli-0.9.7 → thoughtleaders_cli-0.9.8}/skills/tl-save-report/references/widgets.md
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{thoughtleaders_cli-0.9.7 → thoughtleaders_cli-0.9.8}/skills/tl-views-guarantee/scripts/vg.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{thoughtleaders_cli-0.9.7 → thoughtleaders_cli-0.9.8}/src/tl_cli/commands/_comments_common.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|