kiteml-cli 0.1.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.
kite_cli/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """Kite CLI and MCP server for the Kite robotics training platform."""
2
+
3
+ __version__ = "0.1.0"
kite_cli/auth.py ADDED
@@ -0,0 +1,184 @@
1
+ """
2
+ Authentication for the Kite CLI.
3
+
4
+ Supports:
5
+ - API key: KITE_API_KEY env var (recommended for MCP / programmatic access)
6
+ - Browser OAuth: `kite auth login` opens the dashboard in a browser → an API key is stored
7
+ - Environment variable: KITE_TOKEN for CI/headless
8
+ - Token file: ~/.kite/credentials.json (written by login, read by client)
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import json
14
+ import os
15
+ import secrets
16
+ import sys
17
+ import threading
18
+ import time
19
+ import webbrowser
20
+ from http.server import HTTPServer, BaseHTTPRequestHandler
21
+ from pathlib import Path
22
+ from typing import Optional
23
+ from urllib.parse import parse_qs, urlparse
24
+
25
+ from kite_cli.config import KITE_DIR, get_app_url
26
+
27
+ CREDENTIALS_PATH = KITE_DIR / "credentials.json"
28
+
29
+
30
+ def get_token() -> Optional[str]:
31
+ """Get a valid auth token. Checks env vars first, then credentials file."""
32
+ # 1. API key (MCP / programmatic access)
33
+ api_key = os.environ.get("KITE_API_KEY")
34
+ if api_key:
35
+ return api_key
36
+
37
+ # 2. Token override (CI, headless)
38
+ env_token = os.environ.get("KITE_TOKEN")
39
+ if env_token:
40
+ return env_token
41
+
42
+ # 3. Stored credentials
43
+ creds = _load_credentials()
44
+ if creds and creds.get("id_token"):
45
+ return creds["id_token"]
46
+
47
+ return None
48
+
49
+
50
+ def save_credentials(id_token: str, user_info: Optional[dict] = None) -> None:
51
+ """Save credentials to disk with restricted permissions."""
52
+ KITE_DIR.mkdir(parents=True, exist_ok=True)
53
+ creds = {
54
+ "id_token": id_token,
55
+ "user_info": user_info,
56
+ "saved_at": time.time(),
57
+ }
58
+ CREDENTIALS_PATH.write_text(json.dumps(creds, indent=2) + "\n")
59
+ # Restrict file permissions (owner read/write only)
60
+ try:
61
+ CREDENTIALS_PATH.chmod(0o600)
62
+ except OSError:
63
+ pass
64
+
65
+
66
+ def clear_credentials() -> None:
67
+ """Remove stored credentials."""
68
+ if CREDENTIALS_PATH.exists():
69
+ CREDENTIALS_PATH.unlink()
70
+
71
+
72
+ def _load_credentials() -> Optional[dict]:
73
+ """Load credentials from disk."""
74
+ if not CREDENTIALS_PATH.exists():
75
+ return None
76
+ try:
77
+ return json.loads(CREDENTIALS_PATH.read_text())
78
+ except (json.JSONDecodeError, OSError):
79
+ return None
80
+
81
+
82
+ class _CallbackHandler(BaseHTTPRequestHandler):
83
+ """HTTP handler that receives the OAuth callback with the token."""
84
+
85
+ token: Optional[str] = None
86
+ user_info: Optional[dict] = None
87
+ expected_state: Optional[str] = None
88
+
89
+ def do_GET(self):
90
+ parsed = urlparse(self.path)
91
+ params = parse_qs(parsed.query)
92
+
93
+ if parsed.path != "/callback":
94
+ self.send_response(404)
95
+ self.end_headers()
96
+ return
97
+
98
+ state = params.get("state", [None])[0]
99
+ token = params.get("token", [None])[0]
100
+ email = params.get("email", [None])[0]
101
+
102
+ # Only accept the login this process started. Without this, any local
103
+ # page could hit the ephemeral port and plant a token. compare_digest
104
+ # keeps the nonce from being probed a byte at a time.
105
+ expected = _CallbackHandler.expected_state
106
+ if not expected or not state or not secrets.compare_digest(state, expected):
107
+ self._reply(403, "Login failed", "This callback did not come from the login you started.")
108
+ return
109
+
110
+ if not token:
111
+ self._reply(400, "Login failed", "No token received.")
112
+ return
113
+
114
+ _CallbackHandler.token = token
115
+ _CallbackHandler.user_info = {"email": email} if email else None
116
+ self._reply(200, "Login successful!", "You can close this tab.")
117
+
118
+ def _reply(self, status: int, heading: str, detail: str) -> None:
119
+ self.send_response(status)
120
+ self.send_header("Content-Type", "text/html")
121
+ self.end_headers()
122
+ self.wfile.write(f"<html><body><h2>{heading}</h2><p>{detail}</p></body></html>".encode())
123
+
124
+ def log_message(self, format, *args):
125
+ pass # Suppress HTTP server logs
126
+
127
+
128
+ def login_browser(timeout: int = 120) -> bool:
129
+ """Open browser for OAuth login. Returns True on success."""
130
+ import socket
131
+
132
+ # Find a free port
133
+ with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
134
+ s.bind(("127.0.0.1", 0))
135
+ port = s.getsockname()[1]
136
+
137
+ # Single-use nonce the /cli-auth page echoes back, so we can tell our own
138
+ # callback apart from any other request that reaches this port.
139
+ state = secrets.token_urlsafe(24)
140
+
141
+ # Reset handler state
142
+ _CallbackHandler.token = None
143
+ _CallbackHandler.user_info = None
144
+ _CallbackHandler.expected_state = state
145
+
146
+ server = HTTPServer(("127.0.0.1", port), _CallbackHandler)
147
+ # Poll in short slices so a stray request can't hold us past the deadline.
148
+ server.timeout = 1
149
+
150
+ # The dashboard's /cli-auth page signs you in, then redirects to our
151
+ # loopback callback with the token. Derive nothing from the API URL: the
152
+ # app is a different host (app.kiteml.com vs api.kiteml.com).
153
+ login_url = f"{get_app_url().rstrip('/')}/cli-auth?redirect_port={port}&state={state}"
154
+
155
+ print(f"Opening browser for login...")
156
+ print(f"If the browser doesn't open, visit: {login_url}")
157
+ webbrowser.open(login_url)
158
+
159
+ # Wait for callback
160
+ deadline = time.time() + timeout
161
+ while time.time() < deadline and _CallbackHandler.token is None:
162
+ server.handle_request()
163
+
164
+ server.server_close()
165
+
166
+ if _CallbackHandler.token:
167
+ save_credentials(
168
+ id_token=_CallbackHandler.token,
169
+ user_info=_CallbackHandler.user_info,
170
+ )
171
+ return True
172
+
173
+ return False
174
+
175
+
176
+ def login_headless() -> bool:
177
+ """Headless login: prompt user to paste a token."""
178
+ print("Visit your Kite dashboard to generate an API token.")
179
+ print("Then paste it here:")
180
+ token = input("> ").strip()
181
+ if token:
182
+ save_credentials(id_token=token)
183
+ return True
184
+ return False
File without changes
File without changes
@@ -0,0 +1,164 @@
1
+ """`kite augment` — dataset augmentation jobs via the public /v1 API.
2
+
3
+ create / status / download / cancel / list. Authenticates with KITE_API_KEY like the
4
+ rest of the CLI; calls the bare `/v1/augmentations` surface.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import base64
10
+ import json
11
+ from pathlib import Path
12
+ from typing import List, Optional
13
+
14
+ import typer
15
+
16
+ from kite_cli.client import KiteClient, KiteAPIError
17
+ from kite_cli.output import print_error, print_json
18
+ from kite_cli.progress import wait_for_completion
19
+
20
+ app = typer.Typer(no_args_is_help=True, help="Dataset augmentation jobs")
21
+
22
+ _TERMINAL = {"succeeded", "failed", "canceled"}
23
+ _MEDIA_TYPES = {".jpg": "image/jpeg", ".jpeg": "image/jpeg", ".png": "image/png", ".webp": "image/webp"}
24
+
25
+
26
+ def reference_image(path: str, camera: Optional[str] = None) -> dict:
27
+ """A local photo of the target look → a /v1 ``reference_images`` entry (base64 inline)."""
28
+ p = Path(path).expanduser()
29
+ media_type = _MEDIA_TYPES.get(p.suffix.lower())
30
+ if media_type is None:
31
+ raise ValueError(f"{p}: reference images must be .jpg, .png or .webp")
32
+ entry = {"data": base64.b64encode(p.read_bytes()).decode("ascii"), "media_type": media_type}
33
+ return {"camera": camera, **entry} if camera else entry
34
+
35
+
36
+ @app.command()
37
+ def create(
38
+ repo_id: str = typer.Option(..., "--repo-id", help="Source HF dataset id, e.g. lerobot/pusht"),
39
+ instructions: str = typer.Option(..., "-i", "--instructions", help="Augmentation instruction"),
40
+ episodes: int = typer.Option(3, "-n", "--episodes", help="Episodes to generate with video augmentation (1-50); relight covers every episode"),
41
+ model: Optional[str] = typer.Option(
42
+ None, "--model", help="auto (default: pick relight or video) | relight (colour/exposure, every episode) | video (generative)"
43
+ ),
44
+ reference_images: Optional[List[str]] = typer.Option(
45
+ None, "--reference-image", help="Photo of the target look, [camera=]PATH (repeatable)"
46
+ ),
47
+ relight: Optional[str] = typer.Option(
48
+ None, "--relight", help='JSON relight params per camera, e.g. \'{"*": {"exposure": -1.2, "temperature": 3200}}\''
49
+ ),
50
+ output: str = typer.Option("download", "--output", help="'download' or 'huggingface'"),
51
+ hf_repo: Optional[str] = typer.Option(None, "--hf-repo", help="Target HF repo (for --output huggingface)"),
52
+ private: bool = typer.Option(True, "--private/--public", help="HF output visibility"),
53
+ variation_prompts: Optional[str] = typer.Option(
54
+ None, "--variation-prompts", help="JSON array of per-episode prompts (overrides --episodes)"
55
+ ),
56
+ wait: bool = typer.Option(False, "--wait", help="Poll until the run finishes"),
57
+ ):
58
+ """Create an augmentation run."""
59
+ body: dict = {
60
+ "source": {"type": "huggingface", "repo_id": repo_id},
61
+ "instructions": instructions,
62
+ "config": {"episode_count": episodes},
63
+ "output": {"type": output, "private": private},
64
+ }
65
+ if hf_repo:
66
+ body["output"]["repo_id"] = hf_repo
67
+ if model:
68
+ body["model"] = model
69
+ try:
70
+ if relight:
71
+ body["config"]["relight"] = json.loads(relight)
72
+ if reference_images:
73
+ body["reference_images"] = [
74
+ reference_image(path, camera or None)
75
+ for camera, _, path in (
76
+ spec.partition("=") if "=" in spec and not Path(spec).exists() else ("", "", spec)
77
+ for spec in reference_images
78
+ )
79
+ ]
80
+ except (json.JSONDecodeError, OSError, ValueError) as e:
81
+ print_error(f"invalid --relight / --reference-image: {e}")
82
+ return
83
+ if variation_prompts:
84
+ try:
85
+ body["config"]["variation_prompts"] = json.loads(variation_prompts)
86
+ except json.JSONDecodeError:
87
+ print_error("--variation-prompts must be a JSON array of strings")
88
+ return
89
+ try:
90
+ client = KiteClient()
91
+ result = client.post("/v1/augmentations", json_data=body)
92
+ if wait and result.get("id"):
93
+ result = wait_for_completion(
94
+ client, f"/v1/augmentations/{result['id']}", terminal_states=_TERMINAL
95
+ )
96
+ print_json(result)
97
+ except KiteAPIError as e:
98
+ print_error(e.message, code=e.code, details=e.details)
99
+
100
+
101
+ @app.command()
102
+ def status(
103
+ aug_id: str = typer.Argument(..., help="Augmentation id (aug_...)"),
104
+ wait: bool = typer.Option(False, "--wait", help="Poll until the run finishes"),
105
+ ):
106
+ """Get an augmentation's status."""
107
+ try:
108
+ client = KiteClient()
109
+ path = f"/v1/augmentations/{aug_id}"
110
+ result = wait_for_completion(client, path, terminal_states=_TERMINAL) if wait else client.get(path)
111
+ print_json(result)
112
+ except KiteAPIError as e:
113
+ print_error(e.message, code=e.code, details=e.details)
114
+
115
+
116
+ @app.command("list")
117
+ def list_augmentations(limit: int = typer.Option(20, "--limit", help="Max rows (1-100)")):
118
+ """List your augmentation runs (newest first)."""
119
+ try:
120
+ client = KiteClient()
121
+ result = client.get("/v1/augmentations", params={"limit": limit})
122
+ print_json(result)
123
+ except KiteAPIError as e:
124
+ print_error(e.message, code=e.code, details=e.details)
125
+
126
+
127
+ @app.command()
128
+ def cancel(aug_id: str = typer.Argument(..., help="Augmentation id (aug_...)")):
129
+ """Cancel a running augmentation."""
130
+ try:
131
+ client = KiteClient()
132
+ result = client.post(f"/v1/augmentations/{aug_id}/cancel")
133
+ print_json(result)
134
+ except KiteAPIError as e:
135
+ print_error(e.message, code=e.code, details=e.details)
136
+
137
+
138
+ @app.command()
139
+ def download(
140
+ aug_id: str = typer.Argument(..., help="Augmentation id (aug_...)"),
141
+ out: Path = typer.Option(Path("."), "-o", "--out", help="Output directory"),
142
+ ):
143
+ """Download a finished download-output dataset, preserving its file layout."""
144
+ try:
145
+ client = KiteClient()
146
+ result = client.get(f"/v1/augmentations/{aug_id}")
147
+ if result.get("status") != "succeeded":
148
+ print_error(f"Augmentation is '{result.get('status')}', not succeeded yet.")
149
+ return
150
+ files = (result.get("output") or {}).get("files") or []
151
+ if not files:
152
+ print_error("No downloadable files (is this a 'download'-output run?).")
153
+ return
154
+ written = []
155
+ for f in files:
156
+ # Always fetch via the authenticated proxy route (never send the Bearer token
157
+ # to a GCS signed URL); the layout is reconstructed from each file's path.
158
+ dest = out / f["path"]
159
+ dest.parent.mkdir(parents=True, exist_ok=True)
160
+ client.download(f"/v1/augmentations/{aug_id}/files/{f['path']}", str(dest))
161
+ written.append(str(dest))
162
+ print_json({"downloaded": written, "dir": str(out)})
163
+ except KiteAPIError as e:
164
+ print_error(e.message, code=e.code, details=e.details)
@@ -0,0 +1,57 @@
1
+ """kite auth — login, logout, whoami, token."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Optional
6
+
7
+ import typer
8
+
9
+ from kite_cli.output import print_json, print_error
10
+
11
+ app = typer.Typer(no_args_is_help=True)
12
+
13
+
14
+ @app.command()
15
+ def login(headless: bool = typer.Option(False, help="Paste token instead of browser flow")):
16
+ """Log in to Kite via browser OAuth or token paste."""
17
+ from kite_cli.auth import login_browser, login_headless
18
+
19
+ if headless:
20
+ ok = login_headless()
21
+ else:
22
+ ok = login_browser()
23
+
24
+ if ok:
25
+ print_json({"logged_in": True})
26
+ else:
27
+ print_error("Login failed. Try --headless to paste a token manually.")
28
+
29
+
30
+ @app.command()
31
+ def logout():
32
+ """Clear stored credentials."""
33
+ from kite_cli.auth import clear_credentials
34
+ clear_credentials()
35
+ print_json({"logged_out": True})
36
+
37
+
38
+ @app.command()
39
+ def whoami():
40
+ """Show the key or session you are signed in with."""
41
+ from kite_cli.client import KiteClient, KiteAPIError
42
+ try:
43
+ client = KiteClient()
44
+ print_json(client.get("/v1/keys/me"))
45
+ except KiteAPIError as e:
46
+ print_error(e.message, code=e.code)
47
+
48
+
49
+ @app.command()
50
+ def token():
51
+ """Print the current auth token (for piping)."""
52
+ from kite_cli.auth import get_token
53
+ t = get_token()
54
+ if t:
55
+ print(t)
56
+ else:
57
+ print_error("No token found. Run: kite auth login")
@@ -0,0 +1,39 @@
1
+ """kite config — get, set, show, set-project."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import typer
6
+
7
+ from kite_cli.config import get_value, set_value, load_config
8
+ from kite_cli.output import print_json
9
+
10
+ app = typer.Typer(no_args_is_help=True)
11
+
12
+
13
+ @app.command()
14
+ def show():
15
+ """Show all configuration values."""
16
+ print_json(load_config())
17
+
18
+
19
+ @app.command()
20
+ def get(key: str = typer.Argument(..., help="Config key to read")):
21
+ """Get a configuration value."""
22
+ print_json({"key": key, "value": get_value(key)})
23
+
24
+
25
+ @app.command("set")
26
+ def set_cmd(
27
+ key: str = typer.Argument(..., help="Config key"),
28
+ value: str = typer.Argument(..., help="Config value"),
29
+ ):
30
+ """Set a configuration value."""
31
+ set_value(key, value)
32
+ print_json({"key": key, "value": value, "saved": True})
33
+
34
+
35
+ @app.command("set-project")
36
+ def set_project(project_id: str = typer.Argument(..., help="Default project ID")):
37
+ """Set the default project ID."""
38
+ set_value("default_project_id", project_id)
39
+ print_json({"default_project_id": project_id, "saved": True})