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/main.py ADDED
@@ -0,0 +1,601 @@
1
+ """CLI entry-point: Click command group and dispatch."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import dataclasses
6
+ import logging
7
+
8
+ import click
9
+
10
+ from tescmd._internal.async_utils import run_async
11
+ from tescmd.api.errors import (
12
+ AuthError,
13
+ KeyNotEnrolledError,
14
+ MissingScopesError,
15
+ RegistrationRequiredError,
16
+ SessionError,
17
+ TierError,
18
+ VehicleAsleepError,
19
+ )
20
+ from tescmd.output.formatter import OutputFormatter
21
+
22
+ # ---------------------------------------------------------------------------
23
+ # Application context (stored in ctx.obj)
24
+ # ---------------------------------------------------------------------------
25
+
26
+
27
+ @dataclasses.dataclass
28
+ class AppContext:
29
+ """Shared state passed to every Click command via ``@click.pass_obj``."""
30
+
31
+ vin: str | None
32
+ profile: str
33
+ output_format: str | None
34
+ quiet: bool
35
+ region: str | None
36
+ verbose: bool
37
+ no_cache: bool = False
38
+ auto_wake: bool = False
39
+ temp_unit: str = "F"
40
+ distance_unit: str = "mi"
41
+ pressure_unit: str = "psi"
42
+ _formatter: OutputFormatter | None = dataclasses.field(default=None, repr=False)
43
+
44
+ @property
45
+ def formatter(self) -> OutputFormatter:
46
+ if self._formatter is None:
47
+ from tescmd.output.rich_output import (
48
+ DisplayUnits,
49
+ DistanceUnit,
50
+ PressureUnit,
51
+ TempUnit,
52
+ )
53
+
54
+ force = "quiet" if self.quiet else self.output_format
55
+ units = DisplayUnits(
56
+ temp=TempUnit(self.temp_unit),
57
+ distance=DistanceUnit(self.distance_unit),
58
+ pressure=PressureUnit(self.pressure_unit),
59
+ )
60
+ self._formatter = OutputFormatter(force_format=force, units=units)
61
+ return self._formatter
62
+
63
+
64
+ # ---------------------------------------------------------------------------
65
+ # Root Click group
66
+ # ---------------------------------------------------------------------------
67
+
68
+
69
+ @click.group()
70
+ @click.option("--vin", default=None, envvar="TESLA_VIN", help="Vehicle VIN")
71
+ @click.option("--profile", default="default", help="Config profile name")
72
+ @click.option(
73
+ "--format",
74
+ "output_format",
75
+ type=click.Choice(["rich", "json", "quiet"]),
76
+ default=None,
77
+ help="Output format (default: auto-detect)",
78
+ )
79
+ @click.option("--quiet", is_flag=True, default=False, help="Suppress normal output")
80
+ @click.option(
81
+ "--region",
82
+ type=click.Choice(["na", "eu", "cn"]),
83
+ default=None,
84
+ help="Tesla API region",
85
+ )
86
+ @click.option("--verbose", is_flag=True, default=False, help="Enable verbose logging")
87
+ @click.option(
88
+ "--no-cache", "--fresh", "no_cache", is_flag=True, default=False, help="Bypass response cache"
89
+ )
90
+ @click.option(
91
+ "--wake", is_flag=True, default=False, help="Auto-wake vehicle without confirmation (billable)"
92
+ )
93
+ @click.option(
94
+ "--units",
95
+ type=click.Choice(["us", "metric"]),
96
+ default=None,
97
+ help="Display units preset (us: °F/mi/psi, metric: °C/km/bar)",
98
+ )
99
+ @click.pass_context
100
+ def cli(
101
+ ctx: click.Context,
102
+ vin: str | None,
103
+ profile: str,
104
+ output_format: str | None,
105
+ quiet: bool,
106
+ region: str | None,
107
+ verbose: bool,
108
+ no_cache: bool,
109
+ wake: bool,
110
+ units: str | None,
111
+ ) -> None:
112
+ """Query and control Tesla vehicles via the Fleet API."""
113
+ if verbose:
114
+ logging.basicConfig(
115
+ level=logging.DEBUG,
116
+ format="%(name)s %(levelname)s: %(message)s",
117
+ )
118
+
119
+ # Resolve display units: --units flag > env vars > defaults
120
+ from tescmd.models.config import AppSettings
121
+
122
+ settings = AppSettings()
123
+ if units == "metric":
124
+ temp_unit, distance_unit, pressure_unit = "C", "km", "bar"
125
+ elif units == "us":
126
+ temp_unit, distance_unit, pressure_unit = "F", "mi", "psi"
127
+ else:
128
+ temp_unit = settings.temp_unit
129
+ distance_unit = settings.distance_unit
130
+ pressure_unit = settings.pressure_unit
131
+
132
+ ctx.ensure_object(dict)
133
+ ctx.obj = AppContext(
134
+ vin=vin,
135
+ profile=profile,
136
+ output_format=output_format,
137
+ quiet=quiet,
138
+ region=region,
139
+ verbose=verbose,
140
+ no_cache=no_cache,
141
+ auto_wake=wake,
142
+ temp_unit=temp_unit,
143
+ distance_unit=distance_unit,
144
+ pressure_unit=pressure_unit,
145
+ )
146
+
147
+
148
+ # ---------------------------------------------------------------------------
149
+ # Register subcommand groups (lazy imports keep startup fast)
150
+ # ---------------------------------------------------------------------------
151
+
152
+
153
+ def _register_commands() -> None:
154
+ """Import and attach all subcommand groups to the root CLI."""
155
+ from tescmd.cli.auth import auth_group
156
+ from tescmd.cli.billing import billing_group
157
+ from tescmd.cli.cache import cache_group
158
+ from tescmd.cli.charge import charge_group
159
+ from tescmd.cli.climate import climate_group
160
+ from tescmd.cli.energy import energy_group
161
+ from tescmd.cli.key import key_group
162
+ from tescmd.cli.media import media_group
163
+ from tescmd.cli.nav import nav_group
164
+ from tescmd.cli.partner import partner_group
165
+ from tescmd.cli.raw import raw_group
166
+ from tescmd.cli.security import security_group
167
+ from tescmd.cli.setup import setup_cmd
168
+ from tescmd.cli.sharing import sharing_group
169
+ from tescmd.cli.software import software_group
170
+ from tescmd.cli.status import status_cmd
171
+ from tescmd.cli.trunk import trunk_group
172
+ from tescmd.cli.user import user_group
173
+ from tescmd.cli.vehicle import vehicle_group
174
+
175
+ cli.add_command(auth_group)
176
+ cli.add_command(cache_group)
177
+ cli.add_command(charge_group)
178
+ cli.add_command(billing_group)
179
+ cli.add_command(climate_group)
180
+ cli.add_command(energy_group)
181
+ cli.add_command(key_group)
182
+ cli.add_command(media_group)
183
+ cli.add_command(nav_group)
184
+ cli.add_command(partner_group)
185
+ cli.add_command(raw_group)
186
+ cli.add_command(security_group)
187
+ cli.add_command(setup_cmd)
188
+ cli.add_command(sharing_group)
189
+ cli.add_command(status_cmd)
190
+ cli.add_command(software_group)
191
+ cli.add_command(trunk_group)
192
+ cli.add_command(user_group)
193
+ cli.add_command(vehicle_group)
194
+
195
+
196
+ _register_commands()
197
+
198
+
199
+ # ---------------------------------------------------------------------------
200
+ # Entry point
201
+ # ---------------------------------------------------------------------------
202
+
203
+
204
+ def main(argv: list[str] | None = None) -> None:
205
+ """Parse arguments and dispatch to the appropriate command handler."""
206
+ try:
207
+ cli(args=argv, standalone_mode=False)
208
+ except click.exceptions.Exit as exc:
209
+ raise SystemExit(exc.exit_code) from None
210
+ except click.exceptions.Abort:
211
+ raise SystemExit(1) from None
212
+ except KeyboardInterrupt:
213
+ raise SystemExit(130) from None
214
+ except SystemExit:
215
+ raise
216
+ except Exception as exc:
217
+ # Reconstruct command name for error messages
218
+ app_ctx = _extract_app_ctx()
219
+ formatter = app_ctx.formatter if app_ctx else OutputFormatter()
220
+ cmd_name = _get_command_name()
221
+
222
+ if _handle_known_error(exc, app_ctx, formatter, cmd_name):
223
+ raise SystemExit(1) from exc
224
+
225
+ formatter.output_error(
226
+ code=type(exc).__name__,
227
+ message=str(exc),
228
+ command=cmd_name,
229
+ )
230
+ raise SystemExit(1) from exc
231
+
232
+
233
+ # ---------------------------------------------------------------------------
234
+ # Helpers for error handling
235
+ # ---------------------------------------------------------------------------
236
+
237
+
238
+ def _extract_app_ctx() -> AppContext | None:
239
+ """Try to extract AppContext from the current Click context."""
240
+ ctx = click.get_current_context(silent=True)
241
+ while ctx is not None:
242
+ if isinstance(ctx.obj, AppContext):
243
+ return ctx.obj
244
+ ctx = ctx.parent
245
+ return None
246
+
247
+
248
+ def _get_command_name() -> str:
249
+ """Reconstruct a dotted command name from the Click context chain."""
250
+ ctx = click.get_current_context(silent=True)
251
+ parts: list[str] = []
252
+ while ctx is not None:
253
+ if ctx.info_name and ctx.info_name != "cli":
254
+ parts.append(ctx.info_name)
255
+ ctx = ctx.parent
256
+ return ".".join(reversed(parts)) or "unknown"
257
+
258
+
259
+ def _handle_known_error(
260
+ exc: Exception,
261
+ app_ctx: AppContext | None,
262
+ formatter: OutputFormatter,
263
+ cmd_name: str,
264
+ ) -> bool:
265
+ """Handle well-known errors with friendly output.
266
+
267
+ Returns ``True`` if the error was handled and the caller should exit.
268
+ """
269
+ if isinstance(exc, MissingScopesError):
270
+ _handle_missing_scopes(exc, formatter, cmd_name)
271
+ return True
272
+ if isinstance(exc, AuthError):
273
+ _handle_auth_error(exc, formatter, cmd_name)
274
+ return True
275
+ if isinstance(exc, VehicleAsleepError):
276
+ _handle_vehicle_asleep(exc, formatter, cmd_name)
277
+ return True
278
+ if isinstance(exc, RegistrationRequiredError):
279
+ _handle_registration_required(exc, app_ctx, formatter, cmd_name)
280
+ return True
281
+ if isinstance(exc, TierError):
282
+ _handle_tier_error(exc, formatter, cmd_name)
283
+ return True
284
+ if isinstance(exc, KeyNotEnrolledError):
285
+ _handle_key_not_enrolled(exc, formatter, cmd_name)
286
+ return True
287
+ if isinstance(exc, SessionError):
288
+ _handle_session_error(exc, formatter, cmd_name)
289
+ return True
290
+
291
+ # Keyring failures (e.g. headless Linux with no keyring daemon)
292
+ try:
293
+ from keyring.errors import KeyringError
294
+
295
+ if isinstance(exc, KeyringError):
296
+ _handle_keyring_error(exc, formatter, cmd_name)
297
+ return True
298
+ except ImportError:
299
+ pass
300
+
301
+ return False
302
+
303
+
304
+ def _handle_missing_scopes(
305
+ exc: MissingScopesError,
306
+ formatter: OutputFormatter,
307
+ cmd_name: str,
308
+ ) -> None:
309
+ """Show a friendly error when the token lacks required OAuth scopes."""
310
+ message = "Your OAuth token is missing required scopes for this command."
311
+
312
+ if formatter.format == "json":
313
+ formatter.output_error(
314
+ code="missing_scopes",
315
+ message=(
316
+ f"{message} Re-login with 'tescmd auth login'. If the error persists, "
317
+ "check your Tesla Developer Portal app and ensure all required "
318
+ "scopes are enabled. Note: 'tescmd auth status' shows requested scopes, "
319
+ "not what Tesla granted — Tesla silently drops scopes your app "
320
+ "isn't configured for."
321
+ ),
322
+ command=cmd_name,
323
+ )
324
+ return
325
+
326
+ formatter.rich.error(message)
327
+ formatter.rich.info("")
328
+ formatter.rich.info("Next steps:")
329
+ formatter.rich.info(" 1. Re-login to refresh your token:")
330
+ formatter.rich.info(" [cyan]tescmd auth login[/cyan]")
331
+ formatter.rich.info("")
332
+ formatter.rich.info(" 2. If the error persists, check your Tesla Developer Portal app")
333
+ formatter.rich.info(" at [cyan]https://developer.tesla.com[/cyan] and ensure all")
334
+ formatter.rich.info(" required scopes are enabled (user_data, energy_device_data, etc.).")
335
+ formatter.rich.info("")
336
+ formatter.rich.info(
337
+ "[dim]Note: 'tescmd auth status' shows scopes that were requested, not necessarily"
338
+ " what Tesla granted. Tesla silently drops scopes your Developer Portal app"
339
+ " isn't configured for.[/dim]"
340
+ )
341
+
342
+
343
+ def _handle_auth_error(
344
+ exc: AuthError,
345
+ formatter: OutputFormatter,
346
+ cmd_name: str,
347
+ ) -> None:
348
+ """Show a friendly authentication error with next steps.
349
+
350
+ Uses the exception's message when available, falling back to a generic
351
+ description.
352
+ """
353
+ message = str(exc) or "Authentication failed. Your access token may be expired or invalid."
354
+ hint = "Run 'tescmd auth login' to re-authenticate."
355
+
356
+ if formatter.format == "json":
357
+ formatter.output_error(
358
+ code="auth_failed",
359
+ message=f"{message} {hint}",
360
+ command=cmd_name,
361
+ )
362
+ return
363
+
364
+ formatter.rich.error(message)
365
+ formatter.rich.info("")
366
+ formatter.rich.info("Next steps:")
367
+ formatter.rich.info(" [cyan]tescmd auth login[/cyan]")
368
+ formatter.rich.info("")
369
+ formatter.rich.info(
370
+ "[dim]If a refresh token is available and TESLA_CLIENT_ID is set,"
371
+ " tescmd will auto-refresh on the next request.[/dim]"
372
+ )
373
+
374
+
375
+ def _handle_vehicle_asleep(
376
+ exc: VehicleAsleepError,
377
+ formatter: OutputFormatter,
378
+ cmd_name: str,
379
+ ) -> None:
380
+ """Show a friendly message when the vehicle is asleep.
381
+
382
+ Uses the exception's message directly — it already distinguishes
383
+ between user-cancelled wake and actual API failure.
384
+ """
385
+ message = str(exc)
386
+ hint = "Use --wake to send a billable wake via the API, or wake from the Tesla app for free."
387
+
388
+ if formatter.format == "json":
389
+ formatter.output_error(
390
+ code="vehicle_asleep",
391
+ message=f"{message} {hint}",
392
+ command=cmd_name,
393
+ )
394
+ return
395
+
396
+ formatter.rich.info(f"[yellow]{message}[/yellow]")
397
+ formatter.rich.info("")
398
+ formatter.rich.info("Next steps:")
399
+ formatter.rich.info(" [cyan]tescmd vehicle wake --wait[/cyan] (billable)")
400
+ formatter.rich.info(" Or wake from the Tesla app (free), then retry.")
401
+
402
+
403
+ def _handle_registration_required(
404
+ exc: RegistrationRequiredError,
405
+ app_ctx: AppContext | None,
406
+ formatter: OutputFormatter,
407
+ cmd_name: str,
408
+ ) -> None:
409
+ """Try auto-registration, or show friendly instructions."""
410
+ from tescmd.auth.oauth import register_partner_account
411
+ from tescmd.models.config import AppSettings
412
+
413
+ settings = AppSettings()
414
+ region = (app_ctx.region if app_ctx else None) or settings.region
415
+
416
+ message = str(exc) or "Your application is not registered with the Fleet API."
417
+ if formatter.format == "json":
418
+ formatter.output_error(
419
+ code="registration_required",
420
+ message=f"{message} Run 'tescmd auth register' to fix this.",
421
+ command=cmd_name,
422
+ )
423
+ return
424
+
425
+ can_register = settings.client_id and settings.client_secret
426
+ domain = settings.domain
427
+
428
+ # Prompt for domain if we have credentials but no domain
429
+ if can_register and not domain:
430
+ from tescmd.cli.auth import _prompt_for_domain
431
+
432
+ domain = _prompt_for_domain(formatter)
433
+
434
+ # Try auto-fix if we have everything
435
+ if can_register and domain:
436
+ assert settings.client_id is not None
437
+ assert settings.client_secret is not None
438
+ formatter.rich.info(
439
+ "[yellow]Your app is not yet registered with the Fleet API."
440
+ " Registering now...[/yellow]"
441
+ )
442
+ try:
443
+ _result, _scopes = run_async(
444
+ register_partner_account(
445
+ client_id=settings.client_id,
446
+ client_secret=settings.client_secret,
447
+ domain=domain,
448
+ region=region,
449
+ )
450
+ )
451
+ formatter.rich.info("[green]Registration successful![/green]")
452
+ formatter.rich.info("")
453
+ formatter.rich.info("Please re-run your command:")
454
+ formatter.rich.info(f" [cyan]tescmd {cmd_name.replace('.', ' ')}[/cyan]")
455
+ return
456
+ except Exception as reg_exc:
457
+ formatter.rich.info(f"[red]Registration failed:[/red] {reg_exc}")
458
+
459
+ formatter.rich.info("")
460
+ formatter.rich.info(
461
+ "[yellow]Your application is not registered with the"
462
+ " Tesla Fleet API for this region.[/yellow]"
463
+ )
464
+ formatter.rich.info("")
465
+ formatter.rich.info("To fix this, run:")
466
+ formatter.rich.info(" [cyan]tescmd auth register[/cyan]")
467
+ formatter.rich.info("")
468
+ formatter.rich.info(
469
+ "[dim]This is a one-time step after creating your developer application.[/dim]"
470
+ )
471
+
472
+
473
+ def _handle_tier_error(
474
+ exc: TierError,
475
+ formatter: OutputFormatter,
476
+ cmd_name: str,
477
+ ) -> None:
478
+ """Show a friendly tier-mismatch error with upgrade guidance."""
479
+ message = str(exc) or "This command requires 'full' tier setup."
480
+
481
+ if formatter.format == "json":
482
+ formatter.output_error(
483
+ code="tier_readonly",
484
+ message=message,
485
+ command=cmd_name,
486
+ )
487
+ return
488
+
489
+ formatter.rich.error(message)
490
+ formatter.rich.info("")
491
+ formatter.rich.info("Your setup tier is [yellow]readonly[/yellow].")
492
+ formatter.rich.info(
493
+ "Vehicle commands (lock, charge, climate, etc.) require [cyan]full[/cyan] tier."
494
+ )
495
+ formatter.rich.info("")
496
+ formatter.rich.info("To upgrade:")
497
+ formatter.rich.info(" [cyan]tescmd setup[/cyan]")
498
+
499
+
500
+ def _handle_key_not_enrolled(
501
+ exc: KeyNotEnrolledError,
502
+ formatter: OutputFormatter,
503
+ cmd_name: str,
504
+ ) -> None:
505
+ """Show a friendly key-not-enrolled error with enrollment steps."""
506
+ from tescmd.models.config import AppSettings
507
+
508
+ message = str(exc) or "Your key is not enrolled on this vehicle."
509
+ settings = AppSettings()
510
+ domain = settings.domain
511
+
512
+ if formatter.format == "json":
513
+ if domain:
514
+ message += f" Enroll at https://tesla.com/_ak/{domain}"
515
+ formatter.output_error(
516
+ code="key_not_enrolled",
517
+ message=message,
518
+ command=cmd_name,
519
+ )
520
+ return
521
+
522
+ formatter.rich.error(message)
523
+ formatter.rich.info("")
524
+ if domain:
525
+ enroll_url = f"https://tesla.com/_ak/{domain}"
526
+ formatter.rich.info("To enroll your key:")
527
+ formatter.rich.info(f" 1. Open [link={enroll_url}]{enroll_url}[/link] on your phone")
528
+ formatter.rich.info(" 2. Tap [bold]Finish Setup[/bold] on the web page")
529
+ formatter.rich.info(
530
+ " 3. Approve the [bold]Add Virtual Key[/bold] prompt in the Tesla app"
531
+ )
532
+ formatter.rich.info("")
533
+ formatter.rich.info(
534
+ "Or run [cyan]tescmd key enroll[/cyan] to see the full enrollment guide."
535
+ )
536
+ else:
537
+ formatter.rich.info("To enroll your key:")
538
+ formatter.rich.info(" [cyan]tescmd key enroll[/cyan]")
539
+ formatter.rich.info("")
540
+ formatter.rich.info("[dim]Set TESLA_DOMAIN first if not already configured.[/dim]")
541
+
542
+
543
+ def _handle_session_error(
544
+ exc: SessionError,
545
+ formatter: OutputFormatter,
546
+ cmd_name: str,
547
+ ) -> None:
548
+ """Show a friendly session handshake error with recovery steps."""
549
+ message = str(exc) or "Session handshake with the vehicle failed."
550
+
551
+ if formatter.format == "json":
552
+ formatter.output_error(
553
+ code="session_error",
554
+ message=message,
555
+ command=cmd_name,
556
+ )
557
+ return
558
+
559
+ formatter.rich.error(message)
560
+ formatter.rich.info("")
561
+ formatter.rich.info("Possible causes:")
562
+ formatter.rich.info(" - Vehicle is temporarily unreachable")
563
+ formatter.rich.info(" - Local key pair is corrupted")
564
+ formatter.rich.info(" - Key was re-generated but not re-enrolled")
565
+ formatter.rich.info("")
566
+ formatter.rich.info("Try again, or if the problem persists:")
567
+ formatter.rich.info(" [cyan]tescmd key generate --force[/cyan] (regenerate key pair)")
568
+ formatter.rich.info(" [cyan]tescmd key deploy[/cyan] (re-deploy public key)")
569
+ formatter.rich.info(" [cyan]tescmd key enroll[/cyan] (re-enroll on vehicle)")
570
+
571
+
572
+ def _handle_keyring_error(
573
+ exc: Exception,
574
+ formatter: OutputFormatter,
575
+ cmd_name: str,
576
+ ) -> None:
577
+ """Show a friendly error when the OS keyring is unavailable."""
578
+ message = f"OS keyring error: {exc}"
579
+
580
+ if formatter.format == "json":
581
+ formatter.output_error(
582
+ code="keyring_error",
583
+ message=(
584
+ f"{message} Set TESLA_TOKEN_FILE to store tokens in a file "
585
+ "instead of the OS keyring."
586
+ ),
587
+ command=cmd_name,
588
+ )
589
+ return
590
+
591
+ formatter.rich.error(message)
592
+ formatter.rich.info("")
593
+ formatter.rich.info("The OS keyring (macOS Keychain, GNOME Keyring, etc.) is unavailable.")
594
+ formatter.rich.info("This is common in Docker, headless Linux, and CI environments.")
595
+ formatter.rich.info("")
596
+ formatter.rich.info("To fix this, set a file-based token path:")
597
+ formatter.rich.info(" [cyan]export TESLA_TOKEN_FILE=~/.config/tescmd/tokens.json[/cyan]")
598
+ formatter.rich.info("")
599
+ formatter.rich.info(
600
+ "[dim]Tokens will be stored in plaintext with restricted file permissions.[/dim]"
601
+ )