foodforthought-cli 0.2.7__py3-none-any.whl → 0.3.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.
Files changed (131) hide show
  1. ate/__init__.py +6 -0
  2. ate/__main__.py +16 -0
  3. ate/auth/__init__.py +1 -0
  4. ate/auth/device_flow.py +141 -0
  5. ate/auth/token_store.py +96 -0
  6. ate/behaviors/__init__.py +100 -0
  7. ate/behaviors/approach.py +399 -0
  8. ate/behaviors/common.py +686 -0
  9. ate/behaviors/tree.py +454 -0
  10. ate/cli.py +855 -3995
  11. ate/client.py +90 -0
  12. ate/commands/__init__.py +168 -0
  13. ate/commands/auth.py +389 -0
  14. ate/commands/bridge.py +448 -0
  15. ate/commands/data.py +185 -0
  16. ate/commands/deps.py +111 -0
  17. ate/commands/generate.py +384 -0
  18. ate/commands/memory.py +907 -0
  19. ate/commands/parts.py +166 -0
  20. ate/commands/primitive.py +399 -0
  21. ate/commands/protocol.py +288 -0
  22. ate/commands/recording.py +524 -0
  23. ate/commands/repo.py +154 -0
  24. ate/commands/simulation.py +291 -0
  25. ate/commands/skill.py +303 -0
  26. ate/commands/skills.py +487 -0
  27. ate/commands/team.py +147 -0
  28. ate/commands/workflow.py +271 -0
  29. ate/detection/__init__.py +38 -0
  30. ate/detection/base.py +142 -0
  31. ate/detection/color_detector.py +399 -0
  32. ate/detection/trash_detector.py +322 -0
  33. ate/drivers/__init__.py +39 -0
  34. ate/drivers/ble_transport.py +405 -0
  35. ate/drivers/mechdog.py +942 -0
  36. ate/drivers/wifi_camera.py +477 -0
  37. ate/interfaces/__init__.py +187 -0
  38. ate/interfaces/base.py +273 -0
  39. ate/interfaces/body.py +267 -0
  40. ate/interfaces/detection.py +282 -0
  41. ate/interfaces/locomotion.py +422 -0
  42. ate/interfaces/manipulation.py +408 -0
  43. ate/interfaces/navigation.py +389 -0
  44. ate/interfaces/perception.py +362 -0
  45. ate/interfaces/sensors.py +247 -0
  46. ate/interfaces/types.py +371 -0
  47. ate/llm_proxy.py +239 -0
  48. ate/mcp_server.py +387 -0
  49. ate/memory/__init__.py +35 -0
  50. ate/memory/cloud.py +244 -0
  51. ate/memory/context.py +269 -0
  52. ate/memory/embeddings.py +184 -0
  53. ate/memory/export.py +26 -0
  54. ate/memory/merge.py +146 -0
  55. ate/memory/migrate/__init__.py +34 -0
  56. ate/memory/migrate/base.py +89 -0
  57. ate/memory/migrate/pipeline.py +189 -0
  58. ate/memory/migrate/sources/__init__.py +13 -0
  59. ate/memory/migrate/sources/chroma.py +170 -0
  60. ate/memory/migrate/sources/pinecone.py +120 -0
  61. ate/memory/migrate/sources/qdrant.py +110 -0
  62. ate/memory/migrate/sources/weaviate.py +160 -0
  63. ate/memory/reranker.py +353 -0
  64. ate/memory/search.py +26 -0
  65. ate/memory/store.py +548 -0
  66. ate/recording/__init__.py +83 -0
  67. ate/recording/demonstration.py +378 -0
  68. ate/recording/session.py +415 -0
  69. ate/recording/upload.py +304 -0
  70. ate/recording/visual.py +416 -0
  71. ate/recording/wrapper.py +95 -0
  72. ate/robot/__init__.py +221 -0
  73. ate/robot/agentic_servo.py +856 -0
  74. ate/robot/behaviors.py +493 -0
  75. ate/robot/ble_capture.py +1000 -0
  76. ate/robot/ble_enumerate.py +506 -0
  77. ate/robot/calibration.py +668 -0
  78. ate/robot/calibration_state.py +388 -0
  79. ate/robot/commands.py +3735 -0
  80. ate/robot/direction_calibration.py +554 -0
  81. ate/robot/discovery.py +441 -0
  82. ate/robot/introspection.py +330 -0
  83. ate/robot/llm_system_id.py +654 -0
  84. ate/robot/locomotion_calibration.py +508 -0
  85. ate/robot/manager.py +270 -0
  86. ate/robot/marker_generator.py +611 -0
  87. ate/robot/perception.py +502 -0
  88. ate/robot/primitives.py +614 -0
  89. ate/robot/profiles.py +281 -0
  90. ate/robot/registry.py +322 -0
  91. ate/robot/servo_mapper.py +1153 -0
  92. ate/robot/skill_upload.py +675 -0
  93. ate/robot/target_calibration.py +500 -0
  94. ate/robot/teach.py +515 -0
  95. ate/robot/types.py +242 -0
  96. ate/robot/visual_labeler.py +1048 -0
  97. ate/robot/visual_servo_loop.py +494 -0
  98. ate/robot/visual_servoing.py +570 -0
  99. ate/robot/visual_system_id.py +906 -0
  100. ate/transports/__init__.py +121 -0
  101. ate/transports/base.py +394 -0
  102. ate/transports/ble.py +405 -0
  103. ate/transports/hybrid.py +444 -0
  104. ate/transports/serial.py +345 -0
  105. ate/urdf/__init__.py +30 -0
  106. ate/urdf/capture.py +582 -0
  107. ate/urdf/cloud.py +491 -0
  108. ate/urdf/collision.py +271 -0
  109. ate/urdf/commands.py +708 -0
  110. ate/urdf/depth.py +360 -0
  111. ate/urdf/inertial.py +312 -0
  112. ate/urdf/kinematics.py +330 -0
  113. ate/urdf/lifting.py +415 -0
  114. ate/urdf/meshing.py +300 -0
  115. ate/urdf/models/__init__.py +110 -0
  116. ate/urdf/models/depth_anything.py +253 -0
  117. ate/urdf/models/sam2.py +324 -0
  118. ate/urdf/motion_analysis.py +396 -0
  119. ate/urdf/pipeline.py +468 -0
  120. ate/urdf/scale.py +256 -0
  121. ate/urdf/scan_session.py +411 -0
  122. ate/urdf/segmentation.py +299 -0
  123. ate/urdf/synthesis.py +319 -0
  124. ate/urdf/topology.py +336 -0
  125. ate/urdf/validation.py +371 -0
  126. {foodforthought_cli-0.2.7.dist-info → foodforthought_cli-0.3.0.dist-info}/METADATA +9 -1
  127. foodforthought_cli-0.3.0.dist-info/RECORD +166 -0
  128. {foodforthought_cli-0.2.7.dist-info → foodforthought_cli-0.3.0.dist-info}/WHEEL +1 -1
  129. foodforthought_cli-0.2.7.dist-info/RECORD +0 -44
  130. {foodforthought_cli-0.2.7.dist-info → foodforthought_cli-0.3.0.dist-info}/entry_points.txt +0 -0
  131. {foodforthought_cli-0.2.7.dist-info → foodforthought_cli-0.3.0.dist-info}/top_level.txt +0 -0
