foodforthought-cli 0.2.4__py3-none-any.whl → 0.2.8__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 (37) hide show
  1. ate/__init__.py +1 -1
  2. ate/behaviors/__init__.py +88 -0
  3. ate/behaviors/common.py +686 -0
  4. ate/behaviors/tree.py +454 -0
  5. ate/cli.py +610 -54
  6. ate/drivers/__init__.py +27 -0
  7. ate/drivers/mechdog.py +606 -0
  8. ate/interfaces/__init__.py +171 -0
  9. ate/interfaces/base.py +271 -0
  10. ate/interfaces/body.py +267 -0
  11. ate/interfaces/detection.py +282 -0
  12. ate/interfaces/locomotion.py +422 -0
  13. ate/interfaces/manipulation.py +408 -0
  14. ate/interfaces/navigation.py +389 -0
  15. ate/interfaces/perception.py +362 -0
  16. ate/interfaces/types.py +371 -0
  17. ate/mcp_server.py +387 -0
  18. ate/recording/__init__.py +44 -0
  19. ate/recording/demonstration.py +378 -0
  20. ate/recording/session.py +405 -0
  21. ate/recording/upload.py +304 -0
  22. ate/recording/wrapper.py +95 -0
  23. ate/robot/__init__.py +79 -0
  24. ate/robot/calibration.py +583 -0
  25. ate/robot/commands.py +3603 -0
  26. ate/robot/discovery.py +339 -0
  27. ate/robot/introspection.py +330 -0
  28. ate/robot/manager.py +270 -0
  29. ate/robot/profiles.py +275 -0
  30. ate/robot/registry.py +319 -0
  31. ate/robot/skill_upload.py +393 -0
  32. ate/robot/visual_labeler.py +1039 -0
  33. {foodforthought_cli-0.2.4.dist-info → foodforthought_cli-0.2.8.dist-info}/METADATA +9 -1
  34. {foodforthought_cli-0.2.4.dist-info → foodforthought_cli-0.2.8.dist-info}/RECORD +37 -8
  35. {foodforthought_cli-0.2.4.dist-info → foodforthought_cli-0.2.8.dist-info}/WHEEL +0 -0
  36. {foodforthought_cli-0.2.4.dist-info → foodforthought_cli-0.2.8.dist-info}/entry_points.txt +0 -0
  37. {foodforthought_cli-0.2.4.dist-info → foodforthought_cli-0.2.8.dist-info}/top_level.txt +0 -0
ate/cli.py CHANGED
@@ -30,30 +30,37 @@ class ATEClient:
30
30
  self.headers = {
31
31
  "Content-Type": "application/json",
32
32
  }
33
-
34
- # Priority: Explicit Arg > Env Var > Global Default
33
+ self._config = {}
34
+ self._device_id = None
35
+
36
+ # Try to load from config file first (device auth flow)
37
+ if CONFIG_FILE.exists():
38
+ try:
39
+ with open(CONFIG_FILE) as f:
40
+ self._config = json.load(f)
41
+
42
+ # Prefer access_token from device auth flow
43
+ access_token = self._config.get("access_token")
44
+ if access_token:
45
+ self.headers["Authorization"] = f"Bearer {access_token}"
46
+ self._device_id = self._config.get("device_id")
47
+ else:
48
+ # Fall back to legacy api_key
49
+ stored_key = self._config.get("api_key")
50
+ if stored_key:
51
+ self.headers["Authorization"] = f"Bearer {stored_key}"
52
+ except Exception:
53
+ pass
54
+
55
+ # Override with explicit api_key or env var if provided
35
56
  if api_key is None:
36
57
  api_key = os.getenv("ATE_API_KEY", API_KEY)
37
-
58
+
38
59
  if api_key:
39
- # Ensure API key has correct format
40
- if not api_key.startswith("ate_"):
41
- print("Warning: API key should start with 'ate_'", file=sys.stderr)
42
60
  self.headers["Authorization"] = f"Bearer {api_key}"
