thoughtleaders-cli 0.9.13__tar.gz → 0.9.14__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.13 → thoughtleaders_cli-0.9.14}/.claude-plugin/plugin.json +1 -1
- {thoughtleaders_cli-0.9.13 → thoughtleaders_cli-0.9.14}/AGENTS.md +1 -1
- {thoughtleaders_cli-0.9.13 → thoughtleaders_cli-0.9.14}/PKG-INFO +9 -4
- {thoughtleaders_cli-0.9.13 → thoughtleaders_cli-0.9.14}/README.md +8 -3
- {thoughtleaders_cli-0.9.13 → thoughtleaders_cli-0.9.14}/pyproject.toml +1 -1
- {thoughtleaders_cli-0.9.13 → thoughtleaders_cli-0.9.14}/skills/tl/SKILL.md +3 -6
- {thoughtleaders_cli-0.9.13 → thoughtleaders_cli-0.9.14}/skills/tl/references/firebolt-schema.md +6 -4
- {thoughtleaders_cli-0.9.13 → thoughtleaders_cli-0.9.14}/src/tl_cli/__init__.py +1 -1
- thoughtleaders_cli-0.9.14/src/tl_cli/auth/commands.py +329 -0
- {thoughtleaders_cli-0.9.13 → thoughtleaders_cli-0.9.14}/src/tl_cli/auth/login.py +111 -35
- {thoughtleaders_cli-0.9.13 → thoughtleaders_cli-0.9.14}/src/tl_cli/auth/token_store.py +3 -1
- {thoughtleaders_cli-0.9.13 → thoughtleaders_cli-0.9.14}/src/tl_cli/client/errors.py +25 -11
- {thoughtleaders_cli-0.9.13 → thoughtleaders_cli-0.9.14}/src/tl_cli/client/http.py +44 -16
- {thoughtleaders_cli-0.9.13 → thoughtleaders_cli-0.9.14}/src/tl_cli/commands/recommender.py +2 -2
- {thoughtleaders_cli-0.9.13 → thoughtleaders_cli-0.9.14}/src/tl_cli/config.py +6 -1
- thoughtleaders_cli-0.9.14/tests/test_auth.py +636 -0
- thoughtleaders_cli-0.9.14/tests/test_http_auth.py +213 -0
- {thoughtleaders_cli-0.9.13 → thoughtleaders_cli-0.9.14}/uv.lock +1 -1
- thoughtleaders_cli-0.9.13/src/tl_cli/auth/commands.py +0 -211
- thoughtleaders_cli-0.9.13/tests/test_auth.py +0 -145
- thoughtleaders_cli-0.9.13/tests/test_http_auth.py +0 -62
- {thoughtleaders_cli-0.9.13 → thoughtleaders_cli-0.9.14}/.claude-plugin/marketplace.json +0 -0
- {thoughtleaders_cli-0.9.13 → thoughtleaders_cli-0.9.14}/.github/dependabot.yml +0 -0
- {thoughtleaders_cli-0.9.13 → thoughtleaders_cli-0.9.14}/.github/workflows/ci.yml +0 -0
- {thoughtleaders_cli-0.9.13 → thoughtleaders_cli-0.9.14}/.github/workflows/cli-integration.yml +0 -0
- {thoughtleaders_cli-0.9.13 → thoughtleaders_cli-0.9.14}/.github/workflows/python-publish.yml +0 -0
- {thoughtleaders_cli-0.9.13 → thoughtleaders_cli-0.9.14}/.gitignore +0 -0
- {thoughtleaders_cli-0.9.13 → thoughtleaders_cli-0.9.14}/API.md +0 -0
- {thoughtleaders_cli-0.9.13 → thoughtleaders_cli-0.9.14}/CLAUDE.md +0 -0
- {thoughtleaders_cli-0.9.13 → thoughtleaders_cli-0.9.14}/LICENSE +0 -0
- {thoughtleaders_cli-0.9.13 → thoughtleaders_cli-0.9.14}/agents/keyword-context-classifier.md +0 -0
- {thoughtleaders_cli-0.9.13 → thoughtleaders_cli-0.9.14}/agents/keyword-entity-resolver.md +0 -0
- {thoughtleaders_cli-0.9.13 → thoughtleaders_cli-0.9.14}/agents/keyword-relevance-validator.md +0 -0
- {thoughtleaders_cli-0.9.13 → thoughtleaders_cli-0.9.14}/agents/tl-analyst.md +0 -0
- {thoughtleaders_cli-0.9.13 → thoughtleaders_cli-0.9.14}/agents/youtube-comment-classifier.md +0 -0
- {thoughtleaders_cli-0.9.13 → thoughtleaders_cli-0.9.14}/hooks/hooks.json +0 -0
- {thoughtleaders_cli-0.9.13 → thoughtleaders_cli-0.9.14}/hooks/scripts/load-tl-skill.mjs +0 -0
- {thoughtleaders_cli-0.9.13 → thoughtleaders_cli-0.9.14}/hooks/scripts/post-usage.sh +0 -0
- {thoughtleaders_cli-0.9.13 → thoughtleaders_cli-0.9.14}/hooks/scripts/pre-check.sh +0 -0
- {thoughtleaders_cli-0.9.13 → thoughtleaders_cli-0.9.14}/skills/tl/references/business-glossary.md +0 -0
- {thoughtleaders_cli-0.9.13 → thoughtleaders_cli-0.9.14}/skills/tl/references/elasticsearch-schema.md +0 -0
- {thoughtleaders_cli-0.9.13 → thoughtleaders_cli-0.9.14}/skills/tl/references/postgres-schema.md +0 -0
- {thoughtleaders_cli-0.9.13 → thoughtleaders_cli-0.9.14}/skills/tl-channel-authenticity/.gitignore +0 -0
- {thoughtleaders_cli-0.9.13 → thoughtleaders_cli-0.9.14}/skills/tl-channel-authenticity/SKILL.md +0 -0
- {thoughtleaders_cli-0.9.13 → thoughtleaders_cli-0.9.14}/skills/tl-channel-authenticity/references/comment-patterns.md +0 -0
- {thoughtleaders_cli-0.9.13 → thoughtleaders_cli-0.9.14}/skills/tl-channel-authenticity/references/peer-cohort.md +0 -0
- {thoughtleaders_cli-0.9.13 → thoughtleaders_cli-0.9.14}/skills/tl-channel-authenticity/references/red-flags.md +0 -0
- {thoughtleaders_cli-0.9.13 → thoughtleaders_cli-0.9.14}/skills/tl-channel-authenticity/references/scoring.md +0 -0
- {thoughtleaders_cli-0.9.13 → thoughtleaders_cli-0.9.14}/skills/tl-channel-authenticity/scripts/_io_utf8.py +0 -0
- {thoughtleaders_cli-0.9.13 → thoughtleaders_cli-0.9.14}/skills/tl-channel-authenticity/scripts/analyze_channel.py +0 -0
- {thoughtleaders_cli-0.9.13 → thoughtleaders_cli-0.9.14}/skills/tl-channel-authenticity/scripts/anomaly_detector.py +0 -0
- {thoughtleaders_cli-0.9.13 → thoughtleaders_cli-0.9.14}/skills/tl-channel-authenticity/scripts/comment_analyzer.py +0 -0
- {thoughtleaders_cli-0.9.13 → thoughtleaders_cli-0.9.14}/skills/tl-channel-authenticity/scripts/comment_scraper.py +0 -0
- {thoughtleaders_cli-0.9.13 → thoughtleaders_cli-0.9.14}/skills/tl-channel-authenticity/scripts/engagement_ratios.py +0 -0
- {thoughtleaders_cli-0.9.13 → thoughtleaders_cli-0.9.14}/skills/tl-channel-authenticity/scripts/peer_cohort.py +0 -0
- {thoughtleaders_cli-0.9.13 → thoughtleaders_cli-0.9.14}/skills/tl-channel-authenticity/scripts/report.py +0 -0
- {thoughtleaders_cli-0.9.13 → thoughtleaders_cli-0.9.14}/skills/tl-channel-authenticity/scripts/resolve_channel.py +0 -0
- {thoughtleaders_cli-0.9.13 → thoughtleaders_cli-0.9.14}/skills/tl-channel-authenticity/scripts/score.py +0 -0
- {thoughtleaders_cli-0.9.13 → thoughtleaders_cli-0.9.14}/skills/tl-channel-authenticity/scripts/tl_cli.py +0 -0
- {thoughtleaders_cli-0.9.13 → thoughtleaders_cli-0.9.14}/skills/tl-channel-authenticity/scripts/video_integrity.py +0 -0
- {thoughtleaders_cli-0.9.13 → thoughtleaders_cli-0.9.14}/skills/tl-channel-authenticity/scripts/view_curves.py +0 -0
- {thoughtleaders_cli-0.9.13 → thoughtleaders_cli-0.9.14}/skills/tl-create-workflow/SKILL.md +0 -0
- {thoughtleaders_cli-0.9.13 → thoughtleaders_cli-0.9.14}/skills/tl-create-workflow/references/creating-in-app.md +0 -0
- {thoughtleaders_cli-0.9.13 → thoughtleaders_cli-0.9.14}/skills/tl-create-workflow/references/methodology.md +0 -0
- {thoughtleaders_cli-0.9.13 → thoughtleaders_cli-0.9.14}/skills/tl-create-workflow/references/pitfalls.md +0 -0
- {thoughtleaders_cli-0.9.13 → thoughtleaders_cli-0.9.14}/skills/tl-create-workflow/references/workflow-model.md +0 -0
- {thoughtleaders_cli-0.9.13 → thoughtleaders_cli-0.9.14}/skills/tl-keyword-research/SKILL.md +0 -0
- {thoughtleaders_cli-0.9.13 → thoughtleaders_cli-0.9.14}/skills/tl-keyword-research/references/elasticsearch-content-search.md +0 -0
- {thoughtleaders_cli-0.9.13 → thoughtleaders_cli-0.9.14}/skills/tl-keyword-research/references/help.md +0 -0
- {thoughtleaders_cli-0.9.13 → thoughtleaders_cli-0.9.14}/skills/tl-keyword-research/scripts/build_report.py +0 -0
- {thoughtleaders_cli-0.9.13 → thoughtleaders_cli-0.9.14}/skills/tl-keyword-research/scripts/expand_entities.py +0 -0
- {thoughtleaders_cli-0.9.13 → thoughtleaders_cli-0.9.14}/skills/tl-keyword-research/scripts/fetch_context.py +0 -0
- {thoughtleaders_cli-0.9.13 → thoughtleaders_cli-0.9.14}/skills/tl-keyword-research/scripts/probe.py +0 -0
- {thoughtleaders_cli-0.9.13 → thoughtleaders_cli-0.9.14}/skills/tl-keyword-research/scripts/search_channels.py +0 -0
- {thoughtleaders_cli-0.9.13 → thoughtleaders_cli-0.9.14}/skills/tl-keyword-research/scripts/search_videos.py +0 -0
- {thoughtleaders_cli-0.9.13 → thoughtleaders_cli-0.9.14}/skills/tl-keyword-research/scripts/select_keywords.py +0 -0
- {thoughtleaders_cli-0.9.13 → thoughtleaders_cli-0.9.14}/skills/tl-save-report/SKILL.md +0 -0
- {thoughtleaders_cli-0.9.13 → thoughtleaders_cli-0.9.14}/skills/tl-save-report/references/columns_brands.md +0 -0
- {thoughtleaders_cli-0.9.13 → thoughtleaders_cli-0.9.14}/skills/tl-save-report/references/columns_channels.md +0 -0
- {thoughtleaders_cli-0.9.13 → thoughtleaders_cli-0.9.14}/skills/tl-save-report/references/columns_content.md +0 -0
- {thoughtleaders_cli-0.9.13 → thoughtleaders_cli-0.9.14}/skills/tl-save-report/references/columns_sponsorships.md +0 -0
- {thoughtleaders_cli-0.9.13 → thoughtleaders_cli-0.9.14}/skills/tl-save-report/references/intelligence_filterset_schema.json +0 -0
- {thoughtleaders_cli-0.9.13 → thoughtleaders_cli-0.9.14}/skills/tl-save-report/references/intelligence_widget_schema.json +0 -0
- {thoughtleaders_cli-0.9.13 → thoughtleaders_cli-0.9.14}/skills/tl-save-report/references/report_glossary.md +0 -0
- {thoughtleaders_cli-0.9.13 → thoughtleaders_cli-0.9.14}/skills/tl-save-report/references/sortable_columns.json +0 -0
- {thoughtleaders_cli-0.9.13 → thoughtleaders_cli-0.9.14}/skills/tl-save-report/references/sponsorship_filterset_schema.json +0 -0
- {thoughtleaders_cli-0.9.13 → thoughtleaders_cli-0.9.14}/skills/tl-save-report/references/sponsorship_widget_schema.json +0 -0
- {thoughtleaders_cli-0.9.13 → thoughtleaders_cli-0.9.14}/skills/tl-save-report/references/widgets.md +0 -0
- {thoughtleaders_cli-0.9.13 → thoughtleaders_cli-0.9.14}/skills/tl-top-partnerships/SKILL.md +0 -0
- {thoughtleaders_cli-0.9.13 → thoughtleaders_cli-0.9.14}/skills/tl-top-partnerships/scripts/top_partnerships.py +0 -0
- {thoughtleaders_cli-0.9.13 → thoughtleaders_cli-0.9.14}/skills/tl-views-guarantee/SKILL.md +0 -0
- {thoughtleaders_cli-0.9.13 → thoughtleaders_cli-0.9.14}/skills/tl-views-guarantee/scripts/vg.py +0 -0
- {thoughtleaders_cli-0.9.13 → thoughtleaders_cli-0.9.14}/src/tl_cli/__main__.py +0 -0
- {thoughtleaders_cli-0.9.13 → thoughtleaders_cli-0.9.14}/src/tl_cli/_completions.py +0 -0
- {thoughtleaders_cli-0.9.13 → thoughtleaders_cli-0.9.14}/src/tl_cli/_typer_utils.py +0 -0
- {thoughtleaders_cli-0.9.13 → thoughtleaders_cli-0.9.14}/src/tl_cli/auth/__init__.py +0 -0
- {thoughtleaders_cli-0.9.13 → thoughtleaders_cli-0.9.14}/src/tl_cli/auth/pkce.py +0 -0
- {thoughtleaders_cli-0.9.13 → thoughtleaders_cli-0.9.14}/src/tl_cli/client/__init__.py +0 -0
- {thoughtleaders_cli-0.9.13 → thoughtleaders_cli-0.9.14}/src/tl_cli/commands/__init__.py +0 -0
- {thoughtleaders_cli-0.9.13 → thoughtleaders_cli-0.9.14}/src/tl_cli/commands/_comments_common.py +0 -0
- {thoughtleaders_cli-0.9.13 → thoughtleaders_cli-0.9.14}/src/tl_cli/commands/balance.py +0 -0
- {thoughtleaders_cli-0.9.13 → thoughtleaders_cli-0.9.14}/src/tl_cli/commands/brands.py +0 -0
- {thoughtleaders_cli-0.9.13 → thoughtleaders_cli-0.9.14}/src/tl_cli/commands/bulk_import.py +0 -0
- {thoughtleaders_cli-0.9.13 → thoughtleaders_cli-0.9.14}/src/tl_cli/commands/changelog.py +0 -0
- {thoughtleaders_cli-0.9.13 → thoughtleaders_cli-0.9.14}/src/tl_cli/commands/channels.py +0 -0
- {thoughtleaders_cli-0.9.13 → thoughtleaders_cli-0.9.14}/src/tl_cli/commands/credits.py +0 -0
- {thoughtleaders_cli-0.9.13 → thoughtleaders_cli-0.9.14}/src/tl_cli/commands/db.py +0 -0
- {thoughtleaders_cli-0.9.13 → thoughtleaders_cli-0.9.14}/src/tl_cli/commands/deals.py +0 -0
- {thoughtleaders_cli-0.9.13 → thoughtleaders_cli-0.9.14}/src/tl_cli/commands/describe.py +0 -0
- {thoughtleaders_cli-0.9.13 → thoughtleaders_cli-0.9.14}/src/tl_cli/commands/doctor.py +0 -0
- {thoughtleaders_cli-0.9.13 → thoughtleaders_cli-0.9.14}/src/tl_cli/commands/matches.py +0 -0
- {thoughtleaders_cli-0.9.13 → thoughtleaders_cli-0.9.14}/src/tl_cli/commands/memory.py +0 -0
- {thoughtleaders_cli-0.9.13 → thoughtleaders_cli-0.9.14}/src/tl_cli/commands/profiles.py +0 -0
- {thoughtleaders_cli-0.9.13 → thoughtleaders_cli-0.9.14}/src/tl_cli/commands/proposals.py +0 -0
- {thoughtleaders_cli-0.9.13 → thoughtleaders_cli-0.9.14}/src/tl_cli/commands/reports.py +0 -0
- {thoughtleaders_cli-0.9.13 → thoughtleaders_cli-0.9.14}/src/tl_cli/commands/schema.py +0 -0
- {thoughtleaders_cli-0.9.13 → thoughtleaders_cli-0.9.14}/src/tl_cli/commands/setup.py +0 -0
- {thoughtleaders_cli-0.9.13 → thoughtleaders_cli-0.9.14}/src/tl_cli/commands/skills.py +0 -0
- {thoughtleaders_cli-0.9.13 → thoughtleaders_cli-0.9.14}/src/tl_cli/commands/snapshots.py +0 -0
- {thoughtleaders_cli-0.9.13 → thoughtleaders_cli-0.9.14}/src/tl_cli/commands/sponsorships.py +0 -0
- {thoughtleaders_cli-0.9.13 → thoughtleaders_cli-0.9.14}/src/tl_cli/commands/uploads.py +0 -0
- {thoughtleaders_cli-0.9.13 → thoughtleaders_cli-0.9.14}/src/tl_cli/commands/whoami.py +0 -0
- {thoughtleaders_cli-0.9.13 → thoughtleaders_cli-0.9.14}/src/tl_cli/commands/workflows.py +0 -0
- {thoughtleaders_cli-0.9.13 → thoughtleaders_cli-0.9.14}/src/tl_cli/filters.py +0 -0
- {thoughtleaders_cli-0.9.13 → thoughtleaders_cli-0.9.14}/src/tl_cli/hints.py +0 -0
- {thoughtleaders_cli-0.9.13 → thoughtleaders_cli-0.9.14}/src/tl_cli/main.py +0 -0
- {thoughtleaders_cli-0.9.13 → thoughtleaders_cli-0.9.14}/src/tl_cli/output/__init__.py +0 -0
- {thoughtleaders_cli-0.9.13 → thoughtleaders_cli-0.9.14}/src/tl_cli/output/formatter.py +0 -0
- {thoughtleaders_cli-0.9.13 → thoughtleaders_cli-0.9.14}/src/tl_cli/query_history.py +0 -0
- {thoughtleaders_cli-0.9.13 → thoughtleaders_cli-0.9.14}/src/tl_cli/self_update.py +0 -0
- {thoughtleaders_cli-0.9.13 → thoughtleaders_cli-0.9.14}/src/tl_cli/skill_registry.py +0 -0
- {thoughtleaders_cli-0.9.13 → thoughtleaders_cli-0.9.14}/tests/__init__.py +0 -0
- {thoughtleaders_cli-0.9.13 → thoughtleaders_cli-0.9.14}/tests/test_brands_winner_channels.py +0 -0
- {thoughtleaders_cli-0.9.13 → thoughtleaders_cli-0.9.14}/tests/test_build_report.py +0 -0
- {thoughtleaders_cli-0.9.13 → thoughtleaders_cli-0.9.14}/tests/test_channels_lookalike.py +0 -0
- {thoughtleaders_cli-0.9.13 → thoughtleaders_cli-0.9.14}/tests/test_db_repeat_warning.py +0 -0
- {thoughtleaders_cli-0.9.13 → thoughtleaders_cli-0.9.14}/tests/test_describe.py +0 -0
- {thoughtleaders_cli-0.9.13 → thoughtleaders_cli-0.9.14}/tests/test_error_hints.py +0 -0
- {thoughtleaders_cli-0.9.13 → thoughtleaders_cli-0.9.14}/tests/test_expand_entities.py +0 -0
- {thoughtleaders_cli-0.9.13 → thoughtleaders_cli-0.9.14}/tests/test_filters.py +0 -0
- {thoughtleaders_cli-0.9.13 → thoughtleaders_cli-0.9.14}/tests/test_memory.py +0 -0
- {thoughtleaders_cli-0.9.13 → thoughtleaders_cli-0.9.14}/tests/test_output.py +0 -0
- {thoughtleaders_cli-0.9.13 → thoughtleaders_cli-0.9.14}/tests/test_probe.py +0 -0
- {thoughtleaders_cli-0.9.13 → thoughtleaders_cli-0.9.14}/tests/test_profiles.py +0 -0
- {thoughtleaders_cli-0.9.13 → thoughtleaders_cli-0.9.14}/tests/test_quota_refusal_message.py +0 -0
- {thoughtleaders_cli-0.9.13 → thoughtleaders_cli-0.9.14}/tests/test_refusal_rendering.py +0 -0
- {thoughtleaders_cli-0.9.13 → thoughtleaders_cli-0.9.14}/tests/test_reports.py +0 -0
- {thoughtleaders_cli-0.9.13 → thoughtleaders_cli-0.9.14}/tests/test_scripts_executable.py +0 -0
- {thoughtleaders_cli-0.9.13 → thoughtleaders_cli-0.9.14}/tests/test_search_channels.py +0 -0
- {thoughtleaders_cli-0.9.13 → thoughtleaders_cli-0.9.14}/tests/test_search_videos.py +0 -0
- {thoughtleaders_cli-0.9.13 → thoughtleaders_cli-0.9.14}/tests/test_select_keywords.py +0 -0
- {thoughtleaders_cli-0.9.13 → thoughtleaders_cli-0.9.14}/tests/test_setup.py +0 -0
- {thoughtleaders_cli-0.9.13 → thoughtleaders_cli-0.9.14}/tests/test_setup_marker_respect.py +0 -0
- {thoughtleaders_cli-0.9.13 → thoughtleaders_cli-0.9.14}/tests/test_skill_registry.py +0 -0
- {thoughtleaders_cli-0.9.13 → thoughtleaders_cli-0.9.14}/tests/test_skills_command.py +0 -0
- {thoughtleaders_cli-0.9.13 → thoughtleaders_cli-0.9.14}/tests/test_sponsorships.py +0 -0
- {thoughtleaders_cli-0.9.13 → thoughtleaders_cli-0.9.14}/tests/test_whoami_newsletter.py +0 -0
- {thoughtleaders_cli-0.9.13 → thoughtleaders_cli-0.9.14}/tests/test_workflows.py +0 -0
- {thoughtleaders_cli-0.9.13 → thoughtleaders_cli-0.9.14}/tests_cli/AGENTS.md +0 -0
- {thoughtleaders_cli-0.9.13 → thoughtleaders_cli-0.9.14}/tests_cli/conftest.py +0 -0
- {thoughtleaders_cli-0.9.13 → thoughtleaders_cli-0.9.14}/tests_cli/test_balance.py +0 -0
- {thoughtleaders_cli-0.9.13 → thoughtleaders_cli-0.9.14}/tests_cli/test_db_es.py +0 -0
- {thoughtleaders_cli-0.9.13 → thoughtleaders_cli-0.9.14}/tests_cli/test_db_fb.py +0 -0
- {thoughtleaders_cli-0.9.13 → thoughtleaders_cli-0.9.14}/tests_cli/test_db_pg.py +0 -0
- {thoughtleaders_cli-0.9.13 → thoughtleaders_cli-0.9.14}/tests_cli/test_schema.py +0 -0
- {thoughtleaders_cli-0.9.13 → thoughtleaders_cli-0.9.14}/tests_cli/test_whoami.py +0 -0
|
@@ -19,7 +19,7 @@ When adding a new data command, follow this pattern. See `sponsorships.py` for t
|
|
|
19
19
|
|
|
20
20
|
## Auth Flow (`auth/`)
|
|
21
21
|
|
|
22
|
-
- **PKCE + Auth0**: Browser-based login with localhost callback server (`login.py`)
|
|
22
|
+
- **PKCE + Auth0**: Browser-based login with a localhost callback server (`login.py`) against `auth.thoughtleaders.io`, the host the web platform and the Chrome extension sign in on, so a browser already signed in to the platform completes `tl auth login` without a password; the callback then sends that browser to the platform's `/signin?go=1&from=cli` so the web session follows. `tl auth logout` posts once to `/auth/sign-out` and ThoughtLeaders signs the user out everywhere; the CLI then revokes its refresh token and clears the local store. A 401 with `code: signed_out` means the session ended elsewhere, so the client drops its credentials instead of refreshing (`forget_session`).
|
|
23
23
|
- **Token Storage** (`token_store.py`): OS keyring primary, `~/.config/tl/credentials.json` fallback (chmod 0o600)
|
|
24
24
|
- **Env override**: `TL_API_KEY` env var takes priority over keyring (for CI)
|
|
25
25
|
- **Auto-refresh**: `TLClient` refreshes expired tokens on 401
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: thoughtleaders-cli
|
|
3
|
-
Version: 0.9.
|
|
3
|
+
Version: 0.9.14
|
|
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
|
|
@@ -122,9 +122,15 @@ tl setup codex # install Codex CLI skill (optional)
|
|
|
122
122
|
|
|
123
123
|
`tl auth login` offers three options:
|
|
124
124
|
|
|
125
|
-
1. **OAuth2 in a local browser** (default) — opens a URL on this machine.
|
|
125
|
+
1. **OAuth2 in a local browser** (default) — opens a URL on this machine. If that browser is already signed in to the web platform, no password is asked; either way the browser ends up signed in to the platform too, and the Chrome extension follows. Pass `--no-browser` to print the URL instead of opening a window.
|
|
126
126
|
2. **Device code** — for headless environments; complete the flow on another device.
|
|
127
|
-
3. **API key** — paste
|
|
127
|
+
3. **API key** — paste an API key issued to you by ThoughtLeaders. The CLI verifies it via `/whoami` and stores it tagged so every request sends `X-TL-Auth: API-KEY`.
|
|
128
|
+
|
|
129
|
+
Pass `--method browser|device|api-key` to skip the menu; without a terminal the menu is an error, so agents must pass it. Without a terminal the browser method prints the URL instead of opening a window (`--no-browser` does the same at a terminal).
|
|
130
|
+
|
|
131
|
+
`tl auth logout` signs you out everywhere — this machine, the web platform, the Chrome extension and any other machine running `tl`. It is always global: there is no local-only logout. If ThoughtLeaders can't be reached, this machine's credentials are still cleared, but your other sessions stay signed in until you sign out on the web platform.
|
|
132
|
+
|
|
133
|
+
`tl auth status` prints who you are signed in as; `--quiet` prints nothing and just exits 0 when the next command will authenticate and 2 when it will not, which is what agent hooks check.
|
|
128
134
|
|
|
129
135
|
## Quick Start
|
|
130
136
|
|
|
@@ -180,7 +186,6 @@ tl channels similar "Tremending girls" min-score:0.85 --limit 5
|
|
|
180
186
|
tl recommender tags # List every tag (free)
|
|
181
187
|
tl recommender tags cooking # Search tag names by substring
|
|
182
188
|
tl recommender top-channels "Cooking" msn:yes --limit 50 # Top channels for a tag
|
|
183
|
-
tl recommender top-profiles "Cooking" mbn:yes --limit 30 # Top brand profiles (one brand → potentially multiple profiles)
|
|
184
189
|
tl recommender top-brands "Cooking" --limit 30 # Top brands (deduped from profiles)
|
|
185
190
|
tl recommender channels-with-tag "Cooking" # ALL channel IDs loaded on a tag (--min defaults to 0.00001; paged; 1 credit/result)
|
|
186
191
|
tl recommender inspect-channel 12345 # Per-tag breakdown of a channel's vector
|
|
@@ -94,9 +94,15 @@ tl setup codex # install Codex CLI skill (optional)
|
|
|
94
94
|
|
|
95
95
|
`tl auth login` offers three options:
|
|
96
96
|
|
|
97
|
-
1. **OAuth2 in a local browser** (default) — opens a URL on this machine.
|
|
97
|
+
1. **OAuth2 in a local browser** (default) — opens a URL on this machine. If that browser is already signed in to the web platform, no password is asked; either way the browser ends up signed in to the platform too, and the Chrome extension follows. Pass `--no-browser` to print the URL instead of opening a window.
|
|
98
98
|
2. **Device code** — for headless environments; complete the flow on another device.
|
|
99
|
-
3. **API key** — paste
|
|
99
|
+
3. **API key** — paste an API key issued to you by ThoughtLeaders. The CLI verifies it via `/whoami` and stores it tagged so every request sends `X-TL-Auth: API-KEY`.
|
|
100
|
+
|
|
101
|
+
Pass `--method browser|device|api-key` to skip the menu; without a terminal the menu is an error, so agents must pass it. Without a terminal the browser method prints the URL instead of opening a window (`--no-browser` does the same at a terminal).
|
|
102
|
+
|
|
103
|
+
`tl auth logout` signs you out everywhere — this machine, the web platform, the Chrome extension and any other machine running `tl`. It is always global: there is no local-only logout. If ThoughtLeaders can't be reached, this machine's credentials are still cleared, but your other sessions stay signed in until you sign out on the web platform.
|
|
104
|
+
|
|
105
|
+
`tl auth status` prints who you are signed in as; `--quiet` prints nothing and just exits 0 when the next command will authenticate and 2 when it will not, which is what agent hooks check.
|
|
100
106
|
|
|
101
107
|
## Quick Start
|
|
102
108
|
|
|
@@ -152,7 +158,6 @@ tl channels similar "Tremending girls" min-score:0.85 --limit 5
|
|
|
152
158
|
tl recommender tags # List every tag (free)
|
|
153
159
|
tl recommender tags cooking # Search tag names by substring
|
|
154
160
|
tl recommender top-channels "Cooking" msn:yes --limit 50 # Top channels for a tag
|
|
155
|
-
tl recommender top-profiles "Cooking" mbn:yes --limit 30 # Top brand profiles (one brand → potentially multiple profiles)
|
|
156
161
|
tl recommender top-brands "Cooking" --limit 30 # Top brands (deduped from profiles)
|
|
157
162
|
tl recommender channels-with-tag "Cooking" # ALL channel IDs loaded on a tag (--min defaults to 0.00001; paged; 1 credit/result)
|
|
158
163
|
tl recommender inspect-channel 12345 # Per-tag breakdown of a channel's vector
|
|
@@ -124,7 +124,7 @@ When querying sponsorship bookings, filter the rows with `publish_status = 3` (s
|
|
|
124
124
|
|
|
125
125
|
Where possible, if searching for a sponsorship match between channels and brands, first search for what do similar brands sponsor / which brands is the channel usually sponsored by. The similarity judgement should be preferably based on similar topics, similar upload frequency, similar channel sizes, and only after all that, on demographics.
|
|
126
126
|
|
|
127
|
-
Use the `tl channels similar` and `tl brands similar` commands to find channels or brands similar to a particular channel or brand. For category- or topic-driven discovery (e.g. "Find me Cooking channels", "Who scores high on USA share?"), use `tl recommender top-channels "<tag>"` (or `top-brands
|
|
127
|
+
Use the `tl channels similar` and `tl brands similar` commands to find channels or brands similar to a particular channel or brand. For category- or topic-driven discovery (e.g. "Find me Cooking channels", "Who scores high on USA share?"), use `tl recommender top-channels "<tag>"` (or `top-brands`) against the recommender — that's faster, ranked by category-strength. Run `tl recommender tags` to discover the valid tag names.
|
|
128
128
|
|
|
129
129
|
## Workflow
|
|
130
130
|
|
|
@@ -205,7 +205,6 @@ tl brands find <query> # Resolve a string to {id, name}; matches
|
|
|
205
205
|
tl brands similar <id-or-name> # Find similar brands via similarity search
|
|
206
206
|
tl recommender tags [query] # List similarity tag names — categories, demographics, formats
|
|
207
207
|
tl recommender top-channels "<tag>" # Top channels loaded on a similarity tag (Intelligence)
|
|
208
|
-
tl recommender top-profiles "<tag>" # Top brand profiles loaded on a similarity tag
|
|
209
208
|
tl recommender top-brands "<tag>" # Top brands (deduped from profiles) loaded on a similarity tag
|
|
210
209
|
tl recommender channels-with-tag "<tag>" [--min <v>] # ALL channel IDs scoring >= v on a tag (--min default 0.00001 drops zero-loading channels; paged, enumerates the full set; 1 credit/result; Intelligence)
|
|
211
210
|
tl recommender inspect-channel <ref> # Show a channel's similarity-profile breakdown (Intelligence)
|
|
@@ -591,7 +590,7 @@ tl brands find "NordVPN"
|
|
|
591
590
|
|
|
592
591
|
`tl channels find` resolves spacing/typo variants on its own ("Deco Destiny" → "DecoDestiny") via YouTube lookups and fuzzy similarity matching — no need to retry with hand-made name variations. A real channel that isn't in the index yet gets queued for analysis automatically (the response says to check back in ~24 hours). A plain "Not found" means even YouTube couldn't find it — treat that as the answer.
|
|
593
592
|
|
|
594
|
-
**Path 2. Curated tag / category / demographic** — user named a topic that maps cleanly to a recommender tag (`"Cooking"`, `"Tech"`, `"USA share"`, content categories, format hints). Use the recommender — it ranks channels by how strongly they load on a tag, returning ranked similarity scores instead of forcing exact equality. It also
|
|
593
|
+
**Path 2. Curated tag / category / demographic** — user named a topic that maps cleanly to a recommender tag (`"Cooking"`, `"Tech"`, `"USA share"`, content categories, format hints). Use the recommender — it ranks channels by how strongly they load on a tag, returning ranked similarity scores instead of forcing exact equality. It also ranks brands on the same tags (`top-brands`) — useful when the user wants to know "who buys this kind of inventory."
|
|
595
594
|
|
|
596
595
|
```bash
|
|
597
596
|
# Discover the available tag name first (free)
|
|
@@ -600,7 +599,7 @@ tl recommender tags cooking
|
|
|
600
599
|
# Discover tag names containing the substring
|
|
601
600
|
tl recommender tags crypto
|
|
602
601
|
|
|
603
|
-
# Top channels &
|
|
602
|
+
# Top channels & brands loaded on a similarity tag (Intelligence)
|
|
604
603
|
tl recommender top-channels "Cooking" msn:yes --limit 50
|
|
605
604
|
tl recommender top-channels "Tech" --limit 30
|
|
606
605
|
tl recommender top-brands "USA share" mbn:yes --limit 50
|
|
@@ -611,7 +610,6 @@ tl recommender top-brands "USA share" mbn:yes --limit 50
|
|
|
611
610
|
| Command | Filters |
|
|
612
611
|
| --- | --- |
|
|
613
612
|
| `top-channels` | `msn:<yes\|no\|all>` (default all), `exclude-for-profile:<id>` |
|
|
614
|
-
| `top-profiles` | `mbn:<yes\|no\|all>` (default all), `exclude-for-channel:<id>` |
|
|
615
613
|
| `top-brands` | `mbn:<yes\|no\|all>` (default all) |
|
|
616
614
|
| `channels-for-profile` | `language:<iso>` (default `en`), `msn:<yes\|no>` (default `no`) |
|
|
617
615
|
| `channels-for-brand` | same as `channels-for-profile` |
|
|
@@ -887,7 +885,6 @@ tl channels similar 29834 min-subs:1000000 exclude:477487 --limit 15 # client-s
|
|
|
887
885
|
tl recommender tags # Full tag list (free)
|
|
888
886
|
tl recommender tags cooking # Search tag names by substring
|
|
889
887
|
tl recommender top-channels "Cooking" msn:yes --limit 50 # Top channels loaded on a tag (25 credits)
|
|
890
|
-
tl recommender top-profiles "Cooking" --limit 30 # Top brand profiles for the tag
|
|
891
888
|
tl recommender top-brands "USA share" mbn:yes --limit 30 # Top brands (deduped) — demographic tag, MBN only
|
|
892
889
|
tl recommender top-channels "Tech" exclude-for-profile:842 # Drop channels already proposed for profile 842
|
|
893
890
|
tl recommender inspect-channel 29834 # Per-tag breakdown of a channel's vector
|
{thoughtleaders_cli-0.9.13 → thoughtleaders_cli-0.9.14}/skills/tl/references/firebolt-schema.md
RENAMED
|
@@ -63,7 +63,7 @@ Tracks YouTube video metrics over time. Each row = one scrape of one video on on
|
|
|
63
63
|
|
|
64
64
|
**Primary Index: `(channel_id, id)`** — queries MUST filter by `channel_id` first.
|
|
65
65
|
|
|
66
|
-
**Shorts vs Longform:** `
|
|
66
|
+
**Shorts vs Longform:** `article_metrics` has no `content_type` column, and `duration` cannot stand in for one — YouTube counts uploads up to 180s as Shorts, so any duration cutoff mislabels them. Take the split from Elasticsearch's `content_type` (`longform` / `short` / `live`), matching on the document id `<channel_id>:<video_id>`.
|
|
67
67
|
|
|
68
68
|
### `channel_metrics` — Channel-Level Time-Series
|
|
69
69
|
|
|
@@ -158,7 +158,7 @@ tl db fb "SELECT id, age, view_count FROM article_metrics
|
|
|
158
158
|
ORDER BY id, age"
|
|
159
159
|
```
|
|
160
160
|
|
|
161
|
-
For non-indexed filters (`age IN (30, 180)`, `
|
|
161
|
+
For non-indexed filters (`age IN (30, 180)`, `view_count > 1000`), pull a slightly wider slice and filter in `jq`/Python.
|
|
162
162
|
|
|
163
163
|
## Common Query Patterns
|
|
164
164
|
|
|
@@ -171,16 +171,18 @@ tl db fb "SELECT id, age, view_count, like_count, comment_count, duration
|
|
|
171
171
|
ORDER BY age"
|
|
172
172
|
```
|
|
173
173
|
|
|
174
|
-
### Get all videos for a channel, then filter client-side to milestone ages
|
|
174
|
+
### Get all videos for a channel, then filter client-side to milestone ages
|
|
175
175
|
|
|
176
176
|
```bash
|
|
177
177
|
tl db fb "SELECT id, age, view_count, like_count, comment_count, duration, publication_date
|
|
178
178
|
FROM article_metrics
|
|
179
179
|
WHERE channel_id = 12345
|
|
180
180
|
ORDER BY id, age" --json \
|
|
181
|
-
| jq '.results[] | select(.
|
|
181
|
+
| jq '.results[] | select(.age == 7 or .age == 30 or .age == 60 or .age == 90 or .age == 180 or .age == 365)'
|
|
182
182
|
```
|
|
183
183
|
|
|
184
|
+
To keep longform only, pull `content_type` from Elasticsearch and intersect on video id — Firebolt cannot tell Shorts apart.
|
|
185
|
+
|
|
184
186
|
### Channel subscriber/view growth over time
|
|
185
187
|
|
|
186
188
|
```bash
|
|
@@ -0,0 +1,329 @@
|
|
|
1
|
+
"""Auth CLI commands: tl auth login/logout/status."""
|
|
2
|
+
|
|
3
|
+
import sys
|
|
4
|
+
import time
|
|
5
|
+
|
|
6
|
+
import typer
|
|
7
|
+
from rich.console import Console
|
|
8
|
+
from rich.markup import escape
|
|
9
|
+
from rich.prompt import Prompt
|
|
10
|
+
|
|
11
|
+
from tl_cli._typer_utils import AlphaSortedTyperGroup
|
|
12
|
+
from tl_cli.auth.login import login_browser, login_device_code, revoke_refresh_token
|
|
13
|
+
from tl_cli.auth.token_store import KIND_API_KEY, StoredTokens, clear_tokens, load_tokens, save_tokens
|
|
14
|
+
from tl_cli.client.errors import SIGNED_OUT_CODE, ApiError
|
|
15
|
+
from tl_cli.client.http import get_client
|
|
16
|
+
from tl_cli.config import get_config
|
|
17
|
+
|
|
18
|
+
app = typer.Typer(cls=AlphaSortedTyperGroup, help="Authentication commands")
|
|
19
|
+
console = Console(stderr=True)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _read_masked(prompt: str) -> str:
|
|
23
|
+
"""Read a line of input echoing `*` for each character.
|
|
24
|
+
|
|
25
|
+
Falls back to plain `input()` when stdin is not a TTY (piped input,
|
|
26
|
+
test harness). Uses stdlib `termios` on Unix and `msvcrt` on Windows
|
|
27
|
+
so no extra dependency is needed.
|
|
28
|
+
"""
|
|
29
|
+
if not sys.stdin.isatty():
|
|
30
|
+
return input(prompt)
|
|
31
|
+
|
|
32
|
+
sys.stdout.write(prompt)
|
|
33
|
+
sys.stdout.flush()
|
|
34
|
+
|
|
35
|
+
buf: list[str] = []
|
|
36
|
+
if sys.platform == 'win32':
|
|
37
|
+
import msvcrt
|
|
38
|
+
while True:
|
|
39
|
+
ch = msvcrt.getwch()
|
|
40
|
+
if ch in ('\r', '\n'):
|
|
41
|
+
sys.stdout.write('\n')
|
|
42
|
+
sys.stdout.flush()
|
|
43
|
+
break
|
|
44
|
+
if ch == '\x03': # Ctrl-C
|
|
45
|
+
raise KeyboardInterrupt
|
|
46
|
+
if ch in ('\b', '\x7f'):
|
|
47
|
+
if buf:
|
|
48
|
+
buf.pop()
|
|
49
|
+
sys.stdout.write('\b \b')
|
|
50
|
+
sys.stdout.flush()
|
|
51
|
+
continue
|
|
52
|
+
buf.append(ch)
|
|
53
|
+
sys.stdout.write('*')
|
|
54
|
+
sys.stdout.flush()
|
|
55
|
+
else:
|
|
56
|
+
import termios
|
|
57
|
+
import tty
|
|
58
|
+
fd = sys.stdin.fileno()
|
|
59
|
+
old = termios.tcgetattr(fd)
|
|
60
|
+
try:
|
|
61
|
+
tty.setcbreak(fd)
|
|
62
|
+
while True:
|
|
63
|
+
ch = sys.stdin.read(1)
|
|
64
|
+
if ch in ('\r', '\n'):
|
|
65
|
+
sys.stdout.write('\n')
|
|
66
|
+
sys.stdout.flush()
|
|
67
|
+
break
|
|
68
|
+
if ch == '\x03':
|
|
69
|
+
raise KeyboardInterrupt
|
|
70
|
+
if ch in ('\b', '\x7f'):
|
|
71
|
+
if buf:
|
|
72
|
+
buf.pop()
|
|
73
|
+
sys.stdout.write('\b \b')
|
|
74
|
+
sys.stdout.flush()
|
|
75
|
+
continue
|
|
76
|
+
buf.append(ch)
|
|
77
|
+
sys.stdout.write('*')
|
|
78
|
+
sys.stdout.flush()
|
|
79
|
+
finally:
|
|
80
|
+
termios.tcsetattr(fd, termios.TCSADRAIN, old)
|
|
81
|
+
|
|
82
|
+
return ''.join(buf)
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
LOGIN_METHODS = {"browser": "1", "device": "2", "api-key": "3"}
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _interactive() -> bool:
|
|
89
|
+
"""Is a person at a terminal — i.e. may we ask a question or open a browser
|
|
90
|
+
window? Output goes to stderr, so that is the stream that matters; stdout
|
|
91
|
+
may well be a file or a pipe in a perfectly interactive session."""
|
|
92
|
+
return sys.stdin.isatty() and sys.stderr.isatty()
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
@app.command("login", help="Log in to ThoughtLeaders.")
|
|
96
|
+
def login_cmd(
|
|
97
|
+
method: str | None = typer.Option(
|
|
98
|
+
None,
|
|
99
|
+
"--method",
|
|
100
|
+
"-m",
|
|
101
|
+
help="Skip the menu: browser (OAuth2 on this machine), device (code for another "
|
|
102
|
+
"device), or api-key. Required when there is no terminal to answer the menu.",
|
|
103
|
+
),
|
|
104
|
+
no_browser: bool = typer.Option(
|
|
105
|
+
False,
|
|
106
|
+
"--no-browser",
|
|
107
|
+
help="Browser method only: print the login URL instead of opening a browser, then wait "
|
|
108
|
+
"up to two minutes for it to be opened on this machine. Implied when there is no "
|
|
109
|
+
"terminal, so a window never pops up at an agent.",
|
|
110
|
+
),
|
|
111
|
+
) -> None:
|
|
112
|
+
"""Log in to ThoughtLeaders.
|
|
113
|
+
|
|
114
|
+
The default flow opens a browser on this machine for OAuth2 (Auth0). If
|
|
115
|
+
you are already signed in to the web platform in that browser, it
|
|
116
|
+
completes without a password; either way it leaves the browser signed in
|
|
117
|
+
to the platform too, and the Chrome extension follows. A device-code flow
|
|
118
|
+
is available for headless environments, and a pre-issued API key can be
|
|
119
|
+
configured for CI/scripts.
|
|
120
|
+
"""
|
|
121
|
+
if method is not None:
|
|
122
|
+
if method not in LOGIN_METHODS:
|
|
123
|
+
console.print(
|
|
124
|
+
f"[red]Unknown --method {method!r}.[/red] "
|
|
125
|
+
f"Choose one of: {', '.join(LOGIN_METHODS)}"
|
|
126
|
+
)
|
|
127
|
+
raise typer.Exit(1)
|
|
128
|
+
choice = LOGIN_METHODS[method]
|
|
129
|
+
elif not _interactive():
|
|
130
|
+
console.print(
|
|
131
|
+
"[red]No terminal to answer the login menu.[/red] "
|
|
132
|
+
"Pass --method browser, --method device or --method api-key."
|
|
133
|
+
)
|
|
134
|
+
raise typer.Exit(2)
|
|
135
|
+
else:
|
|
136
|
+
console.print("[bold]How would you like to authenticate?[/bold]")
|
|
137
|
+
console.print(
|
|
138
|
+
" [cyan]1[/cyan] — OAuth2 in a browser on this machine "
|
|
139
|
+
"[dim](default — opens a URL in the local browser)[/dim]"
|
|
140
|
+
)
|
|
141
|
+
console.print(" [cyan]2[/cyan] — Device code (use a browser on another device)")
|
|
142
|
+
console.print(" [cyan]3[/cyan] — API key (paste a pre-issued key; for CI / non-interactive use)")
|
|
143
|
+
console.print()
|
|
144
|
+
choice = Prompt.ask("Choose", choices=["1", "2", "3"], default="1", console=console)
|
|
145
|
+
|
|
146
|
+
if choice == "3":
|
|
147
|
+
_login_api_key()
|
|
148
|
+
return
|
|
149
|
+
|
|
150
|
+
if choice == "2":
|
|
151
|
+
login_device_code()
|
|
152
|
+
else:
|
|
153
|
+
login_browser(open_browser=not no_browser and _interactive())
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def _login_api_key() -> None:
|
|
157
|
+
"""Store a user-supplied API key as the active credential.
|
|
158
|
+
|
|
159
|
+
No browser, no finalize call — the server has already issued this key
|
|
160
|
+
against an existing user/organization. The stored record is tagged
|
|
161
|
+
`kind=api_key` so the HTTP client sends `X-TL-Auth: API-KEY` on every
|
|
162
|
+
request. We immediately call /whoami to (a) verify the key is valid and
|
|
163
|
+
(b) capture the owning user's email for `tl auth status` output.
|
|
164
|
+
"""
|
|
165
|
+
key = _read_masked("Paste your API key: ").strip()
|
|
166
|
+
if not key:
|
|
167
|
+
console.print("[red]No key provided.[/red]")
|
|
168
|
+
raise typer.Exit(1)
|
|
169
|
+
|
|
170
|
+
save_tokens(
|
|
171
|
+
StoredTokens(
|
|
172
|
+
access_token=key,
|
|
173
|
+
refresh_token=None,
|
|
174
|
+
expires_at=time.time() + 10 * 365 * 24 * 3600,
|
|
175
|
+
email=None,
|
|
176
|
+
kind=KIND_API_KEY,
|
|
177
|
+
)
|
|
178
|
+
)
|
|
179
|
+
|
|
180
|
+
client = get_client()
|
|
181
|
+
try:
|
|
182
|
+
data = client.get("/whoami")
|
|
183
|
+
except ApiError as e:
|
|
184
|
+
clear_tokens()
|
|
185
|
+
console.print(f"[red]API key rejected:[/red] {escape(e.detail)}")
|
|
186
|
+
raise typer.Exit(1)
|
|
187
|
+
finally:
|
|
188
|
+
client.close()
|
|
189
|
+
|
|
190
|
+
email = (data.get("user") or {}).get("email")
|
|
191
|
+
if not email:
|
|
192
|
+
clear_tokens()
|
|
193
|
+
console.print(
|
|
194
|
+
"[red]API key accepted but the server returned no email for the owning user.[/red] "
|
|
195
|
+
"This usually means the user record is incomplete — contact support."
|
|
196
|
+
)
|
|
197
|
+
raise typer.Exit(1)
|
|
198
|
+
|
|
199
|
+
save_tokens(
|
|
200
|
+
StoredTokens(
|
|
201
|
+
access_token=key,
|
|
202
|
+
refresh_token=None,
|
|
203
|
+
expires_at=time.time() + 10 * 365 * 24 * 3600,
|
|
204
|
+
email=email,
|
|
205
|
+
kind=KIND_API_KEY,
|
|
206
|
+
)
|
|
207
|
+
)
|
|
208
|
+
console.print(f"[green]API key stored.[/green] Authenticated as: {email}")
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
@app.command("logout")
|
|
212
|
+
def logout_cmd() -> None:
|
|
213
|
+
"""Sign out of ThoughtLeaders everywhere.
|
|
214
|
+
|
|
215
|
+
Every sign-out is global: ThoughtLeaders ends this session on every surface
|
|
216
|
+
and device — the web platform, the Chrome extension and any other machine
|
|
217
|
+
running `tl` — and this machine then revokes and clears its own stored
|
|
218
|
+
credentials.
|
|
219
|
+
"""
|
|
220
|
+
tokens = load_tokens()
|
|
221
|
+
if tokens is None and get_config().api_key:
|
|
222
|
+
# An API key from the environment is not a session and is not ours to
|
|
223
|
+
# remove; with nothing stored there is nothing to clear either.
|
|
224
|
+
console.print(
|
|
225
|
+
"[yellow]TL_API_KEY is set in your environment; it is not a session and stays in effect. "
|
|
226
|
+
"Nothing is stored on this machine to clear.[/yellow]"
|
|
227
|
+
)
|
|
228
|
+
return
|
|
229
|
+
outcome = SIGNED_OUT
|
|
230
|
+
if tokens and tokens.is_api_key:
|
|
231
|
+
# An API key is not a session — it was issued in advance and nothing
|
|
232
|
+
# about it ends by signing out. Clearing it here is all there is to do.
|
|
233
|
+
console.print("[dim]An API key is not a session; clearing this machine's stored key.[/dim]")
|
|
234
|
+
elif tokens:
|
|
235
|
+
outcome = _sign_out_everywhere()
|
|
236
|
+
# That call may have refreshed the session (rotating the refresh token)
|
|
237
|
+
# or, finding it already ended, cleared the store: revoke what is there now.
|
|
238
|
+
tokens = load_tokens()
|
|
239
|
+
# Revoke the long-lived credential so a leaked/synced copy of the local
|
|
240
|
+
# token store can't keep minting access tokens. Best-effort — API-key auth
|
|
241
|
+
# has no refresh token, and an offline revoke must not block clearing the
|
|
242
|
+
# local credentials.
|
|
243
|
+
if tokens and not tokens.is_api_key and tokens.refresh_token:
|
|
244
|
+
if revoke_refresh_token(tokens.refresh_token):
|
|
245
|
+
console.print("[dim]Refresh token revoked at Auth0.[/dim]")
|
|
246
|
+
else:
|
|
247
|
+
console.print(
|
|
248
|
+
"[yellow]Could not reach Auth0 to revoke the refresh token; "
|
|
249
|
+
"clearing local credentials anyway.[/yellow]"
|
|
250
|
+
)
|
|
251
|
+
clear_tokens()
|
|
252
|
+
console.print("[green]Logged out successfully.[/green]")
|
|
253
|
+
if outcome == UNREACHABLE:
|
|
254
|
+
console.print(
|
|
255
|
+
"[yellow]Could not reach ThoughtLeaders to sign out everywhere. This machine's credentials "
|
|
256
|
+
"are cleared, but your other sessions stay signed in; sign out on the web platform once "
|
|
257
|
+
"you are online to end them.[/yellow]"
|
|
258
|
+
)
|
|
259
|
+
elif outcome == REFUSED:
|
|
260
|
+
console.print("[yellow]Your other sessions stay signed in; sign out on the web platform to end them.[/yellow]")
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
# What telling ThoughtLeaders about a sign-out came to.
|
|
264
|
+
SIGNED_OUT = "signed_out" # recorded now, or already by another surface
|
|
265
|
+
REFUSED = "refused" # ThoughtLeaders answered, and said no
|
|
266
|
+
UNREACHABLE = "unreachable" # no answer at all
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
def _sign_out_everywhere() -> str:
|
|
270
|
+
"""Ask ThoughtLeaders to end this session everywhere. Best effort: nothing
|
|
271
|
+
here may stop the local logout — not an unreachable service, and not a token
|
|
272
|
+
refresh giving up (`SystemExit`). Speaks for the stored session even when
|
|
273
|
+
`TL_API_KEY` is set: that key is not the session being ended."""
|
|
274
|
+
client = None
|
|
275
|
+
try:
|
|
276
|
+
client = get_client(stored_session_only=True)
|
|
277
|
+
client.post("/auth/sign-out", json_body={})
|
|
278
|
+
console.print("[dim]Signed out everywhere.[/dim]")
|
|
279
|
+
return SIGNED_OUT
|
|
280
|
+
except ApiError as e:
|
|
281
|
+
if isinstance(e.raw, dict) and e.raw.get("code") == SIGNED_OUT_CODE:
|
|
282
|
+
# Someone got there first: this session was already ended
|
|
283
|
+
# elsewhere, which is exactly the state we were after.
|
|
284
|
+
console.print("[dim]Already signed out everywhere.[/dim]")
|
|
285
|
+
return SIGNED_OUT
|
|
286
|
+
console.print(f"[yellow]ThoughtLeaders did not sign you out everywhere: {escape(e.detail)}[/yellow]")
|
|
287
|
+
return REFUSED
|
|
288
|
+
except (Exception, SystemExit): # noqa: BLE001 — declared best-effort: nothing may stop the local logout
|
|
289
|
+
return UNREACHABLE
|
|
290
|
+
finally:
|
|
291
|
+
if client is not None:
|
|
292
|
+
client.close()
|
|
293
|
+
|
|
294
|
+
|
|
295
|
+
@app.command("status")
|
|
296
|
+
def status_cmd(
|
|
297
|
+
quiet: bool = typer.Option(
|
|
298
|
+
False, "--quiet", "-q", help="Print nothing; exit 0 if logged in, 2 otherwise."
|
|
299
|
+
),
|
|
300
|
+
) -> None:
|
|
301
|
+
"""Show current authentication status: exit 0 when the next command will
|
|
302
|
+
authenticate, 2 when it will not."""
|
|
303
|
+
if get_config().api_key:
|
|
304
|
+
# TL_API_KEY wins over anything stored (see the HTTP client), so this
|
|
305
|
+
# machine is authenticated whatever the keychain holds.
|
|
306
|
+
if not quiet:
|
|
307
|
+
console.print("[green]Authenticated[/green] via API key from TL_API_KEY.")
|
|
308
|
+
return
|
|
309
|
+
|
|
310
|
+
tokens = load_tokens()
|
|
311
|
+
if not tokens:
|
|
312
|
+
if not quiet:
|
|
313
|
+
console.print("[yellow]Not logged in.[/yellow] Run: tl auth login")
|
|
314
|
+
raise SystemExit(2)
|
|
315
|
+
|
|
316
|
+
# An expired access token with a refresh token is still a working session:
|
|
317
|
+
# the next command refreshes it on its own.
|
|
318
|
+
if tokens.is_expired and not tokens.refresh_token:
|
|
319
|
+
if not quiet:
|
|
320
|
+
console.print(f"[yellow]Token expired.[/yellow] Logged in as: {tokens.email or 'unknown'}")
|
|
321
|
+
console.print("Run: tl auth login")
|
|
322
|
+
raise SystemExit(2)
|
|
323
|
+
|
|
324
|
+
if quiet:
|
|
325
|
+
return
|
|
326
|
+
if tokens.is_api_key:
|
|
327
|
+
console.print("[green]Authenticated[/green] via API key.")
|
|
328
|
+
else:
|
|
329
|
+
console.print(f"[green]Authenticated[/green] as: {tokens.email or 'unknown'}")
|