tescmd 0.3.1__py3-none-any.whl → 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.
tescmd/__init__.py CHANGED
@@ -1,3 +1,3 @@
1
1
  """tescmd — A Python CLI for querying and controlling Tesla vehicles via the Fleet API."""
2
2
 
3
- __version__ = "0.3.1"
3
+ __version__ = "0.5.0"
tescmd/auth/oauth.py CHANGED
@@ -208,6 +208,14 @@ async def register_partner_account(
208
208
  )
209
209
 
210
210
  if resp.status_code >= 400:
211
+ # HTTP 422 with "already been taken" means the key or domain is
212
+ # already registered. Treat as success — registration is idempotent.
213
+ if resp.status_code == 422 and "already been taken" in resp.text:
214
+ logger.info(
215
+ "Partner already registered for %s region (key already taken)",
216
+ region,
217
+ )
218
+ return {"already_registered": True}, granted_scopes
211
219
  raise AuthError(
212
220
  f"Partner registration failed (HTTP {resp.status_code}): {resp.text}",
213
221
  status_code=resp.status_code,
@@ -282,6 +290,8 @@ async def login_flow(
282
290
  state,
283
291
  force_consent=force_consent,
284
292
  )
293
+ logger.info("Authorization URL: %s", url)
294
+ print(f"\n {url}\n")
285
295
  webbrowser.open(url)
286
296
 
287
297
  code, callback_state = server.wait_for_callback(timeout=120)
tescmd/cli/auth.py CHANGED
@@ -3,8 +3,10 @@
3
3
  from __future__ import annotations
4
4
 
5
5
  import json
6
+ import os
6
7
  import sys
7
8
  import time
9
+ import uuid
8
10
  import webbrowser
9
11
  from pathlib import Path
10
12
  from typing import TYPE_CHECKING
@@ -31,7 +33,9 @@ from tescmd.models.config import AppSettings
31
33
 
32
34
  if TYPE_CHECKING:
33
35
  from tescmd.cli.main import AppContext
36
+ from tescmd.deploy.tailscale_serve import KeyServer
34
37
  from tescmd.output.formatter import OutputFormatter
38
+ from tescmd.telemetry.tailscale import TailscaleManager
35
39
 
36
40
  DEVELOPER_PORTAL_URL = "https://developer.tesla.com/dashboard"
37
41
 
@@ -374,9 +378,10 @@ async def _cmd_register(app_ctx: AppContext) -> None:
374
378
  region=region,
375
379
  )
376
380
 
381
+ already = bool(_result.get("already_registered"))
377
382
  if formatter.format == "json":
378
383
  data: dict[str, object] = {
379
- "status": "registered",
384
+ "status": "already_registered" if already else "registered",
380
385
  "region": region,
381
386
  "domain": domain,
382
387
  }
@@ -384,7 +389,10 @@ async def _cmd_register(app_ctx: AppContext) -> None:
384
389
  data["partner_scopes"] = partner_scopes
385
390
  formatter.output(data, command="auth.register")
386
391
  else:
387
- formatter.rich.info("[green]Registration successful.[/green]")
392
+ if already:
393
+ formatter.rich.info("[green]Already registered — no action needed.[/green]")
394
+ else:
395
+ formatter.rich.info("[green]Registration successful.[/green]")
388
396
  if partner_scopes:
389
397
  formatter.rich.info(f"Partner scopes: {', '.join(partner_scopes)}")
390
398
  missing = sorted(set(PARTNER_SCOPES) - set(partner_scopes))
@@ -448,7 +456,10 @@ async def _auto_register(
448
456
  domain=domain,
449
457
  region=region,
450
458
  )
451
- formatter.rich.info("[green]Registration successful.[/green]")
459
+ if _result.get("already_registered"):
460
+ formatter.rich.info("[green]Already registered — no action needed.[/green]")
461
+ else:
462
+ formatter.rich.info("[green]Registration successful.[/green]")
452
463
  if partner_scopes:
