tescmd 0.1.2__py3-none-any.whl → 0.3.1__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 (90) hide show
  1. tescmd/__init__.py +1 -1
  2. tescmd/api/client.py +49 -5
  3. tescmd/api/command.py +1 -1
  4. tescmd/api/errors.py +13 -0
  5. tescmd/api/signed_command.py +19 -14
  6. tescmd/api/vehicle.py +19 -1
  7. tescmd/auth/oauth.py +5 -1
  8. tescmd/auth/server.py +6 -1
  9. tescmd/auth/token_store.py +8 -1
  10. tescmd/cache/response_cache.py +11 -3
  11. tescmd/cli/_client.py +142 -20
  12. tescmd/cli/_options.py +2 -4
  13. tescmd/cli/auth.py +121 -11
  14. tescmd/cli/energy.py +2 -0
  15. tescmd/cli/key.py +149 -14
  16. tescmd/cli/main.py +70 -7
  17. tescmd/cli/mcp_cmd.py +153 -0
  18. tescmd/cli/nav.py +3 -1
  19. tescmd/cli/openclaw.py +169 -0
  20. tescmd/cli/security.py +7 -1
  21. tescmd/cli/serve.py +923 -0
  22. tescmd/cli/setup.py +244 -25
  23. tescmd/cli/sharing.py +2 -0
  24. tescmd/cli/status.py +1 -1
  25. tescmd/cli/trunk.py +8 -17
  26. tescmd/cli/user.py +16 -1
  27. tescmd/cli/vehicle.py +156 -20
  28. tescmd/crypto/__init__.py +3 -1
  29. tescmd/crypto/ecdh.py +9 -0
  30. tescmd/crypto/schnorr.py +191 -0
  31. tescmd/deploy/github_pages.py +8 -0
  32. tescmd/deploy/tailscale_serve.py +154 -0
  33. tescmd/mcp/__init__.py +7 -0
  34. tescmd/mcp/server.py +648 -0
  35. tescmd/models/__init__.py +0 -2
  36. tescmd/models/auth.py +24 -2
  37. tescmd/models/config.py +1 -0
  38. tescmd/models/energy.py +0 -9
  39. tescmd/openclaw/__init__.py +23 -0
  40. tescmd/openclaw/bridge.py +330 -0
  41. tescmd/openclaw/config.py +167 -0
  42. tescmd/openclaw/dispatcher.py +522 -0
  43. tescmd/openclaw/emitter.py +175 -0
  44. tescmd/openclaw/filters.py +123 -0
  45. tescmd/openclaw/gateway.py +687 -0
  46. tescmd/openclaw/telemetry_store.py +53 -0
  47. tescmd/output/rich_output.py +46 -14
  48. tescmd/protocol/commands.py +2 -2
  49. tescmd/protocol/encoder.py +16 -13
  50. tescmd/protocol/payloads.py +132 -11
  51. tescmd/protocol/session.py +18 -8
  52. tescmd/protocol/signer.py +3 -17
  53. tescmd/telemetry/__init__.py +28 -0
  54. tescmd/telemetry/cache_sink.py +154 -0
  55. tescmd/telemetry/csv_sink.py +180 -0
  56. tescmd/telemetry/dashboard.py +227 -0
  57. tescmd/telemetry/decoder.py +284 -0
  58. tescmd/telemetry/fanout.py +49 -0
  59. tescmd/telemetry/fields.py +427 -0
  60. tescmd/telemetry/flatbuf.py +162 -0
  61. tescmd/telemetry/mapper.py +239 -0
  62. tescmd/telemetry/protos/__init__.py +4 -0
  63. tescmd/telemetry/protos/vehicle_alert.proto +31 -0
  64. tescmd/telemetry/protos/vehicle_alert_pb2.py +42 -0
  65. tescmd/telemetry/protos/vehicle_alert_pb2.pyi +44 -0
  66. tescmd/telemetry/protos/vehicle_connectivity.proto +23 -0
  67. tescmd/telemetry/protos/vehicle_connectivity_pb2.py +40 -0
  68. tescmd/telemetry/protos/vehicle_connectivity_pb2.pyi +33 -0
  69. tescmd/telemetry/protos/vehicle_data.proto +768 -0
  70. tescmd/telemetry/protos/vehicle_data_pb2.py +136 -0
  71. tescmd/telemetry/protos/vehicle_data_pb2.pyi +1336 -0
  72. tescmd/telemetry/protos/vehicle_error.proto +23 -0
  73. tescmd/telemetry/protos/vehicle_error_pb2.py +44 -0
  74. tescmd/telemetry/protos/vehicle_error_pb2.pyi +39 -0
  75. tescmd/telemetry/protos/vehicle_metric.proto +22 -0
  76. tescmd/telemetry/protos/vehicle_metric_pb2.py +44 -0
  77. tescmd/telemetry/protos/vehicle_metric_pb2.pyi +37 -0
  78. tescmd/telemetry/server.py +300 -0
  79. tescmd/telemetry/setup.py +468 -0
  80. tescmd/telemetry/tailscale.py +300 -0
  81. tescmd/telemetry/tui.py +1716 -0
  82. tescmd/triggers/__init__.py +18 -0
  83. tescmd/triggers/manager.py +264 -0
  84. tescmd/triggers/models.py +93 -0
  85. {tescmd-0.1.2.dist-info → tescmd-0.3.1.dist-info}/METADATA +125 -40
  86. tescmd-0.3.1.dist-info/RECORD +128 -0
  87. tescmd-0.1.2.dist-info/RECORD +0 -81
  88. {tescmd-0.1.2.dist-info → tescmd-0.3.1.dist-info}/WHEEL +0 -0
  89. {tescmd-0.1.2.dist-info → tescmd-0.3.1.dist-info}/entry_points.txt +0 -0
  90. {tescmd-0.1.2.dist-info → tescmd-0.3.1.dist-info}/licenses/LICENSE +0 -0
