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.
Files changed (25) hide show
  1. kiteml_cli-0.3.0/.gitignore +9 -0
  2. {kiteml_cli-0.1.0 → kiteml_cli-0.3.0}/PKG-INFO +13 -9
  3. {kiteml_cli-0.1.0 → kiteml_cli-0.3.0}/README.md +10 -0
  4. {kiteml_cli-0.1.0 → kiteml_cli-0.3.0}/pyproject.toml +10 -5
  5. {kiteml_cli-0.1.0 → kiteml_cli-0.3.0}/src/kite_cli/__init__.py +1 -1
  6. kiteml_cli-0.3.0/src/kite_cli/archive.py +108 -0
  7. kiteml_cli-0.3.0/src/kite_cli/auth.py +252 -0
  8. {kiteml_cli-0.1.0 → kiteml_cli-0.3.0}/src/kite_cli/cli/commands/auth_cmd.py +8 -12
  9. kiteml_cli-0.3.0/src/kite_cli/cli/commands/sim.py +439 -0
  10. {kiteml_cli-0.1.0 → kiteml_cli-0.3.0}/src/kite_cli/cli/commands/twin.py +10 -85
  11. {kiteml_cli-0.1.0 → kiteml_cli-0.3.0}/src/kite_cli/cli/main.py +21 -4
  12. {kiteml_cli-0.1.0 → kiteml_cli-0.3.0}/src/kite_cli/client.py +15 -5
  13. kiteml_cli-0.3.0/src/kite_cli/mcp/common.py +22 -0
  14. {kiteml_cli-0.1.0 → kiteml_cli-0.3.0}/src/kite_cli/mcp/server.py +156 -31
  15. kiteml_cli-0.1.0/.gitignore +0 -7
  16. kiteml_cli-0.1.0/src/kite_cli/auth.py +0 -184
  17. {kiteml_cli-0.1.0 → kiteml_cli-0.3.0}/src/kite_cli/cli/__init__.py +0 -0
  18. {kiteml_cli-0.1.0 → kiteml_cli-0.3.0}/src/kite_cli/cli/commands/__init__.py +0 -0
  19. {kiteml_cli-0.1.0 → kiteml_cli-0.3.0}/src/kite_cli/cli/commands/augment.py +0 -0
  20. {kiteml_cli-0.1.0 → kiteml_cli-0.3.0}/src/kite_cli/cli/commands/config_cmd.py +0 -0
  21. {kiteml_cli-0.1.0 → kiteml_cli-0.3.0}/src/kite_cli/cli/commands/rl.py +0 -0
  22. {kiteml_cli-0.1.0 → kiteml_cli-0.3.0}/src/kite_cli/config.py +0 -0
  23. {kiteml_cli-0.1.0 → kiteml_cli-0.3.0}/src/kite_cli/mcp/__init__.py +0 -0
  24. {kiteml_cli-0.1.0 → kiteml_cli-0.3.0}/src/kite_cli/output.py +0 -0
  25. {kiteml_cli-0.1.0 → kiteml_cli-0.3.0}/src/kite_cli/progress.py +0 -0
@@ -0,0 +1,9 @@
1
+ # The nearest .gitignore to pyproject.toml: hatchling excludes by it and ships it in the sdist,
2
+ # in place of the monorepo's root one.
3
+ __pycache__/
4
+ *.py[cod]
5
+ *.egg-info/
6
+ dist/
7
+ build/
8
+ .venv/
9
+ .pytest_cache/
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: kiteml-cli
3
- Version: 0.1.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>=0.25
29
+ Requires-Dist: httpx<1,>=0.25
30
30
  Requires-Dist: rich>=13.0
31
- Requires-Dist: typer>=0.9
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
- version = "0.1.0"
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.9",
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", "uvicorn>=0.30", "starlette>=0.37"]
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]
@@ -1,3 +1,3 @@
1
1
  """Kite CLI and MCP server for the Kite robotics training platform."""
2
2
 
3
- __version__ = "0.1.0"
3
+ __version__ = "0.3.0"
@@ -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 via browser OAuth or token paste."""
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
- ok = login_headless()
21
- else:
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
- """Clear stored credentials."""
33
- from kite_cli.auth import clear_credentials
34
- clear_credentials()
35
- print_json({"logged_out": True})
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()