453
464
  missing = sorted(set(PARTNER_SCOPES) - set(partner_scopes))
454
465
  if missing:
@@ -469,6 +480,8 @@ def _interactive_setup(
469
480
  *,
470
481
  domain: str = "",
471
482
  tailscale_hostname: str = "",
483
+ full_tier: bool = False,
484
+ force: bool = False,
472
485
  ) -> tuple[str, str]:
473
486
  """Walk the user through first-time Tesla API credential setup.
474
487
 
@@ -476,9 +489,11 @@ def _interactive_setup(
476
489
  portal instructions show ``https://{domain}`` as the Allowed Origin URL.
477
490
  Tesla's Fleet API requires the origin to match the registration domain.
478
491
 
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.
492
+ When *full_tier* is True and *tailscale_hostname* is provided (or
493
+ auto-detected), the user is offered the chance to start a Tailscale
494
+ Funnel so Tesla can verify the origin URL when the portal app config
495
+ is saved. The Tailscale prompt appears **before** the browser is
496
+ opened so the developer portal steps (1-6) are uninterrupted.
482
497
  """
483
498
  info = formatter.rich.info
484
499
  origin_url = f"https://{domain}" if domain else f"http://localhost:{port}"
@@ -492,170 +507,222 @@ def _interactive_setup(
492
507
  )
493
508
  info("")
494
509
 
495
- # Offer to open the developer portal
496
- try:
497
- answer = input("Open the Tesla Developer Portal in your browser? [Y/n] ")
498
- except (EOFError, KeyboardInterrupt):
499
- info("")
500
- return ("", "")
510
+ # --- Tailscale detection + Funnel prompt (before browser opens) ----
511
+ # Only shown during the full setup wizard — the standalone ``auth
512
+ # setup`` command is credentials-only and defaults full_tier=False.
513
+ ts_hostname = tailscale_hostname if full_tier else ""
514
+ ts_funnel_started = False
515
+ _ts_manager: TailscaleManager | None = None
516
+ _key_server: KeyServer | None = None
501
517
 
502
- if answer.strip().lower() != "n":
503
- webbrowser.open(DEVELOPER_PORTAL_URL)
504
- info("[dim]Browser opened.[/dim]")
518
+ if full_tier:
519
+ if not ts_hostname:
520
+ try:
521
+ from tescmd.deploy.tailscale_serve import is_tailscale_serve_ready
505
522
 
506
- info("")
507
- info(
508
- "Follow these steps to create a Fleet API application."
509
- " If you already have one, skip to the credentials prompt below."
510
- )
511
- info("")
523
+ if run_async(is_tailscale_serve_ready()):
524
+ from tescmd.telemetry.tailscale import TailscaleManager
512
525
 
513
- # Step 1 — Registration
514
- info("[bold]Step 1 — Registration[/bold]")
515
- info(" Select [cyan]Just for me[/cyan] and click Next.")
516
- info("")
526
+ ts_hostname = run_async(TailscaleManager().get_hostname())
527
+ except Exception:
528
+ pass
517
529
 