tescmd/cli/auth.py CHANGED
@@ -20,14 +20,20 @@ from tescmd.auth.oauth import (
20
20
  )
21
21
  from tescmd.auth.token_store import TokenStore
22
22
  from tescmd.cli._options import global_options
23
- from tescmd.models.auth import DEFAULT_SCOPES, PARTNER_SCOPES, TokenData, decode_jwt_scopes
23
+ from tescmd.models.auth import (
24
+ DEFAULT_SCOPES,
25
+ PARTNER_SCOPES,
26
+ TokenData,
27
+ decode_jwt_payload,
28
+ decode_jwt_scopes,
29
+ )
24
30
  from tescmd.models.config import AppSettings
25
31
 
26
32
  if TYPE_CHECKING:
27
33
  from tescmd.cli.main import AppContext
28
34
  from tescmd.output.formatter import OutputFormatter
29
35
 
30
- DEVELOPER_PORTAL_URL = "https://developer.tesla.com"
36
+ DEVELOPER_PORTAL_URL = "https://developer.tesla.com/dashboard"
31
37
 
32
38
 
33
39
  # ---------------------------------------------------------------------------
@@ -43,7 +49,12 @@ auth_group = click.Group("auth", help="Authentication commands")
43
49
 
44
50
 
45
51
  @auth_group.command("login")
46
- @click.option("--port", type=int, default=8085, help="Local callback port")
52
+ @click.option(
53
+ "--port",
54
+ type=int,
55
+ default=None,
56
+ help="Local callback port (default: 8085 or TESCMD_OAUTH_PORT)",
57
+ )
47
58
  @click.option(
48
59
  "--reconsent",
49
60
  is_flag=True,
@@ -51,15 +62,20 @@ auth_group = click.Group("auth", help="Authentication commands")
51
62
  help="Force Tesla to re-display the scope consent screen.",
52
63
  )
53
64
  @global_options
54
- def login_cmd(app_ctx: AppContext, port: int, reconsent: bool) -> None:
65
+ def login_cmd(app_ctx: AppContext, port: int | None, reconsent: bool) -> None:
55
66
  """Log in via OAuth2 PKCE flow."""
56
67
  run_async(_cmd_login(app_ctx, port, reconsent=reconsent))
57
68
 
58
69
 
