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
|
@@ -0,0 +1,1266 @@
|
|
|
1
|
+
"""`corethink connection`: the workspace's real systems (an HTTP API, an OpenAPI service, an MCP server, a database, your
|
|
2
|
+
own tool code), registered once, tested, and used by environments as live tools.
|
|
3
|
+
|
|
4
|
+
Admins create, edit and delete connections and set their secrets; members test and check them; everyone sees the
|
|
5
|
+
definitions. Secret values are write-only: typed once (hidden prompt, stdin or an environment variable), stored by the
|
|
6
|
+
Workbench, never shown again.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import json
|
|
12
|
+
import os
|
|
13
|
+
import sys
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
from typing import Any
|
|
16
|
+
from urllib.parse import quote, urlsplit
|
|
17
|
+
|
|
18
|
+
import typer
|
|
19
|
+
from rich.text import Text
|
|
20
|
+
|
|
21
|
+
from corethink import output as ui
|
|
22
|
+
from corethink.context import client
|
|
23
|
+
|
|
24
|
+
app = typer.Typer(
|
|
25
|
+
help="Your APIs, MCP servers, databases and tool code, as live tools.",
|
|
26
|
+
no_args_is_help=True,
|
|
27
|
+
)
|
|
28
|
+
secret_app = typer.Typer(help="A connection's secrets: set or clear (values are never shown).", no_args_is_help=True)
|
|
29
|
+
|
|
30
|
+
KINDS = ("http", "openapi", "mcp", "database", "code")
|
|
31
|
+
KIND_LABEL = {
|
|
32
|
+
"http": "HTTP API",
|
|
33
|
+
"openapi": "OpenAPI",
|
|
34
|
+
"mcp": "MCP server",
|
|
35
|
+
"database": "Database",
|
|
36
|
+
"code": "Your own tool code",
|
|
37
|
+
}
|
|
38
|
+
KIND_WHAT = {
|
|
39
|
+
"http": "An HTTPS API. Each endpoint you describe is a tool.",
|
|
40
|
+
"openapi": "An HTTPS API with an OpenAPI spec. Pick operations as tools.",
|
|
41
|
+
"mcp": "An MCP server. Its tools are discovered.",
|
|
42
|
+
"database": "A read-only database, queried one SELECT at a time.",
|
|
43
|
+
"code": "Python tool functions in a .zip, run in a sandbox.",
|
|
44
|
+
}
|
|
45
|
+
WRITES_WHAT = {
|
|
46
|
+
"refuse": "Write tools are never called.",
|
|
47
|
+
"dry_run": "Write tools return ok without calling your system.",
|
|
48
|
+
"allow": "Write tools are called. Sandbox or staging only.",
|
|
49
|
+
}
|
|
50
|
+
AUTH_TYPES = ("none", "bearer", "header", "query", "basic")
|
|
51
|
+
DATABASE_TOOLS = ["list_tables", "describe_table", "run_select"]
|
|
52
|
+
MIN_SECRET_CHARS = 6
|
|
53
|
+
MAX_SECRET_BYTES = 64 * 1024
|
|
54
|
+
MAX_PACKAGE_BYTES = 20 * 1024 * 1024
|
|
55
|
+
# fields the Workbench sets itself: never sent back
|
|
56
|
+
SERVER_FIELDS = ("schema", "org", "version", "name")
|
|
57
|
+
|
|
58
|
+
YES_OPTION = typer.Option(False, "--yes", "-y", help="Do not ask.")
|
|
59
|
+
FROM_OPTION = typer.Option(
|
|
60
|
+
None,
|
|
61
|
+
"--from",
|
|
62
|
+
"-f",
|
|
63
|
+
help="A JSON definition (plvr.connection/v1); - reads stdin.",
|
|
64
|
+
show_default=False,
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
# ---- helpers ---------------------------------------------------------------------------------------------------------
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _path(name: str, tail: str = "") -> str:
|
|
72
|
+
return f"/api/v1/connections/{quote(name, safe='')}{tail}"
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _connections() -> list[dict[str, Any]]:
|
|
76
|
+
return (client().get("/api/v1/connections") or {}).get("connections") or []
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _resolve(given: str) -> str:
|
|
80
|
+
"""A connection by its name or the start of it: exit 5 when none matches, 2 when several do."""
|
|
81
|
+
names = [str(row.get("name")) for row in _connections()]
|
|
82
|
+
if given in names:
|
|
83
|
+
return given
|
|
84
|
+
matches = [name for name in names if name.startswith(given.lower())]
|
|
85
|
+
if len(matches) == 1:
|
|
86
|
+
return matches[0]
|
|
87
|
+
if not matches:
|
|
88
|
+
ui.error(f"No connection named {given}. See `corethink connection list`.")
|
|
89
|
+
raise typer.Exit(5)
|
|
90
|
+
ui.error(f"{given} matches more than one connection ({', '.join(sorted(matches))}); give more of the name.")
|
|
91
|
+
raise typer.Exit(2)
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def _resolve_job(given: str) -> str:
|
|
95
|
+
"""A run by its ID or the start of it."""
|
|
96
|
+
given = given.strip().lower()
|
|
97
|
+
if len(given) == 36:
|
|
98
|
+
return given
|
|
99
|
+
jobs = (client().get("/api/v1/harnesses/training-jobs") or {}).get("jobs") or []
|
|
100
|
+
matches = [str(job.get("job_id")) for job in jobs if str(job.get("job_id", "")).startswith(given)]
|
|
101
|
+
if len(matches) == 1:
|
|
102
|
+
return matches[0]
|
|
103
|
+
if not matches:
|
|
104
|
+
ui.error(f"No run has an ID starting with {given}. See `corethink train list`.")
|
|
105
|
+
raise typer.Exit(5)
|
|
106
|
+
ui.error("More than one run starts with that; give more of its ID.")
|
|
107
|
+
raise typer.Exit(2)
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def _read_json(source: str) -> Any:
|
|
111
|
+
try:
|
|
112
|
+
text = sys.stdin.read() if source == "-" else Path(source).expanduser().read_text(encoding="utf-8")
|
|
113
|
+
except OSError as error:
|
|
114
|
+
ui.error(f"Cannot read {source}: {error.strerror or error}.")
|
|
115
|
+
raise typer.Exit(2) from None
|
|
116
|
+
try:
|
|
117
|
+
return json.loads(text)
|
|
118
|
+
except ValueError as error:
|
|
119
|
+
ui.error(f"{source} is not JSON: {error}")
|
|
120
|
+
raise typer.Exit(2) from None
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def _load_definition(source: str) -> dict[str, Any]:
|
|
124
|
+
"""A definition from a file: plain, or as `corethink --json connection show` prints it (under "definition")."""
|
|
125
|
+
data = _read_json(source)
|
|
126
|
+
if isinstance(data, dict) and isinstance(data.get("definition"), dict) and "record" in data:
|
|
127
|
+
data = data["definition"]
|
|
128
|
+
if not isinstance(data, dict):
|
|
129
|
+
ui.error(f"{source} must hold one JSON object: the connection's definition.")
|
|
130
|
+
raise typer.Exit(2)
|
|
131
|
+
return data
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def _outgoing(definition: dict[str, Any]) -> dict[str, Any]:
|
|
135
|
+
"""What is sent: the definition without the fields the Workbench sets."""
|
|
136
|
+
return {key: value for key, value in definition.items() if key not in SERVER_FIELDS}
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def _host_of(address: str | None) -> str:
|
|
140
|
+
if not address:
|
|
141
|
+
return ""
|
|
142
|
+
parts = urlsplit(address)
|
|
143
|
+
if parts.scheme != "https" or not parts.hostname:
|
|
144
|
+
return ""
|
|
145
|
+
return parts.hostname.lower() + (f":{parts.port}" if parts.port else "")
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def _main_host(definition: dict[str, Any]) -> str:
|
|
149
|
+
"""The host the definition points at, which allowed_hosts must name."""
|
|
150
|
+
database = definition.get("database") or {}
|
|
151
|
+
if database.get("host"):
|
|
152
|
+
port = database.get("port")
|
|
153
|
+
host = str(database["host"]).lower()
|
|
154
|
+
return f"{host}:{port}" if port and port != 5432 else host
|
|
155
|
+
for kind, key in (
|
|
156
|
+
("http", "base_url"),
|
|
157
|
+
("openapi", "base_url"),
|
|
158
|
+
("openapi", "spec_url"),
|
|
159
|
+
("mcp", "url"),
|
|
160
|
+
):
|
|
161
|
+
host = _host_of((definition.get(kind) or {}).get(key))
|
|
162
|
+
if host:
|
|
163
|
+
return host
|
|
164
|
+
return ""
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def _tools_named(definition: dict[str, Any]) -> list[str]:
|
|
168
|
+
"""The tools a definition itself names (endpoints, chosen operations, MCP tools, a database's standard tools)."""
|
|
169
|
+
names: list[str] = []
|
|
170
|
+
|
|
171
|
+
def add(name: Any) -> None:
|
|
172
|
+
if name and name != "*" and name not in names:
|
|
173
|
+
names.append(str(name))
|
|
174
|
+
|
|
175
|
+
for endpoint in (definition.get("http") or {}).get("endpoints") or []:
|
|
176
|
+
add(endpoint.get("tool"))
|
|
177
|
+
for operation in (definition.get("openapi") or {}).get("operations") or []:
|
|
178
|
+
add(operation)
|
|
179
|
+
for tool in (definition.get("mcp") or {}).get("tools") or []:
|
|
180
|
+
add(tool)
|
|
181
|
+
if definition.get("database"):
|
|
182
|
+
for tool in DATABASE_TOOLS:
|
|
183
|
+
add(tool)
|
|
184
|
+
tools = definition.get("tools") or {}
|
|
185
|
+
for tool in [*(tools.get("read") or []), *(tools.get("write") or [])]:
|
|
186
|
+
add(tool)
|
|
187
|
+
return names
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def _auth_of(definition: dict[str, Any]) -> dict[str, Any] | None:
|
|
191
|
+
for kind in ("http", "openapi", "mcp"):
|
|
192
|
+
block = definition.get(kind)
|
|
193
|
+
if isinstance(block, dict) and isinstance(block.get("auth"), dict):
|
|
194
|
+
return block["auth"]
|
|
195
|
+
return None
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def _auth_text(auth: dict[str, Any] | None) -> str | None:
|
|
199
|
+
if not auth:
|
|
200
|
+
return None
|
|
201
|
+
kind = auth.get("type") or "none"
|
|
202
|
+
if kind == "none":
|
|
203
|
+
return "none"
|
|
204
|
+
where = {
|
|
205
|
+
"bearer": "Authorization: Bearer",
|
|
206
|
+
"header": f"header {auth.get('header') or 'X-Api-Key'}",
|
|
207
|
+
"query": f"query parameter {auth.get('param')}",
|
|
208
|
+
"basic": "basic",
|
|
209
|
+
"oauth_client_credentials": "OAuth client credentials",
|
|
210
|
+
}.get(kind, kind)
|
|
211
|
+
return f"{where} · secret {auth.get('secret')}" if auth.get("secret") else where
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def _target_text(definition: dict[str, Any]) -> str | None:
|
|
215
|
+
kind = definition.get("kind")
|
|
216
|
+
block = definition.get(kind) if isinstance(kind, str) else None
|
|
217
|
+
if not isinstance(block, dict):
|
|
218
|
+
return None
|
|
219
|
+
if kind == "http":
|
|
220
|
+
return block.get("base_url")
|
|
221
|
+
if kind == "openapi":
|
|
222
|
+
return block.get("base_url") or block.get("spec_url") or "spec in the definition"
|
|
223
|
+
if kind == "mcp":
|
|
224
|
+
return f"{block.get('url')} ({block.get('transport') or 'streamable_http'})"
|
|
225
|
+
if kind == "database":
|
|
226
|
+
port = block.get("port") or 5432
|
|
227
|
+
return f"{block.get('engine')}://{block.get('user')}@{block.get('host')}:{port}/{block.get('database')}"
|
|
228
|
+
if kind == "code":
|
|
229
|
+
package = str(block.get("package") or "").split("/")[-1]
|
|
230
|
+
return f"{block.get('entry')}" + (f" in {package}" if package else " (no package yet)")
|
|
231
|
+
return None
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
def _mark(ok: Any) -> Text:
|
|
235
|
+
if ok is None:
|
|
236
|
+
return Text("–", style="dim")
|
|
237
|
+
return Text("✓", style="green") if ok else Text("✗", style="red")
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
def _clip(value: Any, size: int = 80) -> str:
|
|
241
|
+
text = value if isinstance(value, str) else json.dumps(value, ensure_ascii=False, default=str)
|
|
242
|
+
text = " ".join(str(text).split())
|
|
243
|
+
return text if len(text) <= size else text[: size - 1] + "…"
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
def _writes_value(value: str | None) -> str | None:
|
|
247
|
+
if value is None:
|
|
248
|
+
return None
|
|
249
|
+
normal = value.strip().lower().replace("-", "_")
|
|
250
|
+
if normal not in WRITES_WHAT:
|
|
251
|
+
ui.error("--writes is refuse, dry-run or allow.")
|
|
252
|
+
raise typer.Exit(2)
|
|
253
|
+
return normal
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
def _classify(definition: dict[str, Any], reads: list[str] | None, writes: list[str] | None) -> None:
|
|
257
|
+
"""Mark tools read or write (a tool moves from the other list)."""
|
|
258
|
+
tools = definition.setdefault("tools", {})
|
|
259
|
+
read = list(tools.get("read") or [])
|
|
260
|
+
write = list(tools.get("write") or [])
|
|
261
|
+
for name in reads or []:
|
|
262
|
+
if name in write:
|
|
263
|
+
write.remove(name)
|
|
264
|
+
if name not in read:
|
|
265
|
+
read.append(name)
|
|
266
|
+
for name in writes or []:
|
|
267
|
+
if name in read:
|
|
268
|
+
read.remove(name)
|
|
269
|
+
if name not in write:
|
|
270
|
+
write.append(name)
|
|
271
|
+
tools["read"], tools["write"] = read, write
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
def _unclassified(definition: dict[str, Any]) -> list[str]:
|
|
275
|
+
tools = definition.get("tools") or {}
|
|
276
|
+
known = set(tools.get("read") or []) | set(tools.get("write") or [])
|
|
277
|
+
return [name for name in _tools_named(definition) if name not in known]
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
def _upload_package(name: str, package: Path) -> str:
|
|
281
|
+
"""Send a code package (.zip) straight to storage; the upload's ID, used when the definition is saved."""
|
|
282
|
+
if not package.is_file():
|
|
283
|
+
ui.error(f"{package} is not a file.")
|
|
284
|
+
raise typer.Exit(2)
|
|
285
|
+
if package.suffix.lower() != ".zip":
|
|
286
|
+
ui.error("A package is a .zip of your module and its files (dependencies vendored in).")
|
|
287
|
+
raise typer.Exit(2)
|
|
288
|
+
data = package.read_bytes()
|
|
289
|
+
if not data or len(data) > MAX_PACKAGE_BYTES:
|
|
290
|
+
ui.error("A package is a .zip of at most 20 MB.")
|
|
291
|
+
raise typer.Exit(2)
|
|
292
|
+
target = client().post(_path(name, "/package"), {"size": len(data)})
|
|
293
|
+
file = target.get("file") or {}
|
|
294
|
+
with ui.err.status(f"Uploading {package.name}…", spinner="dots"):
|
|
295
|
+
client().put_signed(
|
|
296
|
+
str(file.get("url")),
|
|
297
|
+
data,
|
|
298
|
+
headers=file.get("headers") or {"content-type": "application/zip"},
|
|
299
|
+
method=str(file.get("method") or "PUT"),
|
|
300
|
+
)
|
|
301
|
+
return str(target["upload"])
|
|
302
|
+
|
|
303
|
+
|
|
304
|
+
def _next_steps(name: str, view: dict[str, Any]) -> None:
|
|
305
|
+
unset = [secret for secret, status in (view.get("secrets") or {}).items() if not (status or {}).get("set")]
|
|
306
|
+
for secret in unset:
|
|
307
|
+
ui.info(f"Set its secret: corethink connection secret set {name} {secret}")
|
|
308
|
+
ui.info(f"Test it: corethink connection test {name}")
|
|
309
|
+
|
|
310
|
+
|
|
311
|
+
# ---- list and show ---------------------------------------------------------------------------------------------------
|
|
312
|
+
|
|
313
|
+
|
|
314
|
+
@app.command("list")
|
|
315
|
+
def list_(
|
|
316
|
+
job: str = typer.Option(None, "--job", help="Only the connections this run was given, with their versions."),
|
|
317
|
+
) -> None:
|
|
318
|
+
"""Your workspace's connections.
|
|
319
|
+
|
|
320
|
+
Example: corethink connection list
|
|
321
|
+
"""
|
|
322
|
+
if job:
|
|
323
|
+
job_id = _resolve_job(job)
|
|
324
|
+
given = (client().get("/api/v1/connections", job=job_id) or {}).get("connections") or {}
|
|
325
|
+
rows = [{"name": name, "version": version} for name, version in sorted(given.items())]
|
|
326
|
+
|
|
327
|
+
def human_job() -> None:
|
|
328
|
+
ui.table(
|
|
329
|
+
rows,
|
|
330
|
+
[("Name", "name"), ("Version", lambda r: f"v{r['version']}")],
|
|
331
|
+
empty="This run used no connections.",
|
|
332
|
+
)
|
|
333
|
+
|
|
334
|
+
ui.emit({"job": job_id, "connections": given}, human_job)
|
|
335
|
+
return
|
|
336
|
+
|
|
337
|
+
rows = _connections()
|
|
338
|
+
|
|
339
|
+
def last_check(row: dict[str, Any]) -> Text | str:
|
|
340
|
+
check = row.get("last_check")
|
|
341
|
+
if not check:
|
|
342
|
+
return "never"
|
|
343
|
+
return _mark(check.get("ok")) + Text(f" {ui.ago(check.get('at'))}")
|
|
344
|
+
|
|
345
|
+
def human() -> None:
|
|
346
|
+
ui.table(
|
|
347
|
+
rows,
|
|
348
|
+
[
|
|
349
|
+
("Name", "name"),
|
|
350
|
+
("Kind", lambda r: KIND_LABEL.get(r.get("kind"), r.get("kind"))),
|
|
351
|
+
("Version", lambda r: f"v{r.get('version')}"),
|
|
352
|
+
(
|
|
353
|
+
"Tools",
|
|
354
|
+
lambda r: (
|
|
355
|
+
f"{len((r.get('tools') or {}).get('read') or [])} read · "
|
|
356
|
+
f"{len((r.get('tools') or {}).get('write') or [])} write"
|
|
357
|
+
),
|
|
358
|
+
),
|
|
359
|
+
("Writes", lambda r: str(r.get("writes") or "refuse").replace("_", "-")),
|
|
360
|
+
("Last check", last_check),
|
|
361
|
+
("Updated", lambda r: ui.ago(r.get("updated_at"))),
|
|
362
|
+
],
|
|
363
|
+
empty="No connections yet. Make one with `corethink connection create`.",
|
|
364
|
+
)
|
|
365
|
+
|
|
366
|
+
ui.emit(rows, human)
|
|
367
|
+
|
|
368
|
+
|
|
369
|
+
@app.command("show")
|
|
370
|
+
def show(
|
|
371
|
+
name: str = typer.Argument(..., help="The connection (its name, or the start of it)."),
|
|
372
|
+
version: int = typer.Option(None, "--version", min=1, help="An earlier version (default: the newest)."),
|
|
373
|
+
definition_only: bool = typer.Option(
|
|
374
|
+
False,
|
|
375
|
+
"--definition",
|
|
376
|
+
help="Print only the definition as JSON (to edit and save with `update --from`).",
|
|
377
|
+
),
|
|
378
|
+
) -> None:
|
|
379
|
+
"""A connection: what it reaches, its tools, secrets (set or not) and versions.
|
|
380
|
+
|
|
381
|
+
Example: corethink connection show orders-api --definition > orders.json
|
|
382
|
+
"""
|
|
383
|
+
name = _resolve(name)
|
|
384
|
+
view = client().get(_path(name), version=version) or {}
|
|
385
|
+
definition = view.get("definition") or {}
|
|
386
|
+
if definition_only:
|
|
387
|
+
ui.print_json(definition)
|
|
388
|
+
return
|
|
389
|
+
record = view.get("record") or {}
|
|
390
|
+
tools = definition.get("tools") or {}
|
|
391
|
+
writes = str(definition.get("writes") or "refuse")
|
|
392
|
+
|
|
393
|
+
def human() -> None:
|
|
394
|
+
unclassified = _unclassified(definition)
|
|
395
|
+
secrets = view.get("secrets") or {}
|
|
396
|
+
secret_text = (
|
|
397
|
+
", ".join(
|
|
398
|
+
f"{secret} ({'set · ' + ui.when(status.get('updated_at')) if status.get('set') else 'not set'})"
|
|
399
|
+
for secret, status in sorted(secrets.items())
|
|
400
|
+
)
|
|
401
|
+
or "none"
|
|
402
|
+
)
|
|
403
|
+
versions = [str(entry.get("version")) for entry in view.get("versions") or []]
|
|
404
|
+
kind = str(definition.get("kind"))
|
|
405
|
+
ui.fields(
|
|
406
|
+
[
|
|
407
|
+
("Name", definition.get("name") or name),
|
|
408
|
+
("Kind", f"{KIND_LABEL.get(kind, kind)} · {KIND_WHAT.get(kind, '')}".rstrip(" ·")),
|
|
409
|
+
("Description", definition.get("description")),
|
|
410
|
+
("Version", f"v{definition.get('version') or record.get('version')}"),
|
|
411
|
+
(
|
|
412
|
+
"Updated",
|
|
413
|
+
f"{ui.when(record.get('updated_at'))} by {record.get('updated_by') or '—'}",
|
|
414
|
+
),
|
|
415
|
+
("Target", _target_text(definition)),
|
|
416
|
+
("Auth", _auth_text(_auth_of(definition))),
|
|
417
|
+
("Reaches", ", ".join(definition.get("allowed_hosts") or []) or None),
|
|
418
|
+
("Read tools", ", ".join(tools.get("read") or []) or "none"),
|
|
419
|
+
("Write tools", ", ".join(tools.get("write") or []) or "none"),
|
|
420
|
+
(
|
|
421
|
+
"Unclassified",
|
|
422
|
+
f"{', '.join(unclassified)} (count as writes)" if unclassified else None,
|
|
423
|
+
),
|
|
424
|
+
("Writes", f"{writes.replace('_', '-')}: {WRITES_WHAT.get(writes, '')}"),
|
|
425
|
+
(
|
|
426
|
+
"Secrets",
|
|
427
|
+
secret_text if view.get("secretsAvailable", True) else "cannot be stored here",
|
|
428
|
+
),
|
|
429
|
+
("Used by", ", ".join(view.get("usedBy") or []) or "no environment"),
|
|
430
|
+
("Versions", ", ".join(versions) if len(versions) > 1 else None),
|
|
431
|
+
]
|
|
432
|
+
)
|
|
433
|
+
|
|
434
|
+
ui.emit(view, human)
|
|
435
|
+
|
|
436
|
+
|
|
437
|
+
# ---- create, update, delete ------------------------------------------------------------------------------------------
|
|
438
|
+
|
|
439
|
+
|
|
440
|
+
def _parse_endpoint(text: str) -> dict[str, Any]:
|
|
441
|
+
"""`get_order=GET /orders/{order_id}` → an HTTP endpoint."""
|
|
442
|
+
tool, sep, route = text.partition("=")
|
|
443
|
+
if not sep or not tool.strip():
|
|
444
|
+
ui.error(f"--endpoint {text}: use TOOL=METHOD /path, e.g. get_order=GET /orders/{{order_id}}.")
|
|
445
|
+
raise typer.Exit(2)
|
|
446
|
+
parts = route.split()
|
|
447
|
+
if len(parts) == 1:
|
|
448
|
+
method, path = "GET", parts[0]
|
|
449
|
+
elif len(parts) == 2:
|
|
450
|
+
method, path = parts[0].upper(), parts[1]
|
|
451
|
+
else:
|
|
452
|
+
ui.error(f"--endpoint {text}: use TOOL=METHOD /path.")
|
|
453
|
+
raise typer.Exit(2)
|
|
454
|
+
if not path.startswith("/"):
|
|
455
|
+
ui.error(f"--endpoint {text}: the path starts with / (e.g. /orders/{{order_id}}).")
|
|
456
|
+
raise typer.Exit(2)
|
|
457
|
+
return {"tool": tool.strip(), "method": method, "path": path}
|
|
458
|
+
|
|
459
|
+
|
|
460
|
+
@app.command("create")
|
|
461
|
+
def create(
|
|
462
|
+
name: str = typer.Argument(None, help="Its name: lowercase letters, digits and -. Or give it in --from."),
|
|
463
|
+
source: str = FROM_OPTION,
|
|
464
|
+
kind: str = typer.Option(None, "--kind", "-k", help="http, openapi, mcp, database or code."),
|
|
465
|
+
description: str = typer.Option(None, "--description", help="What it is, in a line."),
|
|
466
|
+
url: str = typer.Option(
|
|
467
|
+
None,
|
|
468
|
+
"--url",
|
|
469
|
+
help="HTTP API: its base URL. MCP: the server's URL. OpenAPI: the spec's https:// address.",
|
|
470
|
+
rich_help_panel="Where it is",
|
|
471
|
+
),
|
|
472
|
+
spec: Path = typer.Option(
|
|
473
|
+
None,
|
|
474
|
+
"--spec",
|
|
475
|
+
help="OpenAPI: the spec as a JSON file (instead of --url).",
|
|
476
|
+
rich_help_panel="Where it is",
|
|
477
|
+
),
|
|
478
|
+
base_url: str = typer.Option(
|
|
479
|
+
None,
|
|
480
|
+
"--base-url",
|
|
481
|
+
help="OpenAPI: the API's address when the spec does not say.",
|
|
482
|
+
rich_help_panel="Where it is",
|
|
483
|
+
),
|
|
484
|
+
endpoints: list[str] = typer.Option(
|
|
485
|
+
None,
|
|
486
|
+
"--endpoint",
|
|
487
|
+
"-e",
|
|
488
|
+
help="HTTP API: a tool, as TOOL=METHOD /path (repeatable).",
|
|
489
|
+
rich_help_panel="Where it is",
|
|
490
|
+
),
|
|
491
|
+
operations: list[str] = typer.Option(
|
|
492
|
+
None,
|
|
493
|
+
"--operation",
|
|
494
|
+
help="OpenAPI: an operationId to use as a tool (repeatable).",
|
|
495
|
+
rich_help_panel="Where it is",
|
|
496
|
+
),
|
|
497
|
+
mcp_tools: list[str] = typer.Option(
|
|
498
|
+
None,
|
|
499
|
+
"--tool",
|
|
500
|
+
help="MCP: a tool to use (repeatable; default every tool).",
|
|
501
|
+
rich_help_panel="Where it is",
|
|
502
|
+
),
|
|
503
|
+
transport: str = typer.Option(
|
|
504
|
+
None,
|
|
505
|
+
"--transport",
|
|
506
|
+
help="MCP: streamable_http (default) or sse.",
|
|
507
|
+
rich_help_panel="Where it is",
|
|
508
|
+
),
|
|
509
|
+
allow_hosts: list[str] = typer.Option(
|
|
510
|
+
None,
|
|
511
|
+
"--allow-host",
|
|
512
|
+
help="A host it may reach, host or host:port (repeatable; default: the host it points at).",
|
|
513
|
+
rich_help_panel="Where it is",
|
|
514
|
+
),
|
|
515
|
+
auth: str = typer.Option(
|
|
516
|
+
None,
|
|
517
|
+
"--auth",
|
|
518
|
+
help="none, bearer, header, query or basic (default bearer with --secret).",
|
|
519
|
+
rich_help_panel="Auth",
|
|
520
|
+
),
|
|
521
|
+
secret: str = typer.Option(
|
|
522
|
+
None,
|
|
523
|
+
"--secret",
|
|
524
|
+
help="The name of the secret holding the token or key, e.g. api_key (its value is set with `secret set`).",
|
|
525
|
+
rich_help_panel="Auth",
|
|
526
|
+
),
|
|
527
|
+
auth_header: str = typer.Option(
|
|
528
|
+
None,
|
|
529
|
+
"--auth-header",
|
|
530
|
+
help="header auth: the header (default X-Api-Key).",
|
|
531
|
+
rich_help_panel="Auth",
|
|
532
|
+
),
|
|
533
|
+
auth_prefix: str = typer.Option(
|
|
534
|
+
None, "--auth-prefix", help="header auth: text before the value.", rich_help_panel="Auth"
|
|
535
|
+
),
|
|
536
|
+
auth_param: str = typer.Option(
|
|
537
|
+
None, "--auth-param", help="query auth: the query parameter.", rich_help_panel="Auth"
|
|
538
|
+
),
|
|
539
|
+
username: str = typer.Option(None, "--username", help="basic auth: the user name.", rich_help_panel="Auth"),
|
|
540
|
+
engine: str = typer.Option(None, "--engine", help="Database: postgres.", rich_help_panel="Database"),
|
|
541
|
+
host: str = typer.Option(None, "--host", help="Database: its host.", rich_help_panel="Database"),
|
|
542
|
+
port: int = typer.Option(None, "--port", help="Database: its port (default 5432).", rich_help_panel="Database"),
|
|
543
|
+
database: str = typer.Option(None, "--database", help="Database: its name.", rich_help_panel="Database"),
|
|
544
|
+
user: str = typer.Option(
|
|
545
|
+
None, "--user", help="Database: the user (read-only, ideally).", rich_help_panel="Database"
|
|
546
|
+
),
|
|
547
|
+
password_secret: str = typer.Option(
|
|
548
|
+
None,
|
|
549
|
+
"--password-secret",
|
|
550
|
+
help="Database: the secret holding the password (default db_password).",
|
|
551
|
+
rich_help_panel="Database",
|
|
552
|
+
),
|
|
553
|
+
schemas: list[str] = typer.Option(
|
|
554
|
+
None,
|
|
555
|
+
"--schema",
|
|
556
|
+
help="Database: a schema it may query (repeatable; default public).",
|
|
557
|
+
rich_help_panel="Database",
|
|
558
|
+
),
|
|
559
|
+
package: Path = typer.Option(
|
|
560
|
+
None,
|
|
561
|
+
"--package",
|
|
562
|
+
help="Code: the .zip of your tool functions (at most 20 MB).",
|
|
563
|
+
rich_help_panel="Code",
|
|
564
|
+
),
|
|
565
|
+
entry: str = typer.Option(
|
|
566
|
+
None,
|
|
567
|
+
"--entry",
|
|
568
|
+
help="Code: module:NAME, a list of tool functions (default tools:TOOLS).",
|
|
569
|
+
rich_help_panel="Code",
|
|
570
|
+
),
|
|
571
|
+
code_secrets: list[str] = typer.Option(
|
|
572
|
+
None,
|
|
573
|
+
"--code-secret",
|
|
574
|
+
help="Code: a secret its functions receive (repeatable).",
|
|
575
|
+
rich_help_panel="Code",
|
|
576
|
+
),
|
|
577
|
+
reads: list[str] = typer.Option(
|
|
578
|
+
None,
|
|
579
|
+
"--read",
|
|
580
|
+
help="Mark a tool read: it only reads (repeatable).",
|
|
581
|
+
rich_help_panel="Tools",
|
|
582
|
+
),
|
|
583
|
+
writes_tools: list[str] = typer.Option(
|
|
584
|
+
None,
|
|
585
|
+
"--write",
|
|
586
|
+
help="Mark a tool write: it changes something (repeatable).",
|
|
587
|
+
rich_help_panel="Tools",
|
|
588
|
+
),
|
|
589
|
+
writes: str = typer.Option(
|
|
590
|
+
None,
|
|
591
|
+
"--writes",
|
|
592
|
+
help="Write tools in training: refuse (default), dry-run or allow.",
|
|
593
|
+
rich_help_panel="Tools",
|
|
594
|
+
),
|
|
595
|
+
timeout: float = typer.Option(
|
|
596
|
+
None,
|
|
597
|
+
"--timeout",
|
|
598
|
+
help="Seconds to wait for an answer (default 20).",
|
|
599
|
+
rich_help_panel="Tools",
|
|
600
|
+
),
|
|
601
|
+
dry_run: bool = typer.Option(False, "--dry-run", help="Print the definition that would be sent, and stop."),
|
|
602
|
+
) -> None:
|
|
603
|
+
"""Make a connection (admins) from flags, a JSON definition (--from), or both (flags win).
|
|
604
|
+
|
|
605
|
+
Tools are read or write: read tools are called freely in tests and training; write tools follow --writes, and a tool in neither list counts as a write. Secrets are named here and set afterwards with `secret set`.
|
|
606
|
+
|
|
607
|
+
Examples:
|
|
608
|
+
corethink connection create orders-api --kind http --url https://api.example.com --secret api_key \\
|
|
609
|
+
-e "get_order=GET /orders/{order_id}" --read get_order
|
|
610
|
+
corethink connection create docs --kind mcp --url https://mcp.example.com/mcp --auth none
|
|
611
|
+
corethink connection create crm --from crm.json
|
|
612
|
+
corethink connection create my-tools --kind code --package tools.zip --entry tools:TOOLS
|
|
613
|
+
"""
|
|
614
|
+
definition: dict[str, Any] = _load_definition(source) if source else {}
|
|
615
|
+
name = name or definition.get("name")
|
|
616
|
+
if not name:
|
|
617
|
+
ui.error("Name the connection: corethink connection create NAME …")
|
|
618
|
+
raise typer.Exit(2)
|
|
619
|
+
kind = (kind or definition.get("kind") or "").lower()
|
|
620
|
+
if kind not in KINDS:
|
|
621
|
+
ui.error("Choose a kind with --kind: http, openapi, mcp, database or code.")
|
|
622
|
+
raise typer.Exit(2)
|
|
623
|
+
definition = _outgoing(definition)
|
|
624
|
+
definition["name"] = name
|
|
625
|
+
definition["kind"] = kind
|
|
626
|
+
if description is not None:
|
|
627
|
+
definition["description"] = description
|
|
628
|
+
block: dict[str, Any] = dict(definition.get(kind) or {})
|
|
629
|
+
|
|
630
|
+
if kind in ("http", "openapi", "mcp"):
|
|
631
|
+
given = dict(block.get("auth") or {})
|
|
632
|
+
if auth or secret or "type" not in given:
|
|
633
|
+
chosen = (auth or ("bearer" if secret else given.get("type") or "none")).lower()
|
|
634
|
+
if chosen not in AUTH_TYPES:
|
|
635
|
+
ui.error(f"--auth is one of {', '.join(AUTH_TYPES)} (OAuth: use --from).")
|
|
636
|
+
raise typer.Exit(2)
|
|
637
|
+
given["type"] = chosen
|
|
638
|
+
if given.get("type") != "none" and secret:
|
|
639
|
+
given["secret"] = secret
|
|
640
|
+
elif given.get("type") != "none" and not given.get("secret"):
|
|
641
|
+
given["secret"] = "api_key"
|
|
642
|
+
for key, value in (
|
|
643
|
+
("header", auth_header),
|
|
644
|
+
("prefix", auth_prefix),
|
|
645
|
+
("param", auth_param),
|
|
646
|
+
("username", username),
|
|
647
|
+
):
|
|
648
|
+
if value is not None:
|
|
649
|
+
given[key] = value
|
|
650
|
+
block["auth"] = given
|
|
651
|
+
|
|
652
|
+
if kind == "http":
|
|
653
|
+
if url:
|
|
654
|
+
block["base_url"] = url
|
|
655
|
+
if endpoints:
|
|
656
|
+
block["endpoints"] = [
|
|
657
|
+
*(block.get("endpoints") or []),
|
|
658
|
+
*(_parse_endpoint(e) for e in endpoints),
|
|
659
|
+
]
|
|
660
|
+
if not block.get("base_url") or not block.get("endpoints"):
|
|
661
|
+
ui.error("An HTTP API needs --url (its base URL) and at least one --endpoint TOOL=METHOD /path.")
|
|
662
|
+
raise typer.Exit(2)
|
|
663
|
+
elif kind == "openapi":
|
|
664
|
+
if spec:
|
|
665
|
+
loaded = _read_json(str(spec))
|
|
666
|
+
if not isinstance(loaded, dict):
|
|
667
|
+
ui.error(f"{spec} is not an OpenAPI spec (a JSON object).")
|
|
668
|
+
raise typer.Exit(2)
|
|
669
|
+
block["spec"] = loaded
|
|
670
|
+
block.pop("spec_url", None)
|
|
671
|
+
elif url:
|
|
672
|
+
block["spec_url"] = url
|
|
673
|
+
block.pop("spec", None)
|
|
674
|
+
if base_url:
|
|
675
|
+
block["base_url"] = base_url
|
|
676
|
+
if operations:
|
|
677
|
+
block["operations"] = list(dict.fromkeys([*(block.get("operations") or []), *operations]))
|
|
678
|
+
if not block.get("spec") and not block.get("spec_url"):
|
|
679
|
+
ui.error("An OpenAPI connection needs its spec: --spec FILE.json or --url https://…/openapi.json.")
|
|
680
|
+
raise typer.Exit(2)
|
|
681
|
+
elif kind == "mcp":
|
|
682
|
+
if url:
|
|
683
|
+
block["url"] = url
|
|
684
|
+
if transport:
|
|
685
|
+
block["transport"] = transport.replace("-", "_")
|
|
686
|
+
if mcp_tools:
|
|
687
|
+
block["tools"] = list(mcp_tools)
|
|
688
|
+
if not block.get("url"):
|
|
689
|
+
ui.error("An MCP connection needs the server's --url.")
|
|
690
|
+
raise typer.Exit(2)
|
|
691
|
+
elif kind == "database":
|
|
692
|
+
for key, value in (
|
|
693
|
+
("engine", engine),
|
|
694
|
+
("host", host),
|
|
695
|
+
("port", port),
|
|
696
|
+
("database", database),
|
|
697
|
+
("user", user),
|
|
698
|
+
):
|
|
699
|
+
if value is not None:
|
|
700
|
+
block[key] = value
|
|
701
|
+
block.setdefault("engine", "postgres")
|
|
702
|
+
block["password_secret"] = password_secret or block.get("password_secret") or "db_password"
|
|
703
|
+
if schemas:
|
|
704
|
+
gate = dict(block.get("sql_gate") or {})
|
|
705
|
+
gate["allowed_schemas"] = list(schemas)
|
|
706
|
+
block["sql_gate"] = gate
|
|
707
|
+
missing = [
|
|
708
|
+
flag
|
|
709
|
+
for flag, key in (("--host", "host"), ("--database", "database"), ("--user", "user"))
|
|
710
|
+
if not block.get(key)
|
|
711
|
+
]
|
|
712
|
+
if missing:
|
|
713
|
+
ui.error(f"A database connection needs {', '.join(missing)}.")
|
|
714
|
+
raise typer.Exit(2)
|
|
715
|
+
tools = definition.setdefault("tools", {})
|
|
716
|
+
if not tools.get("read") and not tools.get("write"):
|
|
717
|
+
tools["read"] = list(DATABASE_TOOLS)
|
|
718
|
+
elif kind == "code":
|
|
719
|
+
if entry:
|
|
720
|
+
block["entry"] = entry
|
|
721
|
+
block.setdefault("entry", "tools:TOOLS")
|
|
722
|
+
if code_secrets:
|
|
723
|
+
block["secrets"] = list(dict.fromkeys([*(block.get("secrets") or []), *code_secrets]))
|
|
724
|
+
if not package and not block.get("upload"):
|
|
725
|
+
ui.error("A code connection needs its package: --package tools.zip.")
|
|
726
|
+
raise typer.Exit(2)
|
|
727
|
+
definition[kind] = block
|
|
728
|
+
|
|
729
|
+
_classify(definition, reads, writes_tools)
|
|
730
|
+
mode = _writes_value(writes)
|
|
731
|
+
if mode:
|
|
732
|
+
definition["writes"] = mode
|
|
733
|
+
if timeout is not None:
|
|
734
|
+
definition["timeout_s"] = timeout
|
|
735
|
+
if allow_hosts:
|
|
736
|
+
definition["allowed_hosts"] = list(dict.fromkeys([*(definition.get("allowed_hosts") or []), *allow_hosts]))
|
|
737
|
+
if kind != "code" and not definition.get("allowed_hosts"):
|
|
738
|
+
main = _main_host(definition)
|
|
739
|
+
if main:
|
|
740
|
+
definition["allowed_hosts"] = [main]
|
|
741
|
+
|
|
742
|
+
if dry_run:
|
|
743
|
+
ui.print_json(definition)
|
|
744
|
+
return
|
|
745
|
+
if kind == "code" and package:
|
|
746
|
+
definition["code"]["upload"] = _upload_package(name, package)
|
|
747
|
+
view = client().post("/api/v1/connections", definition)
|
|
748
|
+
saved = view.get("definition") or {}
|
|
749
|
+
|
|
750
|
+
def human() -> None:
|
|
751
|
+
ui.success(f"Created {name} ({KIND_LABEL[kind]}, v{saved.get('version', 1)}).")
|
|
752
|
+
unclassified = _unclassified(saved)
|
|
753
|
+
if unclassified:
|
|
754
|
+
ui.warn(
|
|
755
|
+
f"Not classified, so treated as writes: {', '.join(unclassified)}. "
|
|
756
|
+
f"Mark reads: corethink connection update {name} --read TOOL"
|
|
757
|
+
)
|
|
758
|
+
_next_steps(name, view)
|
|
759
|
+
|
|
760
|
+
ui.emit(view, human)
|
|
761
|
+
|
|
762
|
+
|
|
763
|
+
@app.command("update")
|
|
764
|
+
def update(
|
|
765
|
+
name: str = typer.Argument(..., help="The connection."),
|
|
766
|
+
source: str = FROM_OPTION,
|
|
767
|
+
description: str = typer.Option(None, "--description", help="A new description."),
|
|
768
|
+
reads: list[str] = typer.Option(None, "--read", help="Mark a tool read (repeatable)."),
|
|
769
|
+
writes_tools: list[str] = typer.Option(None, "--write", help="Mark a tool write (repeatable)."),
|
|
770
|
+
writes: str = typer.Option(None, "--writes", help="Write tools in training: refuse, dry-run or allow."),
|
|
771
|
+
allow_hosts: list[str] = typer.Option(None, "--allow-host", help="Another host it may reach (repeatable)."),
|
|
772
|
+
) -> None:
|
|
773
|
+
"""Save a connection's next version (admins).
|
|
774
|
+
|
|
775
|
+
A whole definition from --from, or quick changes on the newest. A definition saved from `show --definition` keeps its version: if someone saved another since, the save is refused instead of overwriting theirs.
|
|
776
|
+
|
|
777
|
+
Examples:
|
|
778
|
+
corethink connection show orders-api --definition > orders.json # edit it, then:
|
|
779
|
+
corethink connection update orders-api --from orders.json
|
|
780
|
+
corethink connection update orders-api --read list_orders --write refund_order
|
|
781
|
+
"""
|
|
782
|
+
name = _resolve(name)
|
|
783
|
+
if not (source or description is not None or reads or writes_tools or writes or allow_hosts):
|
|
784
|
+
ui.error("Nothing to change: give --from FILE, or --description, --read, --write, --writes, --allow-host.")
|
|
785
|
+
raise typer.Exit(2)
|
|
786
|
+
current = client().get(_path(name)) or {}
|
|
787
|
+
base = int((current.get("record") or {}).get("version") or (current.get("definition") or {}).get("version") or 0)
|
|
788
|
+
if source:
|
|
789
|
+
definition = _load_definition(source)
|
|
790
|
+
if isinstance(definition.get("version"), int):
|
|
791
|
+
base = definition["version"]
|
|
792
|
+
if definition.get("name") not in (None, name):
|
|
793
|
+
ui.error("A connection cannot be renamed: make a new one.")
|
|
794
|
+
raise typer.Exit(2)
|
|
795
|
+
else:
|
|
796
|
+
definition = dict(current.get("definition") or {})
|
|
797
|
+
definition = _outgoing(definition)
|
|
798
|
+
if description is not None:
|
|
799
|
+
definition["description"] = description
|
|
800
|
+
_classify(definition, reads, writes_tools)
|
|
801
|
+
mode = _writes_value(writes)
|
|
802
|
+
if mode:
|
|
803
|
+
definition["writes"] = mode
|
|
804
|
+
if allow_hosts:
|
|
805
|
+
definition["allowed_hosts"] = list(dict.fromkeys([*(definition.get("allowed_hosts") or []), *allow_hosts]))
|
|
806
|
+
view = client().put(_path(name), {**definition, "base": base})
|
|
807
|
+
saved = view.get("definition") or {}
|
|
808
|
+
ui.emit(view, lambda: ui.success(f"Saved {name} as v{saved.get('version')}."))
|
|
809
|
+
|
|
810
|
+
|
|
811
|
+
@app.command("delete")
|
|
812
|
+
def delete(
|
|
813
|
+
name: str = typer.Argument(..., help="The connection."),
|
|
814
|
+
yes: bool = YES_OPTION,
|
|
815
|
+
) -> None:
|
|
816
|
+
"""Delete a connection, every version and its secrets (admins).
|
|
817
|
+
|
|
818
|
+
Refused while an environment's scoring uses it.
|
|
819
|
+
"""
|
|
820
|
+
name = _resolve(name)
|
|
821
|
+
ui.confirm(f"Delete {name}, every version and its secrets? This cannot be undone.", yes=yes)
|
|
822
|
+
done = client().delete(_path(name)) or {"deleted": name}
|
|
823
|
+
ui.emit(done, lambda: ui.success(f"Deleted {name}."))
|
|
824
|
+
|
|
825
|
+
|
|
826
|
+
@app.command("upload")
|
|
827
|
+
def upload(
|
|
828
|
+
name: str = typer.Argument(..., help="A code connection."),
|
|
829
|
+
package: Path = typer.Argument(..., help="The .zip of your tool functions (at most 20 MB)."),
|
|
830
|
+
entry: str = typer.Option(None, "--entry", help="A new entry, module:NAME (default: as it is)."),
|
|
831
|
+
) -> None:
|
|
832
|
+
"""Upload a code connection's new package (admins) and save it as the next version.
|
|
833
|
+
|
|
834
|
+
The package holds your module and everything it imports (nothing is installed). A new code connection is made with `create --kind code --package FILE`.
|
|
835
|
+
|
|
836
|
+
Example: corethink connection upload my-tools dist/tools.zip
|
|
837
|
+
"""
|
|
838
|
+
name = _resolve(name)
|
|
839
|
+
current = client().get(_path(name)) or {}
|
|
840
|
+
definition = _outgoing(dict(current.get("definition") or {}))
|
|
841
|
+
if definition.get("kind") != "code":
|
|
842
|
+
ui.error(f"{name} is not a code connection: only those have a package.")
|
|
843
|
+
raise typer.Exit(2)
|
|
844
|
+
upload_id = _upload_package(name, package)
|
|
845
|
+
code = dict(definition.get("code") or {})
|
|
846
|
+
code["upload"] = upload_id
|
|
847
|
+
if entry:
|
|
848
|
+
code["entry"] = entry
|
|
849
|
+
definition["code"] = code
|
|
850
|
+
base = int((current.get("record") or {}).get("version") or 0)
|
|
851
|
+
view = client().put(_path(name), {**definition, "base": base})
|
|
852
|
+
saved = view.get("definition") or {}
|
|
853
|
+
ui.emit(view, lambda: ui.success(f"Uploaded {package.name}: {name} is now v{saved.get('version')}."))
|
|
854
|
+
|
|
855
|
+
|
|
856
|
+
# ---- secrets ---------------------------------------------------------------------------------------------------------
|
|
857
|
+
|
|
858
|
+
|
|
859
|
+
@secret_app.command("list")
|
|
860
|
+
def secret_list(name: str = typer.Argument(..., help="The connection.")) -> None:
|
|
861
|
+
"""The secrets a connection uses, and whether each is set (never their values)."""
|
|
862
|
+
name = _resolve(name)
|
|
863
|
+
view = client().get(_path(name)) or {}
|
|
864
|
+
rows = [{"secret": key, **(value or {})} for key, value in sorted((view.get("secrets") or {}).items())]
|
|
865
|
+
|
|
866
|
+
def human() -> None:
|
|
867
|
+
ui.table(
|
|
868
|
+
rows,
|
|
869
|
+
[
|
|
870
|
+
("Secret", "secret"),
|
|
871
|
+
(
|
|
872
|
+
"State",
|
|
873
|
+
lambda r: Text("set", style="green") if r.get("set") else Text("not set", style="yellow"),
|
|
874
|
+
),
|
|
875
|
+
("Updated", lambda r: ui.when(r.get("updated_at")) if r.get("set") else None),
|
|
876
|
+
],
|
|
877
|
+
empty=f"{name} uses no secrets.",
|
|
878
|
+
)
|
|
879
|
+
|
|
880
|
+
ui.emit(rows, human)
|
|
881
|
+
|
|
882
|
+
|
|
883
|
+
@secret_app.command("set")
|
|
884
|
+
def secret_set(
|
|
885
|
+
name: str = typer.Argument(..., help="The connection."),
|
|
886
|
+
secret: str = typer.Argument(..., help="The secret's name, e.g. api_key (see `connection show`)."),
|
|
887
|
+
stdin: bool = typer.Option(False, "--stdin", help="Read the value from stdin instead of a hidden prompt."),
|
|
888
|
+
from_env: str = typer.Option(None, "--from-env", help="Read the value from this environment variable."),
|
|
889
|
+
) -> None:
|
|
890
|
+
"""Set a secret's value (admins).
|
|
891
|
+
|
|
892
|
+
Typed at a hidden prompt, or read from stdin or an environment variable; never as an argument. It is stored by the Workbench and never shown again.
|
|
893
|
+
|
|
894
|
+
Examples:
|
|
895
|
+
corethink connection secret set orders-api api_key
|
|
896
|
+
op read op://vault/orders/key | corethink connection secret set orders-api api_key --stdin
|
|
897
|
+
"""
|
|
898
|
+
name = _resolve(name)
|
|
899
|
+
if from_env:
|
|
900
|
+
value = os.environ.get(from_env)
|
|
901
|
+
if value is None:
|
|
902
|
+
ui.error(f"{from_env} is not set.")
|
|
903
|
+
raise typer.Exit(2)
|
|
904
|
+
elif stdin:
|
|
905
|
+
value = sys.stdin.read().rstrip("\r\n")
|
|
906
|
+
elif sys.stdin.isatty():
|
|
907
|
+
value = typer.prompt(f"Value of {secret}", hide_input=True)
|
|
908
|
+
else:
|
|
909
|
+
ui.error("No terminal to type the value in: pipe it with --stdin, or use --from-env VAR.")
|
|
910
|
+
raise typer.Exit(2)
|
|
911
|
+
if len(value) < MIN_SECRET_CHARS:
|
|
912
|
+
ui.error(
|
|
913
|
+
f"A secret has at least {MIN_SECRET_CHARS} characters (shorter ones cannot be kept out of logs safely)."
|
|
914
|
+
)
|
|
915
|
+
raise typer.Exit(2)
|
|
916
|
+
if len(value.encode()) > MAX_SECRET_BYTES:
|
|
917
|
+
ui.error("A secret is at most 64 KB.")
|
|
918
|
+
raise typer.Exit(2)
|
|
919
|
+
status = client().put(_path(name, f"/secrets/{quote(secret, safe='')}"), {"value": value})
|
|
920
|
+
del value
|
|
921
|
+
ui.emit(status, lambda: ui.success(f"Set {secret} on {name}."))
|
|
922
|
+
|
|
923
|
+
|
|
924
|
+
@secret_app.command("clear")
|
|
925
|
+
def secret_clear(
|
|
926
|
+
name: str = typer.Argument(..., help="The connection."),
|
|
927
|
+
secret: str = typer.Argument(..., help="The secret's name."),
|
|
928
|
+
yes: bool = YES_OPTION,
|
|
929
|
+
) -> None:
|
|
930
|
+
"""Remove a secret's value (admins): calls that need it fail until it is set again."""
|
|
931
|
+
name = _resolve(name)
|
|
932
|
+
ui.confirm(f"Clear {secret} on {name}? Calls that need it fail until it is set again.", yes=yes)
|
|
933
|
+
status = client().delete(_path(name, f"/secrets/{quote(secret, safe='')}"))
|
|
934
|
+
ui.emit(status, lambda: ui.success(f"Cleared {secret} on {name}."))
|
|
935
|
+
|
|
936
|
+
|
|
937
|
+
# ---- test, discover, check -------------------------------------------------------------------------------------------
|
|
938
|
+
|
|
939
|
+
|
|
940
|
+
def _samples(given: list[str] | None) -> list[dict[str, Any]]:
|
|
941
|
+
samples: list[dict[str, Any]] = []
|
|
942
|
+
for item in given or []:
|
|
943
|
+
tool, sep, text = item.partition("=")
|
|
944
|
+
if not sep or not tool.strip():
|
|
945
|
+
ui.error(f'--sample {item}: use TOOL=JSON, e.g. get_order=\'{{"order_id": "A-1001"}}\'.')
|
|
946
|
+
raise typer.Exit(2)
|
|
947
|
+
try:
|
|
948
|
+
parsed = json.loads(text)
|
|
949
|
+
except ValueError:
|
|
950
|
+
ui.error(f"--sample {tool}: the arguments are not JSON.")
|
|
951
|
+
raise typer.Exit(2) from None
|
|
952
|
+
calls = parsed if isinstance(parsed, list) else [parsed]
|
|
953
|
+
if not calls or not all(isinstance(call, dict) for call in calls):
|
|
954
|
+
ui.error(f"--sample {tool}: give a JSON object of arguments (or a list of them, one call each).")
|
|
955
|
+
raise typer.Exit(2)
|
|
956
|
+
samples.extend({"tool": tool.strip(), "args": call} for call in calls)
|
|
957
|
+
if len(samples) > 20:
|
|
958
|
+
ui.error("Give at most 20 sample calls.")
|
|
959
|
+
raise typer.Exit(2)
|
|
960
|
+
return samples
|
|
961
|
+
|
|
962
|
+
|
|
963
|
+
def _show_report(report: dict[str, Any], definition: dict[str, Any]) -> None:
|
|
964
|
+
"""A test's or discovery's report, as the console shows it."""
|
|
965
|
+
missing = report.get("missing_secrets") or []
|
|
966
|
+
if missing:
|
|
967
|
+
ui.warn(
|
|
968
|
+
f"Not set yet: {', '.join(missing)}. Set {'it' if len(missing) == 1 else 'them'} with "
|
|
969
|
+
f"`corethink connection secret set {definition.get('name') or 'NAME'} {missing[0]}` and test again."
|
|
970
|
+
)
|
|
971
|
+
lines: list[Text] = []
|
|
972
|
+
if report.get("reachable") is not None:
|
|
973
|
+
status = f" · HTTP {report['status']}" if report.get("status") is not None else ""
|
|
974
|
+
lines.append(
|
|
975
|
+
_mark(report["reachable"]) + Text(f" {'Reachable' if report['reachable'] else 'Not reachable'}{status}")
|
|
976
|
+
)
|
|
977
|
+
if report.get("auth") is not None:
|
|
978
|
+
auth = report["auth"]
|
|
979
|
+
word = {"ok": "Authenticated", "failed": "Authentication failed"}.get(auth, "Authentication not checked")
|
|
980
|
+
lines.append(_mark(None if auth == "not_checked" else auth == "ok") + Text(f" {word}"))
|
|
981
|
+
for line in lines:
|
|
982
|
+
ui.out.print(line)
|
|
983
|
+
if report.get("detail"):
|
|
984
|
+
ui.out.print(Text(str(report["detail"]), style="dim"))
|
|
985
|
+
for text in dict.fromkeys([*(report.get("errors") or []), *(report.get("problems") or [])]):
|
|
986
|
+
ui.out.print(Text(str(text), style="red"))
|
|
987
|
+
for text in report.get("warnings") or []:
|
|
988
|
+
ui.out.print(Text(str(text), style="yellow"))
|
|
989
|
+
tools = report.get("tools")
|
|
990
|
+
if tools:
|
|
991
|
+
_tools_table(tools, report, definition)
|
|
992
|
+
samples = report.get("samples") or []
|
|
993
|
+
if samples:
|
|
994
|
+
ui.heading("Sample calls")
|
|
995
|
+
for sample in samples:
|
|
996
|
+
line = _mark(bool(sample.get("ok"))) + Text(f" {sample.get('tool')}")
|
|
997
|
+
if sample.get("args") is not None:
|
|
998
|
+
line += Text(f" {_clip(sample['args'], 120)}", style="dim")
|
|
999
|
+
if sample.get("status") is not None:
|
|
1000
|
+
line += Text(f" · {sample['status']}")
|
|
1001
|
+
if sample.get("latency_ms") is not None:
|
|
1002
|
+
line += Text(f" · {round(float(sample['latency_ms']))} ms")
|
|
1003
|
+
if sample.get("error"):
|
|
1004
|
+
line += Text(f" · {sample['error']}", style="red")
|
|
1005
|
+
ui.out.print(line)
|
|
1006
|
+
result = sample.get("result", sample.get("result_preview"))
|
|
1007
|
+
if result is not None:
|
|
1008
|
+
ui.out.print(Text(" " + _clip(result, 300), style="dim"))
|
|
1009
|
+
for skipped in report.get("skipped_samples") or []:
|
|
1010
|
+
ui.out.print(Text(f"{skipped.get('tool')} not called: {skipped.get('why')}", style="dim"))
|
|
1011
|
+
if report.get("ok") is not None:
|
|
1012
|
+
if report["ok"]:
|
|
1013
|
+
ui.success("The connection works.")
|
|
1014
|
+
else:
|
|
1015
|
+
ui.error("The connection does not work yet.")
|
|
1016
|
+
|
|
1017
|
+
|
|
1018
|
+
def _tools_table(tools: list[dict[str, Any]], report: dict[str, Any], definition: dict[str, Any]) -> None:
|
|
1019
|
+
read = set((definition.get("tools") or {}).get("read") or [])
|
|
1020
|
+
unclassified = set(report.get("unclassified") or [])
|
|
1021
|
+
|
|
1022
|
+
def access(tool: dict[str, Any]) -> Text:
|
|
1023
|
+
kind = tool.get("access") or ("read" if tool.get("name") in read else "write")
|
|
1024
|
+
label = Text(kind, style="green" if kind == "read" else "yellow")
|
|
1025
|
+
if tool.get("name") in unclassified:
|
|
1026
|
+
label += Text(" (unclassified)", style="dim")
|
|
1027
|
+
return label
|
|
1028
|
+
|
|
1029
|
+
ui.table(
|
|
1030
|
+
tools,
|
|
1031
|
+
[
|
|
1032
|
+
("Tool", "name"),
|
|
1033
|
+
("Access", access),
|
|
1034
|
+
(
|
|
1035
|
+
"System says",
|
|
1036
|
+
lambda t: (
|
|
1037
|
+
t.get("suggested_access")
|
|
1038
|
+
if t.get("suggested_access") and t.get("suggested_access") != (t.get("access") or "")
|
|
1039
|
+
else None
|
|
1040
|
+
),
|
|
1041
|
+
),
|
|
1042
|
+
("Description", lambda t: _clip(t.get("description") or "", 90) or None),
|
|
1043
|
+
],
|
|
1044
|
+
title=f"Tools ({len(tools)})",
|
|
1045
|
+
)
|
|
1046
|
+
if unclassified:
|
|
1047
|
+
ui.warn(
|
|
1048
|
+
f"Not classified, so treated as writes: {', '.join(sorted(unclassified))}. Mark reads: "
|
|
1049
|
+
f"corethink connection update {definition.get('name') or 'NAME'} --read TOOL"
|
|
1050
|
+
)
|
|
1051
|
+
|
|
1052
|
+
|
|
1053
|
+
@app.command("test")
|
|
1054
|
+
def test(
|
|
1055
|
+
name: str = typer.Argument(..., help="The connection."),
|
|
1056
|
+
samples: list[str] = typer.Option(
|
|
1057
|
+
None,
|
|
1058
|
+
"--sample",
|
|
1059
|
+
"-s",
|
|
1060
|
+
help="A sample call of a read tool, TOOL=JSON arguments (repeatable; a JSON list makes one call each).",
|
|
1061
|
+
),
|
|
1062
|
+
source: str = typer.Option(
|
|
1063
|
+
None,
|
|
1064
|
+
"--from",
|
|
1065
|
+
"-f",
|
|
1066
|
+
help="Test an edited definition (JSON file) before saving it (admins).",
|
|
1067
|
+
),
|
|
1068
|
+
) -> None:
|
|
1069
|
+
"""Test a connection: reachable, credentials, tools, and sample calls of read tools.
|
|
1070
|
+
|
|
1071
|
+
Write tools are never called. Sample calls that all answer are repeated by every check from then on. Exits 1 when the connection does not work.
|
|
1072
|
+
|
|
1073
|
+
Example: corethink connection test orders-api -s 'get_order={"order_id": "A-1001"}'
|
|
1074
|
+
"""
|
|
1075
|
+
name = _resolve(name)
|
|
1076
|
+
body: dict[str, Any] = {}
|
|
1077
|
+
picked = _samples(samples)
|
|
1078
|
+
if picked:
|
|
1079
|
+
body["samples"] = picked
|
|
1080
|
+
draft = _outgoing(_load_definition(source)) if source else None
|
|
1081
|
+
if draft is not None:
|
|
1082
|
+
body["definition"] = draft
|
|
1083
|
+
with ui.err.status(f"Testing {name}…", spinner="dots"):
|
|
1084
|
+
report = client().post(_path(name, ":test"), body) or {}
|
|
1085
|
+
definition = draft or (client().get(_path(name)) or {}).get("definition") or {}
|
|
1086
|
+
definition = {**definition, "name": name}
|
|
1087
|
+
ui.emit(report, lambda: _show_report(report, definition))
|
|
1088
|
+
if report.get("ok") is False:
|
|
1089
|
+
raise typer.Exit(1)
|
|
1090
|
+
|
|
1091
|
+
|
|
1092
|
+
@app.command("discover")
|
|
1093
|
+
def discover(
|
|
1094
|
+
name: str = typer.Argument(..., help="The connection."),
|
|
1095
|
+
source: str = typer.Option(None, "--from", "-f", help="Discover with an edited definition (JSON file; admins)."),
|
|
1096
|
+
) -> None:
|
|
1097
|
+
"""The tools a connection exposes, and whether each counts as read or write.
|
|
1098
|
+
|
|
1099
|
+
A tool in neither list of the definition counts as a write. Mark reads with `update NAME --read TOOL`.
|
|
1100
|
+
|
|
1101
|
+
Example: corethink connection discover docs-mcp
|
|
1102
|
+
"""
|
|
1103
|
+
name = _resolve(name)
|
|
1104
|
+
draft = _outgoing(_load_definition(source)) if source else None
|
|
1105
|
+
with ui.err.status(f"Asking {name} for its tools…", spinner="dots"):
|
|
1106
|
+
report = client().post(_path(name, ":discover"), {"definition": draft} if draft is not None else {}) or {}
|
|
1107
|
+
definition = draft or (client().get(_path(name)) or {}).get("definition") or {}
|
|
1108
|
+
definition = {**definition, "name": name}
|
|
1109
|
+
tools = report.get("tools") or []
|
|
1110
|
+
|
|
1111
|
+
def human() -> None:
|
|
1112
|
+
if report.get("missing_secrets"):
|
|
1113
|
+
ui.warn(f"Not set yet: {', '.join(report['missing_secrets'])}.")
|
|
1114
|
+
for text in dict.fromkeys([*(report.get("errors") or []), *(report.get("problems") or [])]):
|
|
1115
|
+
ui.error(str(text))
|
|
1116
|
+
if tools:
|
|
1117
|
+
_tools_table(tools, report, definition)
|
|
1118
|
+
else:
|
|
1119
|
+
ui.info("No tools found.")
|
|
1120
|
+
|
|
1121
|
+
ui.emit(report, human)
|
|
1122
|
+
|
|
1123
|
+
|
|
1124
|
+
@app.command("check")
|
|
1125
|
+
def check(name: str = typer.Argument(..., help="The connection.")) -> None:
|
|
1126
|
+
"""Check a connection's saved version now and keep the result.
|
|
1127
|
+
|
|
1128
|
+
Its remembered sample calls are made too. Exits 1 when the check fails.
|
|
1129
|
+
"""
|
|
1130
|
+
name = _resolve(name)
|
|
1131
|
+
with ui.err.status(f"Checking {name}…", spinner="dots"):
|
|
1132
|
+
result = (client().post(_path(name, ":check"), {}) or {}).get("check") or {}
|
|
1133
|
+
|
|
1134
|
+
def human() -> None:
|
|
1135
|
+
head = f"v{result.get('version')} · {result.get('ms', 0)} ms"
|
|
1136
|
+
if result.get("ok"):
|
|
1137
|
+
ui.success(f"{name} works ({head}).")
|
|
1138
|
+
else:
|
|
1139
|
+
ui.error(f"{name} failed ({head}): {result.get('error') or 'see below'}")
|
|
1140
|
+
if result.get("reachable") is not None:
|
|
1141
|
+
ui.out.print(_mark(result["reachable"]) + Text(" Reachable" if result["reachable"] else " Not reachable"))
|
|
1142
|
+
if result.get("auth"):
|
|
1143
|
+
ui.out.print(
|
|
1144
|
+
_mark(None if result["auth"] == "not_checked" else result["auth"] == "ok")
|
|
1145
|
+
+ Text(f" Auth {result['auth']}")
|
|
1146
|
+
)
|
|
1147
|
+
for tool in result.get("tools") or []:
|
|
1148
|
+
ui.out.print(_mark(tool.get("ok")) + Text(f" {tool.get('tool')} · {tool.get('ms')} ms"))
|
|
1149
|
+
|
|
1150
|
+
ui.emit(result, human)
|
|
1151
|
+
if not result.get("ok"):
|
|
1152
|
+
raise typer.Exit(1)
|
|
1153
|
+
|
|
1154
|
+
|
|
1155
|
+
@app.command("checks")
|
|
1156
|
+
def checks(
|
|
1157
|
+
name: str = typer.Argument(..., help="The connection."),
|
|
1158
|
+
limit: int = typer.Option(10, "--limit", "-n", min=1, help="How many of the newest checks."),
|
|
1159
|
+
) -> None:
|
|
1160
|
+
"""A connection's recent checks, newest first, and how often it is checked."""
|
|
1161
|
+
name = _resolve(name)
|
|
1162
|
+
data = client().get(_path(name, "/checks")) or {}
|
|
1163
|
+
rows = list(reversed(data.get("checks") or []))[:limit]
|
|
1164
|
+
|
|
1165
|
+
def human() -> None:
|
|
1166
|
+
ui.table(
|
|
1167
|
+
rows,
|
|
1168
|
+
[
|
|
1169
|
+
("When", lambda r: ui.when(r.get("at"))),
|
|
1170
|
+
(
|
|
1171
|
+
"Result",
|
|
1172
|
+
lambda r: _mark(r.get("ok")) + Text(" works" if r.get("ok") else " failed"),
|
|
1173
|
+
),
|
|
1174
|
+
("Time", lambda r: f"{r.get('ms')} ms"),
|
|
1175
|
+
("Version", lambda r: f"v{r.get('version')}"),
|
|
1176
|
+
("By", "by"),
|
|
1177
|
+
("Why", lambda r: _clip(r.get("error") or "", 80) or None),
|
|
1178
|
+
],
|
|
1179
|
+
empty="Not checked yet. Check it now: corethink connection check " + name,
|
|
1180
|
+
)
|
|
1181
|
+
if data.get("every_minutes"):
|
|
1182
|
+
ui.info(f"Checked every {data['every_minutes']} minutes.")
|
|
1183
|
+
if data.get("tools"):
|
|
1184
|
+
ui.info(f"A check calls: {', '.join(data['tools'])}.")
|
|
1185
|
+
|
|
1186
|
+
ui.emit(data, human)
|
|
1187
|
+
|
|
1188
|
+
|
|
1189
|
+
@app.command("live")
|
|
1190
|
+
def live(name: str = typer.Argument(..., help="The connection.")) -> None:
|
|
1191
|
+
"""Live calls of your recent runs through a connection: each tool's calls, failures and latency."""
|
|
1192
|
+
name = _resolve(name)
|
|
1193
|
+
data = client().get(_path(name, "/live")) or {}
|
|
1194
|
+
|
|
1195
|
+
def human() -> None:
|
|
1196
|
+
ui.table(
|
|
1197
|
+
data.get("tools") or [],
|
|
1198
|
+
[
|
|
1199
|
+
("Tool", "tool"),
|
|
1200
|
+
("Calls", "calls"),
|
|
1201
|
+
("Failed", "failed"),
|
|
1202
|
+
("p50", lambda r: f"{r.get('p50')} ms" if r.get("p50") is not None else None),
|
|
1203
|
+
("p95", lambda r: f"{r.get('p95')} ms" if r.get("p95") is not None else None),
|
|
1204
|
+
],
|
|
1205
|
+
empty="No live calls in your recent runs.",
|
|
1206
|
+
)
|
|
1207
|
+
|
|
1208
|
+
ui.emit(data, human)
|
|
1209
|
+
|
|
1210
|
+
|
|
1211
|
+
@app.command("calls")
|
|
1212
|
+
def calls(
|
|
1213
|
+
name: str = typer.Argument(..., help="The connection."),
|
|
1214
|
+
job: str = typer.Option(..., "--job", "-j", help="The run (its ID, or the start of it)."),
|
|
1215
|
+
limit: int = typer.Option(50, "--limit", "-n", min=1, help="How many of the newest calls to show."),
|
|
1216
|
+
tool: str = typer.Option(None, "--tool", help="Only this tool's calls."),
|
|
1217
|
+
) -> None:
|
|
1218
|
+
"""The calls a run made through a connection (newest last; no secrets).
|
|
1219
|
+
|
|
1220
|
+
Example: corethink connection calls orders-api --job 3f2a
|
|
1221
|
+
"""
|
|
1222
|
+
name = _resolve(name)
|
|
1223
|
+
job_id = _resolve_job(job)
|
|
1224
|
+
data = client().get(_path(name, "/calls"), job=job_id) or {}
|
|
1225
|
+
rows = [row for row in data.get("calls") or [] if not tool or row.get("tool") == tool]
|
|
1226
|
+
shown = rows[-limit:]
|
|
1227
|
+
status_style = {"ok": "green", "error": "yellow", "failed": "red", "dry_run": "cyan"}
|
|
1228
|
+
|
|
1229
|
+
def human() -> None:
|
|
1230
|
+
ui.table(
|
|
1231
|
+
shown,
|
|
1232
|
+
[
|
|
1233
|
+
("When", lambda r: ui.when(r.get("at"))),
|
|
1234
|
+
("Tool", "tool"),
|
|
1235
|
+
(
|
|
1236
|
+
"Status",
|
|
1237
|
+
lambda r: Text(
|
|
1238
|
+
str(r.get("status") or "—"),
|
|
1239
|
+
style=status_style.get(str(r.get("status")), ""),
|
|
1240
|
+
),
|
|
1241
|
+
),
|
|
1242
|
+
("HTTP", "http_status"),
|
|
1243
|
+
(
|
|
1244
|
+
"ms",
|
|
1245
|
+
lambda r: round(float(r["latency_ms"])) if r.get("latency_ms") is not None else None,
|
|
1246
|
+
),
|
|
1247
|
+
(
|
|
1248
|
+
"Arguments",
|
|
1249
|
+
lambda r: _clip(r.get("args"), 70) if r.get("args") is not None else None,
|
|
1250
|
+
),
|
|
1251
|
+
],
|
|
1252
|
+
empty="This run made no calls through it.",
|
|
1253
|
+
)
|
|
1254
|
+
total = int(data.get("total") or 0)
|
|
1255
|
+
if total > len(shown):
|
|
1256
|
+
more = " (the run kept its newest 500)" if data.get("truncated") else ""
|
|
1257
|
+
ui.info(f"Showing {len(shown)} of {total} calls{more}. --limit shows more; --json shows each in full.")
|
|
1258
|
+
|
|
1259
|
+
ui.emit({**data, "calls": shown}, human)
|
|
1260
|
+
|
|
1261
|
+
|
|
1262
|
+
app.add_typer(secret_app, name="secret")
|
|
1263
|
+
|
|
1264
|
+
|
|
1265
|
+
def register(root: typer.Typer) -> None:
|
|
1266
|
+
root.add_typer(app, name="connection", rich_help_panel="Build")
|