518
- # Step 2 — Application Details
519
- info("[bold]Step 2 — Application Details[/bold]")
520
- info(" Application Name: [cyan]tescmd[/cyan] (or anything you like)")
521
- info(" Description: [cyan]Personal CLI tool for vehicle status and control[/cyan]")
522
- info(
523
- " Purpose of Usage: [cyan]Query vehicle data and send commands from the terminal[/cyan]"
524
- )
525
- info(" Click Next.")
526
- info("")
530
+ if ts_hostname:
531
+ ts_origin = f"https://{ts_hostname}"
527
532
 
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:
533
+ info(f"[green]Tailscale detected:[/green] {ts_hostname}")
534
+ info("")
535
+ info(f" Adding [cyan]{ts_origin}[/cyan] as an Allowed Origin URL")
536
+ info(" will let [cyan]tescmd serve[/cyan] stream telemetry without")
537
+ info(" extra portal changes later.")
538
+ info("")
539
+ try:
540
+ answer = input(
541
+ "Start Tailscale Funnel so Tesla can verify the URL? [Y/n] "
542
+ ).strip()
543
+ except (EOFError, KeyboardInterrupt):
544
+ answer = "n"
545
+
546
+ if answer.lower() != "n":
547
+ info("Starting Tailscale Funnel...")
548
+ try:
549
+ from tescmd.crypto.keys import (
550
+ generate_ec_key_pair,
551
+ has_key_pair,
552
+ load_public_key_pem,
553
+ )
554
+ from tescmd.deploy.tailscale_serve import KeyServer
555
+ from tescmd.telemetry.tailscale import TailscaleManager
556
+
557
+ key_dir = Path(AppSettings().config_dir).expanduser() / "keys"
558
+
559
+ if not has_key_pair(key_dir):
560
+ info("Generating EC P-256 key pair...")
561
+ generate_ec_key_pair(key_dir)
562
+
563
+ pem = load_public_key_pem(key_dir)
564
+ _key_server = KeyServer(pem, port=0)
565
+ _local_port = _key_server.server_address[1]
566
+ _key_server.start()
567
+
568
+ _ts_manager = TailscaleManager()
569
+ run_async(_ts_manager.start_funnel(_local_port))
570
+
571
+ ts_funnel_started = True
572
+ info(f"[green]Funnel active — {ts_origin} is reachable.[/green]")
573
+ except Exception as exc:
574
+ if _key_server is not None:
575
+ _key_server.stop()
576
+ _key_server = None
577
+ _ts_manager = None
578
+ info(f"[yellow]Could not start Funnel: {exc}[/yellow]")
579
+ info("[dim]You can add the origin URL manually later.[/dim]")
580
+ ts_hostname = ""
581
+
582
+ # --- Open browser + credential prompts (with guaranteed cleanup) ---
583
+ try:
532
584
  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
585
+ answer = input("Open the Tesla Developer Portal in your browser? [Y/n] ")
586
+ except (EOFError, KeyboardInterrupt):
587
+ info("")
588
+ return ("", "")
537
589
 
538
- ts_hostname = run_async(TailscaleManager().get_hostname())
539
- except Exception:
540
- pass
590
+ if answer.strip().lower() != "n":
591
+ webbrowser.open(DEVELOPER_PORTAL_URL)
592
+ info("[dim]Browser opened.[/dim]")
541
593
 
542
- if ts_hostname:
543
- ts_origin = f"https://{ts_hostname}"
544
594
  info("")
545
- info(f"[green]Tailscale detected:[/green] {ts_hostname}")
595
+ info(
596
+ "Follow these steps to create a Fleet API application."
597
+ " If you already have one, skip to the credentials prompt below."
598
+ )
546
599
  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.")
600
+
601
+ # Step 1 Create New Application
602
+ info("[bold]Step 1 Create New Application[/bold]")
603
+ info(" Select [cyan]Just for me[/cyan] and click Next.")
550
604
  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
605
 
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
-
569
- # Step 3 — Client Details
570
- info("[bold]Step 3 — Client Details[/bold]")
571
- info(
572
- " OAuth Grant Type: [cyan]Authorization Code and"
573
- " Machine-to-Machine[/cyan] (the default)"
574
- )
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]")
578
- info(f" Allowed Redirect URI: [cyan]{redirect_uri}[/cyan]")
579
- info(" Allowed Returned URL: (leave empty)")
580
- info("")
581
- if not ts_hostname:
606
+ # Step 2 — Application Details
607
+ #
608
+ # Generate a unique app name so the user doesn't collide with anyone
609
+ # else on the Tesla Developer Portal. Reuse a previously saved name
610
+ # if setup is being re-run.
611
+ saved_app_name = "" if force else os.environ.get("TESLA_APP_NAME", "")
612
+ app_name = saved_app_name or f"tescmd-{uuid.uuid4().hex[:8]}"
613
+
614
+ info("[bold]Step 2 Application Details[/bold]")
615
+ info(f" Application Name: [cyan]{app_name}[/cyan]")
616
+ info(" Description: [cyan]Personal CLI tool for vehicle status and control[/cyan]")
582
617
  info(
583
- " [dim]For telemetry streaming, add your Tailscale hostname"
584
- " as an additional origin:[/dim]"
618
+ " Purpose of Usage: [cyan]Query vehicle data and send commands"
619
+ " from the terminal[/cyan]"
585
620
  )