59
- async def _cmd_login(app_ctx: AppContext, port: int, *, reconsent: bool = False) -> None:
70
+ async def _cmd_login(app_ctx: AppContext, port: int | None, *, reconsent: bool = False) -> None:
71
+ from tescmd.models.auth import DEFAULT_PORT
72
+
60
73
  formatter = app_ctx.formatter
61
74
  settings = AppSettings()
62
75
 
76
+ if port is None:
77
+ port = DEFAULT_PORT
78
+
63
79
  client_id = settings.client_id
64
80
  client_secret = settings.client_secret
65
81
 
@@ -182,11 +198,23 @@ async def _cmd_status(app_ctx: AppContext) -> None:
182
198
  region: str = meta.get("region", "unknown")
183
199
  has_refresh = store.refresh_token is not None
184
200
 
185
- # Decode the JWT to show the *actual* granted scopes
201
+ # Decode the JWT to show the *actual* granted scopes and audience
186
202
  token_scopes: list[str] | None = None
203
+ audience: str | None = None
187
204
  access_token = store.access_token
188
205
  if access_token:
189
206
  token_scopes = decode_jwt_scopes(access_token)
207
+ jwt_payload = decode_jwt_payload(access_token)
208
+ if jwt_payload is not None:
209
+ aud_raw = jwt_payload.get("aud")
210
+ if isinstance(aud_raw, list) and aud_raw:
211
+ audience = aud_raw[0] if len(aud_raw) == 1 else ", ".join(str(a) for a in aud_raw)
212
+ elif isinstance(aud_raw, str):
213
+ audience = aud_raw
214
+ # Also check for 'ou' (origin URL) — Tesla-specific claim
215
+ ou = jwt_payload.get("ou")
216
+ if isinstance(ou, str) and ou:
217
+ audience = ou
190
218
 
191
219
  if formatter.format == "json":
192
220
  data: dict[str, object] = {
@@ -198,6 +226,8 @@ async def _cmd_status(app_ctx: AppContext) -> None:
198
226
  }
199
227
  if token_scopes is not None:
200
228
  data["token_scopes"] = token_scopes
229
+ if audience is not None:
230
+ data["audience"] = audience
201
231
  formatter.output(data, command="auth.status")
202
232
  else:
203
233
  formatter.rich.info("Authenticated: yes")
@@ -211,6 +241,8 @@ async def _cmd_status(app_ctx: AppContext) -> None:
211
241
  formatter.rich.info(
212
242
  f" [yellow]Warning: requested but not granted: {not_granted}[/yellow]"
213
243
  )
244
+ if audience is not None:
245
+ formatter.rich.info(f"Audience: {audience}")
214
246
  formatter.rich.info(f"Region: {region}")
215
247
  formatter.rich.info(f"Refresh token: {'yes' if has_refresh else 'no'}")
216
248
 
@@ -245,11 +277,24 @@ async def _cmd_refresh(app_ctx: AppContext) -> None:
245
277
  scopes: list[str] = meta.get("scopes", DEFAULT_SCOPES)
246
278
  region: str = meta.get("region", "na")
247
279
 
248
- token_data = await refresh_access_token(
249
- refresh_token=rt,
250
- client_id=settings.client_id,
251
- client_secret=settings.client_secret,
252
- )
280
+ from tescmd.api.errors import AuthError
281
+ from tescmd.cli._client import reauth_on_expired_refresh
282
+
283
+ try:
284
+ token_data = await refresh_access_token(
285
+ refresh_token=rt,
286
+ client_id=settings.client_id,
287
+ client_secret=settings.client_secret,
288
+ )
289
+ except AuthError as exc:
290
+ if "login_required" not in str(exc):
291
+ raise
292
+ await reauth_on_expired_refresh(store, settings)
293
+ if formatter.format == "json":
294
+ formatter.output({"status": "re-authenticated"}, command="auth.refresh")
295
+ else:
296
+ formatter.rich.info("")
297
+ return
253
298
 
