thoughtleaders-cli 0.7.3__tar.gz → 0.7.5__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.7.3 → thoughtleaders_cli-0.7.5}/.claude-plugin/plugin.json +1 -1
- {thoughtleaders_cli-0.7.3 → thoughtleaders_cli-0.7.5}/PKG-INFO +1 -1
- {thoughtleaders_cli-0.7.3 → thoughtleaders_cli-0.7.5}/pyproject.toml +1 -1
- {thoughtleaders_cli-0.7.3 → thoughtleaders_cli-0.7.5}/skills/tl/SKILL.md +3 -1
- {thoughtleaders_cli-0.7.3 → thoughtleaders_cli-0.7.5}/src/tl_cli/__init__.py +1 -1
- {thoughtleaders_cli-0.7.3 → thoughtleaders_cli-0.7.5}/src/tl_cli/auth/commands.py +23 -5
- {thoughtleaders_cli-0.7.3 → thoughtleaders_cli-0.7.5}/src/tl_cli/auth/login.py +23 -0
- {thoughtleaders_cli-0.7.3 → thoughtleaders_cli-0.7.5}/src/tl_cli/commands/channels.py +0 -8
- {thoughtleaders_cli-0.7.3 → thoughtleaders_cli-0.7.5}/src/tl_cli/commands/reports.py +97 -13
- thoughtleaders_cli-0.7.5/tests/test_auth.py +145 -0
- {thoughtleaders_cli-0.7.3 → thoughtleaders_cli-0.7.5}/tests/test_reports.py +58 -1
- thoughtleaders_cli-0.7.3/src/tl_cli/auth/finalize.py +0 -88
- thoughtleaders_cli-0.7.3/tests/test_auth.py +0 -78
- {thoughtleaders_cli-0.7.3 → thoughtleaders_cli-0.7.5}/.claude-plugin/marketplace.json +0 -0
- {thoughtleaders_cli-0.7.3 → thoughtleaders_cli-0.7.5}/.github/workflows/python-publish.yml +0 -0
- {thoughtleaders_cli-0.7.3 → thoughtleaders_cli-0.7.5}/.gitignore +0 -0
- {thoughtleaders_cli-0.7.3 → thoughtleaders_cli-0.7.5}/AGENTS.md +0 -0
- {thoughtleaders_cli-0.7.3 → thoughtleaders_cli-0.7.5}/API.md +0 -0
- {thoughtleaders_cli-0.7.3 → thoughtleaders_cli-0.7.5}/CLAUDE.md +0 -0
- {thoughtleaders_cli-0.7.3 → thoughtleaders_cli-0.7.5}/LICENSE +0 -0
- {thoughtleaders_cli-0.7.3 → thoughtleaders_cli-0.7.5}/README.md +0 -0
- {thoughtleaders_cli-0.7.3 → thoughtleaders_cli-0.7.5}/agents/tl-analyst.md +0 -0
- {thoughtleaders_cli-0.7.3 → thoughtleaders_cli-0.7.5}/agents/youtube-comment-classifier.md +0 -0
- {thoughtleaders_cli-0.7.3 → thoughtleaders_cli-0.7.5}/hooks/hooks.json +0 -0
- {thoughtleaders_cli-0.7.3 → thoughtleaders_cli-0.7.5}/hooks/scripts/load-tl-skill.mjs +0 -0
- {thoughtleaders_cli-0.7.3 → thoughtleaders_cli-0.7.5}/hooks/scripts/post-usage.sh +0 -0
- {thoughtleaders_cli-0.7.3 → thoughtleaders_cli-0.7.5}/hooks/scripts/pre-check.sh +0 -0
- {thoughtleaders_cli-0.7.3 → thoughtleaders_cli-0.7.5}/skills/tl/references/business-glossary.md +0 -0
- {thoughtleaders_cli-0.7.3 → thoughtleaders_cli-0.7.5}/skills/tl/references/elasticsearch-schema.md +0 -0
- {thoughtleaders_cli-0.7.3 → thoughtleaders_cli-0.7.5}/skills/tl/references/firebolt-schema.md +0 -0
- {thoughtleaders_cli-0.7.3 → thoughtleaders_cli-0.7.5}/skills/tl/references/postgres-schema.md +0 -0
- {thoughtleaders_cli-0.7.3 → thoughtleaders_cli-0.7.5}/skills/tl-channel-authenticity/.gitignore +0 -0
- {thoughtleaders_cli-0.7.3 → thoughtleaders_cli-0.7.5}/skills/tl-channel-authenticity/SKILL.md +0 -0
- {thoughtleaders_cli-0.7.3 → thoughtleaders_cli-0.7.5}/skills/tl-channel-authenticity/references/comment-patterns.md +0 -0
- {thoughtleaders_cli-0.7.3 → thoughtleaders_cli-0.7.5}/skills/tl-channel-authenticity/references/peer-cohort.md +0 -0
- {thoughtleaders_cli-0.7.3 → thoughtleaders_cli-0.7.5}/skills/tl-channel-authenticity/references/red-flags.md +0 -0
- {thoughtleaders_cli-0.7.3 → thoughtleaders_cli-0.7.5}/skills/tl-channel-authenticity/references/scoring.md +0 -0
- {thoughtleaders_cli-0.7.3 → thoughtleaders_cli-0.7.5}/skills/tl-channel-authenticity/scripts/_io_utf8.py +0 -0
- {thoughtleaders_cli-0.7.3 → thoughtleaders_cli-0.7.5}/skills/tl-channel-authenticity/scripts/analyze_channel.py +0 -0
- {thoughtleaders_cli-0.7.3 → thoughtleaders_cli-0.7.5}/skills/tl-channel-authenticity/scripts/anomaly_detector.py +0 -0
- {thoughtleaders_cli-0.7.3 → thoughtleaders_cli-0.7.5}/skills/tl-channel-authenticity/scripts/comment_analyzer.py +0 -0
- {thoughtleaders_cli-0.7.3 → thoughtleaders_cli-0.7.5}/skills/tl-channel-authenticity/scripts/comment_scraper.py +0 -0
- {thoughtleaders_cli-0.7.3 → thoughtleaders_cli-0.7.5}/skills/tl-channel-authenticity/scripts/engagement_ratios.py +0 -0
- {thoughtleaders_cli-0.7.3 → thoughtleaders_cli-0.7.5}/skills/tl-channel-authenticity/scripts/peer_cohort.py +0 -0
- {thoughtleaders_cli-0.7.3 → thoughtleaders_cli-0.7.5}/skills/tl-channel-authenticity/scripts/report.py +0 -0
- {thoughtleaders_cli-0.7.3 → thoughtleaders_cli-0.7.5}/skills/tl-channel-authenticity/scripts/resolve_channel.py +0 -0
- {thoughtleaders_cli-0.7.3 → thoughtleaders_cli-0.7.5}/skills/tl-channel-authenticity/scripts/score.py +0 -0
- {thoughtleaders_cli-0.7.3 → thoughtleaders_cli-0.7.5}/skills/tl-channel-authenticity/scripts/tl_cli.py +0 -0
- {thoughtleaders_cli-0.7.3 → thoughtleaders_cli-0.7.5}/skills/tl-channel-authenticity/scripts/video_integrity.py +0 -0
- {thoughtleaders_cli-0.7.3 → thoughtleaders_cli-0.7.5}/skills/tl-channel-authenticity/scripts/view_curves.py +0 -0
- {thoughtleaders_cli-0.7.3 → thoughtleaders_cli-0.7.5}/skills/tl-import/SKILL.md +0 -0
- {thoughtleaders_cli-0.7.3 → thoughtleaders_cli-0.7.5}/skills/tl-keyword-research/SKILL.md +0 -0
- {thoughtleaders_cli-0.7.3 → thoughtleaders_cli-0.7.5}/skills/tl-keyword-research/scripts/probe.py +0 -0
- {thoughtleaders_cli-0.7.3 → thoughtleaders_cli-0.7.5}/skills/tl-report-builder/SKILL.md +0 -0
- {thoughtleaders_cli-0.7.3 → thoughtleaders_cli-0.7.5}/skills/tl-report-builder/examples/e2e_findings.md +0 -0
- {thoughtleaders_cli-0.7.3 → thoughtleaders_cli-0.7.5}/skills/tl-report-builder/examples/golden_queries.md +0 -0
- {thoughtleaders_cli-0.7.3 → thoughtleaders_cli-0.7.5}/skills/tl-report-builder/references/columns_brands.md +0 -0
- {thoughtleaders_cli-0.7.3 → thoughtleaders_cli-0.7.5}/skills/tl-report-builder/references/columns_channels.md +0 -0
- {thoughtleaders_cli-0.7.3 → thoughtleaders_cli-0.7.5}/skills/tl-report-builder/references/columns_content.md +0 -0
- {thoughtleaders_cli-0.7.3 → thoughtleaders_cli-0.7.5}/skills/tl-report-builder/references/columns_sponsorships.md +0 -0
- {thoughtleaders_cli-0.7.3 → thoughtleaders_cli-0.7.5}/skills/tl-report-builder/references/intelligence_filterset_schema.json +0 -0
- {thoughtleaders_cli-0.7.3 → thoughtleaders_cli-0.7.5}/skills/tl-report-builder/references/intelligence_widget_schema.json +0 -0
- {thoughtleaders_cli-0.7.3 → thoughtleaders_cli-0.7.5}/skills/tl-report-builder/references/report_glossary.md +0 -0
- {thoughtleaders_cli-0.7.3 → thoughtleaders_cli-0.7.5}/skills/tl-report-builder/references/sortable_columns.json +0 -0
- {thoughtleaders_cli-0.7.3 → thoughtleaders_cli-0.7.5}/skills/tl-report-builder/references/sponsorship_filterset_schema.json +0 -0
- {thoughtleaders_cli-0.7.3 → thoughtleaders_cli-0.7.5}/skills/tl-report-builder/references/sponsorship_widget_schema.json +0 -0
- {thoughtleaders_cli-0.7.3 → thoughtleaders_cli-0.7.5}/skills/tl-report-builder/references/widgets.md +0 -0
- {thoughtleaders_cli-0.7.3 → thoughtleaders_cli-0.7.5}/skills/tl-report-builder/tools/column_builder.md +0 -0
- {thoughtleaders_cli-0.7.3 → thoughtleaders_cli-0.7.5}/skills/tl-report-builder/tools/database_query.md +0 -0
- {thoughtleaders_cli-0.7.3 → thoughtleaders_cli-0.7.5}/skills/tl-report-builder/tools/name_resolver.md +0 -0
- {thoughtleaders_cli-0.7.3 → thoughtleaders_cli-0.7.5}/skills/tl-report-builder/tools/sample_judge.md +0 -0
- {thoughtleaders_cli-0.7.3 → thoughtleaders_cli-0.7.5}/skills/tl-report-builder/tools/similar_channels.md +0 -0
- {thoughtleaders_cli-0.7.3 → thoughtleaders_cli-0.7.5}/skills/tl-report-builder/tools/topic_matcher.md +0 -0
- {thoughtleaders_cli-0.7.3 → thoughtleaders_cli-0.7.5}/skills/tl-report-builder/tools/widget_builder.md +0 -0
- {thoughtleaders_cli-0.7.3 → thoughtleaders_cli-0.7.5}/skills/tl-save-report/SKILL.md +0 -0
- {thoughtleaders_cli-0.7.3 → thoughtleaders_cli-0.7.5}/skills/tl-save-report/references/columns_brands.md +0 -0
- {thoughtleaders_cli-0.7.3 → thoughtleaders_cli-0.7.5}/skills/tl-save-report/references/columns_channels.md +0 -0
- {thoughtleaders_cli-0.7.3 → thoughtleaders_cli-0.7.5}/skills/tl-save-report/references/columns_content.md +0 -0
- {thoughtleaders_cli-0.7.3 → thoughtleaders_cli-0.7.5}/skills/tl-save-report/references/columns_sponsorships.md +0 -0
- {thoughtleaders_cli-0.7.3 → thoughtleaders_cli-0.7.5}/skills/tl-save-report/references/intelligence_filterset_schema.json +0 -0
- {thoughtleaders_cli-0.7.3 → thoughtleaders_cli-0.7.5}/skills/tl-save-report/references/intelligence_widget_schema.json +0 -0
- {thoughtleaders_cli-0.7.3 → thoughtleaders_cli-0.7.5}/skills/tl-save-report/references/report_glossary.md +0 -0
- {thoughtleaders_cli-0.7.3 → thoughtleaders_cli-0.7.5}/skills/tl-save-report/references/sortable_columns.json +0 -0
- {thoughtleaders_cli-0.7.3 → thoughtleaders_cli-0.7.5}/skills/tl-save-report/references/sponsorship_filterset_schema.json +0 -0
- {thoughtleaders_cli-0.7.3 → thoughtleaders_cli-0.7.5}/skills/tl-save-report/references/sponsorship_widget_schema.json +0 -0
- {thoughtleaders_cli-0.7.3 → thoughtleaders_cli-0.7.5}/skills/tl-save-report/references/widgets.md +0 -0
- {thoughtleaders_cli-0.7.3 → thoughtleaders_cli-0.7.5}/skills/tl-top-partnerships/SKILL.md +0 -0
- {thoughtleaders_cli-0.7.3 → thoughtleaders_cli-0.7.5}/skills/tl-top-partnerships/scripts/top_partnerships.py +0 -0
- {thoughtleaders_cli-0.7.3 → thoughtleaders_cli-0.7.5}/skills/tl-views-guarantee/SKILL.md +0 -0
- {thoughtleaders_cli-0.7.3 → thoughtleaders_cli-0.7.5}/skills/tl-views-guarantee/scripts/vg.py +0 -0
- {thoughtleaders_cli-0.7.3 → thoughtleaders_cli-0.7.5}/src/tl_cli/_completions.py +0 -0
- {thoughtleaders_cli-0.7.3 → thoughtleaders_cli-0.7.5}/src/tl_cli/_typer_utils.py +0 -0
- {thoughtleaders_cli-0.7.3 → thoughtleaders_cli-0.7.5}/src/tl_cli/auth/__init__.py +0 -0
- {thoughtleaders_cli-0.7.3 → thoughtleaders_cli-0.7.5}/src/tl_cli/auth/pkce.py +0 -0
- {thoughtleaders_cli-0.7.3 → thoughtleaders_cli-0.7.5}/src/tl_cli/auth/token_store.py +0 -0
- {thoughtleaders_cli-0.7.3 → thoughtleaders_cli-0.7.5}/src/tl_cli/client/__init__.py +0 -0
- {thoughtleaders_cli-0.7.3 → thoughtleaders_cli-0.7.5}/src/tl_cli/client/errors.py +0 -0
- {thoughtleaders_cli-0.7.3 → thoughtleaders_cli-0.7.5}/src/tl_cli/client/http.py +0 -0
- {thoughtleaders_cli-0.7.3 → thoughtleaders_cli-0.7.5}/src/tl_cli/commands/__init__.py +0 -0
- {thoughtleaders_cli-0.7.3 → thoughtleaders_cli-0.7.5}/src/tl_cli/commands/_comments_common.py +0 -0
- {thoughtleaders_cli-0.7.3 → thoughtleaders_cli-0.7.5}/src/tl_cli/commands/balance.py +0 -0
- {thoughtleaders_cli-0.7.3 → thoughtleaders_cli-0.7.5}/src/tl_cli/commands/brands.py +0 -0
- {thoughtleaders_cli-0.7.3 → thoughtleaders_cli-0.7.5}/src/tl_cli/commands/bulk_import.py +0 -0
- {thoughtleaders_cli-0.7.3 → thoughtleaders_cli-0.7.5}/src/tl_cli/commands/changelog.py +0 -0
- {thoughtleaders_cli-0.7.3 → thoughtleaders_cli-0.7.5}/src/tl_cli/commands/credits.py +0 -0
- {thoughtleaders_cli-0.7.3 → thoughtleaders_cli-0.7.5}/src/tl_cli/commands/db.py +0 -0
- {thoughtleaders_cli-0.7.3 → thoughtleaders_cli-0.7.5}/src/tl_cli/commands/deals.py +0 -0
- {thoughtleaders_cli-0.7.3 → thoughtleaders_cli-0.7.5}/src/tl_cli/commands/describe.py +0 -0
- {thoughtleaders_cli-0.7.3 → thoughtleaders_cli-0.7.5}/src/tl_cli/commands/doctor.py +0 -0
- {thoughtleaders_cli-0.7.3 → thoughtleaders_cli-0.7.5}/src/tl_cli/commands/matches.py +0 -0
- {thoughtleaders_cli-0.7.3 → thoughtleaders_cli-0.7.5}/src/tl_cli/commands/proposals.py +0 -0
- {thoughtleaders_cli-0.7.3 → thoughtleaders_cli-0.7.5}/src/tl_cli/commands/recommender.py +0 -0
- {thoughtleaders_cli-0.7.3 → thoughtleaders_cli-0.7.5}/src/tl_cli/commands/schema.py +0 -0
- {thoughtleaders_cli-0.7.3 → thoughtleaders_cli-0.7.5}/src/tl_cli/commands/setup.py +0 -0
- {thoughtleaders_cli-0.7.3 → thoughtleaders_cli-0.7.5}/src/tl_cli/commands/snapshots.py +0 -0
- {thoughtleaders_cli-0.7.3 → thoughtleaders_cli-0.7.5}/src/tl_cli/commands/sponsorships.py +0 -0
- {thoughtleaders_cli-0.7.3 → thoughtleaders_cli-0.7.5}/src/tl_cli/commands/uploads.py +0 -0
- {thoughtleaders_cli-0.7.3 → thoughtleaders_cli-0.7.5}/src/tl_cli/commands/whoami.py +0 -0
- {thoughtleaders_cli-0.7.3 → thoughtleaders_cli-0.7.5}/src/tl_cli/config.py +0 -0
- {thoughtleaders_cli-0.7.3 → thoughtleaders_cli-0.7.5}/src/tl_cli/filters.py +0 -0
- {thoughtleaders_cli-0.7.3 → thoughtleaders_cli-0.7.5}/src/tl_cli/hints.py +0 -0
- {thoughtleaders_cli-0.7.3 → thoughtleaders_cli-0.7.5}/src/tl_cli/main.py +0 -0
- {thoughtleaders_cli-0.7.3 → thoughtleaders_cli-0.7.5}/src/tl_cli/output/__init__.py +0 -0
- {thoughtleaders_cli-0.7.3 → thoughtleaders_cli-0.7.5}/src/tl_cli/output/formatter.py +0 -0
- {thoughtleaders_cli-0.7.3 → thoughtleaders_cli-0.7.5}/src/tl_cli/self_update.py +0 -0
- {thoughtleaders_cli-0.7.3 → thoughtleaders_cli-0.7.5}/tests/__init__.py +0 -0
- {thoughtleaders_cli-0.7.3 → thoughtleaders_cli-0.7.5}/tests/test_describe.py +0 -0
- {thoughtleaders_cli-0.7.3 → thoughtleaders_cli-0.7.5}/tests/test_filters.py +0 -0
- {thoughtleaders_cli-0.7.3 → thoughtleaders_cli-0.7.5}/tests/test_http_auth.py +0 -0
- {thoughtleaders_cli-0.7.3 → thoughtleaders_cli-0.7.5}/tests/test_output.py +0 -0
- {thoughtleaders_cli-0.7.3 → thoughtleaders_cli-0.7.5}/tests/test_setup.py +0 -0
- {thoughtleaders_cli-0.7.3 → thoughtleaders_cli-0.7.5}/tests/test_sponsorships.py +0 -0
- {thoughtleaders_cli-0.7.3 → thoughtleaders_cli-0.7.5}/uv.lock +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: thoughtleaders-cli
|
|
3
|
-
Version: 0.7.
|
|
3
|
+
Version: 0.7.5
|
|
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
|
|
@@ -150,6 +150,8 @@ Unless the user specifically asks for running a specific report or showing the r
|
|
|
150
150
|
|
|
151
151
|
If the user says yes (or uses any of the save-trigger phrases, like `save it`, `save the list`, `make a report`, `persist this`, `turn this into a campaign`, `I want to come back to this`), invoke the `tl-save-report` skill — it owns the filter-vs-list decision flow, the FilterSet mapping, and the `tl reports create` / `tl reports save-list` save call. **Don't try to compose the report config yourself**; hand off to the skill.
|
|
152
152
|
|
|
153
|
+
**When the user already has specific IDs in hand** — "make a report of these sponsorship IDs", "save these exact channels", or a curated set you just resolved — that is always a **list-style** report: pin the IDs with `tl reports save-list <entity> --ids-file` (or add them to an existing report with `tl bulk-import <entity> -c <report-id>`). **Never** route an explicit-ID request through `tl reports create "<natural-language prompt>"` — the prompt path builds *predicate filters* and pins none of those IDs, so the report comes back showing unrelated records, not the ones the user named. After creating it, confirm the report actually contains those records before sharing the link.
|
|
154
|
+
|
|
153
155
|
Skip the save offer when the result clearly doesn't fit a report type — a single scalar count, an aggregate roll-up across entity types, view-curve time series, schema introspection output, or anything that isn't a list of channels / brands / videos / sponsorships. A trailing offer on those would just be noise.
|
|
154
156
|
|
|
155
157
|
Prefer writing shell code, `jq` commands, or `duckdb` commands that fetch or analysise large sets of data instead of analysing it yourself. On Mac and Linux, create temporary files in `/tmp` that can be analysed later in different ways. On Windows, create them in the directory pointed to by the `%TEMP%` environment variable. When coding, do it in Python.
|
|
@@ -765,7 +767,7 @@ tl channels similar 29834 msn:no --limit 30 # non-MSN channels
|
|
|
765
767
|
tl channels similar 29834 tpp:yes --limit 30 # TPP (TL-managed) channels only
|
|
766
768
|
tl channels similar 29834 min-subs:1000000 exclude:477487 --limit 15 # client-side filters
|
|
767
769
|
```
|
|
768
|
-
**Both `tl channels show` and `tl channels similar` accept either a numeric channel ID or a channel name.** Name arguments are case-insensitive partial matches; if more than one active channel matches, the command prints a candidates table (channel_id, subscribers, name) and exits 1 so you can retry with a specific ID. The `msn` filter on `similar` is tri-state: `yes` (only MSN channels — the default), `no` (only non-MSN channels), `both` (no MSN filter). `tl channels look-alike` is a hidden alias for `similar` that matches the internal "look-alike channels" terminology.
|
|
770
|
+
**Both `tl channels show` and `tl channels similar` accept either a numeric channel ID or a channel name.** Name arguments are case-insensitive partial matches; if more than one active channel matches, the command prints a candidates table (channel_id, subscribers, name) and exits 1 so you can retry with a specific ID. The `msn` filter on `similar` is tri-state: `yes` (only MSN channels — the default), `no` (only non-MSN channels), `both` (no MSN filter). `tl channels look-alike` is a hidden alias for `similar` that matches the internal "look-alike channels" terminology. `tl channels show` returns a `tl_url` field — the canonical ThoughtLeaders web-app analysis page for the channel; use it verbatim when linking a user to the channel instead of constructing a URL by hand.
|
|
769
771
|
|
|
770
772
|
### "Browse the recommender" (categories, demographics, formats):
|
|
771
773
|
```bash
|
|
@@ -8,9 +8,9 @@ from tl_cli._typer_utils import AlphaSortedTyperGroup
|
|
|
8
8
|
from rich.console import Console
|
|
9
9
|
from rich.prompt import Prompt
|
|
10
10
|
|
|
11
|
-
from tl_cli.auth.
|
|
12
|
-
from tl_cli.auth.login import login_browser, login_device_code
|
|
11
|
+
from tl_cli.auth.login import login_browser, login_device_code, revoke_refresh_token
|
|
13
12
|
from tl_cli.auth.token_store import KIND_API_KEY, StoredTokens, clear_tokens, load_tokens, save_tokens
|
|
13
|
+
from tl_cli.config import get_config
|
|
14
14
|
|
|
15
15
|
app = typer.Typer(cls=AlphaSortedTyperGroup, help="Authentication commands")
|
|
16
16
|
console = Console(stderr=True)
|
|
@@ -106,8 +106,6 @@ def login_cmd() -> None:
|
|
|
106
106
|
else:
|
|
107
107
|
login_browser()
|
|
108
108
|
|
|
109
|
-
finalize_signup()
|
|
110
|
-
|
|
111
109
|
|
|
112
110
|
def _login_api_key() -> None:
|
|
113
111
|
"""Store a user-supplied API key as the active credential.
|
|
@@ -171,7 +169,27 @@ def _login_api_key() -> None:
|
|
|
171
169
|
|
|
172
170
|
@app.command("logout")
|
|
173
171
|
def logout_cmd() -> None:
|
|
174
|
-
"""
|
|
172
|
+
"""Log out: revoke the refresh token at Auth0, then clear stored tokens."""
|
|
173
|
+
tokens = load_tokens()
|
|
174
|
+
# Revoke the long-lived credential server-side so a leaked/synced copy of
|
|
175
|
+
# the local token store can't keep minting access tokens. Best-effort —
|
|
176
|
+
# API-key auth has no refresh token, and an offline revoke must not block
|
|
177
|
+
# clearing local credentials.
|
|
178
|
+
if tokens and not tokens.is_api_key and tokens.refresh_token:
|
|
179
|
+
if revoke_refresh_token(tokens.refresh_token):
|
|
180
|
+
console.print("[dim]Refresh token revoked at Auth0.[/dim]")
|
|
181
|
+
else:
|
|
182
|
+
console.print(
|
|
183
|
+
"[yellow]Could not reach Auth0 to revoke the refresh token; "
|
|
184
|
+
"clearing local credentials anyway.[/yellow]"
|
|
185
|
+
)
|
|
186
|
+
# Revoking the refresh token doesn't end the browser SSO session that
|
|
187
|
+
# the interactive login established. Point the user at Auth0's logout
|
|
188
|
+
# URL so the next `tl auth login` doesn't silently SSO straight back in.
|
|
189
|
+
logout_url = f"https://{get_config().auth0_domain}/logout"
|
|
190
|
+
console.print(
|
|
191
|
+
f"To end your Auth0 browser session, visit: [cyan]{logout_url}[/cyan]"
|
|
192
|
+
)
|
|
175
193
|
clear_tokens()
|
|
176
194
|
console.print("[green]Logged out successfully.[/green]")
|
|
177
195
|
|
|
@@ -212,6 +212,29 @@ def refresh_access_token(refresh_token: str) -> StoredTokens:
|
|
|
212
212
|
return tokens
|
|
213
213
|
|
|
214
214
|
|
|
215
|
+
def revoke_refresh_token(refresh_token: str) -> bool:
|
|
216
|
+
"""Best-effort revocation of a refresh token at Auth0 (RFC 7009).
|
|
217
|
+
|
|
218
|
+
Invalidates the long-lived credential server-side so it can no longer mint
|
|
219
|
+
new access tokens. Public-client call — `client_id` only, no secret. Returns
|
|
220
|
+
True on success; never raises — network / Auth0 errors are swallowed so
|
|
221
|
+
`tl auth logout` can still clear the local credentials when offline.
|
|
222
|
+
"""
|
|
223
|
+
config = get_config()
|
|
224
|
+
try:
|
|
225
|
+
response = httpx.post(
|
|
226
|
+
f"https://{config.auth0_domain}/oauth/revoke",
|
|
227
|
+
json={
|
|
228
|
+
"client_id": config.auth0_client_id,
|
|
229
|
+
"token": refresh_token,
|
|
230
|
+
},
|
|
231
|
+
timeout=10,
|
|
232
|
+
)
|
|
233
|
+
except httpx.HTTPError:
|
|
234
|
+
return False
|
|
235
|
+
return response.status_code == 200
|
|
236
|
+
|
|
237
|
+
|
|
215
238
|
def _exchange_code(
|
|
216
239
|
code: str,
|
|
217
240
|
code_verifier: str,
|
|
@@ -64,14 +64,6 @@ def show_cmd(
|
|
|
64
64
|
client = get_client()
|
|
65
65
|
try:
|
|
66
66
|
data = client.get(f"/channels/{encoded_ref}")
|
|
67
|
-
for i, r in enumerate(data.get("results", []) if isinstance(data.get("results"), list) else []):
|
|
68
|
-
renamed = {}
|
|
69
|
-
for k, v in r.items():
|
|
70
|
-
if k == "id":
|
|
71
|
-
renamed["channel_id"] = v
|
|
72
|
-
else:
|
|
73
|
-
renamed[k] = v
|
|
74
|
-
data["results"][i] = renamed
|
|
75
67
|
output_single(data, fmt)
|
|
76
68
|
if fmt == "table" and data.get("show_cta"):
|
|
77
69
|
record = data.get("results", data)
|
|
@@ -22,6 +22,71 @@ REPORT_TYPE_LABELS = {1: "Content", 2: "Brands", 3: "Channels", 8: "Sponsorships
|
|
|
22
22
|
|
|
23
23
|
POLL_INTERVAL = 2 # seconds between server polls
|
|
24
24
|
|
|
25
|
+
# Filterset keys that pin specific entities into a report (the curated-ID
|
|
26
|
+
# lists). A report built from these holds exactly those records; a report built
|
|
27
|
+
# from predicate filters re-evaluates against live data and pins nothing.
|
|
28
|
+
_PIN_ENTITIES = ("channels", "brands", "sponsorships", "articles")
|
|
29
|
+
# Filterset keys that are structural, not user-facing predicate filters.
|
|
30
|
+
_NON_PREDICATE_KEYS = {"keyword_operator", "sort"}
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _summarize_report_contents(config: dict) -> dict:
|
|
34
|
+
"""Summarize what a saved report config actually contains.
|
|
35
|
+
|
|
36
|
+
Returns the report-type label, a per-entity count of *pinned* IDs (the
|
|
37
|
+
curated lists that freeze specific channels/brands/sponsorships/articles
|
|
38
|
+
into the report), and the active predicate-filter keys. Lets a caller —
|
|
39
|
+
human or agent — catch a filters-vs-pinned-IDs mismatch (or a wholly blank
|
|
40
|
+
report) before sharing the link: a report built from a natural-language
|
|
41
|
+
prompt yields predicate filters and pins *nothing*, so "pinned: none" is
|
|
42
|
+
the tell that it doesn't contain the specific records the user named.
|
|
43
|
+
"""
|
|
44
|
+
filterset = config.get("filterset") or {}
|
|
45
|
+
pinned = {
|
|
46
|
+
entity: len(filterset[entity])
|
|
47
|
+
for entity in _PIN_ENTITIES
|
|
48
|
+
if isinstance(filterset.get(entity), list) and filterset[entity]
|
|
49
|
+
}
|
|
50
|
+
skip = set(_PIN_ENTITIES) | {f"exclude_{e}" for e in _PIN_ENTITIES} | _NON_PREDICATE_KEYS
|
|
51
|
+
filters = sorted(
|
|
52
|
+
key
|
|
53
|
+
for key, value in filterset.items()
|
|
54
|
+
if key not in skip and value not in (None, "", [], {}, False)
|
|
55
|
+
)
|
|
56
|
+
return {
|
|
57
|
+
"report_type": REPORT_TYPE_LABELS.get(config.get("report_type"), config.get("report_type")),
|
|
58
|
+
"pinned": pinned,
|
|
59
|
+
"filters": filters,
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _render_contents_line(summary: dict) -> str:
|
|
64
|
+
"""One-line human rendering of a `_summarize_report_contents` result."""
|
|
65
|
+
pinned = summary.get("pinned") or {}
|
|
66
|
+
pin_str = ", ".join(f"{count} {entity}" for entity, count in pinned.items()) if pinned else "none"
|
|
67
|
+
filters = summary.get("filters") or []
|
|
68
|
+
filt_str = ", ".join(filters) if filters else "none"
|
|
69
|
+
return f"Contents: {summary.get('report_type')} report · pinned: {pin_str} · filters: {filt_str}"
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _print_contents_summary(summary: dict) -> None:
|
|
73
|
+
"""Surface a saved report's contents on stderr (safe in every output mode).
|
|
74
|
+
|
|
75
|
+
A report with no pinned entities *and* no filters gets a loud warning —
|
|
76
|
+
that blank/default state is the failure behind "you sent me an empty
|
|
77
|
+
report". Otherwise a dim one-liner lets the caller confirm the report holds
|
|
78
|
+
what the user actually asked for.
|
|
79
|
+
"""
|
|
80
|
+
line = _render_contents_line(summary)
|
|
81
|
+
if not summary.get("pinned") and not summary.get("filters"):
|
|
82
|
+
err.print(
|
|
83
|
+
f"[yellow]⚠ {line}\n"
|
|
84
|
+
f" This report has no filters and no pinned entities — it will show "
|
|
85
|
+
f"a blank/default view.[/yellow]"
|
|
86
|
+
)
|
|
87
|
+
else:
|
|
88
|
+
err.print(f"[dim]{line}[/dim]")
|
|
89
|
+
|
|
25
90
|
|
|
26
91
|
@app.callback(invoke_without_command=True)
|
|
27
92
|
def reports(
|
|
@@ -330,6 +395,13 @@ def create_report(
|
|
|
330
395
|
With a prompt, runs the AI Report Builder pipeline (keyword research, config
|
|
331
396
|
generation, review) and saves the resulting campaign.
|
|
332
397
|
|
|
398
|
+
Have specific IDs already? A report of *exactly* the channels / brands /
|
|
399
|
+
sponsorships / articles you already have the IDs for is a different job —
|
|
400
|
+
use `tl reports save-list <entity> --ids-file` to pin those IDs into a new
|
|
401
|
+
report, or `tl bulk-import <entity> -c <report-id>` to add them to an
|
|
402
|
+
existing one. A natural-language prompt here builds *predicate filters* and
|
|
403
|
+
pins none of those IDs, so the report comes back showing unrelated records.
|
|
404
|
+
|
|
333
405
|
With --config '<json>' or --config-file <path>, skips the orchestration
|
|
334
406
|
pipeline and saves the provided config directly. Useful when an external
|
|
335
407
|
agent (e.g. the tl-report-builder Claude Code skill) has already produced a
|
|
@@ -404,6 +476,13 @@ def create_report(
|
|
|
404
476
|
report_url = result.get("report_url", "")
|
|
405
477
|
campaign_id = result.get("campaign_id", "")
|
|
406
478
|
|
|
479
|
+
# Echo what the report actually contains. Surfaced even under
|
|
480
|
+
# --yes/--json (the agent path), so a report built from a prompt —
|
|
481
|
+
# which yields predicate filters and pins no specific IDs — can be
|
|
482
|
+
# spotted before its link is shared.
|
|
483
|
+
summary = _summarize_report_contents(config)
|
|
484
|
+
data["report_contents"] = summary
|
|
485
|
+
|
|
407
486
|
if toon_output:
|
|
408
487
|
print(toon_encode(data))
|
|
409
488
|
elif json_output:
|
|
@@ -422,6 +501,8 @@ def create_report(
|
|
|
422
501
|
if usage:
|
|
423
502
|
err.print(f"\n [dim]{usage.get('credits_charged', 0)} credits · {usage.get('balance_remaining', '?')} remaining[/dim]")
|
|
424
503
|
|
|
504
|
+
_print_contents_summary(summary)
|
|
505
|
+
|
|
425
506
|
except ApiError as e:
|
|
426
507
|
handle_api_error(e)
|
|
427
508
|
finally:
|
|
@@ -541,23 +622,26 @@ def save_list_cmd(
|
|
|
541
622
|
finally:
|
|
542
623
|
client.close()
|
|
543
624
|
|
|
625
|
+
summary = _summarize_report_contents(config)
|
|
626
|
+
data["report_contents"] = summary
|
|
627
|
+
|
|
544
628
|
if fmt == "toon":
|
|
545
629
|
print(toon_encode(data))
|
|
546
|
-
|
|
547
|
-
if fmt == "json":
|
|
630
|
+
elif fmt == "json":
|
|
548
631
|
print(json.dumps(data, indent=2, default=str))
|
|
549
|
-
|
|
632
|
+
else:
|
|
633
|
+
results = data.get("results", [{}])
|
|
634
|
+
result = results[0] if results else {}
|
|
635
|
+
err.print()
|
|
636
|
+
err.print("[green bold]Report created![/green bold]")
|
|
637
|
+
err.print(f" Campaign ID: {result.get('campaign_id', '?')}")
|
|
638
|
+
err.print(f" URL: https://app.thoughtleaders.io{result.get('report_url', '')}")
|
|
639
|
+
err.print(
|
|
640
|
+
"\n[dim]Refine columns, widgets, title, or description with "
|
|
641
|
+
"`tl reports update <id>`.[/dim]"
|
|
642
|
+
)
|
|
550
643
|
|
|
551
|
-
|
|
552
|
-
result = results[0] if results else {}
|
|
553
|
-
err.print()
|
|
554
|
-
err.print("[green bold]Report created![/green bold]")
|
|
555
|
-
err.print(f" Campaign ID: {result.get('campaign_id', '?')}")
|
|
556
|
-
err.print(f" URL: https://app.thoughtleaders.io{result.get('report_url', '')}")
|
|
557
|
-
err.print(
|
|
558
|
-
"\n[dim]Refine columns, widgets, title, or description with "
|
|
559
|
-
"`tl reports update <id>`.[/dim]"
|
|
560
|
-
)
|
|
644
|
+
_print_contents_summary(summary)
|
|
561
645
|
|
|
562
646
|
|
|
563
647
|
@app.command("update")
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
"""Tests for PKCE and token storage."""
|
|
2
|
+
|
|
3
|
+
import httpx
|
|
4
|
+
from typer.testing import CliRunner
|
|
5
|
+
|
|
6
|
+
from tl_cli.auth import commands as auth_commands
|
|
7
|
+
from tl_cli.auth import login as auth_login
|
|
8
|
+
from tl_cli.auth.commands import app as auth_app
|
|
9
|
+
from tl_cli.auth.login import revoke_refresh_token
|
|
10
|
+
from tl_cli.auth.pkce import generate_pkce_pair
|
|
11
|
+
from tl_cli.auth.token_store import KIND_API_KEY, KIND_BEARER, StoredTokens
|
|
12
|
+
|
|
13
|
+
runner = CliRunner()
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class TestPKCE:
|
|
17
|
+
def test_generates_pair(self):
|
|
18
|
+
verifier, challenge = generate_pkce_pair()
|
|
19
|
+
assert len(verifier) > 40
|
|
20
|
+
assert len(challenge) > 20
|
|
21
|
+
assert verifier != challenge
|
|
22
|
+
|
|
23
|
+
def test_different_each_time(self):
|
|
24
|
+
v1, c1 = generate_pkce_pair()
|
|
25
|
+
v2, c2 = generate_pkce_pair()
|
|
26
|
+
assert v1 != v2
|
|
27
|
+
assert c1 != c2
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class TestStoredTokens:
|
|
31
|
+
def test_roundtrip_json(self):
|
|
32
|
+
tokens = StoredTokens(
|
|
33
|
+
access_token="abc",
|
|
34
|
+
refresh_token="def",
|
|
35
|
+
expires_at=9999999999.0,
|
|
36
|
+
email="test@example.com",
|
|
37
|
+
)
|
|
38
|
+
json_str = tokens.to_json()
|
|
39
|
+
restored = StoredTokens.from_json(json_str)
|
|
40
|
+
assert restored.access_token == "abc"
|
|
41
|
+
assert restored.refresh_token == "def"
|
|
42
|
+
assert restored.email == "test@example.com"
|
|
43
|
+
|
|
44
|
+
def test_is_expired(self):
|
|
45
|
+
tokens = StoredTokens(
|
|
46
|
+
access_token="abc", refresh_token=None, expires_at=0.0
|
|
47
|
+
)
|
|
48
|
+
assert tokens.is_expired
|
|
49
|
+
|
|
50
|
+
def test_not_expired(self):
|
|
51
|
+
tokens = StoredTokens(
|
|
52
|
+
access_token="abc", refresh_token=None, expires_at=9999999999.0
|
|
53
|
+
)
|
|
54
|
+
assert not tokens.is_expired
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
class TestStoredTokensKind:
|
|
58
|
+
def test_default_kind_is_bearer(self):
|
|
59
|
+
tokens = StoredTokens(access_token="x", refresh_token=None, expires_at=9e9)
|
|
60
|
+
assert tokens.kind == KIND_BEARER
|
|
61
|
+
assert not tokens.is_api_key
|
|
62
|
+
|
|
63
|
+
def test_api_key_never_expires(self):
|
|
64
|
+
tokens = StoredTokens(
|
|
65
|
+
access_token="k", refresh_token=None, expires_at=0.0, kind=KIND_API_KEY,
|
|
66
|
+
)
|
|
67
|
+
assert tokens.is_api_key
|
|
68
|
+
# 0.0 would mark a bearer token as expired; API keys ignore expiry.
|
|
69
|
+
assert not tokens.is_expired
|
|
70
|
+
|
|
71
|
+
def test_kind_roundtrips_through_json(self):
|
|
72
|
+
tokens = StoredTokens(
|
|
73
|
+
access_token="k", refresh_token=None, expires_at=0.0,
|
|
74
|
+
email="user@example.com", kind=KIND_API_KEY,
|
|
75
|
+
)
|
|
76
|
+
restored = StoredTokens.from_json(tokens.to_json())
|
|
77
|
+
assert restored.kind == KIND_API_KEY
|
|
78
|
+
assert restored.is_api_key
|
|
79
|
+
assert restored.email == "user@example.com"
|
|
80
|
+
|
|
81
|
+
def test_legacy_payload_without_kind_defaults_to_bearer(self):
|
|
82
|
+
# Pre-API-key clients wrote payloads with no `kind` field. Loading
|
|
83
|
+
# those must still produce a working bearer token.
|
|
84
|
+
legacy = '{"access_token": "x", "refresh_token": "y", "expires_at": 1.0, "email": "e"}'
|
|
85
|
+
restored = StoredTokens.from_json(legacy)
|
|
86
|
+
assert restored.kind == KIND_BEARER
|
|
87
|
+
assert not restored.is_api_key
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
class _FakeResponse:
|
|
91
|
+
def __init__(self, status_code: int) -> None:
|
|
92
|
+
self.status_code = status_code
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
class TestRevokeRefreshToken:
|
|
96
|
+
def test_returns_true_on_200(self, monkeypatch) -> None:
|
|
97
|
+
monkeypatch.setattr(auth_login.httpx, "post", lambda *a, **k: _FakeResponse(200))
|
|
98
|
+
assert revoke_refresh_token("rt") is True
|
|
99
|
+
|
|
100
|
+
def test_returns_false_on_non_200(self, monkeypatch) -> None:
|
|
101
|
+
monkeypatch.setattr(auth_login.httpx, "post", lambda *a, **k: _FakeResponse(400))
|
|
102
|
+
assert revoke_refresh_token("rt") is False
|
|
103
|
+
|
|
104
|
+
def test_swallows_network_error(self, monkeypatch) -> None:
|
|
105
|
+
def boom(*a, **k):
|
|
106
|
+
raise httpx.ConnectError("offline")
|
|
107
|
+
monkeypatch.setattr(auth_login.httpx, "post", boom)
|
|
108
|
+
# Must not raise — logout has to proceed offline.
|
|
109
|
+
assert revoke_refresh_token("rt") is False
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
class TestLogoutCommand:
|
|
113
|
+
def _patch(self, monkeypatch, tokens):
|
|
114
|
+
calls = {"revoked": None, "cleared": False}
|
|
115
|
+
monkeypatch.setattr(auth_commands, "load_tokens", lambda: tokens)
|
|
116
|
+
monkeypatch.setattr(auth_commands, "clear_tokens", lambda: calls.__setitem__("cleared", True))
|
|
117
|
+
monkeypatch.setattr(auth_commands, "revoke_refresh_token", lambda rt: calls.__setitem__("revoked", rt) or True)
|
|
118
|
+
return calls
|
|
119
|
+
|
|
120
|
+
def test_bearer_logout_revokes_then_clears(self, monkeypatch) -> None:
|
|
121
|
+
tokens = StoredTokens(access_token="a", refresh_token="rt", expires_at=None, email="e@x.com")
|
|
122
|
+
calls = self._patch(monkeypatch, tokens)
|
|
123
|
+
result = runner.invoke(auth_app, ["logout"])
|
|
124
|
+
assert result.exit_code == 0
|
|
125
|
+
assert calls["revoked"] == "rt" # revoked with the stored refresh token
|
|
126
|
+
assert calls["cleared"] is True # local tokens still cleared
|
|
127
|
+
# Points the user at Auth0's session-logout URL built from auth0_domain.
|
|
128
|
+
assert "/logout" in result.output
|
|
129
|
+
assert auth_commands.get_config().auth0_domain in result.output
|
|
130
|
+
|
|
131
|
+
def test_api_key_logout_skips_revoke(self, monkeypatch) -> None:
|
|
132
|
+
tokens = StoredTokens(access_token="k", refresh_token=None, expires_at=None, email=None, kind=KIND_API_KEY)
|
|
133
|
+
calls = self._patch(monkeypatch, tokens)
|
|
134
|
+
result = runner.invoke(auth_app, ["logout"])
|
|
135
|
+
assert result.exit_code == 0
|
|
136
|
+
assert calls["revoked"] is None # no refresh token → no Auth0 call
|
|
137
|
+
assert calls["cleared"] is True
|
|
138
|
+
assert "/logout" not in result.output # no browser session for API-key auth
|
|
139
|
+
|
|
140
|
+
def test_logged_out_already_just_clears(self, monkeypatch) -> None:
|
|
141
|
+
calls = self._patch(monkeypatch, None)
|
|
142
|
+
result = runner.invoke(auth_app, ["logout"])
|
|
143
|
+
assert result.exit_code == 0
|
|
144
|
+
assert calls["revoked"] is None
|
|
145
|
+
assert calls["cleared"] is True
|
|
@@ -7,11 +7,68 @@ import pytest
|
|
|
7
7
|
import typer
|
|
8
8
|
from typer.testing import CliRunner
|
|
9
9
|
|
|
10
|
-
from tl_cli.commands.reports import
|
|
10
|
+
from tl_cli.commands.reports import (
|
|
11
|
+
_parse_config_arg,
|
|
12
|
+
_render_contents_line,
|
|
13
|
+
_summarize_report_contents,
|
|
14
|
+
app,
|
|
15
|
+
)
|
|
11
16
|
|
|
12
17
|
runner = CliRunner()
|
|
13
18
|
|
|
14
19
|
|
|
20
|
+
# ---------------------------------------------------------------------------
|
|
21
|
+
# _summarize_report_contents / _render_contents_line
|
|
22
|
+
# ---------------------------------------------------------------------------
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class TestReportContentsSummary:
|
|
26
|
+
def test_filter_only_report_pins_nothing(self) -> None:
|
|
27
|
+
# The exact shape that caused the real bug: a sponsorships report the
|
|
28
|
+
# AI builder produced from a prompt — a generic format predicate, no
|
|
29
|
+
# pinned deals.
|
|
30
|
+
summary = _summarize_report_contents(
|
|
31
|
+
{"report_type": 8, "filterset": {"channel_formats": [4]}}
|
|
32
|
+
)
|
|
33
|
+
assert summary["report_type"] == "Sponsorships"
|
|
34
|
+
assert summary["pinned"] == {}
|
|
35
|
+
assert summary["filters"] == ["channel_formats"]
|
|
36
|
+
|
|
37
|
+
def test_pinned_ids_counted_per_entity(self) -> None:
|
|
38
|
+
summary = _summarize_report_contents(
|
|
39
|
+
{"report_type": 8, "filterset": {"sponsorships": [1, 2, 3, 4, 5]}}
|
|
40
|
+
)
|
|
41
|
+
assert summary["pinned"] == {"sponsorships": 5}
|
|
42
|
+
assert summary["filters"] == []
|
|
43
|
+
|
|
44
|
+
def test_blank_report_has_no_pins_and_no_filters(self) -> None:
|
|
45
|
+
summary = _summarize_report_contents({"report_type": 3, "filterset": {}})
|
|
46
|
+
assert summary["pinned"] == {}
|
|
47
|
+
assert summary["filters"] == []
|
|
48
|
+
line = _render_contents_line(summary)
|
|
49
|
+
assert "pinned: none" in line and "filters: none" in line
|
|
50
|
+
|
|
51
|
+
def test_structural_keys_are_not_counted_as_filters(self) -> None:
|
|
52
|
+
# keyword_operator / sort are structural, not user-facing predicates.
|
|
53
|
+
summary = _summarize_report_contents(
|
|
54
|
+
{"report_type": 3, "filterset": {"keyword_operator": "and", "sort": "x", "channels": [9]}}
|
|
55
|
+
)
|
|
56
|
+
assert summary["pinned"] == {"channels": 1}
|
|
57
|
+
assert summary["filters"] == []
|
|
58
|
+
|
|
59
|
+
def test_empty_and_falsey_filter_values_ignored(self) -> None:
|
|
60
|
+
summary = _summarize_report_contents(
|
|
61
|
+
{"report_type": 3, "filterset": {"languages": [], "is_offline": False, "reach_from": 1000}}
|
|
62
|
+
)
|
|
63
|
+
assert summary["filters"] == ["reach_from"]
|
|
64
|
+
|
|
65
|
+
def test_render_line_mentions_pins_and_filters(self) -> None:
|
|
66
|
+
line = _render_contents_line(
|
|
67
|
+
{"report_type": "Sponsorships", "pinned": {"sponsorships": 5}, "filters": ["channel_formats"]}
|
|
68
|
+
)
|
|
69
|
+
assert "5 sponsorships" in line and "channel_formats" in line
|
|
70
|
+
|
|
71
|
+
|
|
15
72
|
# ---------------------------------------------------------------------------
|
|
16
73
|
# _parse_config_arg
|
|
17
74
|
# ---------------------------------------------------------------------------
|
|
@@ -1,88 +0,0 @@
|
|
|
1
|
-
"""Server-side signup finalize, called once per login.
|
|
2
|
-
|
|
3
|
-
After Auth0 returns a valid token the CLI asks the server whether an
|
|
4
|
-
account exists for the email. If yes, this is a no-op. If no, the CLI
|
|
5
|
-
prompts for a persona (Media Buyer or Creator) and POSTs it back; the
|
|
6
|
-
server creates the User, Organization, Profile and CreditAccount.
|
|
7
|
-
|
|
8
|
-
Errors here never abort login — the user can always retry the prompt
|
|
9
|
-
on the next call. We do print a clear message so it's obvious whether
|
|
10
|
-
the account is fully set up.
|
|
11
|
-
"""
|
|
12
|
-
|
|
13
|
-
from __future__ import annotations
|
|
14
|
-
|
|
15
|
-
import typer
|
|
16
|
-
from rich.console import Console
|
|
17
|
-
from rich.prompt import Prompt
|
|
18
|
-
|
|
19
|
-
from tl_cli.client.errors import ApiError
|
|
20
|
-
from tl_cli.client.http import get_client
|
|
21
|
-
|
|
22
|
-
console = Console(stderr=True)
|
|
23
|
-
|
|
24
|
-
PERSONA_LABEL_TO_KEY = {
|
|
25
|
-
"Media Buyer": "media_buyer",
|
|
26
|
-
"Creator": "creator",
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
def finalize_signup() -> None:
|
|
31
|
-
"""POST /auth/finalize, prompting for persona if the server asks for one."""
|
|
32
|
-
client = get_client()
|
|
33
|
-
try:
|
|
34
|
-
# First call: no body. Server tells us whether persona is required.
|
|
35
|
-
try:
|
|
36
|
-
result = client.post("/auth/finalize", {})
|
|
37
|
-
except ApiError as exc:
|
|
38
|
-
if exc.status_code == 400 and isinstance(exc.raw, dict) and exc.raw.get("code") == "persona_required":
|
|
39
|
-
result = _prompt_and_finalize(client, exc.raw.get("allowed_personas") or [])
|
|
40
|
-
elif exc.status_code == 404:
|
|
41
|
-
# Server predates this endpoint — silently skip; legacy
|
|
42
|
-
# accounts already exist and don't need provisioning.
|
|
43
|
-
return
|
|
44
|
-
else:
|
|
45
|
-
console.print(f"[yellow]Could not finalize signup: {exc.detail}[/yellow]")
|
|
46
|
-
return
|
|
47
|
-
|
|
48
|
-
if result.get("created"):
|
|
49
|
-
org = result.get("organization", {})
|
|
50
|
-
console.print(
|
|
51
|
-
f"[green]Account created for {org.get('name', 'your organization')}.[/green] "
|
|
52
|
-
"Run [bold]tl balance[/bold] to see your starter credits."
|
|
53
|
-
)
|
|
54
|
-
finally:
|
|
55
|
-
client.close()
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
def _prompt_and_finalize(client, allowed: list[str]) -> dict:
|
|
59
|
-
"""Prompt the user for a persona, then retry /auth/finalize."""
|
|
60
|
-
console.print()
|
|
61
|
-
console.print("[bold]Welcome to ThoughtLeaders![/bold] We need one more detail to set up your account.")
|
|
62
|
-
console.print(" [cyan]1[/cyan] — Media Buyer (brands and agencies buying sponsorships)")
|
|
63
|
-
console.print(" [cyan]2[/cyan] — Creator (channels selling sponsorships)")
|
|
64
|
-
|
|
65
|
-
persona_key: str | None = None
|
|
66
|
-
while persona_key is None:
|
|
67
|
-
choice = Prompt.ask("I am a", choices=["1", "2"], default="1", console=console)
|
|
68
|
-
candidate = "media_buyer" if choice == "1" else "creator"
|
|
69
|
-
if allowed and candidate not in allowed:
|
|
70
|
-
console.print(f"[yellow]Server rejects persona '{candidate}'. Allowed: {', '.join(allowed)}.[/yellow]")
|
|
71
|
-
continue
|
|
72
|
-
persona_key = candidate
|
|
73
|
-
|
|
74
|
-
org_name = Prompt.ask(
|
|
75
|
-
"Organization name (optional, leave blank to use your email)",
|
|
76
|
-
default="",
|
|
77
|
-
console=console,
|
|
78
|
-
).strip()
|
|
79
|
-
|
|
80
|
-
body = {"persona": persona_key}
|
|
81
|
-
if org_name:
|
|
82
|
-
body["organization_name"] = org_name
|
|
83
|
-
|
|
84
|
-
try:
|
|
85
|
-
return client.post("/auth/finalize", body)
|
|
86
|
-
except ApiError as exc:
|
|
87
|
-
console.print(f"[red]Signup failed:[/red] {exc.detail}")
|
|
88
|
-
raise typer.Exit(1)
|
|
@@ -1,78 +0,0 @@
|
|
|
1
|
-
"""Tests for PKCE and token storage."""
|
|
2
|
-
|
|
3
|
-
from tl_cli.auth.pkce import generate_pkce_pair
|
|
4
|
-
from tl_cli.auth.token_store import KIND_API_KEY, KIND_BEARER, StoredTokens
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
class TestPKCE:
|
|
8
|
-
def test_generates_pair(self):
|
|
9
|
-
verifier, challenge = generate_pkce_pair()
|
|
10
|
-
assert len(verifier) > 40
|
|
11
|
-
assert len(challenge) > 20
|
|
12
|
-
assert verifier != challenge
|
|
13
|
-
|
|
14
|
-
def test_different_each_time(self):
|
|
15
|
-
v1, c1 = generate_pkce_pair()
|
|
16
|
-
v2, c2 = generate_pkce_pair()
|
|
17
|
-
assert v1 != v2
|
|
18
|
-
assert c1 != c2
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
class TestStoredTokens:
|
|
22
|
-
def test_roundtrip_json(self):
|
|
23
|
-
tokens = StoredTokens(
|
|
24
|
-
access_token="abc",
|
|
25
|
-
refresh_token="def",
|
|
26
|
-
expires_at=9999999999.0,
|
|
27
|
-
email="test@example.com",
|
|
28
|
-
)
|
|
29
|
-
json_str = tokens.to_json()
|
|
30
|
-
restored = StoredTokens.from_json(json_str)
|
|
31
|
-
assert restored.access_token == "abc"
|
|
32
|
-
assert restored.refresh_token == "def"
|
|
33
|
-
assert restored.email == "test@example.com"
|
|
34
|
-
|
|
35
|
-
def test_is_expired(self):
|
|
36
|
-
tokens = StoredTokens(
|
|
37
|
-
access_token="abc", refresh_token=None, expires_at=0.0
|
|
38
|
-
)
|
|
39
|
-
assert tokens.is_expired
|
|
40
|
-
|
|
41
|
-
def test_not_expired(self):
|
|
42
|
-
tokens = StoredTokens(
|
|
43
|
-
access_token="abc", refresh_token=None, expires_at=9999999999.0
|
|
44
|
-
)
|
|
45
|
-
assert not tokens.is_expired
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
class TestStoredTokensKind:
|
|
49
|
-
def test_default_kind_is_bearer(self):
|
|
50
|
-
tokens = StoredTokens(access_token="x", refresh_token=None, expires_at=9e9)
|
|
51
|
-
assert tokens.kind == KIND_BEARER
|
|
52
|
-
assert not tokens.is_api_key
|
|
53
|
-
|
|
54
|
-
def test_api_key_never_expires(self):
|
|
55
|
-
tokens = StoredTokens(
|
|
56
|
-
access_token="k", refresh_token=None, expires_at=0.0, kind=KIND_API_KEY,
|
|
57
|
-
)
|
|
58
|
-
assert tokens.is_api_key
|
|
59
|
-
# 0.0 would mark a bearer token as expired; API keys ignore expiry.
|
|
60
|
-
assert not tokens.is_expired
|
|
61
|
-
|
|
62
|
-
def test_kind_roundtrips_through_json(self):
|
|
63
|
-
tokens = StoredTokens(
|
|
64
|
-
access_token="k", refresh_token=None, expires_at=0.0,
|
|
65
|
-
email="user@example.com", kind=KIND_API_KEY,
|
|
66
|
-
)
|
|
67
|
-
restored = StoredTokens.from_json(tokens.to_json())
|
|
68
|
-
assert restored.kind == KIND_API_KEY
|
|
69
|
-
assert restored.is_api_key
|
|
70
|
-
assert restored.email == "user@example.com"
|
|
71
|
-
|
|
72
|
-
def test_legacy_payload_without_kind_defaults_to_bearer(self):
|
|
73
|
-
# Pre-API-key clients wrote payloads with no `kind` field. Loading
|
|
74
|
-
# those must still produce a working bearer token.
|
|
75
|
-
legacy = '{"access_token": "x", "refresh_token": "y", "expires_at": 1.0, "email": "e"}'
|
|
76
|
-
restored = StoredTokens.from_json(legacy)
|
|
77
|
-
assert restored.kind == KIND_BEARER
|
|
78
|
-
assert not restored.is_api_key
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{thoughtleaders_cli-0.7.3 → thoughtleaders_cli-0.7.5}/skills/tl/references/business-glossary.md
RENAMED
|
File without changes
|
{thoughtleaders_cli-0.7.3 → thoughtleaders_cli-0.7.5}/skills/tl/references/elasticsearch-schema.md
RENAMED
|
File without changes
|
{thoughtleaders_cli-0.7.3 → thoughtleaders_cli-0.7.5}/skills/tl/references/firebolt-schema.md
RENAMED
|
File without changes
|
{thoughtleaders_cli-0.7.3 → thoughtleaders_cli-0.7.5}/skills/tl/references/postgres-schema.md
RENAMED
|
File without changes
|
{thoughtleaders_cli-0.7.3 → thoughtleaders_cli-0.7.5}/skills/tl-channel-authenticity/.gitignore
RENAMED
|
File without changes
|
{thoughtleaders_cli-0.7.3 → thoughtleaders_cli-0.7.5}/skills/tl-channel-authenticity/SKILL.md
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{thoughtleaders_cli-0.7.3 → thoughtleaders_cli-0.7.5}/skills/tl-keyword-research/scripts/probe.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{thoughtleaders_cli-0.7.3 → thoughtleaders_cli-0.7.5}/skills/tl-report-builder/references/widgets.md
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{thoughtleaders_cli-0.7.3 → thoughtleaders_cli-0.7.5}/skills/tl-report-builder/tools/sample_judge.md
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{thoughtleaders_cli-0.7.3 → thoughtleaders_cli-0.7.5}/skills/tl-save-report/references/widgets.md
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{thoughtleaders_cli-0.7.3 → thoughtleaders_cli-0.7.5}/skills/tl-views-guarantee/scripts/vg.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{thoughtleaders_cli-0.7.3 → thoughtleaders_cli-0.7.5}/src/tl_cli/commands/_comments_common.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|