43
- else:
44
- # Try to load from config file
45
- if CONFIG_FILE.exists():
46
- try:
47
- with open(CONFIG_FILE) as f:
48
- config = json.load(f)
49
- stored_key = config.get("api_key")
50
- if stored_key:
51
- self.headers["Authorization"] = f"Bearer {stored_key}"
52
- except Exception:
53
- pass
54
-
55
- if "Authorization" not in self.headers:
56
- print("Warning: No API key found. Set ATE_API_KEY environment variable or run 'ate login'.", file=sys.stderr)
61
+
62
+ if "Authorization" not in self.headers:
63
+ print("Warning: Not logged in. Run 'ate login' to authenticate.", file=sys.stderr)
57
64
 
58
65
  def _request(self, method: str, endpoint: str, **kwargs) -> Dict:
59
66
  """Make HTTP request to API"""
@@ -583,7 +590,259 @@ class ATEClient:
583
590
  print(f"\n✗ Upload failed: {e}", file=sys.stderr)
584
591
  sys.exit(1)
585
592
 
586
- def check_transfer(self, skill: Optional[str], source: str, target: str,
593
+ # ========================================================================
594
+ # Recording Methods (Data Flywheel)
595
+ # ========================================================================
596
+
597
+ def record_start(self, robot_id: str, skill_id: str, task_description: Optional[str] = None) -> None:
598
+ """Start recording telemetry from a robot"""
599
+ import uuid
600
+
601
+ # Store recording state in a file
602
+ recording_file = CONFIG_DIR / "active_recording.json"
603
+ CONFIG_DIR.mkdir(exist_ok=True)
604
+
605
+ if recording_file.exists():
606
+ print("Error: Recording already in progress. Run 'ate record stop' first.", file=sys.stderr)
607
+ sys.exit(1)
608
+
609
+ recording_id = str(uuid.uuid4())
610
+ recording_state = {
611
+ "id": recording_id,
612
+ "robot_id": robot_id,
613
+ "skill_id": skill_id,
614
+ "task_description": task_description or "",
615
+ "start_time": time.time(),
616
+ "frames": [],
617
+ }
618
+
619
+ with open(recording_file, "w") as f:
620
+ json.dump(recording_state, f, indent=2)
621
+
622
+ print(f"Recording started!")
623
+ print(f" Recording ID: {recording_id}")
624
+ print(f" Robot: {robot_id}")
625
+ print(f" Skill: {skill_id}")
626
+ if task_description:
627
+ print(f" Task: {task_description}")
628
+ print(f"\nRun 'ate record stop' when finished.")
629
+
630
+ def record_stop(self, success: bool = True, notes: Optional[str] = None,
631
+ upload: bool = True, create_labeling_task: bool = False) -> None:
632
+ """Stop recording and optionally upload to FoodforThought"""
633
+ from datetime import datetime
634
+
635
+ recording_file = CONFIG_DIR / "active_recording.json"
636
+
637
+ if not recording_file.exists():
638
+ print("Error: No active recording. Start one with 'ate record start'.", file=sys.stderr)
639
+ sys.exit(1)
640
+
641
+ with open(recording_file, "r") as f:
642
+ recording_state = json.load(f)
643
+
644
+ # Calculate duration
645
+ end_time = time.time()
646
+ duration = end_time - recording_state["start_time"]
647
+ frame_count = len(recording_state.get("frames", []))
648
+
649
+ print(f"Recording stopped!")
650
+ print(f" Recording ID: {recording_state['id']}")
651
+ print(f" Duration: {duration:.1f}s")
652
+ print(f" Frames: {frame_count}")
653
+ print(f" Success: {'Yes' if success else 'No'}")
654
+
655
+ if upload:
656
+ print(f"\nUploading to FoodforThought...")
657
+
658
+ try:
659
+ recording_data = {
660
+ "recording": {
661
+ "id": recording_state["id"],
662
+ "robotId": recording_state["robot_id"],
663
+ "skillId": recording_state["skill_id"],
664
+ "source": "hardware",
665
+ "startTime": datetime.fromtimestamp(recording_state["start_time"]).isoformat(),
666
+ "endTime": datetime.fromtimestamp(end_time).isoformat(),
667
+ "success": success,
668
+ "metadata": {
669
+ "duration": duration,
670
+ "frameRate": frame_count / duration if duration > 0 else 0,
671
+ "totalFrames": frame_count,
672
+ "tags": ["edge_recording", "cli"],
673
+ "notes": notes,
674
+ },
675
+ "frames": recording_state.get("frames", []),
676
+ "events": [],
677
+ },
678
+ }
679
+
680
+ if create_labeling_task:
681
+ recording_data["createLabelingTask"] = True
682
+
683
+ response = self._request("POST", "/telemetry/ingest", json=recording_data)
684
+
685
+ artifact_id = response.get("data", {}).get("artifactId", "")
686
+ print(f"\n✓ Uploaded successfully!")
687
+ print(f" Artifact ID: {artifact_id}")
688
+ print(f" View at: https://foodforthought.kindly.fyi/artifacts/{artifact_id}")
689
+
690
+ if create_labeling_task:
691
+ task_id = response.get("data", {}).get("taskId", "")
692
+ if task_id:
693
+ print(f" Labeling Task: https://foodforthought.kindly.fyi/labeling/{task_id}")
694
+
695
+ except Exception as e:
696
+ print(f"\n✗ Upload failed: {e}", file=sys.stderr)
697
+ print("Recording saved locally. You can upload later.", file=sys.stderr)
698
+
699
+ if notes:
700
+ print(f"\nNotes: {notes}")
701
+
702
+ # Remove recording state file
703
+ recording_file.unlink()
704
+
705
+ def record_status(self) -> None:
706
+ """Get current recording status"""
707
+ recording_file = CONFIG_DIR / "active_recording.json"
708
+
709
+ if not recording_file.exists():
710
+ print("No active recording session.")
711
+ return
712
+
713
+ with open(recording_file, "r") as f:
714
+ recording_state = json.load(f)
715
+
716
+ elapsed = time.time() - recording_state["start_time"]
717
+ frame_count = len(recording_state.get("frames", []))
718
+
719
+ print(f"Recording in progress")
720
+ print(f" Recording ID: {recording_state['id']}")
721
+ print(f" Robot: {recording_state['robot_id']}")
722
+ print(f" Skill: {recording_state['skill_id']}")
723
+ print(f" Elapsed: {elapsed:.1f}s")
724
+ print(f" Frames: {frame_count}")
725
+ if recording_state.get("task_description"):
726
+ print(f" Task: {recording_state['task_description']}")
727
+
728
+ def record_demo(self, robot_id: str, skill_id: str, task_description: str,
729
+ duration_seconds: float = 30.0, create_labeling_task: bool = True) -> None:
730
+ """Record a timed demonstration"""
731
+ import uuid
732
+ from datetime import datetime
733
+
734
+ recording_id = str(uuid.uuid4())
735
+ print(f"Recording demonstration...")
736
+ print(f" Recording ID: {recording_id}")
737
+ print(f" Robot: {robot_id}")
738
+ print(f" Skill: {skill_id}")
739
+ print(f" Task: {task_description}")
740
+ print(f" Duration: {duration_seconds}s")
741
+ print()
742
+
743
+ start_time = time.time()
744
+
745
+ # Show a countdown/progress indicator
746
+ elapsed = 0
747
+ while elapsed < duration_seconds:
748
+ remaining = duration_seconds - elapsed
749
+ print(f"\rRecording... {remaining:.0f}s remaining", end="", flush=True)
750
+ time.sleep(min(1.0, remaining))
751
+ elapsed = time.time() - start_time
752
+
753
+ end_time = time.time()
754
+ actual_duration = end_time - start_time
755
+ print(f"\rRecording complete!{' ' * 20}")
756
+
757
+ print(f"\nUploading to FoodforThought...")
758
+
759
+ try:
760
+ recording_data = {
761
+ "recording": {
762
+ "id": recording_id,
763
+ "robotId": robot_id,
764
+ "skillId": skill_id,
765
+ "source": "hardware",
766
+ "startTime": datetime.fromtimestamp(start_time).isoformat(),
767
+ "endTime": datetime.fromtimestamp(end_time).isoformat(),
768
+ "success": True,
769
+ "metadata": {
770
+ "duration": actual_duration,
771
+ "frameRate": 0,
772
+ "totalFrames": 0,
773
+ "tags": ["demonstration", "cli"],
774
+ "task_description": task_description,
775
+ },
776
+ "frames": [],
777
+ "events": [],
778
+ },
779
+ }
780
+
781
+ if create_labeling_task:
782
+ recording_data["createLabelingTask"] = True
783
+
784
+ response = self._request("POST", "/telemetry/ingest", json=recording_data)
785
+
786
+ artifact_id = response.get("data", {}).get("artifactId", "")
787
+ print(f"\n✓ Uploaded successfully!")
788
+ print(f" Artifact ID: {artifact_id}")
789
+ print(f" View at: https://foodforthought.kindly.fyi/artifacts/{artifact_id}")
790
+
791
+ if create_labeling_task:
792
+ task_id = response.get("data", {}).get("taskId", "")
793
+ if task_id:
794
+ print(f" Labeling Task: https://foodforthought.kindly.fyi/labeling/{task_id}")
795
+
796
+ except Exception as e:
797
+ print(f"\n✗ Upload failed: {e}", file=sys.stderr)
798
+
799
+ def record_list(self, robot_id: Optional[str] = None, skill_id: Optional[str] = None,
800
+ success_only: bool = False, limit: int = 20) -> None:
801
+ """List telemetry recordings from FoodforThought"""
802
+ print("Fetching recordings...")
803
+
804
+ params = {
805
+ "type": "trajectory",
806
+ "limit": limit,
807
+ }
808
+
809
+ if robot_id:
810
+ params["robotModel"] = robot_id
811
+ if skill_id:
812
+ params["task"] = skill_id
813
+
814
+ try:
815
+ response = self._request("GET", "/artifacts", params=params)
816
+ artifacts = response.get("artifacts", [])
817
+
818
+ if not artifacts:
819
+ print("No recordings found.")
820
+ return
821
+
822
+ print(f"\nFound {len(artifacts)} recording(s):\n")
823
+
824
+ for artifact in artifacts:
825
+ metadata = artifact.get("metadata", {})
826
+
827
+ # Skip failed recordings if success_only
828
+ if success_only and not metadata.get("success", True):
829
+ continue
830
+
831
+ success_marker = "✓" if metadata.get("success", True) else "✗"
832
+ print(f"{success_marker} {artifact.get('name', 'Unnamed')}")
833
+ print(f" ID: {artifact.get('id')}")
834
+ print(f" Robot: {metadata.get('robotId', 'Unknown')}")
835
+ print(f" Skill: {metadata.get('skillId', 'Unknown')}")
836
+ print(f" Duration: {metadata.get('duration', 0):.1f}s")
837
+ print(f" Frames: {metadata.get('frameCount', 0)}")
838
+ print(f" Source: {metadata.get('source', 'Unknown')}")
839
+ print()
840
+
841
+ except Exception as e:
842
+ print(f"Error fetching recordings: {e}", file=sys.stderr)
843
+ sys.exit(1)
844
+
845
+ def check_transfer(self, skill: Optional[str], source: str, target: str,
587
846
  min_score: float) -> None:
588
847
  """Check skill transfer compatibility between robots"""
589
848
  print(f"Checking skill transfer compatibility...")
@@ -3106,33 +3365,151 @@ setup(
3106
3365
  ATEClient.data_upload(self, path, skill, stage)
3107
3366
 
3108
3367
 
3368
+ def _generate_pkce():
3369
+ """Generate PKCE code verifier and challenge."""
3370
+ import hashlib
3371
+ import base64
3372
+ import secrets
3373
+
3374
+ # Generate code verifier (43-128 chars, URL-safe)
3375
+ code_verifier = secrets.token_urlsafe(32)
3376
+
3377
+ # Generate code challenge (SHA256 hash of verifier, base64url encoded)
3378
+ digest = hashlib.sha256(code_verifier.encode()).digest()
3379
+ code_challenge = base64.urlsafe_b64encode(digest).rstrip(b'=').decode()
3380
+
3381
+ return code_verifier, code_challenge
3382
+
3383
+
3384
+ def _generate_device_id():
3385
+ """Generate a unique device ID for this CLI installation."""
3386
+ import platform
3387
+ import hashlib
3388
+
3389
+ # Create a stable device ID based on machine info
3390
+ machine_info = f"{platform.node()}-{platform.system()}-{platform.machine()}"
3391
+ return f"cli-{hashlib.sha256(machine_info.encode()).hexdigest()[:16]}"
3392
+
3393
+
3109
3394
  def login_command():
3110
- """Interactive login to store API key"""
3111
- print("Authenticate with FoodforThought")
3112
- print("1. Go to https://kindly.fyi/settings/keys")
3113
- print("2. Create a new API key")
3114
- print("3. Paste it here")
3115
-
3395
+ """Interactive login via browser (GitHub-style device flow)"""
3396
+ import webbrowser
3397
+
3398
+ print("Authenticating with FoodforThought...")
3399
+ print()
3400
+
3401
+ # Generate PKCE
3402
+ code_verifier, code_challenge = _generate_pkce()
3403
+ device_id = _generate_device_id()
3404
+
3405
+ # Step 1: Initiate device auth
3116
3406
  try:
3117
- api_key = getpass.getpass("API Key: ").strip()
3118
- except KeyboardInterrupt:
3119
- print("\nLogin cancelled.")
3407
+ response = requests.post(
3408
+ f"{BASE_URL}/device-auth/initiate",
3409
+ json={
3410
+ "codeChallenge": code_challenge,
3411
+ "deviceId": device_id,
3412
+ "deviceName": "FoodforThought CLI",
3413
+ },
3414
+ timeout=10,
3415
+ )
3416
+ response.raise_for_status()
3417
+ data = response.json()
3418
+ except requests.RequestException as e:
3419
+ print(f"Error: Failed to initiate login: {e}", file=sys.stderr)
3120
3420
  sys.exit(1)
3121
-
3122
- if not api_key:
3123
- print("Error: API key cannot be empty", file=sys.stderr)
3421
+
3422
+ if not data.get("success"):
3423
+ print(f"Error: {data.get('error', 'Unknown error')}", file=sys.stderr)
3124
3424
  sys.exit(1)
3125
-
3126
- if not api_key.startswith("ate_"):
3127
- print("Warning: API key usually starts with 'ate_'. Please check your key.", file=sys.stderr)
3128
- if input("Continue anyway? (y/N): ").lower() != "y":
3425
+
3426
+ auth_url = data["authUrl"]
3427
+ state = data["state"]
3428
+
3429
+ # Step 2: Open browser
3430
+ print("Opening browser for authentication...")
3431
+ print(f"If browser doesn't open, visit: {auth_url}")
3432
+ print()
3433
+
3434
+ try:
3435
+ webbrowser.open(auth_url)
3436
+ except Exception:
3437
+ pass # Browser open is best-effort
3438
+
3439
+ # Step 3: Poll for authorization
3440
+ print("Waiting for authorization...", end="", flush=True)
3441
+
3442
+ max_attempts = 120 # 2 minutes with 1s intervals
3443
+ poll_interval = 1.0
3444
+
3445
+ for attempt in range(max_attempts):
3446
+ time.sleep(poll_interval)
3447
+
3448
+ try:
3449
+ poll_response = requests.post(
3450
+ f"{BASE_URL}/device-auth/poll",
3451
+ json={"state": state, "deviceId": device_id},
3452
+ timeout=10,
3453
+ )
3454
+ poll_data = poll_response.json()
3455
+ except requests.RequestException:
3456
+ print(".", end="", flush=True)
3457
+ continue
3458
+
3459
+ status = poll_data.get("status")
3460
+
3461
+ if status == "pending":
3462
+ print(".", end="", flush=True)
3463
+ continue
3464
+ elif status == "authorized":
3465
+ print(" authorized!")
3466
+ callback_token = poll_data.get("token")
3467
+ break
3468
+ elif status == "expired":
3469
+ print("\nError: Authorization request expired. Please try again.", file=sys.stderr)
3129
3470
  sys.exit(1)
3130
-
3131
- # Create config directory
3471
+ elif status == "exchanged":
3472
+ print("\nError: This authorization was already used. Please try again.", file=sys.stderr)
3473
+ sys.exit(1)
3474
+ else:
3475
+ print(".", end="", flush=True)
3476
+ else:
3477
+ print("\nError: Timeout waiting for authorization.", file=sys.stderr)
3478
+ sys.exit(1)
3479
+
3480
+ # Step 4: Exchange for access token
3481
+ print("Exchanging for access token...")
3482
+
3483
+ try:
3484
+ exchange_response = requests.post(
3485
+ f"{BASE_URL}/device-auth/exchange",
3486
+ json={
3487
+ "token": callback_token,
3488
+ "state": state,
3489
+ "codeVerifier": code_verifier,
3490
+ "deviceId": device_id,
3491
+ },
3492
+ timeout=10,
3493
+ )
3494
+ exchange_response.raise_for_status()
3495
+ exchange_data = exchange_response.json()
3496
+ except requests.RequestException as e:
3497
+ print(f"Error: Failed to exchange token: {e}", file=sys.stderr)
3498
+ sys.exit(1)
3499
+
3500
+ if not exchange_data.get("success"):
3501
+ print(f"Error: {exchange_data.get('error', 'Unknown error')}", file=sys.stderr)
3502
+ sys.exit(1)
3503
+
3504
+ access_token = exchange_data["accessToken"]
3505
+ refresh_token = exchange_data["refreshToken"]
3506
+ user = exchange_data.get("user", {})
3507
+ expires_at = exchange_data.get("expiresAt")
3508
+
3509
+ # Step 5: Save credentials
3132
3510
  try:
3133
3511
  CONFIG_DIR.mkdir(parents=True, exist_ok=True)
3134
-
3135
- # Load existing config if present
3512
+
3136
3513
  config = {}
3137
3514
  if CONFIG_FILE.exists():
3138
3515
  try:
@@ -3140,26 +3517,114 @@ def login_command():
3140
3517
  config = json.load(f)
3141
3518
  except Exception:
3142
3519
  pass
3143
-
3144
- # Update key
3145
- config["api_key"] = api_key
3146
-
3147
- # Write config
3520
+
3521
+ config["access_token"] = access_token
3522
+ config["refresh_token"] = refresh_token
3523
+ config["device_id"] = device_id
3524
+ config["expires_at"] = expires_at
3525
+ config["user"] = {
3526
+ "id": user.get("id"),
3527
+ "email": user.get("email"),
3528
+ "name": user.get("name"),
3529
+ }
3530
+
3148
3531
  with open(CONFIG_FILE, "w") as f:
3149
3532
  json.dump(config, f, indent=2)
3150
-
3151
- print(f"✓ Successfully logged in. Credentials saved to {CONFIG_FILE}")
3152
-
3533
+
3534
+ # Set restrictive permissions
3535
+ CONFIG_FILE.chmod(0o600)
3536
+
3153
3537
  except Exception as e:
3154
3538
  print(f"Error saving credentials: {e}", file=sys.stderr)
3155
3539
  sys.exit(1)
3156
3540
 
3541
+ print()
3542
+ print(f"✓ Logged in as {user.get('name') or user.get('email')}")
3543
+ print(f" Credentials saved to {CONFIG_FILE}")
3544
+
3545
+
3546
+ def logout_command():
3547
+ """Log out and remove stored credentials."""
3548
+ if not CONFIG_FILE.exists():
3549
+ print("Not logged in.")
3550
+ return
3551
+
3552
+ try:
3553
+ # Load config to get device_id for revoking
3554
+ with open(CONFIG_FILE) as f:
3555
+ config = json.load(f)
3556
+
3557
+ access_token = config.get("access_token")
3558
+ device_id = config.get("device_id")
3559
+
3560
+ # Try to revoke the session on the server
3561
+ if access_token and device_id:
3562
+ try:
3563
+ requests.post(
3564
+ f"{BASE_URL}/device-auth/revoke",
3565
+ json={"accessToken": access_token, "deviceId": device_id},
3566
+ timeout=5,
3567
+ )
3568
+ except Exception:
3569
+ pass # Best effort
3570
+
3571
+ # Remove local credentials
3572
+ CONFIG_FILE.unlink()
3573
+ print("✓ Logged out successfully.")
3574
+
3575
+ except Exception as e:
3576
+ print(f"Error during logout: {e}", file=sys.stderr)
3577
+ # Still try to remove the file
3578
+ try:
3579
+ CONFIG_FILE.unlink()
3580
+ except Exception:
3581
+ pass
3582
+
3583
+
3584
+ def whoami_command():
3585
+ """Show current logged-in user."""
3586
+ if not CONFIG_FILE.exists():
3587
+ print("Not logged in. Run 'ate login' to authenticate.")
3588
+ sys.exit(1)
3589
+
3590
+ try:
3591
+ with open(CONFIG_FILE) as f:
3592
+ config = json.load(f)
3593
+
3594
+ user = config.get("user", {})
3595
+ access_token = config.get("access_token")
3596
+ expires_at = config.get("expires_at")
3597
+
3598
+ if not access_token:
3599
+ # Legacy api_key mode
3600
+ if config.get("api_key"):
3601
+ print("Authenticated via API key (legacy mode)")
3602
+ print("Run 'ate login' to upgrade to device authentication.")
3603
+ return
3604
+ else:
3605
+ print("Not logged in. Run 'ate login' to authenticate.")
3606
+ sys.exit(1)
3607
+
3608
+ print(f"Logged in as: {user.get('name') or 'Unknown'}")
3609
+ print(f"Email: {user.get('email') or 'Unknown'}")
3610
+ if expires_at:
3611
+ print(f"Session expires: {expires_at}")
3612
+
3613
+ except Exception as e:
3614
+ print(f"Error reading credentials: {e}", file=sys.stderr)
3615
+ sys.exit(1)
3616
+
3157
3617
 
3158
3618
  def main():
3159
3619
  """Main CLI entry point"""
3160
3620
  parser = argparse.ArgumentParser(description="FoodforThought CLI")
3161
3621
  subparsers = parser.add_subparsers(dest="command", help="Command to run")
3162
3622
 
3623
+ # Auth commands
3624
+ subparsers.add_parser("login", help="Authenticate with FoodforThought via browser")
3625
+ subparsers.add_parser("logout", help="Log out and remove stored credentials")
3626
+ subparsers.add_parser("whoami", help="Show current logged-in user")
3627
+
3163
3628
  # init command
3164
3629
  init_parser = subparsers.add_parser("init", help="Initialize a new repository")
3165
3630
  init_parser.add_argument("name", help="Repository name")
@@ -3741,6 +4206,59 @@ EXAMPLES:
3741
4206
  help="Check deployment status")
3742
4207
  deploy_status_parser.add_argument("target", help="Target fleet or robot")
3743
4208
 
4209
+ # record command - Telemetry recording for Data Flywheel
4210
+ record_parser = subparsers.add_parser("record",
4211
+ help="Record robot telemetry for the Data Flywheel",
4212
+ description="""Record telemetry from robots for training data.
4213
+
4214
+ Examples:
4215
+ ate record start --robot my-robot --skill pick_and_place --task "Pick red cube"
4216
+ ate record stop --success
4217
+ ate record status
4218
+ ate record demo --robot my-robot --skill grasp --task "Grasp object" --duration 30
4219
+ ate record list --robot my-robot""")
4220
+ record_subparsers = record_parser.add_subparsers(dest="record_action", help="Record action")
4221
+
4222
+ # record start
4223
+ record_start_parser = record_subparsers.add_parser("start", help="Start recording telemetry")
4224
+ record_start_parser.add_argument("--robot", "-r", required=True, help="Robot ID to record from")
4225
+ record_start_parser.add_argument("--skill", "-s", required=True, help="Skill ID being executed")
4226
+ record_start_parser.add_argument("--task", "-t", help="Task description (optional)")
4227
+
4228
+ # record stop
4229
+ record_stop_parser = record_subparsers.add_parser("stop", help="Stop recording and upload")
4230
+ record_stop_parser.add_argument("--success", action="store_true", default=True,
4231
+ help="Mark recording as successful (default)")
4232
+ record_stop_parser.add_argument("--failure", action="store_true",
4233
+ help="Mark recording as failed")
4234
+ record_stop_parser.add_argument("--notes", "-n", help="Notes about the recording")
4235
+ record_stop_parser.add_argument("--no-upload", action="store_true",
4236
+ help="Don't upload to FoodforThought")
4237
+ record_stop_parser.add_argument("--create-task", action="store_true",
4238
+ help="Create a labeling task for community annotation")
4239
+
4240
+ # record status
4241
+ record_subparsers.add_parser("status", help="Get current recording status")
4242
+
4243
+ # record demo (timed demonstration)
4244
+ record_demo_parser = record_subparsers.add_parser("demo", help="Record a timed demonstration")
4245
+ record_demo_parser.add_argument("--robot", "-r", required=True, help="Robot ID")
4246
+ record_demo_parser.add_argument("--skill", "-s", required=True, help="Skill being demonstrated")
4247
+ record_demo_parser.add_argument("--task", "-t", required=True, help="Task description")
4248
+ record_demo_parser.add_argument("--duration", "-d", type=float, default=30.0,
4249
+ help="Recording duration in seconds (default: 30)")
4250
+ record_demo_parser.add_argument("--create-task", action="store_true", default=True,
4251
+ help="Create labeling task after upload (default)")
4252
+
4253
+ # record list
4254
+ record_list_parser = record_subparsers.add_parser("list", help="List telemetry recordings")
4255
+ record_list_parser.add_argument("--robot", "-r", help="Filter by robot ID")
4256
+ record_list_parser.add_argument("--skill", "-s", help="Filter by skill ID")
4257
+ record_list_parser.add_argument("--success-only", action="store_true",
4258
+ help="Only show successful recordings")
4259
+ record_list_parser.add_argument("--limit", "-l", type=int, default=20,
4260
+ help="Maximum number of results (default: 20)")
4261
+
3744
4262
  # robot-setup command - Interactive wizard for robot discovery and primitive skill generation
3745
4263
  robot_setup_parser = subparsers.add_parser("robot-setup",
3746
4264
  help="Interactive wizard to discover robot and generate primitive skills")
@@ -3842,15 +4360,21 @@ EXAMPLES:
3842
4360
  parser.print_help()
3843
4361
  sys.exit(1)
3844
4362
 
3845
- # login command
3846
- subparsers.add_parser("login", help="Authenticate with FoodforThought")
3847
-
3848
- client = ATEClient()
3849
-
4363
+ # Handle auth commands first (before creating client)
3850
4364
  if args.command == "login":
3851
4365
  login_command()
3852
4366
  return
3853
4367
 
4368
+ if args.command == "logout":
4369
+ logout_command()
4370
+ return
4371
+
4372
+ if args.command == "whoami":
4373
+ whoami_command()
4374
+ return
4375
+
4376
+ client = ATEClient()
4377
+
3854
4378
  if args.command == "init":
3855
4379
  result = client.init(args.name, args.description, args.visibility)
3856
4380
  print(f"Created repository: {result['repository']['id']}")
@@ -4056,6 +4580,38 @@ EXAMPLES:
4056
4580
  else:
4057
4581
  deploy_parser.print_help()
4058
4582
 
4583
+ elif args.command == "record":
4584
+ if args.record_action == "start":
4585
+ client.record_start(args.robot, args.skill, args.task)
4586
+ elif args.record_action == "stop":
4587
+ success = not args.failure if hasattr(args, 'failure') else args.success
4588
+ upload = not args.no_upload if hasattr(args, 'no_upload') else True
4589
+ client.record_stop(
4590
+ success=success,
4591
+ notes=args.notes if hasattr(args, 'notes') else None,
4592
+ upload=upload,
4593
+ create_labeling_task=args.create_task if hasattr(args, 'create_task') else False
4594
+ )
4595
+ elif args.record_action == "status":
4596
+ client.record_status()
4597
+ elif args.record_action == "demo":
4598
+ client.record_demo(
4599
+ args.robot,
4600
+ args.skill,
4601
+ args.task,
4602
+ args.duration,
4603
+ args.create_task if hasattr(args, 'create_task') else True
4604
+ )
4605
+ elif args.record_action == "list":
4606
+ client.record_list(
4607
+ robot_id=args.robot if hasattr(args, 'robot') else None,
4608
+ skill_id=args.skill if hasattr(args, 'skill') else None,
4609
+ success_only=args.success_only if hasattr(args, 'success_only') else False,
4610
+ limit=args.limit if hasattr(args, 'limit') else 20
4611
+ )
4612
+ else:
4613
+ record_parser.print_help()
4614
+
4059
4615
  elif args.command == "robot-setup":
4060
4616
  from ate.robot_setup import run_wizard, RobotSetupWizard
4061
4617