thoughtleaders-cli 0.5.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (59) hide show
  1. thoughtleaders_cli-0.5.0.dist-info/METADATA +215 -0
  2. thoughtleaders_cli-0.5.0.dist-info/RECORD +59 -0
  3. thoughtleaders_cli-0.5.0.dist-info/WHEEL +4 -0
  4. thoughtleaders_cli-0.5.0.dist-info/entry_points.txt +2 -0
  5. thoughtleaders_cli-0.5.0.dist-info/licenses/LICENSE +21 -0
  6. tl_cli/__init__.py +3 -0
  7. tl_cli/_completions.py +4 -0
  8. tl_cli/_plugin/.claude-plugin/marketplace.json +17 -0
  9. tl_cli/_plugin/.claude-plugin/plugin.json +12 -0
  10. tl_cli/_plugin/agents/tl-analyst.md +66 -0
  11. tl_cli/_plugin/commands/tl-balance.md +10 -0
  12. tl_cli/_plugin/commands/tl-brands.md +16 -0
  13. tl_cli/_plugin/commands/tl-channels.md +31 -0
  14. tl_cli/_plugin/commands/tl-reports.md +16 -0
  15. tl_cli/_plugin/commands/tl-sponsorships.md +23 -0
  16. tl_cli/_plugin/commands/tl.md +28 -0
  17. tl_cli/_plugin/hooks/hooks.json +26 -0
  18. tl_cli/_plugin/hooks/scripts/post-usage.sh +26 -0
  19. tl_cli/_plugin/hooks/scripts/pre-check.sh +30 -0
  20. tl_cli/_plugin/skills/tl/SKILL.md +413 -0
  21. tl_cli/_plugin/skills/tl/references/business-glossary.md +159 -0
  22. tl_cli/_plugin/skills/tl/references/elasticsearch-schema.md +259 -0
  23. tl_cli/_plugin/skills/tl/references/firebolt-schema.md +208 -0
  24. tl_cli/_plugin/skills/tl/references/postgres-schema.md +269 -0
  25. tl_cli/auth/__init__.py +0 -0
  26. tl_cli/auth/commands.py +49 -0
  27. tl_cli/auth/login.py +328 -0
  28. tl_cli/auth/pkce.py +21 -0
  29. tl_cli/auth/token_store.py +98 -0
  30. tl_cli/client/__init__.py +0 -0
  31. tl_cli/client/errors.py +72 -0
  32. tl_cli/client/http.py +109 -0
  33. tl_cli/commands/__init__.py +0 -0
  34. tl_cli/commands/ask.py +54 -0
  35. tl_cli/commands/balance.py +68 -0
  36. tl_cli/commands/brands.py +174 -0
  37. tl_cli/commands/changelog.py +119 -0
  38. tl_cli/commands/channels.py +291 -0
  39. tl_cli/commands/comments.py +63 -0
  40. tl_cli/commands/db.py +104 -0
  41. tl_cli/commands/deals.py +52 -0
  42. tl_cli/commands/describe.py +166 -0
  43. tl_cli/commands/doctor.py +70 -0
  44. tl_cli/commands/matches.py +69 -0
  45. tl_cli/commands/proposals.py +69 -0
  46. tl_cli/commands/reports.py +346 -0
  47. tl_cli/commands/schema.py +55 -0
  48. tl_cli/commands/setup.py +401 -0
  49. tl_cli/commands/snapshots.py +93 -0
  50. tl_cli/commands/sponsorships.py +193 -0
  51. tl_cli/commands/uploads.py +84 -0
  52. tl_cli/commands/whoami.py +206 -0
  53. tl_cli/config.py +55 -0
  54. tl_cli/filters.py +88 -0
  55. tl_cli/hints.py +53 -0
  56. tl_cli/main.py +209 -0
  57. tl_cli/output/__init__.py +0 -0
  58. tl_cli/output/formatter.py +436 -0
  59. tl_cli/self_update.py +173 -0
