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.
- {thoughtleaders_cli-0.9.4 → thoughtleaders_cli-0.9.8}/.claude-plugin/plugin.json +1 -1
- {thoughtleaders_cli-0.9.4 → thoughtleaders_cli-0.9.8}/PKG-INFO +2 -2
- {thoughtleaders_cli-0.9.4 → thoughtleaders_cli-0.9.8}/hooks/scripts/post-usage.sh +1 -1
- {thoughtleaders_cli-0.9.4 → thoughtleaders_cli-0.9.8}/pyproject.toml +1 -1
- {thoughtleaders_cli-0.9.4 → thoughtleaders_cli-0.9.8}/skills/tl/SKILL.md +63 -1
- {thoughtleaders_cli-0.9.4 → thoughtleaders_cli-0.9.8}/skills/tl/references/postgres-schema.md +9 -4
- {thoughtleaders_cli-0.9.4 → 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.4 → thoughtleaders_cli-0.9.8}/src/tl_cli/client/http.py +2 -2
- {thoughtleaders_cli-0.9.4 → thoughtleaders_cli-0.9.8}/src/tl_cli/commands/balance.py +12 -1
- thoughtleaders_cli-0.9.8/src/tl_cli/commands/memory.py +149 -0
- {thoughtleaders_cli-0.9.4 → thoughtleaders_cli-0.9.8}/src/tl_cli/commands/reports.py +106 -0
- {thoughtleaders_cli-0.9.4 → thoughtleaders_cli-0.9.8}/src/tl_cli/commands/whoami.py +25 -0
- {thoughtleaders_cli-0.9.4 → thoughtleaders_cli-0.9.8}/src/tl_cli/main.py +2 -0
- {thoughtleaders_cli-0.9.4 → thoughtleaders_cli-0.9.8}/src/tl_cli/output/formatter.py +54 -17
- thoughtleaders_cli-0.9.8/tests/test_memory.py +213 -0
- {thoughtleaders_cli-0.9.4 → thoughtleaders_cli-0.9.8}/tests/test_output.py +46 -0
- 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.4 → thoughtleaders_cli-0.9.8}/tests/test_reports.py +51 -0
- thoughtleaders_cli-0.9.8/tests/test_whoami_newsletter.py +77 -0
- thoughtleaders_cli-0.9.4/src/tl_cli/client/errors.py +0 -84
- {thoughtleaders_cli-0.9.4 → thoughtleaders_cli-0.9.8}/.claude-plugin/marketplace.json +0 -0
- {thoughtleaders_cli-0.9.4 → thoughtleaders_cli-0.9.8}/.github/dependabot.yml +0 -0
- {thoughtleaders_cli-0.9.4 → thoughtleaders_cli-0.9.8}/.github/workflows/ci.yml +0 -0
- {thoughtleaders_cli-0.9.4 → thoughtleaders_cli-0.9.8}/.github/workflows/cli-integration.yml +0 -0
- {thoughtleaders_cli-0.9.4 → thoughtleaders_cli-0.9.8}/.github/workflows/python-publish.yml +0 -0
- {thoughtleaders_cli-0.9.4 → thoughtleaders_cli-0.9.8}/.gitignore +0 -0
- {thoughtleaders_cli-0.9.4 → thoughtleaders_cli-0.9.8}/AGENTS.md +0 -0
- {thoughtleaders_cli-0.9.4 → thoughtleaders_cli-0.9.8}/API.md +0 -0
- {thoughtleaders_cli-0.9.4 → thoughtleaders_cli-0.9.8}/CLAUDE.md +0 -0
- {thoughtleaders_cli-0.9.4 → thoughtleaders_cli-0.9.8}/LICENSE +0 -0
- {thoughtleaders_cli-0.9.4 → thoughtleaders_cli-0.9.8}/README.md +0 -0
- {thoughtleaders_cli-0.9.4 → thoughtleaders_cli-0.9.8}/agents/keyword-context-classifier.md +0 -0
- {thoughtleaders_cli-0.9.4 → thoughtleaders_cli-0.9.8}/agents/keyword-entity-resolver.md +0 -0
- {thoughtleaders_cli-0.9.4 → thoughtleaders_cli-0.9.8}/agents/keyword-relevance-validator.md +0 -0
- {thoughtleaders_cli-0.9.4 → thoughtleaders_cli-0.9.8}/agents/tl-analyst.md +0 -0
- {thoughtleaders_cli-0.9.4 → thoughtleaders_cli-0.9.8}/agents/youtube-comment-classifier.md +0 -0
- {thoughtleaders_cli-0.9.4 → thoughtleaders_cli-0.9.8}/hooks/hooks.json +0 -0
- {thoughtleaders_cli-0.9.4 → thoughtleaders_cli-0.9.8}/hooks/scripts/load-tl-skill.mjs +0 -0
- {thoughtleaders_cli-0.9.4 → thoughtleaders_cli-0.9.8}/hooks/scripts/pre-check.sh +0 -0
- {thoughtleaders_cli-0.9.4 → thoughtleaders_cli-0.9.8}/skills/tl/references/business-glossary.md +0 -0
- {thoughtleaders_cli-0.9.4 → thoughtleaders_cli-0.9.8}/skills/tl/references/elasticsearch-schema.md +0 -0
- {thoughtleaders_cli-0.9.4 → thoughtleaders_cli-0.9.8}/skills/tl/references/firebolt-schema.md +0 -0
- {thoughtleaders_cli-0.9.4 → thoughtleaders_cli-0.9.8}/skills/tl-channel-authenticity/.gitignore +0 -0
- {thoughtleaders_cli-0.9.4 → thoughtleaders_cli-0.9.8}/skills/tl-channel-authenticity/SKILL.md +0 -0
- {thoughtleaders_cli-0.9.4 → thoughtleaders_cli-0.9.8}/skills/tl-channel-authenticity/references/comment-patterns.md +0 -0
- {thoughtleaders_cli-0.9.4 → thoughtleaders_cli-0.9.8}/skills/tl-channel-authenticity/references/peer-cohort.md +0 -0
- {thoughtleaders_cli-0.9.4 → thoughtleaders_cli-0.9.8}/skills/tl-channel-authenticity/references/red-flags.md +0 -0
- {thoughtleaders_cli-0.9.4 → thoughtleaders_cli-0.9.8}/skills/tl-channel-authenticity/references/scoring.md +0 -0
- {thoughtleaders_cli-0.9.4 → thoughtleaders_cli-0.9.8}/skills/tl-channel-authenticity/scripts/_io_utf8.py +0 -0
- {thoughtleaders_cli-0.9.4 → thoughtleaders_cli-0.9.8}/skills/tl-channel-authenticity/scripts/analyze_channel.py +0 -0
- {thoughtleaders_cli-0.9.4 → thoughtleaders_cli-0.9.8}/skills/tl-channel-authenticity/scripts/anomaly_detector.py +0 -0
- {thoughtleaders_cli-0.9.4 → thoughtleaders_cli-0.9.8}/skills/tl-channel-authenticity/scripts/comment_analyzer.py +0 -0
- {thoughtleaders_cli-0.9.4 → thoughtleaders_cli-0.9.8}/skills/tl-channel-authenticity/scripts/comment_scraper.py +0 -0
- {thoughtleaders_cli-0.9.4 → thoughtleaders_cli-0.9.8}/skills/tl-channel-authenticity/scripts/engagement_ratios.py +0 -0
- {thoughtleaders_cli-0.9.4 → thoughtleaders_cli-0.9.8}/skills/tl-channel-authenticity/scripts/peer_cohort.py +0 -0
- {thoughtleaders_cli-0.9.4 → thoughtleaders_cli-0.9.8}/skills/tl-channel-authenticity/scripts/report.py +0 -0
- {thoughtleaders_cli-0.9.4 → thoughtleaders_cli-0.9.8}/skills/tl-channel-authenticity/scripts/resolve_channel.py +0 -0
- {thoughtleaders_cli-0.9.4 → thoughtleaders_cli-0.9.8}/skills/tl-channel-authenticity/scripts/score.py +0 -0
- {thoughtleaders_cli-0.9.4 → thoughtleaders_cli-0.9.8}/skills/tl-channel-authenticity/scripts/tl_cli.py +0 -0
- {thoughtleaders_cli-0.9.4 → thoughtleaders_cli-0.9.8}/skills/tl-channel-authenticity/scripts/video_integrity.py +0 -0
- {thoughtleaders_cli-0.9.4 → thoughtleaders_cli-0.9.8}/skills/tl-channel-authenticity/scripts/view_curves.py +0 -0
- {thoughtleaders_cli-0.9.4 → thoughtleaders_cli-0.9.8}/skills/tl-create-workflow/SKILL.md +0 -0
- {thoughtleaders_cli-0.9.4 → thoughtleaders_cli-0.9.8}/skills/tl-create-workflow/references/creating-in-app.md +0 -0
- {thoughtleaders_cli-0.9.4 → thoughtleaders_cli-0.9.8}/skills/tl-create-workflow/references/methodology.md +0 -0
- {thoughtleaders_cli-0.9.4 → thoughtleaders_cli-0.9.8}/skills/tl-create-workflow/references/pitfalls.md +0 -0
- {thoughtleaders_cli-0.9.4 → thoughtleaders_cli-0.9.8}/skills/tl-create-workflow/references/workflow-model.md +0 -0
- {thoughtleaders_cli-0.9.4 → thoughtleaders_cli-0.9.8}/skills/tl-keyword-research/SKILL.md +0 -0
- {thoughtleaders_cli-0.9.4 → thoughtleaders_cli-0.9.8}/skills/tl-keyword-research/references/elasticsearch-content-search.md +0 -0
- {thoughtleaders_cli-0.9.4 → thoughtleaders_cli-0.9.8}/skills/tl-keyword-research/references/help.md +0 -0
- {thoughtleaders_cli-0.9.4 → thoughtleaders_cli-0.9.8}/skills/tl-keyword-research/scripts/build_report.py +0 -0
- {thoughtleaders_cli-0.9.4 → thoughtleaders_cli-0.9.8}/skills/tl-keyword-research/scripts/expand_entities.py +0 -0
- {thoughtleaders_cli-0.9.4 → thoughtleaders_cli-0.9.8}/skills/tl-keyword-research/scripts/fetch_context.py +0 -0
- {thoughtleaders_cli-0.9.4 → thoughtleaders_cli-0.9.8}/skills/tl-keyword-research/scripts/probe.py +0 -0
- {thoughtleaders_cli-0.9.4 → thoughtleaders_cli-0.9.8}/skills/tl-keyword-research/scripts/search_channels.py +0 -0
- {thoughtleaders_cli-0.9.4 → thoughtleaders_cli-0.9.8}/skills/tl-keyword-research/scripts/search_videos.py +0 -0
- {thoughtleaders_cli-0.9.4 → thoughtleaders_cli-0.9.8}/skills/tl-keyword-research/scripts/select_keywords.py +0 -0
- {thoughtleaders_cli-0.9.4 → thoughtleaders_cli-0.9.8}/skills/tl-save-report/SKILL.md +0 -0
- {thoughtleaders_cli-0.9.4 → thoughtleaders_cli-0.9.8}/skills/tl-save-report/references/columns_brands.md +0 -0
- {thoughtleaders_cli-0.9.4 → thoughtleaders_cli-0.9.8}/skills/tl-save-report/references/columns_channels.md +0 -0
- {thoughtleaders_cli-0.9.4 → thoughtleaders_cli-0.9.8}/skills/tl-save-report/references/columns_content.md +0 -0
- {thoughtleaders_cli-0.9.4 → thoughtleaders_cli-0.9.8}/skills/tl-save-report/references/columns_sponsorships.md +0 -0
- {thoughtleaders_cli-0.9.4 → thoughtleaders_cli-0.9.8}/skills/tl-save-report/references/intelligence_filterset_schema.json +0 -0
- {thoughtleaders_cli-0.9.4 → thoughtleaders_cli-0.9.8}/skills/tl-save-report/references/intelligence_widget_schema.json +0 -0
- {thoughtleaders_cli-0.9.4 → thoughtleaders_cli-0.9.8}/skills/tl-save-report/references/report_glossary.md +0 -0
- {thoughtleaders_cli-0.9.4 → thoughtleaders_cli-0.9.8}/skills/tl-save-report/references/sortable_columns.json +0 -0
- {thoughtleaders_cli-0.9.4 → thoughtleaders_cli-0.9.8}/skills/tl-save-report/references/sponsorship_filterset_schema.json +0 -0
- {thoughtleaders_cli-0.9.4 → thoughtleaders_cli-0.9.8}/skills/tl-save-report/references/sponsorship_widget_schema.json +0 -0
- {thoughtleaders_cli-0.9.4 → thoughtleaders_cli-0.9.8}/skills/tl-save-report/references/widgets.md +0 -0
- {thoughtleaders_cli-0.9.4 → thoughtleaders_cli-0.9.8}/skills/tl-top-partnerships/SKILL.md +0 -0
- {thoughtleaders_cli-0.9.4 → thoughtleaders_cli-0.9.8}/skills/tl-top-partnerships/scripts/top_partnerships.py +0 -0
- {thoughtleaders_cli-0.9.4 → thoughtleaders_cli-0.9.8}/skills/tl-views-guarantee/SKILL.md +0 -0
- {thoughtleaders_cli-0.9.4 → thoughtleaders_cli-0.9.8}/skills/tl-views-guarantee/scripts/vg.py +0 -0
- {thoughtleaders_cli-0.9.4 → thoughtleaders_cli-0.9.8}/src/tl_cli/_completions.py +0 -0
- {thoughtleaders_cli-0.9.4 → thoughtleaders_cli-0.9.8}/src/tl_cli/_typer_utils.py +0 -0
- {thoughtleaders_cli-0.9.4 → thoughtleaders_cli-0.9.8}/src/tl_cli/auth/__init__.py +0 -0
- {thoughtleaders_cli-0.9.4 → thoughtleaders_cli-0.9.8}/src/tl_cli/auth/commands.py +0 -0
- {thoughtleaders_cli-0.9.4 → thoughtleaders_cli-0.9.8}/src/tl_cli/auth/login.py +0 -0
- {thoughtleaders_cli-0.9.4 → thoughtleaders_cli-0.9.8}/src/tl_cli/auth/pkce.py +0 -0
- {thoughtleaders_cli-0.9.4 → thoughtleaders_cli-0.9.8}/src/tl_cli/auth/token_store.py +0 -0
- {thoughtleaders_cli-0.9.4 → thoughtleaders_cli-0.9.8}/src/tl_cli/client/__init__.py +0 -0
- {thoughtleaders_cli-0.9.4 → thoughtleaders_cli-0.9.8}/src/tl_cli/commands/__init__.py +0 -0
- {thoughtleaders_cli-0.9.4 → thoughtleaders_cli-0.9.8}/src/tl_cli/commands/_comments_common.py +0 -0
- {thoughtleaders_cli-0.9.4 → thoughtleaders_cli-0.9.8}/src/tl_cli/commands/brands.py +0 -0
- {thoughtleaders_cli-0.9.4 → thoughtleaders_cli-0.9.8}/src/tl_cli/commands/bulk_import.py +0 -0
- {thoughtleaders_cli-0.9.4 → thoughtleaders_cli-0.9.8}/src/tl_cli/commands/changelog.py +0 -0
- {thoughtleaders_cli-0.9.4 → thoughtleaders_cli-0.9.8}/src/tl_cli/commands/channels.py +0 -0
- {thoughtleaders_cli-0.9.4 → thoughtleaders_cli-0.9.8}/src/tl_cli/commands/credits.py +0 -0
- {thoughtleaders_cli-0.9.4 → thoughtleaders_cli-0.9.8}/src/tl_cli/commands/db.py +0 -0
- {thoughtleaders_cli-0.9.4 → thoughtleaders_cli-0.9.8}/src/tl_cli/commands/deals.py +0 -0
- {thoughtleaders_cli-0.9.4 → thoughtleaders_cli-0.9.8}/src/tl_cli/commands/describe.py +0 -0
- {thoughtleaders_cli-0.9.4 → thoughtleaders_cli-0.9.8}/src/tl_cli/commands/doctor.py +0 -0
- {thoughtleaders_cli-0.9.4 → thoughtleaders_cli-0.9.8}/src/tl_cli/commands/matches.py +0 -0
- {thoughtleaders_cli-0.9.4 → thoughtleaders_cli-0.9.8}/src/tl_cli/commands/profiles.py +0 -0
- {thoughtleaders_cli-0.9.4 → thoughtleaders_cli-0.9.8}/src/tl_cli/commands/proposals.py +0 -0
- {thoughtleaders_cli-0.9.4 → thoughtleaders_cli-0.9.8}/src/tl_cli/commands/recommender.py +0 -0
- {thoughtleaders_cli-0.9.4 → thoughtleaders_cli-0.9.8}/src/tl_cli/commands/schema.py +0 -0
- {thoughtleaders_cli-0.9.4 → thoughtleaders_cli-0.9.8}/src/tl_cli/commands/setup.py +0 -0
- {thoughtleaders_cli-0.9.4 → thoughtleaders_cli-0.9.8}/src/tl_cli/commands/skills.py +0 -0
- {thoughtleaders_cli-0.9.4 → thoughtleaders_cli-0.9.8}/src/tl_cli/commands/snapshots.py +0 -0
- {thoughtleaders_cli-0.9.4 → thoughtleaders_cli-0.9.8}/src/tl_cli/commands/sponsorships.py +0 -0
- {thoughtleaders_cli-0.9.4 → thoughtleaders_cli-0.9.8}/src/tl_cli/commands/uploads.py +0 -0
- {thoughtleaders_cli-0.9.4 → thoughtleaders_cli-0.9.8}/src/tl_cli/commands/workflows.py +0 -0
- {thoughtleaders_cli-0.9.4 → thoughtleaders_cli-0.9.8}/src/tl_cli/config.py +0 -0
- {thoughtleaders_cli-0.9.4 → thoughtleaders_cli-0.9.8}/src/tl_cli/filters.py +0 -0
- {thoughtleaders_cli-0.9.4 → thoughtleaders_cli-0.9.8}/src/tl_cli/hints.py +0 -0
- {thoughtleaders_cli-0.9.4 → thoughtleaders_cli-0.9.8}/src/tl_cli/output/__init__.py +0 -0
- {thoughtleaders_cli-0.9.4 → thoughtleaders_cli-0.9.8}/src/tl_cli/query_history.py +0 -0
- {thoughtleaders_cli-0.9.4 → thoughtleaders_cli-0.9.8}/src/tl_cli/self_update.py +0 -0
- {thoughtleaders_cli-0.9.4 → thoughtleaders_cli-0.9.8}/src/tl_cli/skill_registry.py +0 -0
- {thoughtleaders_cli-0.9.4 → thoughtleaders_cli-0.9.8}/tests/__init__.py +0 -0
- {thoughtleaders_cli-0.9.4 → thoughtleaders_cli-0.9.8}/tests/test_auth.py +0 -0
- {thoughtleaders_cli-0.9.4 → thoughtleaders_cli-0.9.8}/tests/test_brands_winner_channels.py +0 -0
- {thoughtleaders_cli-0.9.4 → thoughtleaders_cli-0.9.8}/tests/test_build_report.py +0 -0
- {thoughtleaders_cli-0.9.4 → thoughtleaders_cli-0.9.8}/tests/test_channels_lookalike.py +0 -0
- {thoughtleaders_cli-0.9.4 → thoughtleaders_cli-0.9.8}/tests/test_db_repeat_warning.py +0 -0
- {thoughtleaders_cli-0.9.4 → thoughtleaders_cli-0.9.8}/tests/test_describe.py +0 -0
- {thoughtleaders_cli-0.9.4 → thoughtleaders_cli-0.9.8}/tests/test_error_hints.py +0 -0
- {thoughtleaders_cli-0.9.4 → thoughtleaders_cli-0.9.8}/tests/test_expand_entities.py +0 -0
- {thoughtleaders_cli-0.9.4 → thoughtleaders_cli-0.9.8}/tests/test_filters.py +0 -0
- {thoughtleaders_cli-0.9.4 → thoughtleaders_cli-0.9.8}/tests/test_http_auth.py +0 -0
- {thoughtleaders_cli-0.9.4 → thoughtleaders_cli-0.9.8}/tests/test_probe.py +0 -0
- {thoughtleaders_cli-0.9.4 → thoughtleaders_cli-0.9.8}/tests/test_profiles.py +0 -0
- {thoughtleaders_cli-0.9.4 → thoughtleaders_cli-0.9.8}/tests/test_scripts_executable.py +0 -0
- {thoughtleaders_cli-0.9.4 → thoughtleaders_cli-0.9.8}/tests/test_search_channels.py +0 -0
- {thoughtleaders_cli-0.9.4 → thoughtleaders_cli-0.9.8}/tests/test_search_videos.py +0 -0
- {thoughtleaders_cli-0.9.4 → thoughtleaders_cli-0.9.8}/tests/test_select_keywords.py +0 -0
- {thoughtleaders_cli-0.9.4 → thoughtleaders_cli-0.9.8}/tests/test_setup.py +0 -0
- {thoughtleaders_cli-0.9.4 → thoughtleaders_cli-0.9.8}/tests/test_setup_marker_respect.py +0 -0
- {thoughtleaders_cli-0.9.4 → thoughtleaders_cli-0.9.8}/tests/test_skill_registry.py +0 -0
- {thoughtleaders_cli-0.9.4 → thoughtleaders_cli-0.9.8}/tests/test_skills_command.py +0 -0
- {thoughtleaders_cli-0.9.4 → thoughtleaders_cli-0.9.8}/tests/test_sponsorships.py +0 -0
- {thoughtleaders_cli-0.9.4 → thoughtleaders_cli-0.9.8}/tests/test_workflows.py +0 -0
- {thoughtleaders_cli-0.9.4 → thoughtleaders_cli-0.9.8}/tests_cli/AGENTS.md +0 -0
- {thoughtleaders_cli-0.9.4 → thoughtleaders_cli-0.9.8}/tests_cli/conftest.py +0 -0
- {thoughtleaders_cli-0.9.4 → thoughtleaders_cli-0.9.8}/tests_cli/test_balance.py +0 -0
- {thoughtleaders_cli-0.9.4 → thoughtleaders_cli-0.9.8}/tests_cli/test_db_es.py +0 -0
- {thoughtleaders_cli-0.9.4 → thoughtleaders_cli-0.9.8}/tests_cli/test_db_fb.py +0 -0
- {thoughtleaders_cli-0.9.4 → thoughtleaders_cli-0.9.8}/tests_cli/test_db_pg.py +0 -0
- {thoughtleaders_cli-0.9.4 → thoughtleaders_cli-0.9.8}/tests_cli/test_schema.py +0 -0
- {thoughtleaders_cli-0.9.4 → thoughtleaders_cli-0.9.8}/tests_cli/test_whoami.py +0 -0
- {thoughtleaders_cli-0.9.4 → 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
|
|
|
@@ -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.
|
{thoughtleaders_cli-0.9.4 → 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
|
|
|
@@ -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
|
-
-
|
|
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
|
-
`
|
|
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
|
|
|
@@ -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
|
|
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")
|