586
- info(" [dim] https://<machine>.tailnet.ts.net[/dim]")
587
- info(" Click Next.")
588
- info("")
589
-
590
- # Step 4 — API & Scopes
591
- info("[bold]Step 4 — API & Scopes[/bold]")
592
- info(" Under [bold]Fleet API[/bold], check at least:")
593
- info(" [cyan]Vehicle Information[/cyan]")
594
- info(" [cyan]Vehicle Location[/cyan]")
595
- info(" [cyan]Vehicle Commands[/cyan]")
596
- info(" [cyan]Vehicle Charging Management[/cyan]")
597
- info(" Click Next.")
598
- info("")
599
-
600
- # Step 5 — Billing Details
601
- info("[bold]Step 5 — Billing Details[/bold]")
602
- info(" Click [cyan]Skip and Submit[/cyan] at the bottom of the page.")
603
- info("")
604
-
605
- # Post-creation
606
- info("[bold]Step 6 — Copy your credentials[/bold]")
607
- info(
608
- " Open your dashboard:"
609
- " [link=https://developer.tesla.com/en_US/dashboard]"
610
- "developer.tesla.com/dashboard[/link]"
611
- )
612
- info(" Click [cyan]View Details[/cyan] on your app.")
613
- info(" Under the [cyan]Credentials & APIs[/cyan] tab you'll see your")
614
- info(" Client ID (copy icon) and Client Secret (eye icon to reveal).")
615
- info("")
621
+ info(" Click Next.")
622
+ info("")
616
623
 
617
- # Prompt for Client ID
618
- try:
619
- client_id = input("Client ID: ").strip()
620
- except (EOFError, KeyboardInterrupt):
624
+ # Step 3 Client Details
625
+ info("[bold]Step 3 — Client Details[/bold]")
626
+ info(
627
+ " OAuth Grant Type: [cyan]Authorization Code and"
628
+ " Machine-to-Machine[/cyan] (the default)"
629
+ )
630
+ info(f" Allowed Origin URL: [cyan]{origin_url}[/cyan]")
631
+ if ts_hostname:
632
+ info(f" [bold]Also add:[/bold] [cyan]https://{ts_hostname}[/cyan]")
633
+ info(f" Allowed Redirect URI: [cyan]{redirect_uri}[/cyan]")
634
+ info(" Allowed Returned URL: (leave empty)")
635
+ info("")
636
+ if not ts_hostname:
637
+ info(
638
+ " [dim]For telemetry streaming, add your Tailscale hostname"
639
+ " as an additional origin:[/dim]"
640
+ )
641
+ info(" [dim] https://<machine>.tailnet.ts.net[/dim]")
642
+ info(" Click Next.")
621
643
  info("")
622
- return ("", "")
623
644
 
624
- if not client_id:
625
- info("[yellow]No Client ID provided. Setup cancelled.[/yellow]")
626
- return ("", "")
645
+ # Step 4 — API & Scopes
646
+ info("[bold]Step 4 API & Scopes[/bold]")
647
+ if full_tier:
648
+ info(" Under [bold]Fleet API[/bold], click [cyan]Select All[/cyan].")
649
+ else:
650
+ info(" Under [bold]Fleet API[/bold], check at least:")
651
+ info(" [cyan]Vehicle Information[/cyan]")
652
+ info(" [cyan]Vehicle Location[/cyan]")
653
+ info(" [cyan]Energy Information[/cyan]")
654
+ info(" [cyan]User Data[/cyan]")
655
+ info(" Click Next.")
656
+ info("")
627
657
 