@@ -0,0 +1,269 @@
1
+ # ThoughtLeaders PostgreSQL Schema Reference
2
+
3
+ ## How to query
4
+
5
+ ```bash
6
+ tl db pg "SELECT id, weighted_price FROM thoughtleaders_adlink
7
+ WHERE publish_status = 2
8
+ LIMIT 50 OFFSET 0"
9
+ ```
10
+
11
+ `tl schema pg` prints the live table/column listing visible to your user.
12
+
13
+ Accepted SQL:
14
+ - **SELECT only**, single statement. No DDL/DML/transactions/SET/COPY/MERGE.
15
+ - Functions accepted from an explicit list (aggregates, window, string, JSON, math, date-time, array). Catalog-resolving casts (`::regclass`, `::regprocedure`, …) are not accepted.
16
+ - `LIMIT` and `OFFSET` are optional. Omit them and the server fills in `LIMIT 50 OFFSET 0`. Explicit `LIMIT` must be an integer literal ≤ 500. Explicit `OFFSET` ≥ 10,000 is rejected with HTTP 403 (`OFFSET_TOO_DEEP`); paginate with the response's `next_offset`/breadcrumbs instead of jumping deep.
17
+ - SQL ≤ 50,000 chars; AST depth ≤ 64; node count ≤ 5,000.
18
+
19
+ ## Core Tables
20
+
21
+ ### `thoughtleaders_adlink` (Deals/Sponsorships)
22
+
23
+ The main deals table. Each row = one sponsorship deal between a brand and a YouTube channel. Also called "AdLink" in code, exposed as **sponsorship** in the CLI.
24
+
25
+ > 🚨 **Columns that DO NOT exist on `thoughtleaders_adlink` — common hallucinations:**
26
+ > - ❌ `brand_id` — there is NO direct brand FK. Brand is reached via `creator_profile_id → profile → profile_brands → brand`.
27
+ > - ❌ `organization_id` — there is NO direct org FK. Org is reached via `creator_profile_id → profile.organization_id → organization`.
28
+ > - ❌ `channel_id` — channel is reached via `ad_spot_id → adspot.channel_id → channel`.
29
+ > - ❌ `youtube_id` (on channel) — use `external_channel_id`.
30
+
31
+ #### Key Columns
32
+
33
+ | Column | Type | Description |
34
+ |--------|------|-------------|
35
+ | `id` | int | Primary key |
36
+ | `created_at` | timestamptz | When the deal was created |
37
+ | `updated_at` | timestamptz | Last modification |
38
+ | `publish_status` | int | Deal status (see constants below) |
39
+ | `price` | numeric | Deal price (USD) |
40
+ | `price_currency` | varchar | Always USD |
41
+ | `weighted_price` | numeric | `price * (status_weight/100)`, pre-calculated on save |
42
+ | `weighted_price_currency` | varchar | Always USD |
43
+ | `cost` | numeric | Cost to TL |
44
+ | `ad_spot_id` | int FK | → `thoughtleaders_adspot.id` |
45
+ | `creator_profile_id` | int FK | → brand/advertiser profile |
46
+ | `owner_advertiser_id` | int FK | → `auth_user.id` (brand-side owner) |
47
+ | `owner_publisher_id` | int FK | → `auth_user.id` (channel-side owner) |
48
+ | `owner_sales_id` | int FK | → `auth_user.id` (sales rep) |
49
+ | `send_date` | timestamptz | Scheduled send/publish date |
50
+ | `publish_date` | timestamptz | Actual publish date |
51
+ | `outreach_date` | timestamptz | When outreach was sent |
52
+ | `purchase_date` | timestamptz | When deal was purchased/sold |
53
+ | `presented_date` | timestamptz | When presented to brand |
54
+ | `rejected_date` | timestamptz | When rejected |
55
+ | `proposal_approved_date` | timestamptz | When proposal was approved |
56
+ | `draft_expected_date` | date | Expected draft delivery |
57
+ | `actual_end_date` | timestamptz | Actual end date |
58
+ | `scheduled_end_date` | timestamptz | Scheduled end date |
59
+ | `rejection_reason` | int | Rejection reason code |
60
+ | `rejection_reason_details` | text | Free-text rejection details |
61
+ | `payment_status` | int | 0=Unpaid, 1=Paid |
62
+ | `performance_grade` | int | Performance rating (see business-glossary) |
63
+ | `article_id` | varchar | Compound `<channel_id>:<youtube_id>` — links to ES `_id` and ES `id` field |
64
+ | `dashboard_campaign_id` | int FK | Campaign grouping |
65
+ | `created_where` | varchar | Where the deal originated |
66
+ | `tx_data` | jsonb | Transaction metadata |
67
+
68
+ #### `publish_status` Constants
69
+
70
+ | Value | Constant | Label | Pipeline Weight |
71
+ |-------|----------|-------|----------------|
72
+ | 0 | PREVIEW | Proposed | 10% |
73
+ | 1 | UNAVAILABLE | Unavailable | — |
74
+ | 2 | PENDING | Pending | 70% |
75
+ | 3 | SOLD | Sold | — |
76
+ | 4 | DENY | Rejected by Advertiser | 0% |
77
+ | 5 | REJECT | Rejected by Publisher | 0% |
78
+ | 6 | PROPOSAL_APPROVED | Proposal Approved | 25% |
79
+ | 7 | MATCHED | Matched (default) | 1% |
80
+ | 8 | OUTREACH | Reached Out | 5% |
81
+ | 9 | REJECTED_AGENCY | Rejected by Agency | 0% |
82
+ | -1 | CLIENT_SIDE_AVAILABLE | Client Side Available | — |
83
+ | -2 | CLIENT_SIDE_TAKEN | Client Side Taken | — |
84
+
85
+ #### Pipeline Stages
86
+
87
+ - **Active pipeline** = statuses with weight > 0: 0, 2, 6, 7, 8.
88
+ - **Won** = 3 (Sold).
89
+ - **Lost** = 4, 5, 9.
90
+
91
+ ### `thoughtleaders_brand`
92
+
93
+ | Column | Type | Description |
94
+ |--------|------|-------------|
95
+ | `id` | int | Primary key |
96
+ | `name` | varchar | Brand name |
97
+ | `description` | text | Brand description |
98
+ | `creator_id` | int FK | User who created it |
99
+
100
+ #### Junction Tables
101
+
102
+ | Table | Columns | Purpose |
103
+ |-------|---------|---------|
104
+ | `thoughtleaders_profile_brands` | `profile_id`, `brand_id` | Profile↔Brand M2M (Django field `profile.brands`). In practice each profile has one brand attached. |
105
+ | `thoughtleaders_brand_brands` | `from_brand_id`, `to_brand_id` | Self-referential: related brands. |
106
+
107
+ ### `thoughtleaders_adspot` (Ad Catalogue)
108
+
109
+ Buyable ad placements. Each adspot links a channel to a seller. Price/cost here are **list prices** — actual deal values live on the adlink.
110
+
111
+ A channel can have multiple adspots (different sellers: talent manager, direct, multiple agencies).
112
+
113
+ | Column | Type | Description |
114
+ |--------|------|-------------|
115
+ | `id` | int | Primary key |
116
+ | `channel_id` | int FK | → `thoughtleaders_channel.id` |
117
+ | `price` | numeric | List/catalogue price |
118
+ | `cost` | numeric | List/catalogue cost |
119
+ | `integration` | int | 1=YouTube Mentions (live reads). Only one active mention-type adspot per channel. |
120
+ | `is_active` | boolean | Active flag |
121
+ | `publisher_id` | int FK | → `auth_user.id` (NOT `thoughtleaders_profile.id` — see gotcha below) |
122
+
123
+ ### `thoughtleaders_channel` (YouTube Channels)
124
+
125
+ | Column | Type | Description |
126
+ |--------|------|-------------|
127
+ | `id` | int | Primary key |
128
+ | `channel_name` | varchar | Display name |
129
+ | `external_channel_id` | varchar | YouTube channel ID (`UCxxxxxx`). ⚠️ There is NO `youtube_id` column. |
130
+ | `url` | varchar | Channel URL |
131
+ | `media_selling_network_join_date` | date/timestamptz | When channel joined MSN |
132
+ | `is_tl_channel` | boolean | True = TPP/VIP channel |
133
+ | `evergreenness` | float | Cached evergreen score |
134
+
135
+ ### `auth_user` (Django Users)
136
+
137
+ Standard Django user table. Used for owner lookups.
138
+
139
+ | Column | Type | Description |
140
+ |--------|------|-------------|
141
+ | `id` | int | Primary key |
142
+ | `first_name` | varchar | First name |
143
+ | `last_name` | varchar | Last name |
144
+ | `email` | varchar | Email |
145
+
146
+ ## Top Tables by Row Count
147
+
148
+ | Rows | Table | Purpose |
149
+ |------|-------|---------|
150
+ | 1.3M | `thoughtleaders_channel` | YouTube channels |
151
+ | 1.2M | `thoughtleaders_historicaladlink` | Audit trail for adlink changes |
152
+ | 150K | `thoughtleaders_adlink` | Deals/sponsorships |
153
+ | 43K | `thoughtleaders_adspot` | Ad placements |
154
+ | 20K | `auth_user` | Users (team + external) |
155
+ | 20K | `thoughtleaders_profile` | User profiles |
156
+ | 19K | `thoughtleaders_organization` | Organizations |
157
+ | 19K | `dashboard_campaign` | Campaign groupings |
158
+ | 13K | `thoughtleaders_dailymetric` | Daily performance metrics |
159
+ | 12K | `thoughtleaders_leads` | Sales leads |
160
+
161
+ ## Key Relationships
162
+
163
+ ```
164
+ thoughtleaders_adlink
165
+ ├── ad_spot_id → thoughtleaders_adspot.id
166
+ │ └── channel_id → thoughtleaders_channel.id
167
+ ├── owner_advertiser_id → auth_user.id
168
+ ├── owner_publisher_id → auth_user.id
169
+ ├── owner_sales_id → auth_user.id
170
+ └── creator_profile_id → thoughtleaders_profile.id
171
+ ├── organization_id → thoughtleaders_organization.id
172
+ └── profile_brands.profile_id → brand.id
173
+
174
+ ⚠️ thoughtleaders_adlink has NO direct brand_id, organization_id, or channel_id column.
175
+ ⚠️ thoughtleaders_brand has NO organization_id column — org lives on profile.
176
+ ```
177
+
178
+ ### Common Join Paths
179
+
180
+ **Adlink → Channel name:**
181
+ ```sql
182
+ JOIN thoughtleaders_adspot s ON a.ad_spot_id = s.id
183
+ JOIN thoughtleaders_channel ch ON s.channel_id = ch.id
184
+ ```
185
+
186
+ **Adlink → Brand name:**
187
+ ```sql
188
+ JOIN thoughtleaders_profile p ON a.creator_profile_id = p.id
189
+ JOIN thoughtleaders_profile_brands pb ON p.id = pb.profile_id
190
+ JOIN thoughtleaders_brand b ON pb.brand_id = b.id
191
+ -- NEVER: JOIN brand b ON b.id = a.creator_profile_id (different ID spaces, returns wrong data)
192
+ ```
193
+
194
+ **Adlink → Organization:**
195
+ ```sql
196
+ JOIN thoughtleaders_profile p ON a.creator_profile_id = p.id
197
+ JOIN thoughtleaders_organization o ON p.organization_id = o.id
198
+ ```
199
+
200
+ 🚨 **`adspot.publisher_id` is a FK to `auth_user`, not `profile`.** To get the publisher's profile, join through user:
201
+ ```sql
202
+ JOIN auth_user au ON au.id = adspot.publisher_id
203
+ JOIN thoughtleaders_profile p ON p.user_id = adspot.publisher_id
204
+ ```
205
+ Joining `adspot.publisher_id → profile.id` directly mixes ID spaces and returns garbage.
206
+
207
+ ## `thoughtleaders_profile` persona constants
208
+
209
+ | Value | Label |
210
+ |-------|-------|
211
+ | 1 | Direct Brand |
212
+ | 2 | Creator |
213
+ | 3 | Talent Manager |
214
+ | 4 | Media Agency |
215
+ | 5 | Creator Service |
216
+
217
+ ## `thoughtleaders_profile_channels` (Profile ↔ Channel M2M)
218
+
219
+ | Column | Type |
220
+ |--------|------|
221
+ | `id` | int PK |
222
+ | `profile_id` | int FK |
223
+ | `channel_id` | int FK |
224
+
225
+ Note: separate from the adspot publisher relationship. Not always in sync.
226
+
227
+ ## Example queries
228
+
229
+ **Total weighted pipeline by sales rep:**
230
+ ```sql
231
+ SELECT owner_sales_id, SUM(weighted_price) AS pipeline
232
+ FROM thoughtleaders_adlink
233
+ WHERE publish_status IN (0, 2, 6, 7, 8)
234
+ GROUP BY owner_sales_id
235
+ ORDER BY pipeline DESC
236
+ LIMIT 100 OFFSET 0
237
+ ```
238
+
239
+ **Sold deals this month:**
240
+ ```sql
241
+ SELECT id, price, purchase_date, ad_spot_id, creator_profile_id
242
+ FROM thoughtleaders_adlink
243
+ WHERE publish_status = 3
244
+ AND purchase_date >= date_trunc('month', CURRENT_DATE)
245
+ ORDER BY purchase_date DESC
246
+ LIMIT 500 OFFSET 0
247
+ ```
248
+
249
+ **MSN channel joins this month:**
250
+ ```sql
251
+ SELECT id, channel_name, media_selling_network_join_date
252
+ FROM thoughtleaders_channel
253
+ WHERE media_selling_network_join_date >= date_trunc('month', CURRENT_DATE)
254
+ ORDER BY media_selling_network_join_date DESC
255
+ LIMIT 500 OFFSET 0
256
+ ```
257
+
258
+ **Deal with brand and channel name:**
259
+ ```sql
260
+ SELECT a.id, a.price, a.publish_status, b.name AS brand, ch.channel_name
261
+ FROM thoughtleaders_adlink a
262
+ JOIN thoughtleaders_adspot s ON a.ad_spot_id = s.id
263
+ JOIN thoughtleaders_channel ch ON s.channel_id = ch.id
264
+ JOIN thoughtleaders_profile p ON a.creator_profile_id = p.id
265
+ JOIN thoughtleaders_profile_brands pb ON p.id = pb.profile_id
266
+ JOIN thoughtleaders_brand b ON pb.brand_id = b.id
267
+ WHERE a.id = 12345
268
+ LIMIT 1 OFFSET 0
269
+ ```
File without changes
@@ -0,0 +1,49 @@
1
+ """Auth CLI commands: tl auth login/logout/status."""
2
+
3
+ import typer
4
+ from rich.console import Console
5
+ from rich.prompt import Prompt
6
+
7
+ from tl_cli.auth.login import login_browser, login_device_code
8
+ from tl_cli.auth.token_store import clear_tokens, load_tokens
9
+
10
+ app = typer.Typer(help="Authentication commands")
11
+ console = Console(stderr=True)
12
+
13
+
14
+ @app.command("login", help="Log in to ThoughtLeaders.")
15
+ def login_cmd() -> None:
16
+ """Log in to ThoughtLeaders."""
17
+ console.print("[bold]How would you like to authenticate?[/bold]")
18
+ console.print(" [cyan]1[/cyan] — Browser on this machine (default)")
19
+ console.print(" [cyan]2[/cyan] — Device code (use a browser on another device)")
20
+ console.print()
21
+ choice = Prompt.ask("Choose", choices=["1", "2"], default="1", console=console)
22
+
23
+ if choice == "2":
24
+ login_device_code()
25
+ else:
26
+ login_browser()
27
+
28
+
29
+ @app.command("logout")
30
+ def logout_cmd() -> None:
31
+ """Clear stored authentication tokens."""
32
+ clear_tokens()
33
+ console.print("[green]Logged out successfully.[/green]")
34
+
35
+
36
+ @app.command("status")
37
+ def status_cmd() -> None:
38
+ """Show current authentication status."""
39
+ tokens = load_tokens()
40
+ if not tokens:
41
+ console.print("[yellow]Not logged in.[/yellow] Run: tl auth login")
42
+ raise SystemExit(2)
43
+
44
+ if tokens.is_expired:
45
+ console.print(f"[yellow]Token expired.[/yellow] Logged in as: {tokens.email or 'unknown'}")
46
+ console.print("Run: tl auth login")
47
+ raise SystemExit(2)
48
+
49
+ console.print(f"[green]Authenticated[/green] as: {tokens.email or 'unknown'}")
tl_cli/auth/login.py ADDED
@@ -0,0 +1,328 @@
1
+ """Auth0 login flows: browser-based PKCE and headless device code."""
2
+
3
+ import http.server
4
+ import secrets
5
+ import threading
6
+ import time
7
+ import urllib.parse
8
+ import webbrowser
9
+ from dataclasses import dataclass
10
+
11
+ import httpx
12
+ from rich.console import Console
13
+
14
+ from tl_cli.auth.pkce import generate_pkce_pair
15
+ from tl_cli.auth.token_store import StoredTokens, save_tokens
16
+ from tl_cli.config import get_config
17
+
18
+ console = Console(stderr=True)
19
+
20
+
21
+ @dataclass
22
+ class _CallbackResult:
23
+ """Captured from the OAuth callback."""
24
+
25
+ code: str | None = None
26
+ error: str | None = None
27
+ state: str | None = None
28
+
29
+
30
+ def login_browser() -> StoredTokens:
31
+ """Run the Auth0 PKCE login flow with a local browser.
32
+
33
+ 1. Generate PKCE pair + state
34
+ 2. Start localhost callback server
35
+ 3. Open browser to Auth0 /authorize
36
+ 4. Wait for callback with authorization code
37
+ 5. Exchange code for tokens
38
+ 6. Store tokens
39
+ """
40
+ config = get_config()
41
+ code_verifier, code_challenge = generate_pkce_pair()
42
+ state = secrets.token_urlsafe(32)
43
+ result = _CallbackResult()
44
+
45
+ # Start callback server on the fixed port (must match Auth0 allowed callback URLs)
46
+ from tl_cli.config import DEFAULT_AUTH0_CALLBACK_PORT
47
+ server, port = _start_callback_server(result, state, DEFAULT_AUTH0_CALLBACK_PORT)
48
+
49
+ redirect_uri = f"http://localhost:{port}/callback"
50
+
51
+ # Build authorization URL
52
+ params = {
53
+ "response_type": "code",
54
+ "client_id": config.auth0_client_id,
55
+ "redirect_uri": redirect_uri,
56
+ "audience": config.auth0_audience,
57
+ "scope": "openid profile email offline_access",
58
+ "code_challenge": code_challenge,
59
+ "code_challenge_method": "S256",
60
+ "state": state,
61
+ }
62
+ auth_url = f"https://{config.auth0_domain}/authorize?{urllib.parse.urlencode(params)}"
63
+
64
+ console.print("[bold]Opening browser for login...[/bold]")
65
+ console.print(f"[dim]If the browser doesn't open, visit:[/dim]\n{auth_url}\n")
66
+ webbrowser.open(auth_url)
67
+
68
+ # Wait for callback (timeout after 120 seconds)
69
+ deadline = time.time() + 120
70
+ while result.code is None and result.error is None:
71
+ if time.time() > deadline:
72
+ server.shutdown()
73
+ console.print("[red]Login timed out. Please try again.[/red]")
74
+ raise SystemExit(1)
75
+ time.sleep(0.1)
76
+
77
+ server.shutdown()
78
+
79
+ if result.error:
80
+ console.print(f"[red]Login failed: {result.error}[/red]")
81
+ raise SystemExit(1)
82
+
83
+ # Exchange code for tokens
84
+ console.print("[dim]Exchanging authorization code...[/dim]")
85
+ tokens = _exchange_code(
86
+ code=result.code,
87
+ code_verifier=code_verifier,
88
+ redirect_uri=redirect_uri,
89
+ config=config,
90
+ )
91
+
92
+ save_tokens(tokens)
93
+ console.print(f"[green]Logged in as {tokens.email or 'unknown'}[/green]")
94
+ return tokens
95
+
96
+
97
+ def login_device_code() -> StoredTokens:
98
+ """Run the Auth0 Device Authorization Flow (RFC 8628).
99
+
100
+ Works on headless machines — the user authenticates via any browser on any device.
101
+ """
102
+ config = get_config()
103
+
104
+ # Request a device code
105
+ response = httpx.post(
106
+ f"https://{config.auth0_domain}/oauth/device/code",
107
+ data={
108
+ "client_id": config.auth0_client_id,
109
+ "scope": "openid profile email offline_access",
110
+ "audience": config.auth0_audience,
111
+ },
112
+ )
113
+
114
+ if response.status_code != 200:
115
+ console.print(f"[red]Failed to start device login: {response.text}[/red]")
116
+ raise SystemExit(1)
117
+
118
+ data = response.json()
119
+ device_code = data["device_code"]
120
+ user_code = data["user_code"]
121
+ verification_uri = data["verification_uri"]
122
+ verification_uri_complete = data.get("verification_uri_complete", verification_uri)
123
+ interval = data.get("interval", 5)
124
+ expires_in = data.get("expires_in", 900)
125
+
126
+ console.print()
127
+ console.print("[bold]To log in, open this URL on any device:[/bold]")
128
+ console.print(f" {verification_uri_complete}")
129
+ console.print()
130
+ console.print(f"[bold]And enter the code:[/bold] [cyan bold]{user_code}[/cyan bold]")
131
+ console.print()
132
+ console.print(f"[dim]The code expires in {expires_in // 60} minutes.[/dim]")
133
+
134
+ # Poll for token
135
+ deadline = time.time() + expires_in
136
+ while time.time() < deadline:
137
+ time.sleep(interval)
138
+
139
+ token_response = httpx.post(
140
+ f"https://{config.auth0_domain}/oauth/token",
141
+ data={
142
+ "grant_type": "urn:ietf:params:oauth:grant-type:device_code",
143
+ "device_code": device_code,
144
+ "client_id": config.auth0_client_id,
145
+ },
146
+ )
147
+
148
+ token_data = token_response.json()
149
+
150
+ if token_response.status_code == 200:
151
+ # Extract email from ID token if present
152
+ email = None
153
+ id_token = token_data.get("id_token")
154
+ if id_token:
155
+ email = _extract_email_from_jwt(id_token)
156
+
157
+ tokens = StoredTokens(
158
+ access_token=token_data["access_token"],
159
+ refresh_token=token_data.get("refresh_token"),
160
+ expires_at=time.time() + token_data.get("expires_in", 3600),
161
+ email=email,
162
+ )
163
+ save_tokens(tokens)
164
+ console.print(f"\n[green]Logged in as {tokens.email or 'unknown'}[/green]")
165
+ return tokens
166
+
167
+ error = token_data.get("error")
168
+ if error == "authorization_pending":
169
+ continue
170
+ elif error == "slow_down":
171
+ interval += 5
172
+ continue
173
+ elif error == "expired_token":
174
+ console.print("[red]Device code expired. Please try again.[/red]")
175
+ raise SystemExit(1)
176
+ elif error == "access_denied":
177
+ console.print("[red]Login was denied.[/red]")
178
+ raise SystemExit(1)
179
+ else:
180
+ console.print(f"[red]Login failed: {token_data.get('error_description', error)}[/red]")
181
+ raise SystemExit(1)
182
+
183
+ console.print("[red]Login timed out. Please try again.[/red]")
184
+ raise SystemExit(1)
185
+
186
+
187
+ def refresh_access_token(refresh_token: str) -> StoredTokens:
188
+ """Use a refresh token to get a new access token."""
189
+ config = get_config()
190
+
191
+ response = httpx.post(
192
+ f"https://{config.auth0_domain}/oauth/token",
193
+ json={
194
+ "grant_type": "refresh_token",
195
+ "client_id": config.auth0_client_id,
196
+ "refresh_token": refresh_token,
197
+ },
198
+ )
199
+
200
+ if response.status_code != 200:
201
+ console.print("[red]Token refresh failed. Please run: tl auth login[/red]")
202
+ raise SystemExit(2)
203
+
204
+ data = response.json()
205
+ tokens = StoredTokens(
206
+ access_token=data["access_token"],
207
+ refresh_token=data.get("refresh_token", refresh_token),
208
+ expires_at=time.time() + data.get("expires_in", 3600),
209
+ email=None, # Not returned on refresh
210
+ )
211
+ save_tokens(tokens)
212
+ return tokens
213
+
214
+
215
+ def _exchange_code(
216
+ code: str,
217
+ code_verifier: str,
218
+ redirect_uri: str,
219
+ config,
220
+ ) -> StoredTokens:
221
+ """Exchange authorization code for tokens."""
222
+ response = httpx.post(
223
+ f"https://{config.auth0_domain}/oauth/token",
224
+ json={
225
+ "grant_type": "authorization_code",
226
+ "client_id": config.auth0_client_id,
227
+ "code": code,
228
+ "code_verifier": code_verifier,
229
+ "redirect_uri": redirect_uri,
230
+ },
231
+ )
232
+
233
+ if response.status_code != 200:
234
+ console.print(f"[red]Token exchange failed: {response.text}[/red]")
235
+ raise SystemExit(1)
236
+
237
+ data = response.json()
238
+
239
+ # Decode email from ID token if present
240
+ email = None
241
+ id_token = data.get("id_token")
242
+ if id_token:
243
+ email = _extract_email_from_jwt(id_token)
244
+
245
+ return StoredTokens(
246
+ access_token=data["access_token"],
247
+ refresh_token=data.get("refresh_token"),
248
+ expires_at=time.time() + data.get("expires_in", 3600),
249
+ email=email,
250
+ )
251
+
252
+
253
+ def _extract_email_from_jwt(token: str) -> str | None:
254
+ """Extract email from JWT payload without full verification (already trusted from Auth0)."""
255
+ import base64
256
+ import json
257
+
258
+ try:
259
+ payload_part = token.split(".")[1]
260
+ # Add padding
261
+ padding = 4 - len(payload_part) % 4
262
+ payload_part += "=" * padding
263
+ payload = json.loads(base64.urlsafe_b64decode(payload_part))
264
+ return payload.get("email")
265
+ except Exception:
266
+ return None
267
+
268
+
269
+ def _start_callback_server(
270
+ result: _CallbackResult, expected_state: str, port: int = 0
271
+ ) -> tuple[http.server.HTTPServer, int]:
272
+ """Start a temporary HTTP server to receive the OAuth callback."""
273
+
274
+ class CallbackHandler(http.server.BaseHTTPRequestHandler):
275
+ def do_GET(self):
276
+ parsed = urllib.parse.urlparse(self.path)
277
+ params = urllib.parse.parse_qs(parsed.query)
278
+
279
+ if parsed.path != "/callback":
280
+ self.send_response(404)
281
+ self.end_headers()
282
+ return
283
+
284
+ # Check state
285
+ received_state = params.get("state", [None])[0]
286
+ if received_state != expected_state:
287
+ result.error = "State mismatch — possible CSRF attack"
288
+ self._respond("Login failed: state mismatch.")
289
+ return
290
+
291
+ if "error" in params:
292
+ result.error = params["error"][0]
293
+ desc = params.get("error_description", [""])[0]
294
+ self._respond(f"Login failed: {desc or result.error}")
295
+ return
296
+
297
+ code = params.get("code", [None])[0]
298
+ if not code:
299
+ result.error = "No authorization code received"
300
+ self._respond("Login failed: no code received.")
301
+ return
302
+
303
+ result.code = code
304
+ self._respond(
305
+ "Login successful! You can close this tab and return to the terminal."
306
+ )
307
+
308
+ def _respond(self, message: str):
309
+ self.send_response(200)
310
+ self.send_header("Content-Type", "text/html")
311
+ self.end_headers()
312
+ html = f"""<!DOCTYPE html>
313
+ <html><head><title>TL CLI Login</title></head>
314
+ <body style="font-family: system-ui; text-align: center; padding: 60px;">
315
+ <h2>{message}</h2>
316
+ </body></html>"""
317
+ self.wfile.write(html.encode())
318
+
319
+ def log_message(self, format, *args):
320
+ pass # Suppress HTTP logs
321
+
322
+ server = http.server.HTTPServer(("127.0.0.1", port), CallbackHandler)
323
+ port = server.server_address[1]
324
+
325
+ thread = threading.Thread(target=server.serve_forever, daemon=True)
326
+ thread.start()
327
+
328
+ return server, port
tl_cli/auth/pkce.py ADDED
@@ -0,0 +1,21 @@
1
+ """PKCE (Proof Key for Code Exchange) utilities for OAuth 2.1."""
2
+
3
+ import base64
4
+ import hashlib
5
+ import secrets
6
+
7
+
8
+ def generate_pkce_pair() -> tuple[str, str]:
9
+ """Generate a PKCE code_verifier and code_challenge (S256).
10
+
11
+ Returns:
12
+ (code_verifier, code_challenge) tuple.
13
+ """
14
+ # code_verifier: 43-128 characters, URL-safe
15
+ code_verifier = secrets.token_urlsafe(64)
16
+
17
+ # code_challenge: SHA256 hash of verifier, base64url-encoded (no padding)
18
+ digest = hashlib.sha256(code_verifier.encode("ascii")).digest()
19
+ code_challenge = base64.urlsafe_b64encode(digest).rstrip(b"=").decode("ascii")
20
+
21
+ return code_verifier, code_challenge