tescmd 0.1.2__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 (81) hide show
  1. tescmd/__init__.py +3 -0
  2. tescmd/__main__.py +5 -0
  3. tescmd/_internal/__init__.py +0 -0
  4. tescmd/_internal/async_utils.py +25 -0
  5. tescmd/_internal/permissions.py +43 -0
  6. tescmd/_internal/vin.py +44 -0
  7. tescmd/api/__init__.py +1 -0
  8. tescmd/api/charging.py +102 -0
  9. tescmd/api/client.py +189 -0
  10. tescmd/api/command.py +540 -0
  11. tescmd/api/energy.py +146 -0
  12. tescmd/api/errors.py +76 -0
  13. tescmd/api/partner.py +40 -0
  14. tescmd/api/sharing.py +65 -0
  15. tescmd/api/signed_command.py +277 -0
  16. tescmd/api/user.py +38 -0
  17. tescmd/api/vehicle.py +150 -0
  18. tescmd/auth/__init__.py +1 -0
  19. tescmd/auth/oauth.py +312 -0
  20. tescmd/auth/server.py +108 -0
  21. tescmd/auth/token_store.py +273 -0
  22. tescmd/ble/__init__.py +0 -0
  23. tescmd/cache/__init__.py +6 -0
  24. tescmd/cache/keys.py +51 -0
  25. tescmd/cache/response_cache.py +213 -0
  26. tescmd/cli/__init__.py +0 -0
  27. tescmd/cli/_client.py +603 -0
  28. tescmd/cli/_options.py +126 -0
  29. tescmd/cli/auth.py +682 -0
  30. tescmd/cli/billing.py +240 -0
  31. tescmd/cli/cache.py +85 -0
  32. tescmd/cli/charge.py +610 -0
  33. tescmd/cli/climate.py +501 -0
  34. tescmd/cli/energy.py +385 -0
  35. tescmd/cli/key.py +611 -0
  36. tescmd/cli/main.py +601 -0
  37. tescmd/cli/media.py +146 -0
  38. tescmd/cli/nav.py +242 -0
  39. tescmd/cli/partner.py +112 -0
  40. tescmd/cli/raw.py +75 -0
  41. tescmd/cli/security.py +495 -0
  42. tescmd/cli/setup.py +786 -0
  43. tescmd/cli/sharing.py +188 -0
  44. tescmd/cli/software.py +81 -0
  45. tescmd/cli/status.py +106 -0
  46. tescmd/cli/trunk.py +240 -0
  47. tescmd/cli/user.py +145 -0
  48. tescmd/cli/vehicle.py +837 -0
  49. tescmd/config/__init__.py +0 -0
  50. tescmd/crypto/__init__.py +19 -0
  51. tescmd/crypto/ecdh.py +46 -0
  52. tescmd/crypto/keys.py +122 -0
  53. tescmd/deploy/__init__.py +0 -0
  54. tescmd/deploy/github_pages.py +268 -0
  55. tescmd/models/__init__.py +85 -0
  56. tescmd/models/auth.py +108 -0
  57. tescmd/models/command.py +18 -0
  58. tescmd/models/config.py +63 -0
  59. tescmd/models/energy.py +56 -0
  60. tescmd/models/sharing.py +26 -0
  61. tescmd/models/user.py +37 -0
  62. tescmd/models/vehicle.py +185 -0
  63. tescmd/output/__init__.py +5 -0
  64. tescmd/output/formatter.py +132 -0
  65. tescmd/output/json_output.py +83 -0
  66. tescmd/output/rich_output.py +809 -0
  67. tescmd/protocol/__init__.py +23 -0
  68. tescmd/protocol/commands.py +175 -0
  69. tescmd/protocol/encoder.py +122 -0
  70. tescmd/protocol/metadata.py +116 -0
  71. tescmd/protocol/payloads.py +621 -0
  72. tescmd/protocol/protobuf/__init__.py +6 -0
  73. tescmd/protocol/protobuf/messages.py +564 -0
  74. tescmd/protocol/session.py +318 -0
  75. tescmd/protocol/signer.py +84 -0
  76. tescmd/py.typed +0 -0
  77. tescmd-0.1.2.dist-info/METADATA +458 -0
  78. tescmd-0.1.2.dist-info/RECORD +81 -0
  79. tescmd-0.1.2.dist-info/WHEEL +4 -0
  80. tescmd-0.1.2.dist-info/entry_points.txt +2 -0
  81. tescmd-0.1.2.dist-info/licenses/LICENSE +21 -0