628
- # Prompt for Client Secret (optional for public clients)
629
- try:
630
- client_secret = input("Client Secret (optional, press Enter to skip): ").strip()
631
- except (EOFError, KeyboardInterrupt):
658
+ # Step 5 Billing Details
659
+ info("[bold]Step 5 — Billing Details (Optional)[/bold]")
660
+ info(" Click [cyan]Skip and Submit[/cyan] at the bottom of the page.")
632
661
  info("")
633
- return ("", "")
634
662
 
635
- # Offer to persist credentials to .env
636
- info("")
637
- try:
638
- save = input("Save credentials to .env file? [Y/n] ")
639
- except (EOFError, KeyboardInterrupt):
663
+ # Post-creation
664
+ info("[bold]Step 6 — Copy your credentials[/bold]")
665
+ info(
666
+ " Open your dashboard:"
667
+ " [link=https://developer.tesla.com/en_US/dashboard]"
668
+ "developer.tesla.com/dashboard[/link]"
669
+ )
670
+ info(" Click [cyan]View Details[/cyan] on your app.")
671
+ info(" Under the [cyan]Credentials & APIs[/cyan] tab you'll see your")
672
+ info(" Client ID (copy icon) and Client Secret (eye icon to reveal).")
640
673
  info("")
641
- return (client_id, client_secret)
642
674
 
643
- if save.strip().lower() != "n":
675
+ # Prompt for Client ID (required retry on empty)
676
+ client_id = ""
677
+ for _ in range(3):
678
+ try:
679
+ client_id = input("Client ID: ").strip()
680
+ except (EOFError, KeyboardInterrupt):
681
+ info("")
682
+ return ("", "")
683
+ if client_id:
684
+ break
685
+ info("[yellow]Client ID is required.[/yellow]")
686
+
687
+ if not client_id:
688
+ info("[yellow]No Client ID provided. Setup cancelled.[/yellow]")
689
+ return ("", "")
690
+
691
+ # Prompt for Client Secret (required — retry on empty)
692
+ client_secret = ""
693
+ for _ in range(3):
694
+ try:
695
+ client_secret = input("Client Secret: ").strip()
696
+ except (EOFError, KeyboardInterrupt):
697
+ info("")
698
+ return ("", "")
699
+ if client_secret:
700
+ break
701
+ info("[yellow]Client Secret is required.[/yellow]")
702
+
703
+ if not client_secret:
704
+ info("[yellow]No Client Secret provided. Setup cancelled.[/yellow]")
705
+ return ("", "")
706
+
707
+ # Persist credentials to .env
708
+ info("")
644
709
  _write_env_file(client_id, client_secret)
710
+ if not saved_app_name:
711
+ _write_env_value("TESLA_APP_NAME", app_name)
645
712
  info("[green]Credentials saved to .env[/green]")
646
713
 
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
-
657
- info("")
658
- return (client_id, client_secret)
714
+ info("")
715
+ return (client_id, client_secret)
716
+ finally:
717
+ if ts_funnel_started:
718
+ if _key_server is not None:
719
+ _key_server.stop()
720
+ if _ts_manager is not None:
721
+ try:
722
+ run_async(_ts_manager.stop_funnel())
723
+ except Exception as exc:
724
+ info(f"[yellow]Warning: Failed to stop Tailscale Funnel: {exc}[/yellow]")
725
+ info("[dim]You may need to run 'tailscale funnel --bg off' manually.[/dim]")
659
726
 
660
727
 
661
728
  def _prompt_for_domain(formatter: OutputFormatter) -> str:
