corethink 1.0.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.
- corethink/__init__.py +17 -0
- corethink/__main__.py +3 -0
- corethink/_version.py +3 -0
- corethink/account.py +470 -0
- corethink/cli.py +1058 -0
- corethink/client.py +253 -0
- corethink/commands/__init__.py +0 -0
- corethink/commands/admin.py +703 -0
- corethink/commands/api.py +59 -0
- corethink/commands/archive.py +433 -0
- corethink/commands/auth.py +375 -0
- corethink/commands/billing.py +314 -0
- corethink/commands/capture.py +564 -0
- corethink/commands/connection.py +1266 -0
- corethink/commands/endpoint.py +1124 -0
- corethink/commands/env.py +1966 -0
- corethink/commands/hub.py +269 -0
- corethink/commands/library.py +347 -0
- corethink/commands/models.py +258 -0
- corethink/commands/train.py +2655 -0
- corethink/commands/workspace.py +622 -0
- corethink/config.py +181 -0
- corethink/context.py +46 -0
- corethink/env_template.py +117 -0
- corethink/environment_http_store.py +96 -0
- corethink/experiment_cli.py +2187 -0
- corethink/hosted_profiles.py +44 -0
- corethink/hosted_rl.py +688 -0
- corethink/hosted_rl_archive.py +74 -0
- corethink/hosted_rl_attribution.py +44 -0
- corethink/hosted_rl_billing.py +103 -0
- corethink/hosted_rl_state.py +182 -0
- corethink/jobs_cli.py +839 -0
- corethink/main.py +204 -0
- corethink/output.py +201 -0
- corethink/py.typed +0 -0
- corethink/rl_cli.py +997 -0
- corethink/rl_inspect.py +521 -0
- corethink/rl_playground.py +129 -0
- corethink/rl_reassessment.py +214 -0
- corethink/rl_settings.py +43 -0
- corethink/sdk.py +331 -0
- corethink/settings.py +162 -0
- corethink-1.0.0.dist-info/METADATA +120 -0
- corethink-1.0.0.dist-info/RECORD +48 -0
- corethink-1.0.0.dist-info/WHEEL +5 -0
- corethink-1.0.0.dist-info/entry_points.txt +2 -0
- corethink-1.0.0.dist-info/top_level.txt +1 -0
corethink/__init__.py
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
"""CoreThink: the Workbench from your terminal (`corethink --help`), and the older evaluation SDK.
|
|
2
|
+
|
|
3
|
+
The SDK names (Environment, TaskInstance, Template) are loaded only when asked for: they need the evaluation packages,
|
|
4
|
+
which a plain `pip install corethink` does not bring.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from corethink._version import __version__
|
|
8
|
+
|
|
9
|
+
__all__ = ["Environment", "TaskInstance", "Template", "__version__"]
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def __getattr__(name: str):
|
|
13
|
+
if name in {"Environment", "TaskInstance", "Template"}:
|
|
14
|
+
from corethink import sdk
|
|
15
|
+
|
|
16
|
+
return getattr(sdk, name)
|
|
17
|
+
raise AttributeError(f"module 'corethink' has no attribute {name!r}")
|
corethink/__main__.py
ADDED
corethink/_version.py
ADDED
corethink/account.py
ADDED
|
@@ -0,0 +1,470 @@
|
|
|
1
|
+
"""`corethink login` — a CoreThink account, not a model provider's key.
|
|
2
|
+
|
|
3
|
+
Before this, `login` stored whichever provider key an evaluation would send to a
|
|
4
|
+
model endpoint, and `CORETHINK_API_KEY` named that. Nothing authenticated a
|
|
5
|
+
*CoreThink* account at all: hosted work needed a Google login and a shared
|
|
6
|
+
operator token that no customer can be given. This module is the account half,
|
|
7
|
+
and it keeps the two apart on purpose — the failure it is written to prevent is a
|
|
8
|
+
provider key being sent to CoreThink, or a CoreThink key to a provider.
|
|
9
|
+
|
|
10
|
+
Two rules make that concrete:
|
|
11
|
+
|
|
12
|
+
* **A CoreThink credential is recognised by its prefix.** `ctu_` for a browser
|
|
13
|
+
session, `ctk_` for an API key. A value that is neither is refused before any
|
|
14
|
+
request is made, with a message naming where a provider key actually goes. So
|
|
15
|
+
an exported `CORETHINK_API_KEY=sk-...` from the old arrangement produces an
|
|
16
|
+
explanation rather than a leak.
|
|
17
|
+
* **The session lives in its own file**, not in `config.json`, because
|
|
18
|
+
`config list` prints that file and a session token must never be printed.
|
|
19
|
+
|
|
20
|
+
The flow is RFC 8628 device authorization. A browser is the only place the Google
|
|
21
|
+
sign-in can happen, the CLI never sees the Google credential, and the code the
|
|
22
|
+
CLI holds is short-lived, single-use, and worth nothing until somebody signed in
|
|
23
|
+
approves it.
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
from __future__ import annotations
|
|
27
|
+
|
|
28
|
+
import contextlib
|
|
29
|
+
import json
|
|
30
|
+
import os
|
|
31
|
+
import ssl
|
|
32
|
+
import time
|
|
33
|
+
import urllib.error
|
|
34
|
+
import urllib.parse
|
|
35
|
+
import urllib.request
|
|
36
|
+
import webbrowser
|
|
37
|
+
from pathlib import Path
|
|
38
|
+
from typing import Any
|
|
39
|
+
|
|
40
|
+
__all__ = ["AccountError", "CoreThinkClient", "credentials_path", "load_credential", "register"]
|
|
41
|
+
|
|
42
|
+
#: Where the public API lives unless a deployment or a test says otherwise.
|
|
43
|
+
DEFAULT_API_URL = "https://corethink-api-qtb56vjxea-uc.a.run.app"
|
|
44
|
+
|
|
45
|
+
#: What a CoreThink credential looks like. See the module docstring.
|
|
46
|
+
CREDENTIAL_PREFIXES = ("ctu_", "ctk_")
|
|
47
|
+
|
|
48
|
+
#: How long `login` waits for the browser before giving up on its own.
|
|
49
|
+
LOGIN_TIMEOUT_SECONDS = 600
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class AccountError(RuntimeError):
|
|
53
|
+
"""Something the person at the terminal can act on."""
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
# -- where the credential lives ------------------------------------------
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def credentials_path() -> Path:
|
|
60
|
+
"""The session file, beside the config and overridable the same way.
|
|
61
|
+
|
|
62
|
+
`CORETHINK_CREDENTIALS` exists so a test never touches a developer's real
|
|
63
|
+
session, and so two projects on one machine can hold different accounts.
|
|
64
|
+
"""
|
|
65
|
+
|
|
66
|
+
override = os.environ.get("CORETHINK_CREDENTIALS")
|
|
67
|
+
if override:
|
|
68
|
+
return Path(override).expanduser()
|
|
69
|
+
base = os.environ.get("XDG_CONFIG_HOME") or "~/.config"
|
|
70
|
+
return Path(base).expanduser() / "corethink" / "credentials.json"
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def save_credential(payload: dict[str, Any]) -> Path:
|
|
74
|
+
"""Write the session with the permissions a credential deserves.
|
|
75
|
+
|
|
76
|
+
Mode before content, so there is no window in which the token exists in a
|
|
77
|
+
world-readable file.
|
|
78
|
+
"""
|
|
79
|
+
|
|
80
|
+
target = credentials_path()
|
|
81
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
82
|
+
os.chmod(target.parent, 0o700)
|
|
83
|
+
target.touch(mode=0o600, exist_ok=True)
|
|
84
|
+
os.chmod(target, 0o600)
|
|
85
|
+
target.write_text(json.dumps(payload, indent=2, sort_keys=True) + "\n", encoding="utf-8")
|
|
86
|
+
return target
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def clear_credential() -> bool:
|
|
90
|
+
target = credentials_path()
|
|
91
|
+
if not target.is_file():
|
|
92
|
+
return False
|
|
93
|
+
target.unlink()
|
|
94
|
+
return True
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def load_credential() -> dict[str, Any] | None:
|
|
98
|
+
target = credentials_path()
|
|
99
|
+
if not target.is_file():
|
|
100
|
+
return None
|
|
101
|
+
try:
|
|
102
|
+
stored = json.loads(target.read_text(encoding="utf-8"))
|
|
103
|
+
except (OSError, json.JSONDecodeError):
|
|
104
|
+
return None
|
|
105
|
+
return stored if isinstance(stored, dict) and stored.get("access_token") else None
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def resolve_api_url(override: str | None = None) -> str:
|
|
109
|
+
from corethink.config import ConfigError, resolve
|
|
110
|
+
|
|
111
|
+
if override:
|
|
112
|
+
return override.rstrip("/")
|
|
113
|
+
try:
|
|
114
|
+
stored = resolve("api_url")
|
|
115
|
+
except ConfigError: # pragma: no cover - a broken config must not block login
|
|
116
|
+
stored = None
|
|
117
|
+
saved = (load_credential() or {}).get("api_url")
|
|
118
|
+
return str(stored or saved or DEFAULT_API_URL).rstrip("/")
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def resolve_token(override: str | None = None) -> str | None:
|
|
122
|
+
"""The CoreThink credential in force: flag, environment, then the session file.
|
|
123
|
+
|
|
124
|
+
The prefix check is the migration guard. `CORETHINK_API_KEY` used to name a
|
|
125
|
+
*model provider's* key; a value that is not a CoreThink credential is refused
|
|
126
|
+
here, before any request, rather than sent to an endpoint it was never meant
|
|
127
|
+
for.
|
|
128
|
+
"""
|
|
129
|
+
|
|
130
|
+
candidate = (override or os.environ.get("CORETHINK_API_KEY", "")).strip()
|
|
131
|
+
if candidate:
|
|
132
|
+
if not candidate.startswith(CREDENTIAL_PREFIXES):
|
|
133
|
+
raise AccountError(
|
|
134
|
+
"CORETHINK_API_KEY does not hold a CoreThink credential (they start with "
|
|
135
|
+
"ctk_ or ctu_). A model provider's key belongs in "
|
|
136
|
+
"`corethink config set provider_api_key`, or in the variable named by "
|
|
137
|
+
"--model-api-key-env; it is never sent to CoreThink."
|
|
138
|
+
)
|
|
139
|
+
return candidate
|
|
140
|
+
stored = load_credential()
|
|
141
|
+
return str(stored["access_token"]) if stored else None
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
# -- talking to the API ---------------------------------------------------
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def _ssl_context() -> ssl.SSLContext:
|
|
148
|
+
"""Verify TLS properly even where the interpreter has no CA bundle.
|
|
149
|
+
|
|
150
|
+
Shared with `jobs_cli` rather than re-derived: a framework Python on macOS
|
|
151
|
+
ships without a CA bundle, and the fallback is the difference between working
|
|
152
|
+
and CERTIFICATE_VERIFY_FAILED on every HTTPS call.
|
|
153
|
+
"""
|
|
154
|
+
|
|
155
|
+
from corethink.jobs_cli import _ssl_context as shared
|
|
156
|
+
|
|
157
|
+
return shared()
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def request(
|
|
161
|
+
api_url: str,
|
|
162
|
+
method: str,
|
|
163
|
+
path: str,
|
|
164
|
+
*,
|
|
165
|
+
payload: dict[str, Any] | None = None,
|
|
166
|
+
token: str | None = None,
|
|
167
|
+
timeout: float = 60.0,
|
|
168
|
+
) -> tuple[int, Any]:
|
|
169
|
+
"""One API call. Returns (status, decoded body) rather than raising on 4xx.
|
|
170
|
+
|
|
171
|
+
The device-flow poll needs the body of a 400 — `authorization_pending` is a
|
|
172
|
+
normal state, not an error — so the status comes back as data and each caller
|
|
173
|
+
decides what it means.
|
|
174
|
+
"""
|
|
175
|
+
|
|
176
|
+
headers = {"accept": "application/json"}
|
|
177
|
+
if token:
|
|
178
|
+
headers["authorization"] = f"Bearer {token}"
|
|
179
|
+
data = None
|
|
180
|
+
if payload is not None:
|
|
181
|
+
data = json.dumps(payload).encode("utf-8")
|
|
182
|
+
headers["content-type"] = "application/json"
|
|
183
|
+
call = urllib.request.Request( # noqa: S310 - the base URL comes from explicit config
|
|
184
|
+
f"{api_url}{path}", data=data, headers=headers, method=method
|
|
185
|
+
)
|
|
186
|
+
try:
|
|
187
|
+
with urllib.request.urlopen(call, timeout=timeout, context=_ssl_context()) as response: # noqa: S310
|
|
188
|
+
body = response.read().decode("utf-8")
|
|
189
|
+
return response.status, (json.loads(body) if body.strip() else {})
|
|
190
|
+
except urllib.error.HTTPError as error:
|
|
191
|
+
raw = error.read().decode("utf-8", errors="replace")
|
|
192
|
+
try:
|
|
193
|
+
return error.code, json.loads(raw)
|
|
194
|
+
except json.JSONDecodeError:
|
|
195
|
+
return error.code, {"error": {"message": raw.strip() or error.reason}}
|
|
196
|
+
except urllib.error.URLError as error:
|
|
197
|
+
raise AccountError(f"cannot reach {api_url}: {error.reason}") from None
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def _message(body: Any) -> str:
|
|
201
|
+
if isinstance(body, dict):
|
|
202
|
+
error = body.get("error")
|
|
203
|
+
if isinstance(error, dict):
|
|
204
|
+
return str(error.get("message") or error.get("code") or body)
|
|
205
|
+
if isinstance(error, str):
|
|
206
|
+
return str(body.get("error_description") or error)
|
|
207
|
+
return str(body)
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
class CoreThinkClient:
|
|
211
|
+
"""An authenticated caller for every command that reaches the platform.
|
|
212
|
+
|
|
213
|
+
One place where a 401 becomes a sentence somebody can act on, so no command
|
|
214
|
+
has to guess whether a refusal means expired, revoked or under-scoped.
|
|
215
|
+
"""
|
|
216
|
+
|
|
217
|
+
def __init__(self, *, api_url: str | None = None, token: str | None = None) -> None:
|
|
218
|
+
self.api_url = resolve_api_url(api_url)
|
|
219
|
+
self._token = resolve_token(token)
|
|
220
|
+
if not self._token:
|
|
221
|
+
raise AccountError(
|
|
222
|
+
"not signed in to CoreThink. Run `corethink login`, or set "
|
|
223
|
+
"CORETHINK_API_KEY to an API key from the Workbench's Account page."
|
|
224
|
+
)
|
|
225
|
+
|
|
226
|
+
def call(
|
|
227
|
+
self,
|
|
228
|
+
method: str,
|
|
229
|
+
path: str,
|
|
230
|
+
*,
|
|
231
|
+
payload: dict[str, Any] | None = None,
|
|
232
|
+
timeout: float = 60.0,
|
|
233
|
+
) -> Any:
|
|
234
|
+
status, body = request(
|
|
235
|
+
self.api_url, method, path, payload=payload, token=self._token, timeout=timeout
|
|
236
|
+
)
|
|
237
|
+
if status == 401:
|
|
238
|
+
raise AccountError(
|
|
239
|
+
f"{_message(body)}\n"
|
|
240
|
+
"Your CoreThink session may have expired or been revoked. "
|
|
241
|
+
"Run `corethink login` to sign in again."
|
|
242
|
+
)
|
|
243
|
+
if status == 429:
|
|
244
|
+
raise AccountError(_message(body))
|
|
245
|
+
if status >= 400:
|
|
246
|
+
raise AccountError(f"{method} {path} failed [{status}]: {_message(body)}")
|
|
247
|
+
return body
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
# -- the commands ---------------------------------------------------------
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
def _print_account(payload: dict[str, Any]) -> None:
|
|
254
|
+
account = payload.get("account") or {}
|
|
255
|
+
credential = payload.get("credential") or {}
|
|
256
|
+
print(f" account {account.get('email', 'unknown')}")
|
|
257
|
+
print(f" workspace {account.get('workspace', 'unknown')}")
|
|
258
|
+
if credential:
|
|
259
|
+
print(f" credential {credential.get('kind', 'session')} ({credential.get('id', '')})")
|
|
260
|
+
scopes = payload.get("scopes") or []
|
|
261
|
+
if scopes:
|
|
262
|
+
print(f" access {', '.join(scopes)}")
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
def run_login(args: Any) -> int:
|
|
266
|
+
"""Authorize this CLI installation through the browser you already sign in with."""
|
|
267
|
+
|
|
268
|
+
api_url = resolve_api_url(getattr(args, "api_url", None))
|
|
269
|
+
status, started = request(
|
|
270
|
+
api_url, "POST", "/v1/auth/device", payload={"client": "corethink-cli"}
|
|
271
|
+
)
|
|
272
|
+
if status != 200:
|
|
273
|
+
raise AccountError(f"cannot start sign-in: {_message(started)}")
|
|
274
|
+
|
|
275
|
+
# The plain page, never one with the code already in it. The browser half
|
|
276
|
+
# exists to have a person check this code against their own terminal, and a
|
|
277
|
+
# link that fills it in for them is the device-code phishing shape: someone
|
|
278
|
+
# else sends the link, the page pre-fills their code, one click authorizes
|
|
279
|
+
# their laptop as you.
|
|
280
|
+
verification = started.get("verification_uri")
|
|
281
|
+
print("Sign in to CoreThink")
|
|
282
|
+
print(f" code {started['user_code']}")
|
|
283
|
+
print(f" open {verification}")
|
|
284
|
+
print(" type that code on the page; it is not filled in for you")
|
|
285
|
+
if not getattr(args, "no_browser", False):
|
|
286
|
+
# Failure here is ordinary — a headless machine, no session bus — and the
|
|
287
|
+
# printed URL is the fallback, so it is never an error.
|
|
288
|
+
with contextlib.suppress(Exception): # pragma: no cover - platform dependent
|
|
289
|
+
webbrowser.open(verification)
|
|
290
|
+
print("Waiting for approval… (ctrl-c to cancel)")
|
|
291
|
+
|
|
292
|
+
interval = float(started.get("interval") or 5)
|
|
293
|
+
deadline = time.monotonic() + min(
|
|
294
|
+
float(started.get("expires_in") or 600), LOGIN_TIMEOUT_SECONDS
|
|
295
|
+
)
|
|
296
|
+
while time.monotonic() < deadline:
|
|
297
|
+
time.sleep(interval)
|
|
298
|
+
status, body = request(
|
|
299
|
+
api_url,
|
|
300
|
+
"POST",
|
|
301
|
+
"/v1/auth/device/token",
|
|
302
|
+
payload={"device_code": started["device_code"]},
|
|
303
|
+
)
|
|
304
|
+
if status == 200:
|
|
305
|
+
saved = {
|
|
306
|
+
"api_url": api_url,
|
|
307
|
+
"access_token": body["access_token"],
|
|
308
|
+
"account": body.get("account", {}),
|
|
309
|
+
"scopes": body.get("scopes", []),
|
|
310
|
+
"expires_at": body.get("expires_at"),
|
|
311
|
+
}
|
|
312
|
+
written = save_credential(saved)
|
|
313
|
+
print("Signed in.")
|
|
314
|
+
_print_account({"account": saved["account"], "scopes": saved["scopes"]})
|
|
315
|
+
print(f" stored {written} (mode 0600)")
|
|
316
|
+
return 0
|
|
317
|
+
code = body.get("error") if isinstance(body, dict) else None
|
|
318
|
+
if code == "authorization_pending":
|
|
319
|
+
continue
|
|
320
|
+
if code == "slow_down":
|
|
321
|
+
interval += 2
|
|
322
|
+
continue
|
|
323
|
+
if code == "access_denied":
|
|
324
|
+
raise AccountError(
|
|
325
|
+
"the sign-in was declined, or that Google account is not admitted to this "
|
|
326
|
+
"CoreThink workspace. Ask an operator to add it, then run `corethink login` again."
|
|
327
|
+
)
|
|
328
|
+
if code == "expired_token":
|
|
329
|
+
raise AccountError("the sign-in request expired. Run `corethink login` again.")
|
|
330
|
+
raise AccountError(f"sign-in failed: {_message(body)}")
|
|
331
|
+
raise AccountError("timed out waiting for browser approval. Run `corethink login` again.")
|
|
332
|
+
|
|
333
|
+
|
|
334
|
+
def run_whoami(args: Any) -> int:
|
|
335
|
+
client = CoreThinkClient(api_url=getattr(args, "api_url", None))
|
|
336
|
+
payload = client.call("GET", "/v1/account")
|
|
337
|
+
if getattr(args, "json", False):
|
|
338
|
+
print(json.dumps(payload, indent=2, sort_keys=True))
|
|
339
|
+
return 0
|
|
340
|
+
print(f"Signed in to {client.api_url}")
|
|
341
|
+
_print_account(payload)
|
|
342
|
+
return 0
|
|
343
|
+
|
|
344
|
+
|
|
345
|
+
def run_logout(args: Any) -> int:
|
|
346
|
+
"""End the session on the server, then remove it from this machine.
|
|
347
|
+
|
|
348
|
+
In that order, and the local file goes whatever the server says. A CLI that
|
|
349
|
+
kept the credential because logout could not reach the network would leave a
|
|
350
|
+
usable token on a laptop somebody is trying to clean.
|
|
351
|
+
"""
|
|
352
|
+
|
|
353
|
+
stored = load_credential()
|
|
354
|
+
if not stored and not os.environ.get("CORETHINK_API_KEY"):
|
|
355
|
+
print("not signed in")
|
|
356
|
+
return 0
|
|
357
|
+
remote = "not contacted"
|
|
358
|
+
try:
|
|
359
|
+
client = CoreThinkClient(api_url=getattr(args, "api_url", None))
|
|
360
|
+
client.call("POST", "/v1/account/logout")
|
|
361
|
+
remote = "session invalidated"
|
|
362
|
+
except AccountError as error:
|
|
363
|
+
remote = f"server not updated ({error})"
|
|
364
|
+
removed = clear_credential()
|
|
365
|
+
print(f"signed out: {remote}")
|
|
366
|
+
print(f" local credential {'removed' if removed else 'not present'}")
|
|
367
|
+
if os.environ.get("CORETHINK_API_KEY"):
|
|
368
|
+
print(" note: CORETHINK_API_KEY is still set in this shell and still authenticates")
|
|
369
|
+
return 0
|
|
370
|
+
|
|
371
|
+
|
|
372
|
+
def _print_keys(keys: list[dict[str, Any]]) -> None:
|
|
373
|
+
if not keys:
|
|
374
|
+
print("no API keys. Create one with `corethink keys create --name <name>`.")
|
|
375
|
+
return
|
|
376
|
+
print(f"{'ID':<18}{'NAME':<24}{'CREATED':<22}{'LAST USED':<22}STATUS")
|
|
377
|
+
for key in keys:
|
|
378
|
+
status = "revoked" if key.get("revoked_at") else "active"
|
|
379
|
+
print(
|
|
380
|
+
f"{key['id']:<18}{key['name'][:22]:<24}"
|
|
381
|
+
f"{_stamp(key.get('created_at')):<22}{_stamp(key.get('last_used_at')):<22}{status}"
|
|
382
|
+
)
|
|
383
|
+
print(f"{'':<18}scopes: {', '.join(key.get('scopes') or [])}")
|
|
384
|
+
|
|
385
|
+
|
|
386
|
+
def _stamp(value: Any) -> str:
|
|
387
|
+
if not isinstance(value, (int, float)):
|
|
388
|
+
return "never"
|
|
389
|
+
return time.strftime("%Y-%m-%d %H:%M", time.localtime(value))
|
|
390
|
+
|
|
391
|
+
|
|
392
|
+
def run_keys_list(args: Any) -> int:
|
|
393
|
+
client = CoreThinkClient(api_url=getattr(args, "api_url", None))
|
|
394
|
+
payload = client.call("GET", "/v1/account/keys")
|
|
395
|
+
if getattr(args, "json", False):
|
|
396
|
+
print(json.dumps(payload, indent=2, sort_keys=True))
|
|
397
|
+
return 0
|
|
398
|
+
_print_keys(payload.get("keys") or [])
|
|
399
|
+
return 0
|
|
400
|
+
|
|
401
|
+
|
|
402
|
+
def run_keys_create(args: Any) -> int:
|
|
403
|
+
client = CoreThinkClient(api_url=getattr(args, "api_url", None))
|
|
404
|
+
body: dict[str, Any] = {"name": args.name}
|
|
405
|
+
if args.scope:
|
|
406
|
+
body["scopes"] = list(args.scope)
|
|
407
|
+
if args.expires_in_days:
|
|
408
|
+
body["expires_in"] = int(args.expires_in_days) * 86400
|
|
409
|
+
created = client.call("POST", "/v1/account/keys", payload=body)
|
|
410
|
+
if getattr(args, "json", False):
|
|
411
|
+
print(json.dumps(created, indent=2, sort_keys=True))
|
|
412
|
+
return 0
|
|
413
|
+
print(f"created key {created['id']} ({created['name']})")
|
|
414
|
+
print(f" scopes {', '.join(created.get('scopes') or [])}")
|
|
415
|
+
print(
|
|
416
|
+
f" expires {_stamp(created.get('expires_at')) if created.get('expires_at') else 'never'}"
|
|
417
|
+
)
|
|
418
|
+
print()
|
|
419
|
+
print(created["secret"])
|
|
420
|
+
print()
|
|
421
|
+
# Said plainly because it is true: the server stores only a hash, so there is
|
|
422
|
+
# no path by which this can be shown again.
|
|
423
|
+
print("This is the only time the secret is shown. Store it now; it cannot be recovered.")
|
|
424
|
+
return 0
|
|
425
|
+
|
|
426
|
+
|
|
427
|
+
def run_keys_revoke(args: Any) -> int:
|
|
428
|
+
client = CoreThinkClient(api_url=getattr(args, "api_url", None))
|
|
429
|
+
revoked = client.call("DELETE", f"/v1/account/keys/{urllib.parse.quote(args.key_id)}")
|
|
430
|
+
print(f"revoked {revoked['id']} ({revoked.get('name', '')})")
|
|
431
|
+
return 0
|
|
432
|
+
|
|
433
|
+
|
|
434
|
+
def register(commands: Any) -> None:
|
|
435
|
+
"""Add the account commands to the CLI's subparsers."""
|
|
436
|
+
|
|
437
|
+
whoami = commands.add_parser("whoami", help="the CoreThink account this CLI is signed in as")
|
|
438
|
+
whoami.add_argument("--api-url", help="the CoreThink API to ask (default: the stored one)")
|
|
439
|
+
whoami.add_argument("--json", action="store_true")
|
|
440
|
+
whoami.set_defaults(handler=run_whoami)
|
|
441
|
+
|
|
442
|
+
keys = commands.add_parser("keys", help="API keys for scripts and integrations")
|
|
443
|
+
key_commands = keys.add_subparsers(dest="keys_command", required=True)
|
|
444
|
+
|
|
445
|
+
listing = key_commands.add_parser("list", help="every key on this account")
|
|
446
|
+
listing.add_argument("--api-url")
|
|
447
|
+
listing.add_argument("--json", action="store_true")
|
|
448
|
+
listing.set_defaults(handler=run_keys_list)
|
|
449
|
+
|
|
450
|
+
create = key_commands.add_parser("create", help="create a key; the secret is shown once")
|
|
451
|
+
create.add_argument("--name", required=True, help="what this key is for")
|
|
452
|
+
create.add_argument(
|
|
453
|
+
"--scope",
|
|
454
|
+
action="append",
|
|
455
|
+
help="one access scope; repeatable. Defaults to your own access, and can never exceed it.",
|
|
456
|
+
)
|
|
457
|
+
create.add_argument(
|
|
458
|
+
"--expires-in-days",
|
|
459
|
+
type=int,
|
|
460
|
+
help="how long the key lives. A key that never expires is the one nobody "
|
|
461
|
+
"remembers to revoke.",
|
|
462
|
+
)
|
|
463
|
+
create.add_argument("--api-url")
|
|
464
|
+
create.add_argument("--json", action="store_true")
|
|
465
|
+
create.set_defaults(handler=run_keys_create)
|
|
466
|
+
|
|
467
|
+
revoke = key_commands.add_parser("revoke", help="revoke a key, effective immediately")
|
|
468
|
+
revoke.add_argument("key_id")
|
|
469
|
+
revoke.add_argument("--api-url")
|
|
470
|
+
revoke.set_defaults(handler=run_keys_revoke)
|