ate/client.py ADDED
@@ -0,0 +1,90 @@
1
+ """
2
+ FoodforThought API Client.
3
+
4
+ HTTP client for interacting with the FoodforThought API.
5
+ """
6
+
7
+ import json
8
+ import os
9
+ import sys
10
+ from pathlib import Path
11
+ from typing import Optional, Dict
12
+
13
+ import requests
14
+
15
+ BASE_URL = os.getenv("ATE_API_URL", "https://www.kindly.fyi/api")
16
+ API_KEY = os.getenv("ATE_API_KEY", "")
17
+ CONFIG_DIR = Path.home() / ".ate"
18
+ CONFIG_FILE = CONFIG_DIR / "config.json"
19
+
20
+
21
+ class ATEClient:
22
+ """Client for interacting with FoodforThought API."""
23
+
24
+ def __init__(self, base_url: str = BASE_URL, api_key: Optional[str] = None):
25
+ self.base_url = base_url
26
+ self.headers = {
27
+ "Content-Type": "application/json",
28
+ }
29
+ self._config = {}
30
+ self._device_id = None
31
+
32
+ # Try to load from config file first (device auth flow)
33
+ if CONFIG_FILE.exists():
34
+ try:
35
+ with open(CONFIG_FILE) as f:
36
+ self._config = json.load(f)
37
+
38
+ # Prefer access_token from device auth flow
39
+ access_token = self._config.get("access_token")
40
+ if access_token:
41
+ self.headers["Authorization"] = f"Bearer {access_token}"
42
+ self._device_id = self._config.get("device_id")
43
+ else:
44
+ # Fall back to legacy api_key
45
+ stored_key = self._config.get("api_key")
46
+ if stored_key:
47
+ self.headers["Authorization"] = f"Bearer {stored_key}"
48
+ except Exception:
49
+ pass
50
+
51
+ # Override with explicit api_key or env var if provided
52
+ if api_key is None:
53
+ api_key = os.getenv("ATE_API_KEY", API_KEY)
54
+
55
+ if api_key:
56
+ self.headers["Authorization"] = f"Bearer {api_key}"
57
+
58
+ if "Authorization" not in self.headers:
59
+ print("Warning: Not logged in. Run 'ate login' to authenticate.", file=sys.stderr)
60
+
61
+ def _request(self, method: str, endpoint: str, **kwargs) -> Dict:
62
+ """Make HTTP request to API."""
63
+ url = f"{self.base_url}{endpoint}"
64
+ try:
65
+ # Handle params for GET requests
66
+ if method == "GET" and "params" in kwargs:
67
+ response = requests.get(url, headers=self.headers, params=kwargs["params"])
68
+ else:
69
+ response = requests.request(method, url, headers=self.headers, **kwargs)
70
+ response.raise_for_status()
71
+ return response.json()
72
+ except requests.exceptions.RequestException as e:
73
+ print(f"Error: {e}", file=sys.stderr)
74
+ sys.exit(1)
75
+
76
+ def get(self, endpoint: str, **kwargs) -> Dict:
77
+ """Make GET request."""
78
+ return self._request("GET", endpoint, **kwargs)
79
+
80
+ def post(self, endpoint: str, data: Dict = None, **kwargs) -> Dict:
81
+ """Make POST request."""
82
+ return self._request("POST", endpoint, json=data, **kwargs)
83
+
84
+ def put(self, endpoint: str, data: Dict = None, **kwargs) -> Dict:
85
+ """Make PUT request."""
86
+ return self._request("PUT", endpoint, json=data, **kwargs)
87
+
88
+ def delete(self, endpoint: str, **kwargs) -> Dict:
89
+ """Make DELETE request."""
90
+ return self._request("DELETE", endpoint, **kwargs)
@@ -0,0 +1,168 @@
1
+ """
2
+ FoodforThought CLI Commands Registry.
3
+
4
+ This module provides centralized registration and dispatch for all CLI commands.
5
+ """
6
+
7
+
8
+ def register_all_parsers(subparsers):
9
+ """Register all command parsers with the main argparse parser."""
10
+ from . import auth
11
+ from . import repo
12
+ from . import simulation
13
+ from . import skills
14
+ from . import recording
15
+ from . import parts
16
+ from . import deps
17
+ from . import protocol
18
+ from . import primitive
19
+ from . import skill
20
+ from . import bridge
21
+ from . import generate
22
+ from . import workflow
23
+ from . import team
24
+ from . import data
25
+ from . import memory
26
+
27
+ # Register each module's parsers
28
+ auth.register_parser(subparsers)
29
+ repo.register_parser(subparsers)
30
+ simulation.register_parser(subparsers)
31
+ skills.register_parser(subparsers)
32
+ recording.register_parser(subparsers)
33
+ parts.register_parser(subparsers)
34
+ deps.register_parser(subparsers)
35
+ protocol.register_parser(subparsers)
36
+ primitive.register_parser(subparsers)
37
+ skill.register_parser(subparsers)
38
+ bridge.register_parser(subparsers)
39
+ generate.register_parser(subparsers)
40
+ workflow.register_parser(subparsers)
41
+ team.register_parser(subparsers)
42
+ data.register_parser(subparsers)
43
+ memory.register_parser(subparsers)
44
+
45
+
46
+ # Command to module mapping
47
+ _COMMAND_HANDLERS = {
48
+ # Auth commands
49
+ "login": "auth",
50
+ "logout": "auth",
51
+ "whoami": "auth",
52
+ "device-login": "auth",
53
+ # Repo commands
54
+ "init": "repo",
55
+ "clone": "repo",
56
+ "commit": "repo",
57
+ "push": "repo",
58
+ # Simulation commands
59
+ "deploy": "simulation",
60
+ "test": "simulation",
61
+ "benchmark": "simulation",
62
+ # Skills commands
63
+ "adapt": "skills",
64
+ "validate": "skills",
65
+ "stream": "skills",
66
+ "pull": "skills",
67
+ "upload": "skills",
68
+ "check-transfer": "skills",
69
+ "labeling-status": "skills",
70
+ # Recording commands
71
+ "record": "recording",
72
+ # Parts commands
73
+ "parts": "parts",
74
+ # Deps commands
75
+ "deps": "deps",
76
+ # Protocol commands
77
+ "protocol": "protocol",
78
+ # Primitive commands
79
+ "primitive": "primitive",
80
+ # Skill commands
81
+ "skill": "skill",
82
+ # Bridge commands
83
+ "bridge": "bridge",
84
+ # Generate commands
85
+ "generate": "generate",
86
+ "compile": "generate",
87
+ "test-skill": "generate",
88
+ "publish-skill": "generate",
89
+ "check-compatibility": "generate",
90
+ "publish-protocol": "generate",
91
+ # Workflow commands
92
+ "workflow": "workflow",
93
+ # Team commands
94
+ "team": "team",
95
+ # Data commands
96
+ "data": "data",
97
+ # Memory commands
98
+ "memory": "memory",
99
+ }
100
+
101
+
102
+ def handle_command(command: str, client, args):
103
+ """
104
+ Dispatch command to the appropriate handler.
105
+
106
+ Args:
107
+ command: The command name from args.command
108
+ client: The ATEClient instance
109
+ args: The parsed argparse namespace
110
+ """
111
+ module_name = _COMMAND_HANDLERS.get(command)
112
+
113
+ if module_name is None:
114
+ return False
115
+
116
+ # Import the specific module and call its handle function
117
+ if module_name == "auth":
118
+ from . import auth
119
+ auth.handle(client, args)
120
+ elif module_name == "repo":
121
+ from . import repo
122
+ repo.handle(client, args)
123
+ elif module_name == "simulation":
124
+ from . import simulation
125
+ simulation.handle(client, args)
126
+ elif module_name == "skills":
127
+ from . import skills
128
+ skills.handle(client, args)
129
+ elif module_name == "recording":
130
+ from . import recording
131
+ recording.handle(client, args)
132
+ elif module_name == "parts":
133
+ from . import parts
134
+ parts.handle(client, args)
135
+ elif module_name == "deps":
136
+ from . import deps
137
+ deps.handle(client, args)
138
+ elif module_name == "protocol":
139
+ from . import protocol
140
+ protocol.handle(client, args)
141
+ elif module_name == "primitive":
142
+ from . import primitive
143
+ primitive.handle(client, args)
144
+ elif module_name == "skill":
145
+ from . import skill
146
+ skill.handle(client, args)
147
+ elif module_name == "bridge":
148
+ from . import bridge
149
+ bridge.handle(client, args)
150
+ elif module_name == "generate":
151
+ from . import generate
152
+ generate.handle(client, args)
153
+ elif module_name == "workflow":
154
+ from . import workflow
155
+ workflow.handle(client, args)
156
+ elif module_name == "team":
157
+ from . import team
158
+ team.handle(client, args)
159
+ elif module_name == "data":
160
+ from . import data
161
+ data.handle(client, args)
162
+ elif module_name == "memory":
163
+ from . import memory
164
+ memory.handle(client, args)
165
+ else:
166
+ return False
167
+
168
+ return True
ate/commands/auth.py ADDED
@@ -0,0 +1,389 @@
1
+ """
2
+ Authentication commands for FoodforThought CLI.
3
+
4
+ Commands:
5
+ - ate login - Authenticate with FoodforThought via browser
6
+ - ate logout - Log out and remove stored credentials
7
+ - ate whoami - Show current logged-in user
8
+ """
9
+
10
+ import json
11
+ import os
12
+ import sys
13
+ import time
14
+ from pathlib import Path
15
+
16
+ import requests
17
+
18
+ from ate.auth.device_flow import DeviceFlowClient, DeviceFlowTimeout, DeviceFlowDenied, DeviceFlowError
19
+ from ate.auth.token_store import TokenStore
20
+
21
+ BASE_URL = os.getenv("ATE_API_URL", "https://kindly.fyi/api")
22
+ CONFIG_DIR = Path.home() / ".ate"
23
+ CONFIG_FILE = CONFIG_DIR / "config.json"
24
+
25
+
26
+ def _generate_pkce():
27
+ """Generate PKCE code verifier and challenge."""
28
+ import hashlib
29
+ import base64
30
+ import secrets
31
+
32
+ # Generate code verifier (43-128 chars, URL-safe)
33
+ code_verifier = secrets.token_urlsafe(32)
34
+
35
+ # Generate code challenge (SHA256 hash of verifier, base64url encoded)
36
+ digest = hashlib.sha256(code_verifier.encode()).digest()
37
+ code_challenge = base64.urlsafe_b64encode(digest).rstrip(b'=').decode()
38
+
39
+ return code_verifier, code_challenge
40
+
41
+
42
+ def _generate_device_id():
43
+ """Generate a unique device ID for this CLI installation."""
44
+ import platform
45
+ import hashlib
46
+
47
+ # Create a stable device ID based on machine info
48
+ machine_info = f"{platform.node()}-{platform.system()}-{platform.machine()}"
49
+ return f"cli-{hashlib.sha256(machine_info.encode()).hexdigest()[:16]}"
50
+
51
+
52
+ def login_command():
53
+ """Interactive login via browser (GitHub-style device flow)."""
54
+ import webbrowser
55
+
56
+ print("Authenticating with FoodforThought...")
57
+ print()
58
+
59
+ # Generate PKCE
60
+ code_verifier, code_challenge = _generate_pkce()
61
+ device_id = _generate_device_id()
62
+
63
+ # Step 1: Initiate device auth
64
+ try:
65
+ response = requests.post(
66
+ f"{BASE_URL}/device-auth/initiate",
67
+ json={
68
+ "codeChallenge": code_challenge,
69
+ "deviceId": device_id,
70
+ "deviceName": "FoodforThought CLI",
71
+ },
72
+ timeout=10,
73
+ )
74
+ response.raise_for_status()
75
+ data = response.json()
76
+ except requests.RequestException as e:
77
+ print(f"Error: Failed to initiate login: {e}", file=sys.stderr)
78
+ sys.exit(1)
79
+
80
+ if not data.get("success"):
81
+ print(f"Error: {data.get('error', 'Unknown error')}", file=sys.stderr)
82
+ sys.exit(1)
83
+
84
+ auth_url = data["authUrl"]
85
+ state = data["state"]
86
+
87
+ # Step 2: Open browser
88
+ print("Opening browser for authentication...")
89
+ print(f"If browser doesn't open, visit: {auth_url}")
90
+ print()
91
+
92
+ try:
93
+ webbrowser.open(auth_url)
94
+ except Exception:
95
+ pass # Browser open is best-effort
96
+
97
+ # Step 3: Poll for authorization
98
+ print("Waiting for authorization...", end="", flush=True)
99
+
100
+ max_attempts = 120 # 2 minutes with 1s intervals
101
+ poll_interval = 1.0
102
+ callback_token = None
103
+
104
+ for attempt in range(max_attempts):
105
+ time.sleep(poll_interval)
106
+
107
+ try:
108
+ poll_response = requests.post(
109
+ f"{BASE_URL}/device-auth/poll",
110
+ json={"state": state, "deviceId": device_id},
111
+ timeout=10,
112
+ )
113
+ poll_data = poll_response.json()
114
+ except requests.RequestException:
115
+ print(".", end="", flush=True)
116
+ continue
117
+
118
+ status = poll_data.get("status")
119
+
120
+ if status == "pending":
121
+ print(".", end="", flush=True)
122
+ continue
123
+ elif status == "authorized":
124
+ print(" authorized!")
125
+ callback_token = poll_data.get("token")
126
+ break
127
+ elif status == "expired":
128
+ print("\nError: Authorization request expired. Please try again.", file=sys.stderr)
129
+ sys.exit(1)
130
+ elif status == "exchanged":
131
+ print("\nError: This authorization was already used. Please try again.", file=sys.stderr)
132
+ sys.exit(1)
133
+ else:
134
+ print(".", end="", flush=True)
135
+ else:
136
+ print("\nError: Timeout waiting for authorization.", file=sys.stderr)
137
+ sys.exit(1)
138
+
139
+ # Step 4: Exchange for access token
140
+ print("Exchanging for access token...")
141
+
142
+ try:
143
+ exchange_response = requests.post(
144
+ f"{BASE_URL}/device-auth/exchange",
145
+ json={
146
+ "token": callback_token,
147
+ "state": state,
148
+ "codeVerifier": code_verifier,
149
+ "deviceId": device_id,
150
+ },
151
+ timeout=10,
152
+ )
153
+ exchange_response.raise_for_status()
154
+ exchange_data = exchange_response.json()
155
+ except requests.RequestException as e:
156
+ print(f"Error: Failed to exchange token: {e}", file=sys.stderr)
157
+ sys.exit(1)
158
+
159
+ if not exchange_data.get("success"):
160
+ print(f"Error: {exchange_data.get('error', 'Unknown error')}", file=sys.stderr)
161
+ sys.exit(1)
162
+
163
+ access_token = exchange_data["accessToken"]
164
+ refresh_token = exchange_data["refreshToken"]
165
+ user = exchange_data.get("user", {})
166
+ expires_at = exchange_data.get("expiresAt")
167
+
168
+ # Step 5: Save credentials
169
+ try:
170
+ CONFIG_DIR.mkdir(parents=True, exist_ok=True)
171
+
172
+ config = {}
173
+ if CONFIG_FILE.exists():
174
+ try:
175
+ with open(CONFIG_FILE) as f:
176
+ config = json.load(f)
177
+ except Exception:
178
+ pass
179
+
180
+ config["access_token"] = access_token
181
+ config["refresh_token"] = refresh_token
182
+ config["device_id"] = device_id
183
+ config["expires_at"] = expires_at
184
+ config["user"] = {
185
+ "id": user.get("id"),
186
+ "email": user.get("email"),
187
+ "name": user.get("name"),
188
+ }
189
+
190
+ with open(CONFIG_FILE, "w") as f:
191
+ json.dump(config, f, indent=2)
192
+
193
+ # Set restrictive permissions
194
+ CONFIG_FILE.chmod(0o600)
195
+
196
+ except Exception as e:
197
+ print(f"Error saving credentials: {e}", file=sys.stderr)
198
+ sys.exit(1)
199
+
200
+ print()
201
+ print(f"✓ Logged in as {user.get('name') or user.get('email')}")
202
+ print(f" Credentials saved to {CONFIG_FILE}")
203
+
204
+
205
+ def logout_command():
206
+ """Log out and remove stored credentials."""
207
+ if not CONFIG_FILE.exists():
208
+ print("Not logged in.")
209
+ return
210
+
211
+ try:
212
+ # Load config to get device_id for revoking
213
+ with open(CONFIG_FILE) as f:
214
+ config = json.load(f)
215
+
216
+ access_token = config.get("access_token")
217
+ device_id = config.get("device_id")
218
+
219
+ # Try to revoke the session on the server
220
+ if access_token and device_id:
221
+ try:
222
+ requests.post(
223
+ f"{BASE_URL}/device-auth/revoke",
224
+ json={"accessToken": access_token, "deviceId": device_id},
225
+ timeout=5,
226
+ )
227
+ except Exception:
228
+ pass # Best effort
229
+
230
+ # Remove local credentials
231
+ CONFIG_FILE.unlink()
232
+ print("✓ Logged out successfully.")
233
+
234
+ except Exception as e:
235
+ print(f"Error during logout: {e}", file=sys.stderr)
236
+ # Still try to remove the file
237
+ try:
238
+ CONFIG_FILE.unlink()
239
+ except Exception:
240
+ pass
241
+
242
+
243
+ def whoami_command():
244
+ """Show current logged-in user."""
245
+ if not CONFIG_FILE.exists():
246
+ print("Not logged in. Run 'ate login' to authenticate.")
247
+ sys.exit(1)
248
+
249
+ try:
250
+ with open(CONFIG_FILE) as f:
251
+ config = json.load(f)
252
+
253
+ user = config.get("user", {})
254
+ access_token = config.get("access_token")
255
+ expires_at = config.get("expires_at")
256
+
257
+ if not access_token:
258
+ # Legacy api_key mode
259
+ if config.get("api_key"):
260
+ print("Authenticated via API key (legacy mode)")
261
+ print("Run 'ate login' to upgrade to device authentication.")
262
+ return
263
+ else:
264
+ print("Not logged in. Run 'ate login' to authenticate.")
265
+ sys.exit(1)
266
+
267
+ print(f"Logged in as: {user.get('name') or 'Unknown'}")
268
+ print(f"Email: {user.get('email') or 'Unknown'}")
269
+ if expires_at:
270
+ print(f"Session expires: {expires_at}")
271
+
272
+ except Exception as e:
273
+ print(f"Error reading credentials: {e}", file=sys.stderr)
274
+ sys.exit(1)
275
+
276
+
277
+ def device_login_command(args):
278
+ """Handle device-login subcommand: start device flow, check status, or logout."""
279
+ store = TokenStore()
280
+
281
+ # --logout: clear stored tokens
282
+ if getattr(args, "logout", False):
283
+ store.clear()
284
+ print("Device flow credentials cleared.")
285
+ return
286
+
287
+ # --status: show token info
288
+ if getattr(args, "status", False):
289
+ token = store.load()
290
+ if token is None:
291
+ print("Not authenticated via device flow.")
292
+ sys.exit(1)
293
+
294
+ expired = store.is_expired()
295
+ needs_refresh = store.needs_refresh()
296
+ info = {
297
+ "authenticated": True,
298
+ "token_type": token.token_type,
299
+ "expired": expired,
300
+ "needs_refresh": needs_refresh,
301
+ }
302
+ if getattr(args, "format", None) == "json":
303
+ print(json.dumps(info))
304
+ else:
305
+ status = "expired" if expired else ("needs refresh" if needs_refresh else "valid")
306
+ print(f"Authenticated via device flow (status: {status})")
307
+ return
308
+
309
+ # Default: start device flow
310
+ server = getattr(args, "server", "https://kindly.fyi")
311
+ output_format = getattr(args, "format", None)
312
+
313
+ client = DeviceFlowClient(server_url=server)
314
+
315
+ try:
316
+ code_resp = client.request_code()
317
+ except DeviceFlowError as e:
318
+ print(f"Error: {e}", file=sys.stderr)
319
+ sys.exit(1)
320
+
321
+ if output_format == "json":
322
+ print(json.dumps({
323
+ "user_code": code_resp.user_code,
324
+ "verification_uri": code_resp.verification_uri,
325
+ "expires_in": code_resp.expires_in,
326
+ }))
327
+ else:
328
+ print(f"Visit {code_resp.verification_uri} and enter code: {code_resp.user_code}")
329
+
330
+ try:
331
+ token_resp = client.poll_for_token(
332
+ code_resp.device_code,
333
+ interval=code_resp.interval,
334
+ expires_in=code_resp.expires_in,
335
+ )
336
+ except DeviceFlowTimeout:
337
+ print("Error: Authorization timed out.", file=sys.stderr)
338
+ sys.exit(1)
339
+ except DeviceFlowDenied:
340
+ print("Error: Authorization denied.", file=sys.stderr)
341
+ sys.exit(1)
342
+
343
+ store.save(token_resp)
344
+
345
+ if output_format == "json":
346
+ print(json.dumps({"status": "authenticated", "token_type": token_resp.token_type}))
347
+ else:
348
+ print("✓ Authenticated successfully via device flow.")
349
+
350
+
351
+ def register_parser(subparsers):
352
+ """Register auth commands with argparse."""
353
+ subparsers.add_parser("login", help="Authenticate with FoodforThought via browser")
354
+ subparsers.add_parser("logout", help="Log out and remove stored credentials")
355
+ subparsers.add_parser("whoami", help="Show current logged-in user")
356
+
357
+ # Device flow subcommand
358
+ device_parser = subparsers.add_parser(
359
+ "device-login",
360
+ help="Authenticate via OAuth 2.0 Device Flow (agent-friendly)",
361
+ )
362
+ device_parser.add_argument(
363
+ "--server", default="https://kindly.fyi",
364
+ help="Server URL (default: https://kindly.fyi)",
365
+ )
366
+ device_parser.add_argument(
367
+ "--format", choices=["json"], default=None,
368
+ help="Output format (default: human-readable)",
369
+ )
370
+ device_parser.add_argument(
371
+ "--status", action="store_true",
372
+ help="Check current device flow token status",
373
+ )
374
+ device_parser.add_argument(
375
+ "--logout", action="store_true",
376
+ help="Clear stored device flow tokens",
377
+ )
378
+
379
+
380
+ def handle(client, args):
381
+ """Handle auth commands. Note: these don't need the client."""
382
+ if args.command == "login":
383
+ login_command()
384
+ elif args.command == "logout":
385
+ logout_command()
386
+ elif args.command == "whoami":
387
+ whoami_command()
388
+ elif args.command == "device-login":
389
+ device_login_command(args)