kiteml-cli 0.1.0__tar.gz → 0.3.0__tar.gz
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.
- kiteml_cli-0.3.0/.gitignore +9 -0
- {kiteml_cli-0.1.0 → kiteml_cli-0.3.0}/PKG-INFO +13 -9
- {kiteml_cli-0.1.0 → kiteml_cli-0.3.0}/README.md +10 -0
- {kiteml_cli-0.1.0 → kiteml_cli-0.3.0}/pyproject.toml +10 -5
- {kiteml_cli-0.1.0 → kiteml_cli-0.3.0}/src/kite_cli/__init__.py +1 -1
- kiteml_cli-0.3.0/src/kite_cli/archive.py +108 -0
- kiteml_cli-0.3.0/src/kite_cli/auth.py +252 -0
- {kiteml_cli-0.1.0 → kiteml_cli-0.3.0}/src/kite_cli/cli/commands/auth_cmd.py +8 -12
- kiteml_cli-0.3.0/src/kite_cli/cli/commands/sim.py +439 -0
- {kiteml_cli-0.1.0 → kiteml_cli-0.3.0}/src/kite_cli/cli/commands/twin.py +10 -85
- {kiteml_cli-0.1.0 → kiteml_cli-0.3.0}/src/kite_cli/cli/main.py +21 -4
- {kiteml_cli-0.1.0 → kiteml_cli-0.3.0}/src/kite_cli/client.py +15 -5
- kiteml_cli-0.3.0/src/kite_cli/mcp/common.py +22 -0
- {kiteml_cli-0.1.0 → kiteml_cli-0.3.0}/src/kite_cli/mcp/server.py +156 -31
- kiteml_cli-0.1.0/.gitignore +0 -7
- kiteml_cli-0.1.0/src/kite_cli/auth.py +0 -184
- {kiteml_cli-0.1.0 → kiteml_cli-0.3.0}/src/kite_cli/cli/__init__.py +0 -0
- {kiteml_cli-0.1.0 → kiteml_cli-0.3.0}/src/kite_cli/cli/commands/__init__.py +0 -0
- {kiteml_cli-0.1.0 → kiteml_cli-0.3.0}/src/kite_cli/cli/commands/augment.py +0 -0
- {kiteml_cli-0.1.0 → kiteml_cli-0.3.0}/src/kite_cli/cli/commands/config_cmd.py +0 -0
- {kiteml_cli-0.1.0 → kiteml_cli-0.3.0}/src/kite_cli/cli/commands/rl.py +0 -0
- {kiteml_cli-0.1.0 → kiteml_cli-0.3.0}/src/kite_cli/config.py +0 -0
- {kiteml_cli-0.1.0 → kiteml_cli-0.3.0}/src/kite_cli/mcp/__init__.py +0 -0
- {kiteml_cli-0.1.0 → kiteml_cli-0.3.0}/src/kite_cli/output.py +0 -0
- {kiteml_cli-0.1.0 → kiteml_cli-0.3.0}/src/kite_cli/progress.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: kiteml-cli
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.3.0
|
|
4
4
|
Summary: Kite CLI and MCP server: train robot policies with reinforcement learning in simulation, augment LeRobot datasets, and build MuJoCo digital twins, from your terminal or an AI agent.
|
|
5
5
|
Project-URL: Homepage, https://kiteml.com
|
|
6
6
|
Project-URL: Documentation, https://kite-ml.mintlify.app/tools/cli-and-mcp
|
|
@@ -26,24 +26,18 @@ Classifier: Programming Language :: Python :: 3.13
|
|
|
26
26
|
Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
|
|
27
27
|
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
28
28
|
Requires-Python: >=3.10
|
|
29
|
-
Requires-Dist: httpx
|
|
29
|
+
Requires-Dist: httpx<1,>=0.25
|
|
30
30
|
Requires-Dist: rich>=13.0
|
|
31
|
-
Requires-Dist: typer>=0.
|
|
31
|
+
Requires-Dist: typer>=0.15.4
|
|
32
32
|
Provides-Extra: all
|
|
33
33
|
Requires-Dist: mcp<2,>=1.0; extra == 'all'
|
|
34
|
-
Requires-Dist: starlette>=0.37; extra == 'all'
|
|
35
|
-
Requires-Dist: uvicorn>=0.30; extra == 'all'
|
|
36
34
|
Provides-Extra: dev
|
|
37
35
|
Requires-Dist: mcp<2,>=1.0; extra == 'dev'
|
|
38
36
|
Requires-Dist: pytest-asyncio; extra == 'dev'
|
|
39
37
|
Requires-Dist: pytest>=7.0; extra == 'dev'
|
|
40
38
|
Requires-Dist: ruff; extra == 'dev'
|
|
41
|
-
Requires-Dist: starlette>=0.37; extra == 'dev'
|
|
42
|
-
Requires-Dist: uvicorn>=0.30; extra == 'dev'
|
|
43
39
|
Provides-Extra: mcp
|
|
44
40
|
Requires-Dist: mcp<2,>=1.0; extra == 'mcp'
|
|
45
|
-
Requires-Dist: starlette>=0.37; extra == 'mcp'
|
|
46
|
-
Requires-Dist: uvicorn>=0.30; extra == 'mcp'
|
|
47
41
|
Description-Content-Type: text/markdown
|
|
48
42
|
|
|
49
43
|
# Kite CLI and MCP server
|
|
@@ -65,6 +59,8 @@ Model Context Protocol (MCP) client.
|
|
|
65
59
|
Hugging Face.
|
|
66
60
|
- **Digital twins.** Point Kite at one episode of a LeRobot dataset and get an interactable MuJoCo scene of the
|
|
67
61
|
room, with the objects the robot handles built to size.
|
|
62
|
+
- **Sim runs.** Run a Kite SDK spec on Kite's machines, on an NVIDIA L4 for a GPU policy: every episode traced, every
|
|
63
|
+
evaluator scored. You get a scorecard and the run directory, which `kite verify` checks.
|
|
68
64
|
|
|
69
65
|
## Install
|
|
70
66
|
|
|
@@ -112,6 +108,7 @@ lerobot/pusht to look like this photo".
|
|
|
112
108
|
| `kite_rl_status`, `kite_rl_metrics`, `kite_rl_report`, `kite_rl_list` | Follow training and read each run's evaluation verdict |
|
|
113
109
|
| `kite_augment_create`, `kite_augment_status`, `kite_augment_list`, `kite_augment_cancel` | Augment LeRobot datasets |
|
|
114
110
|
| `kite_twin_validate`, `kite_twin_create`, `kite_twin_status`, `kite_twin_list`, `kite_twin_cancel`, `kite_twin_resume` | Build MuJoCo digital twins |
|
|
111
|
+
| `kite_sim_validate`, `kite_sim_run`, `kite_sim_status`, `kite_sim_list`, `kite_sim_report`, `kite_sim_logs`, `kite_sim_cancel`, `kite_sim_resume` | Run Kite SDK specs on Kite's machines and read their scorecards |
|
|
115
112
|
| `kite_doctor` | Check connectivity and authentication |
|
|
116
113
|
|
|
117
114
|
## Use it from the terminal
|
|
@@ -140,6 +137,13 @@ Build a digital twin (private beta):
|
|
|
140
137
|
kite twin create lerobot/svla_so101_pickplace --out ./twins # -> ./twins/twin_.../scene.xml
|
|
141
138
|
```
|
|
142
139
|
|
|
140
|
+
Run a Kite SDK spec on Kite's machines (private beta):
|
|
141
|
+
|
|
142
|
+
```bash
|
|
143
|
+
kite sim validate specs/smoke.yaml # free: the plan and what a run reserves
|
|
144
|
+
kite sim run specs/smoke.yaml --out ./runs # -> ./runs/srun_.../scorecard.json
|
|
145
|
+
```
|
|
146
|
+
|
|
143
147
|
Every command prints JSON (`{"ok": true, "data": ...}`) so scripts and agents can parse it. Run `kite --help` for
|
|
144
148
|
the full list.
|
|
145
149
|
|
|
@@ -17,6 +17,8 @@ Model Context Protocol (MCP) client.
|
|
|
17
17
|
Hugging Face.
|
|
18
18
|
- **Digital twins.** Point Kite at one episode of a LeRobot dataset and get an interactable MuJoCo scene of the
|
|
19
19
|
room, with the objects the robot handles built to size.
|
|
20
|
+
- **Sim runs.** Run a Kite SDK spec on Kite's machines, on an NVIDIA L4 for a GPU policy: every episode traced, every
|
|
21
|
+
evaluator scored. You get a scorecard and the run directory, which `kite verify` checks.
|
|
20
22
|
|
|
21
23
|
## Install
|
|
22
24
|
|
|
@@ -64,6 +66,7 @@ lerobot/pusht to look like this photo".
|
|
|
64
66
|
| `kite_rl_status`, `kite_rl_metrics`, `kite_rl_report`, `kite_rl_list` | Follow training and read each run's evaluation verdict |
|
|
65
67
|
| `kite_augment_create`, `kite_augment_status`, `kite_augment_list`, `kite_augment_cancel` | Augment LeRobot datasets |
|
|
66
68
|
| `kite_twin_validate`, `kite_twin_create`, `kite_twin_status`, `kite_twin_list`, `kite_twin_cancel`, `kite_twin_resume` | Build MuJoCo digital twins |
|
|
69
|
+
| `kite_sim_validate`, `kite_sim_run`, `kite_sim_status`, `kite_sim_list`, `kite_sim_report`, `kite_sim_logs`, `kite_sim_cancel`, `kite_sim_resume` | Run Kite SDK specs on Kite's machines and read their scorecards |
|
|
67
70
|
| `kite_doctor` | Check connectivity and authentication |
|
|
68
71
|
|
|
69
72
|
## Use it from the terminal
|
|
@@ -92,6 +95,13 @@ Build a digital twin (private beta):
|
|
|
92
95
|
kite twin create lerobot/svla_so101_pickplace --out ./twins # -> ./twins/twin_.../scene.xml
|
|
93
96
|
```
|
|
94
97
|
|
|
98
|
+
Run a Kite SDK spec on Kite's machines (private beta):
|
|
99
|
+
|
|
100
|
+
```bash
|
|
101
|
+
kite sim validate specs/smoke.yaml # free: the plan and what a run reserves
|
|
102
|
+
kite sim run specs/smoke.yaml --out ./runs # -> ./runs/srun_.../scorecard.json
|
|
103
|
+
```
|
|
104
|
+
|
|
95
105
|
Every command prints JSON (`{"ok": true, "data": ...}`) so scripts and agents can parse it. Run `kite --help` for
|
|
96
106
|
the full list.
|
|
97
107
|
|
|
@@ -1,10 +1,12 @@
|
|
|
1
1
|
[build-system]
|
|
2
|
-
requires = ["hatchling"]
|
|
2
|
+
requires = ["hatchling>=1.27"]
|
|
3
3
|
build-backend = "hatchling.build"
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
|
+
# Published as kiteml-cli: PyPI refuses kite-cli as too similar to the existing `kitecli` project.
|
|
7
|
+
# The import package (kite_cli) and the commands (kite, kite-mcp) keep their names.
|
|
6
8
|
name = "kiteml-cli"
|
|
7
|
-
|
|
9
|
+
dynamic = ["version"] # from src/kite_cli/__init__.py, the one place it is bumped
|
|
8
10
|
description = "Kite CLI and MCP server: train robot policies with reinforcement learning in simulation, augment LeRobot datasets, and build MuJoCo digital twins, from your terminal or an AI agent."
|
|
9
11
|
readme = "README.md"
|
|
10
12
|
license = "Apache-2.0"
|
|
@@ -34,8 +36,8 @@ classifiers = [
|
|
|
34
36
|
]
|
|
35
37
|
|
|
36
38
|
dependencies = [
|
|
37
|
-
"typer>=0.
|
|
38
|
-
"httpx>=0.25",
|
|
39
|
+
"typer>=0.15.4", # older typers break on click >= 8.2, which pip pairs them with
|
|
40
|
+
"httpx>=0.25,<1",
|
|
39
41
|
"rich>=13.0",
|
|
40
42
|
]
|
|
41
43
|
|
|
@@ -48,7 +50,7 @@ Dashboard = "https://app.kiteml.com"
|
|
|
48
50
|
Blog = "https://kiteml.com/blog"
|
|
49
51
|
|
|
50
52
|
[project.optional-dependencies]
|
|
51
|
-
mcp = ["mcp>=1.0,<2"
|
|
53
|
+
mcp = ["mcp>=1.0,<2"] # mcp brings the starlette/uvicorn the hosted HTTP transport uses
|
|
52
54
|
all = ["kiteml-cli[mcp]"]
|
|
53
55
|
dev = [
|
|
54
56
|
"kiteml-cli[all]",
|
|
@@ -61,6 +63,9 @@ dev = [
|
|
|
61
63
|
kite = "kite_cli.cli.main:app"
|
|
62
64
|
kite-mcp = "kite_cli.mcp.server:main"
|
|
63
65
|
|
|
66
|
+
[tool.hatch.version]
|
|
67
|
+
path = "src/kite_cli/__init__.py"
|
|
68
|
+
|
|
64
69
|
# The published package is the public /v1 client. kite_cli/internal (the dashboard's commands and tools, and the
|
|
65
70
|
# hosted server's HTTP transport) stays in this repo: the hosted image puts the source tree on PYTHONPATH instead.
|
|
66
71
|
[tool.hatch.build.targets.wheel]
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
"""Fetching a finished resource's `.tar.gz` (a twin, a sim run): to `<out>/<id>.tar.gz`, sha256-verified, and unpacked
|
|
2
|
+
only inside `<out>/<id>/`.
|
|
3
|
+
|
|
4
|
+
A signed storage URL is fetched with a plain HTTP client, never with the Kite key; the API's own archive route
|
|
5
|
+
(download.requires_auth) goes through KiteClient.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import hashlib
|
|
11
|
+
import re
|
|
12
|
+
import tarfile
|
|
13
|
+
from pathlib import Path, PurePosixPath
|
|
14
|
+
from typing import Callable
|
|
15
|
+
|
|
16
|
+
import httpx
|
|
17
|
+
|
|
18
|
+
from kite_cli.client import KiteAPIError, KiteClient, user_agent
|
|
19
|
+
|
|
20
|
+
DATA_FILTER = hasattr(tarfile, "data_filter") # Python >= 3.10.12 / 3.11.4 / 3.12
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def storage_client() -> httpx.Client:
|
|
24
|
+
"""A plain client for signed storage URLs: it has no Kite credentials to send."""
|
|
25
|
+
return httpx.Client(timeout=httpx.Timeout(120.0, connect=30.0), follow_redirects=True)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def verified(client: KiteClient, d: dict, out: Path, rid: str, *,
|
|
29
|
+
storage: Callable[[], httpx.Client] = storage_client) -> Path:
|
|
30
|
+
"""`<out>/<rid>.tar.gz`, fetched from the `download` block `d` and checked against its sha256. An archive already
|
|
31
|
+
there that still verifies is reused."""
|
|
32
|
+
if not re.fullmatch(r"[A-Za-z0-9_-]+", rid):
|
|
33
|
+
raise KiteAPIError(502, f"unexpected id {rid!r}", code="archive_invalid")
|
|
34
|
+
expected = str(d.get("sha256") or "").lower()
|
|
35
|
+
if not expected:
|
|
36
|
+
raise KiteAPIError(502, f"'{rid}' has no download.sha256 to verify against.", code="checksum_missing")
|
|
37
|
+
out.mkdir(parents=True, exist_ok=True)
|
|
38
|
+
archive = out / f"{rid}.tar.gz"
|
|
39
|
+
if not (archive.is_file() and sha256(archive) == expected):
|
|
40
|
+
part = out / f"{rid}.tar.gz.part"
|
|
41
|
+
try:
|
|
42
|
+
fetch(client, d, part, storage=storage)
|
|
43
|
+
got = sha256(part)
|
|
44
|
+
if got != expected:
|
|
45
|
+
raise KiteAPIError(502, f"{archive.name}: sha256 {got} != expected {expected}; the file was discarded, "
|
|
46
|
+
f"retry the download", code="checksum_mismatch",
|
|
47
|
+
details={"expected": expected, "actual": got, "bytes": part.stat().st_size})
|
|
48
|
+
part.replace(archive)
|
|
49
|
+
finally:
|
|
50
|
+
part.unlink(missing_ok=True)
|
|
51
|
+
return archive
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def fetch(client: KiteClient, d: dict, dest: Path, *, storage: Callable[[], httpx.Client] = storage_client) -> None:
|
|
55
|
+
"""Stream the archive to `dest`. The Kite key goes to the Kite API only: the archive route through
|
|
56
|
+
KiteClient, a signed storage URL through a plain client that has no key to send."""
|
|
57
|
+
url = str(d.get("url") or "")
|
|
58
|
+
if not url:
|
|
59
|
+
raise KiteAPIError(502, "the download has no url", code="download_failed")
|
|
60
|
+
u = httpx.URL(url)
|
|
61
|
+
if u.is_relative_url or _origin(u) == _origin(httpx.URL(client.base_url)):
|
|
62
|
+
client.download(url, str(dest))
|
|
63
|
+
return
|
|
64
|
+
if d.get("requires_auth"):
|
|
65
|
+
raise KiteAPIError(502, f"download.url is on {u.host}, not the Kite API; refusing to send it the Kite key",
|
|
66
|
+
code="download_failed")
|
|
67
|
+
headers = {"Accept-Encoding": "identity", "User-Agent": user_agent()}
|
|
68
|
+
with storage() as http, http.stream("GET", url, headers=headers) as r:
|
|
69
|
+
if not r.is_success:
|
|
70
|
+
r.read()
|
|
71
|
+
raise KiteAPIError(r.status_code, f"GET {u.host} answered HTTP {r.status_code}: {r.text[:200]}",
|
|
72
|
+
code="download_failed")
|
|
73
|
+
with open(dest, "wb") as f:
|
|
74
|
+
for chunk in r.iter_bytes(1 << 20):
|
|
75
|
+
f.write(chunk)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _origin(u: httpx.URL) -> tuple:
|
|
79
|
+
return u.scheme, u.host, u.port or {"https": 443, "http": 80}.get(u.scheme)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def sha256(path: Path) -> str:
|
|
83
|
+
h = hashlib.sha256()
|
|
84
|
+
with open(path, "rb") as f:
|
|
85
|
+
for chunk in iter(lambda: f.read(1 << 20), b""):
|
|
86
|
+
h.update(chunk)
|
|
87
|
+
return h.hexdigest()
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def unpack(archive: Path, dest: Path, *, top: str) -> None:
|
|
91
|
+
"""Extract into `dest`. Every member must sit under `<top>/` (the resource's id), with no absolute path or `..`.
|
|
92
|
+
Python's tarfile "data" filter, where present, also refuses links out of `dest` and device files; without
|
|
93
|
+
it (Python < 3.10.12), links and special files are refused outright."""
|
|
94
|
+
with tarfile.open(archive, "r:gz") as tar:
|
|
95
|
+
members = tar.getmembers()
|
|
96
|
+
for m in members:
|
|
97
|
+
parts = PurePosixPath(m.name).parts
|
|
98
|
+
if (parts and parts[0] != top) or ".." in parts or not (DATA_FILTER or m.isfile() or m.isdir()):
|
|
99
|
+
raise KiteAPIError(502, f"{archive.name}: refusing to extract {m.name!r}", code="archive_invalid")
|
|
100
|
+
try:
|
|
101
|
+
if DATA_FILTER:
|
|
102
|
+
tar.extractall(dest, members=members, filter="data")
|
|
103
|
+
else:
|
|
104
|
+
for m in members:
|
|
105
|
+
m.mode = 0o755 if m.isdir() else (m.mode | 0o600) & 0o755
|
|
106
|
+
tar.extractall(dest, members=members)
|
|
107
|
+
except tarfile.TarError as e: # includes the data filter's refusals
|
|
108
|
+
raise KiteAPIError(502, f"{archive.name}: {e}", code="archive_invalid") from e
|
|
@@ -0,0 +1,252 @@
|
|
|
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 login: `kite auth login` signs in on the dashboard, whose session token lasts an hour, and trades it
|
|
7
|
+
for an API key that lasts KEY_DAYS: full access (the CLI also calls /api routes), named after this machine so it
|
|
8
|
+
can be found and revoked in the console. Logging in again replaces this machine's key; logging out revokes it.
|
|
9
|
+
- Environment variable: KITE_TOKEN for CI/headless
|
|
10
|
+
- Token file: ~/.kite/credentials.json (written by login, read by client)
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import json
|
|
16
|
+
import os
|
|
17
|
+
import secrets
|
|
18
|
+
import socket
|
|
19
|
+
import sys
|
|
20
|
+
import threading
|
|
21
|
+
import time
|
|
22
|
+
import webbrowser
|
|
23
|
+
from datetime import datetime, timedelta, timezone
|
|
24
|
+
from http.server import HTTPServer, BaseHTTPRequestHandler
|
|
25
|
+
from pathlib import Path
|
|
26
|
+
from typing import Optional
|
|
27
|
+
from urllib.parse import parse_qs, urlparse
|
|
28
|
+
|
|
29
|
+
from kite_cli.config import KITE_DIR, get_app_url
|
|
30
|
+
|
|
31
|
+
CREDENTIALS_PATH = KITE_DIR / "credentials.json"
|
|
32
|
+
KEY_DAYS = 90 # how long the API key a login stores lasts; logging in again before then replaces it
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def get_token() -> Optional[str]:
|
|
36
|
+
"""Get a valid auth token. Checks env vars first, then credentials file."""
|
|
37
|
+
# 1. API key (MCP / programmatic access)
|
|
38
|
+
api_key = os.environ.get("KITE_API_KEY")
|
|
39
|
+
if api_key:
|
|
40
|
+
return api_key
|
|
41
|
+
|
|
42
|
+
# 2. Token override (CI, headless)
|
|
43
|
+
env_token = os.environ.get("KITE_TOKEN")
|
|
44
|
+
if env_token:
|
|
45
|
+
return env_token
|
|
46
|
+
|
|
47
|
+
# 3. Stored credentials: the login's API key, or the session token an older login (or a failed exchange) kept
|
|
48
|
+
creds = _load_credentials()
|
|
49
|
+
if creds:
|
|
50
|
+
return creds.get("api_key") or creds.get("id_token")
|
|
51
|
+
|
|
52
|
+
return None
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def save_credentials(id_token: Optional[str] = None, user_info: Optional[dict] = None, *,
|
|
56
|
+
api_key: Optional[str] = None, api_key_id: Optional[str] = None,
|
|
57
|
+
expires_at: Optional[str] = None) -> None:
|
|
58
|
+
"""Save credentials to disk, readable by this user only from the moment the file exists."""
|
|
59
|
+
KITE_DIR.mkdir(parents=True, exist_ok=True)
|
|
60
|
+
creds: dict = {"user_info": user_info, "saved_at": time.time()}
|
|
61
|
+
if api_key:
|
|
62
|
+
creds.update(api_key=api_key, api_key_id=api_key_id, expires_at=expires_at)
|
|
63
|
+
else:
|
|
64
|
+
creds["id_token"] = id_token
|
|
65
|
+
fd = os.open(CREDENTIALS_PATH, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
|
|
66
|
+
with os.fdopen(fd, "w") as f:
|
|
67
|
+
f.write(json.dumps(creds, indent=2) + "\n")
|
|
68
|
+
try:
|
|
69
|
+
CREDENTIALS_PATH.chmod(0o600) # a file an older version wrote keeps its old mode otherwise
|
|
70
|
+
except OSError:
|
|
71
|
+
pass
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def clear_credentials() -> None:
|
|
75
|
+
"""Remove stored credentials."""
|
|
76
|
+
if CREDENTIALS_PATH.exists():
|
|
77
|
+
CREDENTIALS_PATH.unlink()
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def key_name() -> str:
|
|
81
|
+
"""The name a login gives its API key in the console."""
|
|
82
|
+
return f"Kite CLI on {socket.gethostname() or 'this machine'}"
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _api(method: str, path: str, token: str, json_body: Optional[dict] = None) -> Optional[dict]:
|
|
86
|
+
"""One call to the dashboard's /api routes with `token`; None when it fails or cannot be made."""
|
|
87
|
+
import httpx
|
|
88
|
+
|
|
89
|
+
from kite_cli.client import user_agent
|
|
90
|
+
from kite_cli.config import get_api_url
|
|
91
|
+
|
|
92
|
+
try:
|
|
93
|
+
r = httpx.request(method, f"{get_api_url().rstrip('/')}{path}", json=json_body, timeout=30,
|
|
94
|
+
headers={"Authorization": f"Bearer {token}", "User-Agent": user_agent()})
|
|
95
|
+
except httpx.HTTPError:
|
|
96
|
+
return None
|
|
97
|
+
if r.status_code >= 300:
|
|
98
|
+
return None
|
|
99
|
+
try:
|
|
100
|
+
return r.json()
|
|
101
|
+
except ValueError:
|
|
102
|
+
return {}
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def store_login(id_token: str, user_info: Optional[dict] = None) -> dict:
|
|
106
|
+
"""Keep a login: trade the dashboard session's token, which lasts an hour, for an API key that lasts KEY_DAYS, and
|
|
107
|
+
revoke the key this machine's previous login made. If the API will not make one, keep the session token (and
|
|
108
|
+
say it lasts about an hour). Returns what was kept, for `kite auth login` to print."""
|
|
109
|
+
previous = _load_credentials() or {}
|
|
110
|
+
expires = (datetime.now(timezone.utc) + timedelta(days=KEY_DAYS)).replace(microsecond=0)
|
|
111
|
+
made = _api("POST", "/api/api-keys", id_token,
|
|
112
|
+
{"name": key_name(), "expires_at": expires.isoformat().replace("+00:00", "Z")})
|
|
113
|
+
if not made or not str(made.get("key") or "").startswith("kite_"):
|
|
114
|
+
save_credentials(id_token=id_token, user_info=user_info)
|
|
115
|
+
print("warning: could not create an API key for this machine, so this login lasts about an hour; run "
|
|
116
|
+
"`kite auth login` again, or set KITE_API_KEY", file=sys.stderr)
|
|
117
|
+
return {"credential": "session", "expires_in_minutes": 60}
|
|
118
|
+
save_credentials(user_info=user_info, api_key=made["key"], api_key_id=made.get("id"),
|
|
119
|
+
expires_at=made.get("expires_at") or expires.isoformat())
|
|
120
|
+
old = previous.get("api_key_id")
|
|
121
|
+
if old and old != made.get("id"):
|
|
122
|
+
_api("DELETE", f"/api/api-keys/{old}", id_token) # one key per machine; a key of another account 404s
|
|
123
|
+
return {"credential": "api_key", "key_name": made.get("name") or key_name(),
|
|
124
|
+
"expires_at": made.get("expires_at") or expires.isoformat()}
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def logout() -> dict:
|
|
128
|
+
"""Revoke the API key the login stored (with itself), then forget it. A key that could not be revoked from here is
|
|
129
|
+
named, so it can be revoked in the console."""
|
|
130
|
+
creds = _load_credentials() or {}
|
|
131
|
+
out: dict = {"logged_out": True}
|
|
132
|
+
if creds.get("api_key") and creds.get("api_key_id"):
|
|
133
|
+
revoked = _api("DELETE", f"/api/api-keys/{creds['api_key_id']}", creds["api_key"]) is not None
|
|
134
|
+
out["key_revoked"] = revoked
|
|
135
|
+
if not revoked:
|
|
136
|
+
out["note"] = (f"revoke '{key_name()}' (prefix {str(creds['api_key'])[:12]}...) at "
|
|
137
|
+
f"{get_app_url().rstrip('/')}/platform-api/keys")
|
|
138
|
+
clear_credentials()
|
|
139
|
+
return out
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def _load_credentials() -> Optional[dict]:
|
|
143
|
+
"""Load credentials from disk."""
|
|
144
|
+
if not CREDENTIALS_PATH.exists():
|
|
145
|
+
return None
|
|
146
|
+
try:
|
|
147
|
+
return json.loads(CREDENTIALS_PATH.read_text())
|
|
148
|
+
except (json.JSONDecodeError, OSError):
|
|
149
|
+
return None
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
class _CallbackHandler(BaseHTTPRequestHandler):
|
|
153
|
+
"""HTTP handler that receives the OAuth callback with the token."""
|
|
154
|
+
|
|
155
|
+
token: Optional[str] = None
|
|
156
|
+
user_info: Optional[dict] = None
|
|
157
|
+
expected_state: Optional[str] = None
|
|
158
|
+
|
|
159
|
+
def do_GET(self):
|
|
160
|
+
parsed = urlparse(self.path)
|
|
161
|
+
params = parse_qs(parsed.query)
|
|
162
|
+
|
|
163
|
+
if parsed.path != "/callback":
|
|
164
|
+
self.send_response(404)
|
|
165
|
+
self.end_headers()
|
|
166
|
+
return
|
|
167
|
+
|
|
168
|
+
state = params.get("state", [None])[0]
|
|
169
|
+
token = params.get("token", [None])[0]
|
|
170
|
+
email = params.get("email", [None])[0]
|
|
171
|
+
|
|
172
|
+
# Only accept the login this process started. Without this, any local
|
|
173
|
+
# page could hit the ephemeral port and plant a token. compare_digest
|
|
174
|
+
# keeps the nonce from being probed a byte at a time.
|
|
175
|
+
expected = _CallbackHandler.expected_state
|
|
176
|
+
if not expected or not state or not secrets.compare_digest(state, expected):
|
|
177
|
+
self._reply(403, "Login failed", "This callback did not come from the login you started.")
|
|
178
|
+
return
|
|
179
|
+
|
|
180
|
+
if not token:
|
|
181
|
+
self._reply(400, "Login failed", "No token received.")
|
|
182
|
+
return
|
|
183
|
+
|
|
184
|
+
_CallbackHandler.token = token
|
|
185
|
+
_CallbackHandler.user_info = {"email": email} if email else None
|
|
186
|
+
self._reply(200, "Login successful!", "You can close this tab.")
|
|
187
|
+
|
|
188
|
+
def _reply(self, status: int, heading: str, detail: str) -> None:
|
|
189
|
+
self.send_response(status)
|
|
190
|
+
self.send_header("Content-Type", "text/html")
|
|
191
|
+
self.end_headers()
|
|
192
|
+
self.wfile.write(f"<html><body><h2>{heading}</h2><p>{detail}</p></body></html>".encode())
|
|
193
|
+
|
|
194
|
+
def log_message(self, format, *args):
|
|
195
|
+
pass # Suppress HTTP server logs
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def login_browser(timeout: int = 120) -> Optional[dict]:
|
|
199
|
+
"""Open the dashboard to sign in, then keep the login (store_login). Returns what was kept, or None."""
|
|
200
|
+
import socket
|
|
201
|
+
|
|
202
|
+
# Find a free port
|
|
203
|
+
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
|
|
204
|
+
s.bind(("127.0.0.1", 0))
|
|
205
|
+
port = s.getsockname()[1]
|
|
206
|
+
|
|
207
|
+
# Single-use nonce the /cli-auth page echoes back, so we can tell our own
|
|
208
|
+
# callback apart from any other request that reaches this port.
|
|
209
|
+
state = secrets.token_urlsafe(24)
|
|
210
|
+
|
|
211
|
+
# Reset handler state
|
|
212
|
+
_CallbackHandler.token = None
|
|
213
|
+
_CallbackHandler.user_info = None
|
|
214
|
+
_CallbackHandler.expected_state = state
|
|
215
|
+
|
|
216
|
+
server = HTTPServer(("127.0.0.1", port), _CallbackHandler)
|
|
217
|
+
# Poll in short slices so a stray request can't hold us past the deadline.
|
|
218
|
+
server.timeout = 1
|
|
219
|
+
|
|
220
|
+
# The dashboard's /cli-auth page signs you in, then redirects to our
|
|
221
|
+
# loopback callback with the token. Derive nothing from the API URL: the
|
|
222
|
+
# app is a different host (app.kiteml.com vs api.kiteml.com).
|
|
223
|
+
login_url = f"{get_app_url().rstrip('/')}/cli-auth?redirect_port={port}&state={state}"
|
|
224
|
+
|
|
225
|
+
print(f"Opening browser for login...")
|
|
226
|
+
print(f"If the browser doesn't open, visit: {login_url}")
|
|
227
|
+
webbrowser.open(login_url)
|
|
228
|
+
|
|
229
|
+
# Wait for callback
|
|
230
|
+
deadline = time.time() + timeout
|
|
231
|
+
while time.time() < deadline and _CallbackHandler.token is None:
|
|
232
|
+
server.handle_request()
|
|
233
|
+
|
|
234
|
+
server.server_close()
|
|
235
|
+
|
|
236
|
+
if _CallbackHandler.token:
|
|
237
|
+
return store_login(_CallbackHandler.token, _CallbackHandler.user_info)
|
|
238
|
+
|
|
239
|
+
return None
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
def login_headless() -> Optional[dict]:
|
|
243
|
+
"""Headless login: prompt user to paste an API key (kept as it is) or a session token (traded for a key)."""
|
|
244
|
+
print("Visit your Kite dashboard to generate an API token.")
|
|
245
|
+
print("Then paste it here:")
|
|
246
|
+
token = input("> ").strip()
|
|
247
|
+
if not token:
|
|
248
|
+
return None
|
|
249
|
+
if token.startswith("kite_"):
|
|
250
|
+
save_credentials(api_key=token)
|
|
251
|
+
return {"credential": "api_key"}
|
|
252
|
+
return store_login(token)
|
|
@@ -13,26 +13,22 @@ app = typer.Typer(no_args_is_help=True)
|
|
|
13
13
|
|
|
14
14
|
@app.command()
|
|
15
15
|
def login(headless: bool = typer.Option(False, help="Paste token instead of browser flow")):
|
|
16
|
-
"""Log in to Kite
|
|
16
|
+
"""Log in to Kite in the browser (or by pasting a token): this machine gets its own API key, for 90 days."""
|
|
17
17
|
from kite_cli.auth import login_browser, login_headless
|
|
18
18
|
|
|
19
|
-
if headless
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
ok = login_browser()
|
|
23
|
-
|
|
24
|
-
if ok:
|
|
25
|
-
print_json({"logged_in": True})
|
|
19
|
+
kept = login_headless() if headless else login_browser()
|
|
20
|
+
if kept:
|
|
21
|
+
print_json({"logged_in": True, **kept})
|
|
26
22
|
else:
|
|
27
23
|
print_error("Login failed. Try --headless to paste a token manually.")
|
|
28
24
|
|
|
29
25
|
|
|
30
26
|
@app.command()
|
|
31
27
|
def logout():
|
|
32
|
-
"""
|
|
33
|
-
from kite_cli.auth import
|
|
34
|
-
|
|
35
|
-
print_json(
|
|
28
|
+
"""Revoke this machine's API key and clear the stored credentials."""
|
|
29
|
+
from kite_cli.auth import logout as _logout
|
|
30
|
+
|
|
31
|
+
print_json(_logout())
|
|
36
32
|
|
|
37
33
|
|
|
38
34
|
@app.command()
|