tescmd/cli/key.py CHANGED
@@ -585,13 +585,12 @@ async def _cmd_enroll(
585
585
  formatter.rich.info("")
586
586
  formatter.rich.info(f" Enrollment URL: [link={enroll_url}]{enroll_url}[/link]")
587
587
  formatter.rich.info("")
588
- formatter.rich.info(" 1. Open the URL above [bold]on your phone[/bold]")
589
- formatter.rich.info(" 2. Tap [bold]Finish Setup[/bold] on the web page")
590
- formatter.rich.info(" 3. The Tesla app will show an [bold]Add Virtual Key[/bold] prompt")
591
- formatter.rich.info(" 4. Approve it")
588
+ formatter.rich.info(" 1. Scan the QR code on the page above with your phone[/bold]")
589
+ formatter.rich.info(" 2. The Tesla app will show an [bold]Add Virtual Key[/bold] prompt")
590
+ formatter.rich.info(" 3. Select all Scopes (if shown) and approve it")
592
591
  formatter.rich.info("")
593
592
  formatter.rich.info(" [dim]If the prompt doesn't appear, force-quit the Tesla app,[/dim]")
594
- formatter.rich.info(" [dim]go back to your browser, and tap Finish Setup again.[/dim]")
593
+ formatter.rich.info(" [dim]and scan the QR code again.[/dim]")
595
594
  formatter.rich.info("━" * 55)
596
595
  formatter.rich.info("")
597
596
 
@@ -601,11 +600,12 @@ async def _cmd_enroll(
601
600
  formatter.rich.info("")
602
601
 
603
602
  formatter.rich.info("After approving in the Tesla app, try a command:")
604
- formatter.rich.info(" [cyan]tescmd security lock --wake[/cyan]")
603
+ formatter.rich.info(" [cyan]tescmd vehicle list[/cyan]")
605
604
  formatter.rich.info(" [cyan]tescmd charge status --wake[/cyan]")
606
605
  formatter.rich.info("")
607
606
  formatter.rich.info(
608
- "[dim]Tip: This URL must be opened on your phone, not a desktop browser.[/dim]"
607
+ "[dim]Tip: The QR code must be scanned on your phone"
608
+ " that has the Tesla app installed.[/dim]"
609
609
  )
610
610
 
611
611
 
tescmd/cli/openclaw.py CHANGED
@@ -139,8 +139,11 @@ async def _cmd_bridge(
139
139
  if formatter.format != "json":
140
140
  formatter.rich.info(f"Connecting to OpenClaw Gateway: {config.gateway_url}")
141
141
  await gw.connect_with_backoff(max_attempts=5)
142
+ lifecycle_ok = await bridge.send_connected()
142
143
  if formatter.format != "json":
143
144
  formatter.rich.info("[green]Connected to gateway.[/green]")
145
+ if not lifecycle_ok:
146
+ formatter.rich.info("[yellow]Warning: node.connected event failed[/yellow]")
144
147
  else:
145
148
  if formatter.format != "json":
146
149
  formatter.rich.info("[yellow]Dry-run mode — events will be logged as JSONL.[/yellow]")
tescmd/cli/serve.py CHANGED
@@ -401,8 +401,13 @@ async def _cmd_serve(
401
401
  if is_rich:
402
402
  formatter.rich.info(f"Connecting to OpenClaw Gateway: {config.gateway_url}")
403
403
  await gw.connect_with_backoff(max_attempts=5)
404
+ lifecycle_ok = await oc_bridge.send_connected()
404
405
  if is_rich:
405
406
  formatter.rich.info("[green]Connected to OpenClaw gateway.[/green]")
407
+ if not lifecycle_ok:
408
+ formatter.rich.info(
409
+ "[yellow]Warning: node.connected event failed[/yellow]"
410
+ )
406
411
  else:
407
412
  if is_rich:
408
413
  formatter.rich.info(