foodforthought-cli 0.2.8__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.
- ate/__init__.py +6 -0
- ate/__main__.py +16 -0
- ate/auth/__init__.py +1 -0
- ate/auth/device_flow.py +141 -0
- ate/auth/token_store.py +96 -0
- ate/behaviors/__init__.py +12 -0
- ate/behaviors/approach.py +399 -0
- ate/cli.py +855 -4551
- ate/client.py +90 -0
- ate/commands/__init__.py +168 -0
- ate/commands/auth.py +389 -0
- ate/commands/bridge.py +448 -0
- ate/commands/data.py +185 -0
- ate/commands/deps.py +111 -0
- ate/commands/generate.py +384 -0
- ate/commands/memory.py +907 -0
- ate/commands/parts.py +166 -0
- ate/commands/primitive.py +399 -0
- ate/commands/protocol.py +288 -0
- ate/commands/recording.py +524 -0
- ate/commands/repo.py +154 -0
- ate/commands/simulation.py +291 -0
- ate/commands/skill.py +303 -0
- ate/commands/skills.py +487 -0
- ate/commands/team.py +147 -0
- ate/commands/workflow.py +271 -0
- ate/detection/__init__.py +38 -0
- ate/detection/base.py +142 -0
- ate/detection/color_detector.py +402 -0
- ate/detection/trash_detector.py +322 -0
- ate/drivers/__init__.py +18 -6
- ate/drivers/ble_transport.py +405 -0
- ate/drivers/mechdog.py +360 -24
- ate/drivers/wifi_camera.py +477 -0
- ate/interfaces/__init__.py +16 -0
- ate/interfaces/base.py +2 -0
- ate/interfaces/sensors.py +247 -0
- ate/llm_proxy.py +239 -0
- ate/memory/__init__.py +35 -0
- ate/memory/cloud.py +244 -0
- ate/memory/context.py +269 -0
- ate/memory/embeddings.py +184 -0
- ate/memory/export.py +26 -0
- ate/memory/merge.py +146 -0
- ate/memory/migrate/__init__.py +34 -0
- ate/memory/migrate/base.py +89 -0
- ate/memory/migrate/pipeline.py +189 -0
- ate/memory/migrate/sources/__init__.py +13 -0
- ate/memory/migrate/sources/chroma.py +170 -0
- ate/memory/migrate/sources/pinecone.py +120 -0
- ate/memory/migrate/sources/qdrant.py +110 -0
- ate/memory/migrate/sources/weaviate.py +160 -0
- ate/memory/reranker.py +353 -0
- ate/memory/search.py +26 -0
- ate/memory/store.py +548 -0
- ate/recording/__init__.py +42 -3
- ate/recording/session.py +12 -2
- ate/recording/visual.py +416 -0
- ate/robot/__init__.py +142 -0
- ate/robot/agentic_servo.py +856 -0
- ate/robot/behaviors.py +493 -0
- ate/robot/ble_capture.py +1000 -0
- ate/robot/ble_enumerate.py +506 -0
- ate/robot/calibration.py +88 -3
- ate/robot/calibration_state.py +388 -0
- ate/robot/commands.py +143 -11
- ate/robot/direction_calibration.py +554 -0
- ate/robot/discovery.py +104 -2
- ate/robot/llm_system_id.py +654 -0
- ate/robot/locomotion_calibration.py +508 -0
- ate/robot/marker_generator.py +611 -0
- ate/robot/perception.py +502 -0
- ate/robot/primitives.py +614 -0
- ate/robot/profiles.py +6 -0
- ate/robot/registry.py +5 -2
- ate/robot/servo_mapper.py +1153 -0
- ate/robot/skill_upload.py +285 -3
- ate/robot/target_calibration.py +500 -0
- ate/robot/teach.py +515 -0
- ate/robot/types.py +242 -0
- ate/robot/visual_labeler.py +9 -0
- ate/robot/visual_servo_loop.py +494 -0
- ate/robot/visual_servoing.py +570 -0
- ate/robot/visual_system_id.py +906 -0
- ate/transports/__init__.py +121 -0
- ate/transports/base.py +394 -0
- ate/transports/ble.py +405 -0
- ate/transports/hybrid.py +444 -0
- ate/transports/serial.py +345 -0
- ate/urdf/__init__.py +30 -0
- ate/urdf/capture.py +582 -0
- ate/urdf/cloud.py +491 -0
- ate/urdf/collision.py +271 -0
- ate/urdf/commands.py +708 -0
- ate/urdf/depth.py +360 -0
- ate/urdf/inertial.py +312 -0
- ate/urdf/kinematics.py +330 -0
- ate/urdf/lifting.py +415 -0
- ate/urdf/meshing.py +300 -0
- ate/urdf/models/__init__.py +110 -0
- ate/urdf/models/depth_anything.py +253 -0
- ate/urdf/models/sam2.py +324 -0
- ate/urdf/motion_analysis.py +396 -0
- ate/urdf/pipeline.py +468 -0
- ate/urdf/scale.py +256 -0
- ate/urdf/scan_session.py +411 -0
- ate/urdf/segmentation.py +299 -0
- ate/urdf/synthesis.py +319 -0
- ate/urdf/topology.py +336 -0
- ate/urdf/validation.py +371 -0
- {foodforthought_cli-0.2.8.dist-info → foodforthought_cli-0.3.1.dist-info}/METADATA +1 -1
- foodforthought_cli-0.3.1.dist-info/RECORD +166 -0
- {foodforthought_cli-0.2.8.dist-info → foodforthought_cli-0.3.1.dist-info}/WHEEL +1 -1
- foodforthought_cli-0.2.8.dist-info/RECORD +0 -73
- {foodforthought_cli-0.2.8.dist-info → foodforthought_cli-0.3.1.dist-info}/entry_points.txt +0 -0
- {foodforthought_cli-0.2.8.dist-info → foodforthought_cli-0.3.1.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)
|
ate/commands/__init__.py
ADDED
|
@@ -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)
|