tescmd/cli/auth.py ADDED
@@ -0,0 +1,682 @@
1
+ """CLI commands for authentication (login, logout, status, refresh, export, import, register)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import sys
7
+ import time
8
+ import webbrowser
9
+ from pathlib import Path
10
+ from typing import TYPE_CHECKING
11
+
12
+ import click
13
+
14
+ from tescmd._internal.async_utils import run_async
15
+ from tescmd.api.errors import ConfigError
16
+ from tescmd.auth.oauth import (
17
+ login_flow,
18
+ refresh_access_token,
19
+ register_partner_account,
20
+ )
21
+ from tescmd.auth.token_store import TokenStore
22
+ from tescmd.cli._options import global_options
23
+ from tescmd.models.auth import DEFAULT_SCOPES, PARTNER_SCOPES, TokenData, decode_jwt_scopes
24
+ from tescmd.models.config import AppSettings
25
+
26
+ if TYPE_CHECKING:
27
+ from tescmd.cli.main import AppContext
28
+ from tescmd.output.formatter import OutputFormatter
29
+
30
+ DEVELOPER_PORTAL_URL = "https://developer.tesla.com"
31
+
32
+
33
+ # ---------------------------------------------------------------------------
34
+ # Command group
35
+ # ---------------------------------------------------------------------------
36
+
37
+ auth_group = click.Group("auth", help="Authentication commands")
38
+
39
+
40
+ # ---------------------------------------------------------------------------
41
+ # Commands
42
+ # ---------------------------------------------------------------------------
43
+
44
+
45
+ @auth_group.command("login")
46
+ @click.option("--port", type=int, default=8085, help="Local callback port")
47
+ @click.option(
48
+ "--reconsent",
49
+ is_flag=True,
50
+ default=False,
51
+ help="Force Tesla to re-display the scope consent screen.",
52
+ )
53
+ @global_options
54
+ def login_cmd(app_ctx: AppContext, port: int, reconsent: bool) -> None:
55
+ """Log in via OAuth2 PKCE flow."""
56
+ run_async(_cmd_login(app_ctx, port, reconsent=reconsent))
57
+
58
+
59
+ async def _cmd_login(app_ctx: AppContext, port: int, *, reconsent: bool = False) -> None:
60
+ formatter = app_ctx.formatter
61
+ settings = AppSettings()
62
+
63
+ client_id = settings.client_id
64
+ client_secret = settings.client_secret
65
+
66
+ redirect_uri = f"http://localhost:{port}/callback"
67
+
68
+ if not client_id:
69
+ if formatter.format == "json":
70
+ formatter.output_error(
71
+ code="missing_client_id",
72
+ message=(
73
+ "TESLA_CLIENT_ID is not set. Register an application at"
74
+ " https://developer.tesla.com and set TESLA_CLIENT_ID"
75
+ " in your environment or .env file."
76
+ ),
77
+ command="auth.login",
78
+ )
79
+ return
80
+
81
+ # Redirect first-run to the setup wizard for a guided experience
82
+ from tescmd.cli.setup import _cmd_setup
83
+
84
+ await _cmd_setup(app_ctx)
85
+ return
86
+
87
+ store = TokenStore(
88
+ profile=app_ctx.profile,
89
+ token_file=settings.token_file,
90
+ config_dir=settings.config_dir,
91
+ )
92
+ region = app_ctx.region or settings.region
93
+
94
+ formatter.rich.info("")
95
+ formatter.rich.info("Opening your browser to sign in to Tesla...")
96
+ formatter.rich.info(
97
+ "When prompted, click [cyan]Select All[/cyan] and then"
98
+ " [cyan]Allow[/cyan] to grant tescmd access."
99
+ )
100
+ formatter.rich.info("[dim]If the browser doesn't open, visit the URL printed below.[/dim]")
101
+
102
+ token = await login_flow(
103
+ client_id=client_id,
104
+ client_secret=client_secret,
105
+ redirect_uri=redirect_uri,
106
+ scopes=DEFAULT_SCOPES,
107
+ port=port,
108
+ token_store=store,
109
+ region=region,
110
+ force_consent=reconsent,
111
+ )
112
+
113
+ formatter.rich.info("")
114
+ formatter.rich.info("[bold green]Login successful![/bold green]")
115
+ _warn_missing_scopes(formatter, token, requested=DEFAULT_SCOPES)
116
+
117
+ # Auto-register with the Fleet API (requires client_secret + domain)
118
+ if client_secret and settings.domain:
119
+ await _auto_register(formatter, client_id, client_secret, settings.domain, region)
120
+ else:
121
+ formatter.rich.info("")
122
+ formatter.rich.info("[yellow]Next step:[/yellow] Register your app with the Fleet API.")
123
+ formatter.rich.info(" [cyan]tescmd auth register[/cyan]")
124
+
125
+ formatter.rich.info("")
126
+ formatter.rich.info("Try it out:")
127
+ formatter.rich.info(" [cyan]tescmd vehicle list[/cyan]")
128
+ formatter.rich.info("")
129
+
130
+
131
+ @auth_group.command("logout")
132
+ @global_options
133
+ def logout_cmd(app_ctx: AppContext) -> None:
134
+ """Clear stored tokens."""
135
+ run_async(_cmd_logout(app_ctx))
136
+
137
+
138
+ async def _cmd_logout(app_ctx: AppContext) -> None:
139
+ formatter = app_ctx.formatter
140
+ settings = AppSettings()
141
+ store = TokenStore(
142
+ profile=app_ctx.profile,
143
+ token_file=settings.token_file,
144
+ config_dir=settings.config_dir,
145
+ )
146
+ store.clear()
147
+
148
+ if formatter.format == "json":
149
+ formatter.output({"status": "logged_out"}, command="auth.logout")
150
+ else:
151
+ formatter.rich.info("Tokens cleared.")
152
+
153
+
154
+ @auth_group.command("status")
155
+ @global_options
156
+ def status_cmd(app_ctx: AppContext) -> None:
157
+ """Show authentication status."""
158
+ run_async(_cmd_status(app_ctx))
159
+
160
+
161
+ async def _cmd_status(app_ctx: AppContext) -> None:
162
+ formatter = app_ctx.formatter
163
+ settings = AppSettings()
164
+ store = TokenStore(
165
+ profile=app_ctx.profile,
166
+ token_file=settings.token_file,
167
+ config_dir=settings.config_dir,
168
+ )
169
+
170
+ if not store.has_token:
171
+ if formatter.format == "json":
172
+ formatter.output({"authenticated": False}, command="auth.status")
173
+ else:
174
+ formatter.rich.info("Not logged in.")
175
+ return
176
+
177
+ meta = store.metadata or {}
178
+ expires_at = meta.get("expires_at", 0.0)
179
+ now = time.time()
180
+ expires_in = max(0, int(expires_at - now))
181
+ scopes: list[str] = meta.get("scopes", [])
182
+ region: str = meta.get("region", "unknown")
183
+ has_refresh = store.refresh_token is not None
184
+
185
+ # Decode the JWT to show the *actual* granted scopes
186
+ token_scopes: list[str] | None = None
187
+ access_token = store.access_token
188
+ if access_token:
189
+ token_scopes = decode_jwt_scopes(access_token)
190
+
191
+ if formatter.format == "json":
192
+ data: dict[str, object] = {
193
+ "authenticated": True,
194
+ "expires_in": expires_in,
195
+ "scopes": scopes,
196
+ "region": region,
197
+ "has_refresh_token": has_refresh,
198
+ }
199
+ if token_scopes is not None:
200
+ data["token_scopes"] = token_scopes
201
+ formatter.output(data, command="auth.status")
202
+ else:
203
+ formatter.rich.info("Authenticated: yes")
204
+ formatter.rich.info(f"Expires in: {expires_in}s")
205
+ formatter.rich.info(f"Scopes (stored): {', '.join(scopes)}")
206
+ if token_scopes is not None:
207
+ formatter.rich.info(f"Scopes (token): {', '.join(token_scopes)}")
208
+ missing = set(scopes) - set(token_scopes)
209
+ if missing:
210
+ not_granted = ", ".join(sorted(missing))
211
+ formatter.rich.info(
212
+ f" [yellow]Warning: requested but not granted: {not_granted}[/yellow]"
213
+ )
214
+ formatter.rich.info(f"Region: {region}")
215
+ formatter.rich.info(f"Refresh token: {'yes' if has_refresh else 'no'}")
216
+
217
+
218
+ @auth_group.command("refresh")
219
+ @global_options
220
+ def refresh_cmd(app_ctx: AppContext) -> None:
221
+ """Refresh the access token using the stored refresh token."""
222
+ run_async(_cmd_refresh(app_ctx))
223
+
224
+
225
+ async def _cmd_refresh(app_ctx: AppContext) -> None:
226
+ formatter = app_ctx.formatter
227
+ settings = AppSettings()
228
+ store = TokenStore(
229
+ profile=app_ctx.profile,
230
+ token_file=settings.token_file,
231
+ config_dir=settings.config_dir,
232
+ )
233
+
234
+ rt = store.refresh_token
235
+ if not rt:
236
+ raise ConfigError("No refresh token found. Run 'tescmd auth login' first.")
237
+
238
+ if not settings.client_id:
239
+ raise ConfigError(
240
+ "TESLA_CLIENT_ID is required for token refresh. "
241
+ "Add it to your .env file or set it as an environment variable."
242
+ )
243
+
244
+ meta = store.metadata or {}
245
+ scopes: list[str] = meta.get("scopes", DEFAULT_SCOPES)
246
+ region: str = meta.get("region", "na")
247
+
248
+ token_data = await refresh_access_token(
249
+ refresh_token=rt,
250
+ client_id=settings.client_id,
251
+ client_secret=settings.client_secret,
252
+ )
253
+
254
+ store.save(
255
+ access_token=token_data.access_token,
256
+ refresh_token=token_data.refresh_token or rt,
257
+ expires_at=time.time() + token_data.expires_in,
258
+ scopes=scopes,
259
+ region=region,
260
+ )
261
+
262
+ if formatter.format == "json":
263
+ formatter.output({"status": "refreshed"}, command="auth.refresh")
264
+ else:
265
+ formatter.rich.info("Token refreshed successfully.")
266
+
267
+
268
+ @auth_group.command("export")
269
+ @global_options
270
+ def export_cmd(app_ctx: AppContext) -> None:
271
+ """Export tokens as JSON to stdout."""
272
+ run_async(_cmd_export(app_ctx))
273
+
274
+
275
+ async def _cmd_export(app_ctx: AppContext) -> None:
276
+ settings = AppSettings()
277
+ store = TokenStore(
278
+ profile=app_ctx.profile,
279
+ token_file=settings.token_file,
280
+ config_dir=settings.config_dir,
281
+ )
282
+ data = store.export_dict()
283
+ print(json.dumps(data, indent=2))
284
+
285
+
286
+ @auth_group.command("register")
287
+ @global_options
288
+ def register_cmd(app_ctx: AppContext) -> None:
289
+ """Register app with the Fleet API (one-time)."""
290
+ run_async(_cmd_register(app_ctx))
291
+
292
+
293
+ async def _cmd_register(app_ctx: AppContext) -> None:
294
+ formatter = app_ctx.formatter
295
+ settings = AppSettings()
296
+
297
+ if not settings.client_id:
298
+ raise ConfigError(
299
+ "TESLA_CLIENT_ID is required. Run 'tescmd auth login' to set up your credentials."
300
+ )
301
+ if not settings.client_secret:
302
+ raise ConfigError(
303
+ "TESLA_CLIENT_SECRET is required for Fleet API registration. "
304
+ "Add it to your .env file or set it as an environment variable."
305
+ )
306
+
307
+ region = app_ctx.region or settings.region
308
+ domain = settings.domain
309
+
310
+ # Prompt for domain if not configured
311
+ if not domain and formatter.format != "json":
312
+ domain = _prompt_for_domain(formatter)
313
+ if not domain:
314
+ return
315
+
316
+ if not domain:
317
+ raise ConfigError(
318
+ "TESLA_DOMAIN is required for Fleet API registration. "
319
+ "Set it in your .env file (e.g. TESLA_DOMAIN=myapp.example.com)."
320
+ )
321
+
322
+ if formatter.format != "json":
323
+ formatter.rich.info(f"Registering application with Fleet API ({region} region)...")
324
+
325
+ _result, partner_scopes = await register_partner_account(
326
+ client_id=settings.client_id,
327
+ client_secret=settings.client_secret,
328
+ domain=domain,
329
+ region=region,
330
+ )
331
+
332
+ if formatter.format == "json":
333
+ data: dict[str, object] = {
334
+ "status": "registered",
335
+ "region": region,
336
+ "domain": domain,
337
+ }
338
+ if partner_scopes:
339
+ data["partner_scopes"] = partner_scopes
340
+ formatter.output(data, command="auth.register")
341
+ else:
342
+ formatter.rich.info("[green]Registration successful.[/green]")
343
+ if partner_scopes:
344
+ formatter.rich.info(f"Partner scopes: {', '.join(partner_scopes)}")
345
+ missing = sorted(set(PARTNER_SCOPES) - set(partner_scopes))
346
+ if missing:
347
+ scope_list = ", ".join(missing)
348
+ formatter.rich.info(
349
+ f"[yellow]Warning: partner token is missing: {scope_list}[/yellow]"
350
+ )
351
+ formatter.rich.info(" These scopes won't be available in user tokens.")
352
+ formatter.rich.info(" Check your Tesla Developer Portal app configuration.")
353
+ formatter.rich.info("")
354
+ formatter.rich.info("Try it out:")
355
+ formatter.rich.info(" [cyan]tescmd vehicle list[/cyan]")
356
+ formatter.rich.info("")
357
+
358
+
359
+ @auth_group.command("import")
360
+ @global_options
361
+ def import_cmd(app_ctx: AppContext) -> None:
362
+ """Import tokens from JSON on stdin."""
363
+ run_async(_cmd_import(app_ctx))
364
+
365
+
366
+ async def _cmd_import(app_ctx: AppContext) -> None:
367
+ formatter = app_ctx.formatter
368
+ settings = AppSettings()
369
+ raw = sys.stdin.read()
370
+ data = json.loads(raw)
371
+ store = TokenStore(
372
+ profile=app_ctx.profile,
373
+ token_file=settings.token_file,
374
+ config_dir=settings.config_dir,
375
+ )
376
+ store.import_dict(data)
377
+
378
+ if formatter.format == "json":
379
+ formatter.output({"status": "imported"}, command="auth.import")
380
+ else:
381
+ formatter.rich.info("Tokens imported successfully.")
382
+
383
+
384
+ # ---------------------------------------------------------------------------
385
+ # Private helpers
386
+ # ---------------------------------------------------------------------------
387
+
388
+
389
+ async def _auto_register(
390
+ formatter: OutputFormatter,
391
+ client_id: str,
392
+ client_secret: str,
393
+ domain: str,
394
+ region: str,
395
+ ) -> None:
396
+ """Attempt Fleet API registration silently after login."""
397
+ formatter.rich.info("")
398
+ formatter.rich.info("Registering with the Fleet API...")
399
+ try:
400
+ _result, partner_scopes = await register_partner_account(
401
+ client_id=client_id,
402
+ client_secret=client_secret,
403
+ domain=domain,
404
+ region=region,
405
+ )
406
+ formatter.rich.info("[green]Registration successful.[/green]")
407
+ if partner_scopes:
408
+ missing = sorted(set(PARTNER_SCOPES) - set(partner_scopes))
409
+ if missing:
410
+ scope_list = ", ".join(missing)
411
+ formatter.rich.info(
412
+ f"[yellow]Warning: partner token missing scopes: {scope_list}[/yellow]"
413
+ )
414
+ except Exception:
415
+ formatter.rich.info(
416
+ "[yellow]Registration failed. Run [cyan]tescmd auth register[/cyan] to retry.[/yellow]"
417
+ )
418
+
419
+
420
+ def _interactive_setup(
421
+ formatter: OutputFormatter,
422
+ port: int,
423
+ redirect_uri: str,
424
+ *,
425
+ domain: str = "",
426
+ ) -> tuple[str, str]:
427
+ """Walk the user through first-time Tesla API credential setup.
428
+
429
+ When *domain* is provided (e.g. from the setup wizard), the developer
430
+ portal instructions show ``https://{domain}`` as the Allowed Origin URL.
431
+ Tesla's Fleet API requires the origin to match the registration domain.
432
+ """
433
+ info = formatter.rich.info
434
+ origin_url = f"https://{domain}" if domain else f"http://localhost:{port}"
435
+
436
+ info("")
437
+ info("[bold cyan]Welcome to tescmd![/bold cyan]")
438
+ info("")
439
+ info(
440
+ "To talk to your Tesla you need API credentials from the"
441
+ " Tesla Developer Portal. This wizard will walk you through it."
442
+ )
443
+ info("")
444
+
445
+ # Offer to open the developer portal
446
+ try:
447
+ answer = input("Open the Tesla Developer Portal in your browser? [Y/n] ")
448
+ except (EOFError, KeyboardInterrupt):
449
+ info("")
450
+ return ("", "")
451
+
452
+ if answer.strip().lower() != "n":
453
+ webbrowser.open(DEVELOPER_PORTAL_URL)
454
+ info("[dim]Browser opened.[/dim]")
455
+
456
+ info("")
457
+ info(
458
+ "Follow these steps to create a Fleet API application."
459
+ " If you already have one, skip to the credentials prompt below."
460
+ )
461
+ info("")
462
+
463
+ # Step 1 — Registration
464
+ info("[bold]Step 1 — Registration[/bold]")
465
+ info(" Select [cyan]Just for me[/cyan] and click Next.")
466
+ info("")
467
+
468
+ # Step 2 — Application Details
469
+ info("[bold]Step 2 — Application Details[/bold]")
470
+ info(" Application Name: [cyan]tescmd[/cyan] (or anything you like)")
471
+ info(" Description: [cyan]Personal CLI tool for vehicle status and control[/cyan]")
472
+ info(
473
+ " Purpose of Usage: [cyan]Query vehicle data and send commands from the terminal[/cyan]"
474
+ )
475
+ info(" Click Next.")
476
+ info("")
477
+
478
+ # Step 3 — Client Details
479
+ info("[bold]Step 3 — Client Details[/bold]")
480
+ info(
481
+ " OAuth Grant Type: [cyan]Authorization Code and"
482
+ " Machine-to-Machine[/cyan] (the default)"
483
+ )
484
+ info(f" Allowed Origin URL: [cyan]{origin_url}[/cyan]")
485
+ info(f" Allowed Redirect URI: [cyan]{redirect_uri}[/cyan]")
486
+ info(" Allowed Returned URL: (leave empty)")
487
+ info(" Click Next.")
488
+ info("")
489
+
490
+ # Step 4 — API & Scopes
491
+ info("[bold]Step 4 — API & Scopes[/bold]")
492
+ info(" Under [bold]Fleet API[/bold], check at least:")
493
+ info(" [cyan]Vehicle Information[/cyan]")
494
+ info(" [cyan]Vehicle Location[/cyan]")
495
+ info(" [cyan]Vehicle Commands[/cyan]")
496
+ info(" [cyan]Vehicle Charging Management[/cyan]")
497
+ info(" Click Next.")
498
+ info("")
499
+
500
+ # Step 5 — Billing Details
501
+ info("[bold]Step 5 — Billing Details[/bold]")
502
+ info(" Click [cyan]Skip and Submit[/cyan] at the bottom of the page.")
503
+ info("")
504
+
505
+ # Post-creation
506
+ info("[bold]Step 6 — Copy your credentials[/bold]")
507
+ info(
508
+ " Open your dashboard:"
509
+ " [link=https://developer.tesla.com/en_US/dashboard]"
510
+ "developer.tesla.com/dashboard[/link]"
511
+ )
512
+ info(" Click [cyan]View Details[/cyan] on your app.")
513
+ info(" Under the [cyan]Credentials & APIs[/cyan] tab you'll see your")
514
+ info(" Client ID (copy icon) and Client Secret (eye icon to reveal).")
515
+ info("")
516
+
517
+ # Prompt for Client ID
518
+ try:
519
+ client_id = input("Client ID: ").strip()
520
+ except (EOFError, KeyboardInterrupt):
521
+ info("")
522
+ return ("", "")
523
+
524
+ if not client_id:
525
+ info("[yellow]No Client ID provided. Setup cancelled.[/yellow]")
526
+ return ("", "")
527
+
528
+ # Prompt for Client Secret (optional for public clients)
529
+ try:
530
+ client_secret = input("Client Secret (optional, press Enter to skip): ").strip()
531
+ except (EOFError, KeyboardInterrupt):
532
+ info("")
533
+ return ("", "")
534
+
535
+ # Offer to persist credentials to .env
536
+ info("")
537
+ try:
538
+ save = input("Save credentials to .env file? [Y/n] ")
539
+ except (EOFError, KeyboardInterrupt):
540
+ info("")
541
+ return (client_id, client_secret)
542
+
543
+ if save.strip().lower() != "n":
544
+ _write_env_file(client_id, client_secret)
545
+ info("[green]Credentials saved to .env[/green]")
546
+
547
+ info("")
548
+ return (client_id, client_secret)
549
+
550
+
551
+ def _prompt_for_domain(formatter: OutputFormatter) -> str:
552
+ """Prompt the user for a domain to use for Fleet API registration."""
553
+ info = formatter.rich.info
554
+ info("")
555
+ info("Tesla requires a [bold]registered domain[/bold] to activate your Fleet API access.")
556
+ info("")
557
+ info(" The easiest option is a free [cyan]GitHub Pages[/cyan] site:")
558
+ info(" 1. Create a public repo named [cyan]<username>.github.io[/cyan]")
559
+ info(" 2. Enable GitHub Pages in the repo settings")
560
+ info(" 3. Enter [cyan]<username>.github.io[/cyan] as your domain below")
561
+ info("")
562
+ info(
563
+ "[dim]Any domain you control works. For vehicle commands"
564
+ " (post-MVP) you'll also host a public key there.[/dim]"
565
+ )
566
+ info("")
567
+
568
+ try:
569
+ domain = input("Domain (e.g. yourname.github.io): ").strip()
570
+ except (EOFError, KeyboardInterrupt):
571
+ info("")
572
+ return ""
573
+
574
+ if not domain:
575
+ info("[yellow]No domain provided. Registration cancelled.[/yellow]")
576
+ return ""
577
+
578
+ # Strip protocol if user included it
579
+ for prefix in ("https://", "http://"):
580
+ if domain.startswith(prefix):
581
+ domain = domain[len(prefix) :]
582
+ break
583
+
584
+ # Strip trailing slash and lowercase (Tesla Fleet API rejects uppercase)
585
+ domain = domain.rstrip("/").lower()
586
+
587
+ # Save domain to .env for future use
588
+ _write_env_value("TESLA_DOMAIN", domain)
589
+ info(f"[green]Domain saved to .env: {domain}[/green]")
590
+
591
+ return domain
592
+
593
+
594
+ def _warn_missing_scopes(
595
+ formatter: OutputFormatter,
596
+ token: TokenData,
597
+ *,
598
+ requested: list[str],
599
+ ) -> None:
600
+ """Warn the user if the token has fewer scopes than requested."""
601
+ granted = decode_jwt_scopes(token.access_token)
602
+ if granted is None:
603
+ return
604
+
605
+ # offline_access is a token-lifetime directive, not present in JWTs
606
+ requested_set = {s for s in requested if s != "offline_access"}
607
+ missing = sorted(requested_set - set(granted))
608
+ if not missing:
609
+ return
610
+
611
+ scope_list = ", ".join(missing)
612
+ if formatter.format == "json":
613
+ formatter.output(
614
+ {
615
+ "warning": "missing_scopes",
616
+ "missing": missing,
617
+ "message": (
618
+ f"Token is missing requested scopes: {scope_list}. "
619
+ "Run 'tescmd auth login --reconsent' to re-approve scopes."
620
+ ),
621
+ },
622
+ command="auth.login",
623
+ )
624
+ else:
625
+ formatter.rich.info("")
626
+ formatter.rich.info(f"[yellow]Warning: token is missing scopes: {scope_list}[/yellow]")
627
+ formatter.rich.info(" Tesla is using a cached consent that predates these scopes.")
628
+ formatter.rich.info(
629
+ " Run [cyan]tescmd auth login --reconsent[/cyan] to re-approve all scopes."
630
+ )
631
+
632
+
633
+ def _write_env_file(
634
+ client_id: str,
635
+ client_secret: str,
636
+ domain: str = "",
637
+ ) -> None:
638
+ """Write Tesla API credentials to a ``.env`` file in the working directory."""
639
+ values: dict[str, str] = {"TESLA_CLIENT_ID": client_id}
640
+ if client_secret:
641
+ values["TESLA_CLIENT_SECRET"] = client_secret
642
+ if domain:
643
+ values["TESLA_DOMAIN"] = domain
644
+
645
+ env_path = Path(".env")
646
+ lines: list[str] = []
647
+
648
+ if env_path.exists():
649
+ existing = env_path.read_text()
650
+ for line in existing.splitlines():
651
+ stripped = line.strip()
652
+ if any(stripped.startswith(f"{k}=") for k in values):
653
+ continue
654
+ lines.append(line)
655
+ if lines and lines[-1] != "":
656
+ lines.append("")
657
+
658
+ for key, val in values.items():
659
+ lines.append(f"{key}={val}")
660
+ lines.append("")
661
+
662
+ env_path.write_text("\n".join(lines))
663
+
664
+
665
+ def _write_env_value(key: str, value: str) -> None:
666
+ """Write or update a single key in the ``.env`` file."""
667
+ env_path = Path(".env")
668
+ lines: list[str] = []
669
+
670
+ if env_path.exists():
671
+ existing = env_path.read_text()
672
+ for line in existing.splitlines():
673
+ if line.strip().startswith(f"{key}="):
674
+ continue
675
+ lines.append(line)
676
+ if lines and lines[-1] != "":
677
+ lines.append("")
678
+
679
+ lines.append(f"{key}={value}")
680
+ lines.append("")
681
+
682
+ env_path.write_text("\n".join(lines))