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 +3 -0
- kite_cli/auth.py +184 -0
- kite_cli/cli/__init__.py +0 -0
- kite_cli/cli/commands/__init__.py +0 -0
- kite_cli/cli/commands/augment.py +164 -0
- kite_cli/cli/commands/auth_cmd.py +57 -0
- kite_cli/cli/commands/config_cmd.py +39 -0
- kite_cli/cli/commands/rl.py +356 -0
- kite_cli/cli/commands/twin.py +322 -0
- kite_cli/cli/main.py +88 -0
- kite_cli/client.py +180 -0
- kite_cli/config.py +80 -0
- kite_cli/mcp/__init__.py +0 -0
- kite_cli/mcp/server.py +464 -0
- kite_cli/output.py +79 -0
- kite_cli/progress.py +104 -0
- kiteml_cli-0.1.0.dist-info/METADATA +154 -0
- kiteml_cli-0.1.0.dist-info/RECORD +20 -0
- kiteml_cli-0.1.0.dist-info/WHEEL +4 -0
- kiteml_cli-0.1.0.dist-info/entry_points.txt +3 -0
kite_cli/__init__.py
ADDED
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
|
kite_cli/cli/__init__.py
ADDED
|
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})
|