254
299
  store.save(
255
300
  access_token=token_data.access_token,
@@ -423,12 +468,17 @@ def _interactive_setup(
423
468
  redirect_uri: str,
424
469
  *,
425
470
  domain: str = "",
471
+ tailscale_hostname: str = "",
426
472
  ) -> tuple[str, str]:
427
473
  """Walk the user through first-time Tesla API credential setup.
428
474
 
429
475
  When *domain* is provided (e.g. from the setup wizard), the developer
430
476
  portal instructions show ``https://{domain}`` as the Allowed Origin URL.
431
477
  Tesla's Fleet API requires the origin to match the registration domain.
478
+
479
+ When *tailscale_hostname* is provided (or auto-detected), the user is
480
+ offered the chance to start a Tailscale Funnel so Tesla can verify the
481
+ origin URL when the portal app config is saved.
432
482
  """
433
483
  info = formatter.rich.info
434
484
  origin_url = f"https://{domain}" if domain else f"http://localhost:{port}"
@@ -475,6 +525,47 @@ def _interactive_setup(
475
525
  info(" Click Next.")
476
526
  info("")
477
527
 
528
+ # Detect Tailscale and offer to start Funnel for origin URL verification
529
+ ts_hostname = tailscale_hostname
530
+ ts_funnel_started = False
531
+ if not ts_hostname:
532
+ try:
533
+ from tescmd.deploy.tailscale_serve import is_tailscale_serve_ready
534
+
535
+ if run_async(is_tailscale_serve_ready()):
536
+ from tescmd.telemetry.tailscale import TailscaleManager
537
+
538
+ ts_hostname = run_async(TailscaleManager().get_hostname())
539
+ except Exception:
540
+ pass
541
+
542
+ if ts_hostname:
543
+ ts_origin = f"https://{ts_hostname}"
544
+ info("")
545
+ info(f"[green]Tailscale detected:[/green] {ts_hostname}")
546
+ info("")
547
+ info(f" Adding [cyan]{ts_origin}[/cyan] as an Allowed Origin URL")
548
+ info(" will let [cyan]tescmd serve[/cyan] stream telemetry without")
549
+ info(" extra portal changes later.")
550
+ info("")
551
+ try:
552
+ answer = input("Start Tailscale Funnel so Tesla can verify the URL? [Y/n] ").strip()
553
+ except (EOFError, KeyboardInterrupt):
554
+ answer = "n"
555
+
556
+ if answer.lower() != "n":
557
+ info("Starting Tailscale Funnel...")
558
+ try:
559
+ from tescmd.telemetry.tailscale import TailscaleManager as _TsMgr
560
+
561
+ run_async(_TsMgr().enable_funnel())
562
+ ts_funnel_started = True
563
+ info(f"[green]Funnel active — {ts_origin} is reachable.[/green]")
564
+ except Exception as exc:
565
+ info(f"[yellow]Could not start Funnel: {exc}[/yellow]")
566
+ info("[dim]You can add the origin URL manually later.[/dim]")
567
+ ts_hostname = ""
568
+
478
569
  # Step 3 — Client Details
479
570
  info("[bold]Step 3 — Client Details[/bold]")
480
571
  info(
@@ -482,8 +573,17 @@ def _interactive_setup(
482
573
  " Machine-to-Machine[/cyan] (the default)"
483
574
  )
484
575
  info(f" Allowed Origin URL: [cyan]{origin_url}[/cyan]")
576
+ if ts_hostname:
577
+ info(f" [bold]Also add:[/bold] [cyan]https://{ts_hostname}[/cyan]")
485
578
  info(f" Allowed Redirect URI: [cyan]{redirect_uri}[/cyan]")
486
579
  info(" Allowed Returned URL: (leave empty)")
580
+ info("")
581
+ if not ts_hostname:
582
+ info(
583
+ " [dim]For telemetry streaming, add your Tailscale hostname"
584
+ " as an additional origin:[/dim]"
585
+ )
586
+ info(" [dim] https://<machine>.tailnet.ts.net[/dim]")
487
587
  info(" Click Next.")
488
588
  info("")
489
589
 
@@ -544,6 +644,16 @@ def _interactive_setup(
544
644
  _write_env_file(client_id, client_secret)
545
645
  info("[green]Credentials saved to .env[/green]")
546
646
 
647
+ # Clean up Tailscale Funnel if we started it during this session
648
+ if ts_funnel_started:
649
+ try:
650
+ from tescmd.telemetry.tailscale import TailscaleManager as _TsMgr
651
+
652
+ run_async(_TsMgr._run("tailscale", "funnel", "--bg", "off"))
653
+ except Exception as exc:
654
+ info(f"[yellow]Warning: Failed to stop Tailscale Funnel: {exc}[/yellow]")
655
+ info("[dim]You may need to run 'tailscale funnel --bg off' manually.[/dim]")
656
+
547
657
  info("")
548
658
  return (client_id, client_secret)
549
659
 
tescmd/cli/energy.py CHANGED
@@ -9,6 +9,7 @@ import click
9
9
  from tescmd._internal.async_utils import run_async
10
10
  from tescmd.cli._client import TTL_SLOW, cached_api_call, get_energy_api, invalidate_cache_for_site
11
11
  from tescmd.cli._options import global_options
12
+ from tescmd.models.energy import SiteInfo
12
13
 
13
14
  if TYPE_CHECKING:
14
15
  from tescmd.cli.main import AppContext
@@ -66,6 +67,7 @@ async def _cmd_status(app_ctx: AppContext, site_id: int) -> None:
66
67
  endpoint="energy.status",
67
68
  fetch=lambda: api.site_info(site_id),
68
69
  ttl=TTL_SLOW,
70
+ model_class=SiteInfo,
69
71
  )
70
72
  finally:
71
73
  await client.close()
tescmd/cli/key.py CHANGED
@@ -25,6 +25,7 @@ from tescmd.models.config import AppSettings
25
25
 
26
26
  if TYPE_CHECKING:
27
27
  from tescmd.cli.main import AppContext
28
+ from tescmd.output.formatter import OutputFormatter
28
29
 
29
30
 
30
31
  # ---------------------------------------------------------------------------
@@ -95,22 +96,19 @@ async def _cmd_generate(app_ctx: AppContext, force: bool) -> None:
95
96
  default=None,
96
97
  help="GitHub repo (e.g. user/user.github.io). Auto-detected if omitted.",
97
98
  )
99
+ @click.option(
100
+ "--method",
101
+ type=click.Choice(["auto", "github", "tailscale"]),
102
+ default="auto",
103
+ help="Deployment method (default: auto-detect).",
104
+ )
98
105
  @global_options
99
- def deploy_cmd(app_ctx: AppContext, repo: str | None) -> None:
100
- """Deploy the public key to GitHub Pages."""
101
- run_async(_cmd_deploy(app_ctx, repo))
102
-
106
+ def deploy_cmd(app_ctx: AppContext, repo: str | None, method: str) -> None:
107
+ """Deploy the public key to GitHub Pages or via Tailscale Funnel."""
108
+ run_async(_cmd_deploy(app_ctx, repo, method=method))
103
109
 
104
- async def _cmd_deploy(app_ctx: AppContext, repo: str | None) -> None:
105
- from tescmd.deploy.github_pages import (
106
- create_pages_repo,
107
- deploy_public_key,
108
- get_gh_username,
109
- get_pages_domain,
110
- is_gh_authenticated,
111
- is_gh_available,
112
- )
113
110
 
111
+ async def _cmd_deploy(app_ctx: AppContext, repo: str | None, *, method: str = "auto") -> None:
114
112
  formatter = app_ctx.formatter
115
113
  settings = AppSettings()
116
114
  key_dir = Path(settings.config_dir).expanduser() / "keys"
@@ -127,6 +125,142 @@ async def _cmd_deploy(app_ctx: AppContext, repo: str | None) -> None:
127
125
  formatter.rich.error("No key pair found. Run [cyan]tescmd key generate[/cyan] first.")
128
126
  return
129
127
 
128
+ # Resolve method
129
+ resolved = _resolve_deploy_method(method, settings)
130
+
131
+ if resolved == "tailscale":
132
+ await _cmd_deploy_tailscale(formatter, key_dir)
133
+ elif resolved == "github":
134
+ await _cmd_deploy_github(formatter, settings, key_dir, repo)
135
+ else:
136
+ if formatter.format == "json":
137
+ formatter.output_error(
138
+ code="no_deploy_method",
139
+ message=(
140
+ "No deployment method available. Install 'gh' CLI for GitHub Pages"
141
+ " or Tailscale for Funnel hosting."
142
+ ),
143
+ command="key.deploy",
144
+ )
145
+ else:
146
+ formatter.rich.error(
147
+ "No deployment method available. Install [cyan]gh[/cyan] CLI"
148
+ " (GitHub Pages) or [cyan]tailscale[/cyan] (Funnel hosting)."
149
+ )
150
+
151
+
152
+ def _resolve_deploy_method(method: str, settings: AppSettings) -> str | None:
153
+ """Resolve 'auto' to a concrete method, or validate explicit choice."""
154
+ if method in ("tailscale", "github"):
155
+ return method
156
+
157
+ # Auto-detect: GitHub Pages (always-on) > Tailscale (stable hostname)
158
+ from tescmd.deploy.github_pages import is_gh_authenticated, is_gh_available
159
+
160
+ if is_gh_available() and is_gh_authenticated():
161
+ return "github"
162
+
163
+ from tescmd.deploy.tailscale_serve import is_tailscale_serve_ready
164
+
165
+ if run_async(is_tailscale_serve_ready()):
166
+ return "tailscale"
167
+
168
+ # Check saved hosting method as last resort
169
+ if settings.hosting_method == "tailscale":
170
+ return "tailscale"
171
+ if settings.hosting_method == "github":
172
+ return "github"
173
+
174
+ return None
175
+
176
+
177
+ async def _cmd_deploy_tailscale(
178
+ formatter: OutputFormatter,
179
+ key_dir: Path,
180
+ ) -> None:
181
+ """Deploy public key via Tailscale Funnel."""
182
+ from tescmd.deploy.tailscale_serve import (
183
+ deploy_public_key_tailscale,
184
+ get_key_url,
185
+ is_tailscale_serve_ready,
186
+ start_key_serving,
187
+ wait_for_tailscale_deployment,
188
+ )
189
+
190
+ # Verify Tailscale is ready
191
+ if not await is_tailscale_serve_ready():
192
+ if formatter.format == "json":
193
+ formatter.output_error(
194
+ code="tailscale_not_ready",
195
+ message=(
196
+ "Tailscale is not available or Funnel is not enabled."
197
+ " Ensure Tailscale is running and Funnel is enabled in your tailnet ACL."
198
+ ),
199
+ command="key.deploy",
200
+ )
201
+ else:
202
+ formatter.rich.error(
203
+ "Tailscale is not available or Funnel is not enabled.\n"
204
+ " Ensure Tailscale is running and Funnel is enabled in your tailnet ACL."
205
+ )
206
+ return
207
+
208
+ if formatter.format != "json":
209
+ formatter.rich.info("Deploying public key via Tailscale Funnel...")
210
+
211
+ pem = load_public_key_pem(key_dir)
212
+ await deploy_public_key_tailscale(pem)
213
+ hostname = await start_key_serving()
214
+
215
+ url = get_key_url(hostname)
216
+ if formatter.format != "json":
217
+ formatter.rich.info("[green]Tailscale serve + Funnel started.[/green]")
218
+ formatter.rich.info(f" URL: {url}")
219
+ formatter.rich.info("")
220
+ formatter.rich.info("Waiting for key to become accessible...")
221
+
222
+ deployed = await wait_for_tailscale_deployment(hostname)
223
+
224
+ if formatter.format == "json":
225
+ formatter.output(
226
+ {
227
+ "status": "deployed" if deployed else "pending",
228
+ "method": "tailscale",
229
+ "hostname": hostname,
230
+ "url": url,
231
+ "accessible": deployed,
232
+ },
233
+ command="key.deploy",
234
+ )
235
+ elif deployed:
236
+ formatter.rich.info("[green]Key is live and accessible.[/green]")
237
+ formatter.rich.info("")
238
+ formatter.rich.info(
239
+ "[yellow]Note:[/yellow] The key is served by the Tailscale daemon."
240
+ " If your machine is off or Tailscale stops,"
241
+ " Tesla cannot reach your public key."
242
+ )
243
+ else:
244
+ formatter.rich.info("[yellow]Key deployed but not yet accessible.[/yellow]")
245
+ formatter.rich.info(" Run [cyan]tescmd key validate[/cyan] to check again later.")
246
+
247
+
248
+ async def _cmd_deploy_github(
249
+ formatter: OutputFormatter,
250
+ settings: AppSettings,
251
+ key_dir: Path,
252
+ repo: str | None,
253
+ ) -> None:
254
+ """Deploy public key via GitHub Pages."""
255
+ from tescmd.deploy.github_pages import (
256
+ create_pages_repo,
257
+ deploy_public_key,
258
+ get_gh_username,
259
+ get_pages_domain,
260
+ is_gh_authenticated,
261
+ is_gh_available,
262
+ )
263
+
130
264
  # Check gh CLI
131
265
  if not is_gh_available():
132
266
  if formatter.format == "json":
@@ -180,7 +314,7 @@ async def _cmd_deploy(app_ctx: AppContext, repo: str | None) -> None:
180
314
  formatter.rich.info("[green]Key deployed.[/green]")
181
315
  formatter.rich.info(f" URL: {get_key_url(domain)}")
182
316
  formatter.rich.info("")
183
- formatter.rich.info("Waiting for GitHub Pages to publish (this may take a few minutes)...")
317
+ formatter.rich.info("Waiting for GitHub Pages to publish...")
184
318
 
185
319
  deployed = wait_for_pages_deployment(domain)
186
320
 
@@ -188,6 +322,7 @@ async def _cmd_deploy(app_ctx: AppContext, repo: str | None) -> None:
188
322
  formatter.output(
189
323
  {
190
324
  "status": "deployed" if deployed else "pending",
325
+ "method": "github",
191
326
  "repo": repo_name,
192
327
  "domain": domain,
193
328
  "url": get_key_url(domain),
tescmd/cli/main.py CHANGED
@@ -6,6 +6,7 @@ import dataclasses
6
6
  import logging
7
7
 
8
8
  import click
9
+ from dotenv import load_dotenv
9
10
 
10
11
  from tescmd._internal.async_utils import run_async
11
12
  from tescmd.api.errors import (
@@ -15,6 +16,7 @@ from tescmd.api.errors import (
15
16
  RegistrationRequiredError,
16
17
  SessionError,
17
18
  TierError,
19
+ TunnelError,
18
20
  VehicleAsleepError,
19
21
  )
20
22
  from tescmd.output.formatter import OutputFormatter
@@ -61,6 +63,13 @@ class AppContext:
61
63
  return self._formatter
62
64
 
63
65
 
66
+ # ---------------------------------------------------------------------------
67
+ # Load .env into os.environ before Click resolves any envvar= options.
68
+ # This ensures TESLA_VIN, TESCMD_MCP_CLIENT_ID, etc. from .env files
69
+ # are visible to Click's envvar resolution.
70
+ # ---------------------------------------------------------------------------
71
+ load_dotenv(override=False)
72
+
64
73
  # ---------------------------------------------------------------------------
65
74
  # Root Click group
66
75
  # ---------------------------------------------------------------------------
@@ -159,11 +168,14 @@ def _register_commands() -> None:
159
168
  from tescmd.cli.climate import climate_group
160
169
  from tescmd.cli.energy import energy_group
161
170
  from tescmd.cli.key import key_group
171
+ from tescmd.cli.mcp_cmd import mcp_group
162
172
  from tescmd.cli.media import media_group
163
173
  from tescmd.cli.nav import nav_group
174
+ from tescmd.cli.openclaw import openclaw_group
164
175
  from tescmd.cli.partner import partner_group
165
176
  from tescmd.cli.raw import raw_group
166
177
  from tescmd.cli.security import security_group
178
+ from tescmd.cli.serve import serve_cmd
167
179
  from tescmd.cli.setup import setup_cmd
168
180
  from tescmd.cli.sharing import sharing_group
169
181
  from tescmd.cli.software import software_group
@@ -179,11 +191,14 @@ def _register_commands() -> None:
179
191
  cli.add_command(climate_group)
180
192
  cli.add_command(energy_group)
181
193
  cli.add_command(key_group)
194
+ cli.add_command(mcp_group)
182
195
  cli.add_command(media_group)
183
196
  cli.add_command(nav_group)
197
+ cli.add_command(openclaw_group)
184
198
  cli.add_command(partner_group)
185
199
  cli.add_command(raw_group)
186
200
  cli.add_command(security_group)
201
+ cli.add_command(serve_cmd)
187
202
  cli.add_command(setup_cmd)
188
203
  cli.add_command(sharing_group)
189
204
  cli.add_command(status_cmd)
@@ -287,6 +302,9 @@ def _handle_known_error(
287
302
  if isinstance(exc, SessionError):
288
303
  _handle_session_error(exc, formatter, cmd_name)
289
304
  return True
305
+ if isinstance(exc, TunnelError):
306
+ _handle_tunnel_error(exc, formatter, cmd_name)
307
+ return True
290
308
 
291
309
  # Keyring failures (e.g. headless Linux with no keyring daemon)
292
310
  try:
@@ -559,14 +577,19 @@ def _handle_session_error(
559
577
  formatter.rich.error(message)
560
578
  formatter.rich.info("")
561
579
  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")
580
+ formatter.rich.info(" - Vehicle is temporarily unreachable or asleep")
581
+ formatter.rich.info(" - Session expired retry once and a fresh handshake will occur")
582
+ formatter.rich.info(
583
+ " - Local key pair is corrupted or was re-generated without re-enrollment"
584
+ )
565
585
  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)")
586
+ formatter.rich.info("Try:")
587
+ formatter.rich.info(" 1. Re-run the command (sessions auto-refresh)")
588
+ formatter.rich.info(" 2. Use [cyan]--verbose[/cyan] to see fault codes and signing details")
589
+ formatter.rich.info(" 3. If repeated failures, re-enroll:")
590
+ formatter.rich.info(
591
+ " [cyan]tescmd key generate --force && tescmd key deploy && tescmd key enroll[/cyan]"
592
+ )
570
593
 
571
594
 
572
595
  def _handle_keyring_error(
@@ -599,3 +622,43 @@ def _handle_keyring_error(
599
622
  formatter.rich.info(
600
623
  "[dim]Tokens will be stored in plaintext with restricted file permissions.[/dim]"
601
624
  )
625
+
626
+
627
+ def _handle_tunnel_error(
628
+ exc: TunnelError,
629
+ formatter: OutputFormatter,
630
+ cmd_name: str,
631
+ ) -> None:
632
+ """Show a friendly error for tunnel-related failures.
633
+
634
+ Only shows install guidance when no provider is found on PATH.
635
+ TailscaleError means Tailscale IS installed but hit an operational
636
+ error — install guidance would be confusing.
637
+ """
638
+ message = str(exc) or "No tunnel provider available for telemetry streaming."
639
+
640
+ if formatter.format == "json":
641
+ formatter.output_error(
642
+ code="tunnel_error",
643
+ message=message,
644
+ command=cmd_name,
645
+ )
646
+ return
647
+
648
+ formatter.rich.error(message)
649
+
650
+ # Only show install guidance when no provider was found at all.
651
+ if "No tunnel provider" not in message:
652
+ return
653
+
654
+ formatter.rich.info("")
655
+ formatter.rich.info("Telemetry streaming requires Tailscale Funnel:")
656
+ formatter.rich.info("")
657
+ formatter.rich.info(" 1. Install Tailscale: [cyan]https://tailscale.com/download[/cyan]")
658
+ formatter.rich.info(" 2. Authenticate: [cyan]tailscale up[/cyan]")
659
+ formatter.rich.info(
660
+ " 3. Enable Funnel in your tailnet ACL:"
661
+ " [cyan]https://login.tailscale.com/admin/acls[/cyan]"
662
+ )
663
+ formatter.rich.info("")
664
+ formatter.rich.info("Telemetry deps are included with tescmd.")