nexla-cli 0.2.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.
- nexla_cli/AGENTS.md +300 -0
- nexla_cli/SKILL.md +178 -0
- nexla_cli/__init__.py +247 -0
- nexla_cli/client.py +130 -0
- nexla_cli/dryrun.py +119 -0
- nexla_cli/errors.py +45 -0
- nexla_cli/login.py +42 -0
- nexla_cli/mcp_client.py +139 -0
- nexla_cli/openapi_client.py +196 -0
- nexla_cli/output.py +146 -0
- nexla_cli/py.typed +0 -0
- nexla_cli/resources/__init__.py +1 -0
- nexla_cli/resources/code_containers.py +23 -0
- nexla_cli/resources/connectors.py +125 -0
- nexla_cli/resources/context.py +22 -0
- nexla_cli/resources/credentials.py +130 -0
- nexla_cli/resources/flows.py +90 -0
- nexla_cli/resources/mcp_servers.py +103 -0
- nexla_cli/resources/metrics.py +47 -0
- nexla_cli/resources/nexsets.py +110 -0
- nexla_cli/resources/notifications.py +24 -0
- nexla_cli/resources/orgs.py +53 -0
- nexla_cli/resources/probe.py +94 -0
- nexla_cli/resources/sinks.py +264 -0
- nexla_cli/resources/sources.py +197 -0
- nexla_cli/resources/tools.py +106 -0
- nexla_cli/resources/toolsets.py +134 -0
- nexla_cli/resources/transforms.py +40 -0
- nexla_cli/resources/triage.py +206 -0
- nexla_cli/resources/users.py +32 -0
- nexla_cli/sanitize.py +83 -0
- nexla_cli/schema.py +64 -0
- nexla_cli/validate.py +99 -0
- nexla_cli-0.2.0.dist-info/METADATA +196 -0
- nexla_cli-0.2.0.dist-info/RECORD +38 -0
- nexla_cli-0.2.0.dist-info/WHEEL +4 -0
- nexla_cli-0.2.0.dist-info/entry_points.txt +2 -0
- nexla_cli-0.2.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,264 @@
|
|
|
1
|
+
"""`nexla sinks` — list / get / create / update / activate / pause / delete.
|
|
2
|
+
|
|
3
|
+
Mirrors ``routers/nexla_agent_api/sinks.py``. ``update`` maps to the
|
|
4
|
+
router's pause→apply→reactivate ``PATCH`` cycle — the CLI just calls it,
|
|
5
|
+
the API hides the mechanics.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import json as jsonlib
|
|
11
|
+
|
|
12
|
+
import typer
|
|
13
|
+
|
|
14
|
+
from .. import client, dryrun, output, validate
|
|
15
|
+
from ..errors import EXIT, CliError
|
|
16
|
+
|
|
17
|
+
app = typer.Typer(name="sinks", help="Manage Nexla data sinks.", no_args_is_help=True)
|
|
18
|
+
|
|
19
|
+
_COLUMNS = ["id", "name", "status", "connector", "kind", "nexset_id", "flow_id"]
|
|
20
|
+
|
|
21
|
+
_JSON_OPT = typer.Option(
|
|
22
|
+
None, "--json", help="Raw JSON body; merged under named options, over --params"
|
|
23
|
+
)
|
|
24
|
+
_PARAMS_OPT = typer.Option(
|
|
25
|
+
[], "--params", help="key=value body overrides (repeatable); lowest precedence"
|
|
26
|
+
)
|
|
27
|
+
_DRY_RUN_OPT = typer.Option(
|
|
28
|
+
False, "--dry-run", help="Validate locally against the live schema; fire no mutating call"
|
|
29
|
+
)
|
|
30
|
+
_SKIP_TABLE_CHECK_OPT = typer.Option(
|
|
31
|
+
False,
|
|
32
|
+
"--skip-table-check",
|
|
33
|
+
help="Skip the pre-flight DB/JDBC target-table existence check",
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
# DB/JDBC-family connectors: `sinks create` does NOT auto-provision the
|
|
37
|
+
# destination table (unlike sources). See AGENTS.md's "DB/JDBC sinks
|
|
38
|
+
# require the target table to pre-exist". Used to gate the pre-flight
|
|
39
|
+
# table check below -- not exhaustive, just the common ones.
|
|
40
|
+
_DB_KIND_CONNECTORS = {
|
|
41
|
+
"postgres",
|
|
42
|
+
"postgresql",
|
|
43
|
+
"supabase",
|
|
44
|
+
"redshift",
|
|
45
|
+
"snowflake",
|
|
46
|
+
"mysql",
|
|
47
|
+
"mariadb",
|
|
48
|
+
"sqlserver",
|
|
49
|
+
"sql_server",
|
|
50
|
+
"oracle",
|
|
51
|
+
"db2",
|
|
52
|
+
"cockroachdb",
|
|
53
|
+
"teradata",
|
|
54
|
+
"singlestore",
|
|
55
|
+
"clickhouse",
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _probe_tree_names(credential_id: int) -> list[str] | None:
|
|
60
|
+
"""Best-effort table/schema names from `probe run --action tree`.
|
|
61
|
+
|
|
62
|
+
Returns ``None`` if the probe call fails or nothing name-shaped comes
|
|
63
|
+
back -- callers must treat that as "couldn't check", never as "table
|
|
64
|
+
missing".
|
|
65
|
+
"""
|
|
66
|
+
try:
|
|
67
|
+
resp = client.request(
|
|
68
|
+
"POST",
|
|
69
|
+
"/nexla/probe",
|
|
70
|
+
json={"action": "tree", "credential_id": credential_id, "params": {}},
|
|
71
|
+
)
|
|
72
|
+
except Exception:
|
|
73
|
+
return None
|
|
74
|
+
|
|
75
|
+
names: list[str] = []
|
|
76
|
+
|
|
77
|
+
def _walk(node: object) -> None:
|
|
78
|
+
if isinstance(node, dict):
|
|
79
|
+
for key in ("name", "table", "table_name"):
|
|
80
|
+
value = node.get(key)
|
|
81
|
+
if isinstance(value, str):
|
|
82
|
+
names.append(value)
|
|
83
|
+
for value in node.values():
|
|
84
|
+
_walk(value)
|
|
85
|
+
elif isinstance(node, list):
|
|
86
|
+
for item in node:
|
|
87
|
+
_walk(item)
|
|
88
|
+
|
|
89
|
+
_walk(resp)
|
|
90
|
+
return names or None
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def _check_table_exists(credential_id: int, connector: str, config: object) -> None:
|
|
94
|
+
"""Pre-flight for DB/JDBC sinks: warn or hard-fail if the target table is missing.
|
|
95
|
+
|
|
96
|
+
Best-effort only -- probes the credential's tree (same surface as
|
|
97
|
+
`nexla probe run --action tree`) and looks for the configured
|
|
98
|
+
``table`` name among the returned nodes. Any failure or ambiguous
|
|
99
|
+
result (probe unsupported, empty tree, non-DB connector, no `table`
|
|
100
|
+
config key) degrades to a no-op or a warning; it never blocks. Only a
|
|
101
|
+
*confirmed* miss -- probe succeeded, returned names, table isn't among
|
|
102
|
+
them -- raises before the real `sinks create` call fires.
|
|
103
|
+
"""
|
|
104
|
+
if connector.lower() not in _DB_KIND_CONNECTORS or not isinstance(config, dict):
|
|
105
|
+
return
|
|
106
|
+
table = config.get("table")
|
|
107
|
+
if not isinstance(table, str) or not table:
|
|
108
|
+
return
|
|
109
|
+
|
|
110
|
+
names = _probe_tree_names(credential_id)
|
|
111
|
+
if names is None:
|
|
112
|
+
typer.echo(
|
|
113
|
+
f"WARNING: could not verify table '{table}' exists (tried "
|
|
114
|
+
f"`nexla probe run --action tree --credential-id {credential_id}`). "
|
|
115
|
+
"DB/JDBC sinks do not auto-create the destination table -- confirm "
|
|
116
|
+
"it exists before this sink runs, or it will activate fine and then "
|
|
117
|
+
"stall in runtime_status: PROCESSING with no visible error.",
|
|
118
|
+
err=True,
|
|
119
|
+
)
|
|
120
|
+
return
|
|
121
|
+
if table not in names:
|
|
122
|
+
raise CliError(
|
|
123
|
+
EXIT.VALIDATION,
|
|
124
|
+
f"table '{table}' not found via `nexla probe run --action tree "
|
|
125
|
+
f"--credential-id {credential_id}`. Create it first (matching the "
|
|
126
|
+
"nexset's output_schema columns) or pass --skip-table-check if this "
|
|
127
|
+
"connector's tree probe is unreliable.",
|
|
128
|
+
)
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
@app.command("list")
|
|
132
|
+
def list_(
|
|
133
|
+
ctx: typer.Context,
|
|
134
|
+
connector: str | None = typer.Option(None),
|
|
135
|
+
nexset_id: int | None = typer.Option(None),
|
|
136
|
+
flow_id: int | None = typer.Option(None),
|
|
137
|
+
access_role: str = typer.Option("collaborator"),
|
|
138
|
+
page: int = typer.Option(1),
|
|
139
|
+
per_page: int = typer.Option(50),
|
|
140
|
+
) -> None:
|
|
141
|
+
"""List sinks."""
|
|
142
|
+
params: dict[str, object] = {"access_role": access_role, "page": page, "per_page": per_page}
|
|
143
|
+
if connector is not None:
|
|
144
|
+
params["connector"] = connector
|
|
145
|
+
if nexset_id is not None:
|
|
146
|
+
params["nexset_id"] = nexset_id
|
|
147
|
+
if flow_id is not None:
|
|
148
|
+
params["flow_id"] = flow_id
|
|
149
|
+
if output.ctx_page_all(ctx):
|
|
150
|
+
filters = {k: v for k, v in params.items() if k not in ("page", "per_page")}
|
|
151
|
+
for item in client.paginate("/nexla/sinks", params=filters, per_page=per_page):
|
|
152
|
+
output.emit(item, mode="ndjson", fields=output.ctx_fields(ctx))
|
|
153
|
+
return
|
|
154
|
+
output.emit(
|
|
155
|
+
client.request("GET", "/nexla/sinks", params=params),
|
|
156
|
+
mode=output.ctx_mode(ctx),
|
|
157
|
+
fields=output.ctx_fields(ctx),
|
|
158
|
+
columns=_COLUMNS,
|
|
159
|
+
)
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
@app.command("get")
|
|
163
|
+
def get(ctx: typer.Context, sink_id: int) -> None:
|
|
164
|
+
"""Get one sink by id."""
|
|
165
|
+
output.emit(
|
|
166
|
+
client.request("GET", f"/nexla/sinks/{sink_id}"),
|
|
167
|
+
mode=output.ctx_mode(ctx),
|
|
168
|
+
fields=output.ctx_fields(ctx),
|
|
169
|
+
)
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
@app.command("create")
|
|
173
|
+
def create(
|
|
174
|
+
ctx: typer.Context,
|
|
175
|
+
name: str = typer.Option(...),
|
|
176
|
+
nexset_id: int = typer.Option(...),
|
|
177
|
+
credential_id: int = typer.Option(...),
|
|
178
|
+
connector: str = typer.Option(...),
|
|
179
|
+
endpoint: str | None = typer.Option(None),
|
|
180
|
+
config: str = typer.Option("{}", help="JSON object of connector-specific fields"),
|
|
181
|
+
json_body: str | None = _JSON_OPT,
|
|
182
|
+
params: list[str] = _PARAMS_OPT,
|
|
183
|
+
dry_run: bool = _DRY_RUN_OPT,
|
|
184
|
+
skip_table_check: bool = _SKIP_TABLE_CHECK_OPT,
|
|
185
|
+
) -> None:
|
|
186
|
+
"""Create and activate a sink."""
|
|
187
|
+
named: dict[str, object] = {
|
|
188
|
+
"name": name,
|
|
189
|
+
"nexset_id": nexset_id,
|
|
190
|
+
"credential_id": credential_id,
|
|
191
|
+
"connector": connector,
|
|
192
|
+
"endpoint": endpoint,
|
|
193
|
+
"config": jsonlib.loads(config) if config != "{}" else None,
|
|
194
|
+
}
|
|
195
|
+
body = validate.build_body(named, json_body, params)
|
|
196
|
+
if dry_run:
|
|
197
|
+
dryrun.run_dry_run(resource="sinks", verb="create", body=body)
|
|
198
|
+
if not skip_table_check:
|
|
199
|
+
_check_table_exists(credential_id, connector, body.get("config"))
|
|
200
|
+
output.emit(
|
|
201
|
+
client.request("POST", "/nexla/sinks", json=body),
|
|
202
|
+
mode=output.ctx_mode(ctx),
|
|
203
|
+
fields=output.ctx_fields(ctx),
|
|
204
|
+
)
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
@app.command("update")
|
|
208
|
+
def update(
|
|
209
|
+
ctx: typer.Context,
|
|
210
|
+
sink_id: int,
|
|
211
|
+
name: str | None = typer.Option(None),
|
|
212
|
+
description: str | None = typer.Option(None),
|
|
213
|
+
config: str | None = typer.Option(None, help="JSON object"),
|
|
214
|
+
json_body: str | None = _JSON_OPT,
|
|
215
|
+
params: list[str] = _PARAMS_OPT,
|
|
216
|
+
dry_run: bool = _DRY_RUN_OPT,
|
|
217
|
+
) -> None:
|
|
218
|
+
"""Update a sink's name/description/config."""
|
|
219
|
+
named: dict[str, object] = {
|
|
220
|
+
"name": name,
|
|
221
|
+
"description": description,
|
|
222
|
+
"config": jsonlib.loads(config) if config is not None else None,
|
|
223
|
+
}
|
|
224
|
+
body = validate.build_body(named, json_body, params)
|
|
225
|
+
if dry_run:
|
|
226
|
+
dryrun.run_dry_run(resource="sinks", verb="update", body=body)
|
|
227
|
+
output.emit(
|
|
228
|
+
client.request("PATCH", f"/nexla/sinks/{sink_id}", json=body),
|
|
229
|
+
mode=output.ctx_mode(ctx),
|
|
230
|
+
fields=output.ctx_fields(ctx),
|
|
231
|
+
)
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
@app.command("activate")
|
|
235
|
+
def activate(ctx: typer.Context, sink_id: int, dry_run: bool = _DRY_RUN_OPT) -> None:
|
|
236
|
+
"""Activate a paused sink."""
|
|
237
|
+
if dry_run:
|
|
238
|
+
dryrun.run_dry_run(resource="sinks", verb="activate", body={})
|
|
239
|
+
output.emit(
|
|
240
|
+
client.request("POST", f"/nexla/sinks/{sink_id}/activate"),
|
|
241
|
+
mode=output.ctx_mode(ctx),
|
|
242
|
+
fields=output.ctx_fields(ctx),
|
|
243
|
+
)
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
@app.command("pause")
|
|
247
|
+
def pause(ctx: typer.Context, sink_id: int, dry_run: bool = _DRY_RUN_OPT) -> None:
|
|
248
|
+
"""Pause an active sink."""
|
|
249
|
+
if dry_run:
|
|
250
|
+
dryrun.run_dry_run(resource="sinks", verb="pause", body={})
|
|
251
|
+
output.emit(
|
|
252
|
+
client.request("POST", f"/nexla/sinks/{sink_id}/pause"),
|
|
253
|
+
mode=output.ctx_mode(ctx),
|
|
254
|
+
fields=output.ctx_fields(ctx),
|
|
255
|
+
)
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
@app.command("delete")
|
|
259
|
+
def delete(sink_id: int, dry_run: bool = _DRY_RUN_OPT) -> None:
|
|
260
|
+
"""Delete a sink."""
|
|
261
|
+
if dry_run:
|
|
262
|
+
dryrun.run_dry_run(resource="sinks", verb="delete", body={})
|
|
263
|
+
client.request("DELETE", f"/nexla/sinks/{sink_id}")
|
|
264
|
+
typer.echo(f"deleted sink {sink_id}")
|
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
"""`nexla sources` — list / get / create / update / activate / pause / delete
|
|
2
|
+
/ sample / file-upload.
|
|
3
|
+
|
|
4
|
+
Mirrors ``routers/nexla_agent_api/sources.py``.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import json as jsonlib
|
|
10
|
+
|
|
11
|
+
import typer
|
|
12
|
+
|
|
13
|
+
from .. import client, dryrun, output, validate
|
|
14
|
+
|
|
15
|
+
app = typer.Typer(name="sources", help="Manage Nexla data sources.", no_args_is_help=True)
|
|
16
|
+
|
|
17
|
+
_COLUMNS = ["id", "name", "status", "connector", "kind", "flow_id"]
|
|
18
|
+
|
|
19
|
+
_JSON_OPT = typer.Option(
|
|
20
|
+
None, "--json", help="Raw JSON body; merged under named options, over --params"
|
|
21
|
+
)
|
|
22
|
+
_PARAMS_OPT = typer.Option(
|
|
23
|
+
[], "--params", help="key=value body overrides (repeatable); lowest precedence"
|
|
24
|
+
)
|
|
25
|
+
_DRY_RUN_OPT = typer.Option(
|
|
26
|
+
False, "--dry-run", help="Validate locally against the live schema; fire no mutating call"
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@app.command("list")
|
|
31
|
+
def list_(
|
|
32
|
+
ctx: typer.Context,
|
|
33
|
+
connector: str | None = typer.Option(None),
|
|
34
|
+
flow_id: int | None = typer.Option(None),
|
|
35
|
+
access_role: str = typer.Option("collaborator"),
|
|
36
|
+
page: int = typer.Option(1),
|
|
37
|
+
per_page: int = typer.Option(50),
|
|
38
|
+
) -> None:
|
|
39
|
+
"""List sources."""
|
|
40
|
+
params: dict[str, object] = {"access_role": access_role, "page": page, "per_page": per_page}
|
|
41
|
+
if connector is not None:
|
|
42
|
+
params["connector"] = connector
|
|
43
|
+
if flow_id is not None:
|
|
44
|
+
params["flow_id"] = flow_id
|
|
45
|
+
if output.ctx_page_all(ctx):
|
|
46
|
+
filters = {k: v for k, v in params.items() if k not in ("page", "per_page")}
|
|
47
|
+
for item in client.paginate("/nexla/sources", params=filters, per_page=per_page):
|
|
48
|
+
output.emit(item, mode="ndjson", fields=output.ctx_fields(ctx))
|
|
49
|
+
return
|
|
50
|
+
output.emit(
|
|
51
|
+
client.request("GET", "/nexla/sources", params=params),
|
|
52
|
+
mode=output.ctx_mode(ctx),
|
|
53
|
+
fields=output.ctx_fields(ctx),
|
|
54
|
+
columns=_COLUMNS,
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
@app.command("get")
|
|
59
|
+
def get(ctx: typer.Context, source_id: int) -> None:
|
|
60
|
+
"""Get one source by id."""
|
|
61
|
+
output.emit(
|
|
62
|
+
client.request("GET", f"/nexla/sources/{source_id}"),
|
|
63
|
+
mode=output.ctx_mode(ctx),
|
|
64
|
+
fields=output.ctx_fields(ctx),
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
@app.command("create")
|
|
69
|
+
def create(
|
|
70
|
+
ctx: typer.Context,
|
|
71
|
+
name: str = typer.Option(...),
|
|
72
|
+
connector: str = typer.Option(...),
|
|
73
|
+
description: str | None = typer.Option(None),
|
|
74
|
+
credential_id: int | None = typer.Option(None),
|
|
75
|
+
endpoint: str | None = typer.Option(None),
|
|
76
|
+
mode: str | None = typer.Option(None, help="db connectors only: 'default' or 'query'"),
|
|
77
|
+
config: str = typer.Option("{}", help="JSON object of connector-specific fields"),
|
|
78
|
+
schedule: str = typer.Option("recurring", help="'recurring' or 'once'"),
|
|
79
|
+
json_body: str | None = _JSON_OPT,
|
|
80
|
+
params: list[str] = _PARAMS_OPT,
|
|
81
|
+
dry_run: bool = _DRY_RUN_OPT,
|
|
82
|
+
) -> None:
|
|
83
|
+
"""Create and activate a source."""
|
|
84
|
+
named: dict[str, object] = {
|
|
85
|
+
"name": name,
|
|
86
|
+
"description": description,
|
|
87
|
+
"credential_id": credential_id,
|
|
88
|
+
"connector": connector,
|
|
89
|
+
"endpoint": endpoint,
|
|
90
|
+
"mode": mode,
|
|
91
|
+
"config": jsonlib.loads(config) if config != "{}" else None,
|
|
92
|
+
"schedule": schedule,
|
|
93
|
+
}
|
|
94
|
+
body = validate.build_body(named, json_body, params)
|
|
95
|
+
if dry_run:
|
|
96
|
+
dryrun.run_dry_run(resource="sources", verb="create", body=body)
|
|
97
|
+
output.emit(
|
|
98
|
+
client.request("POST", "/nexla/sources", json=body),
|
|
99
|
+
mode=output.ctx_mode(ctx),
|
|
100
|
+
fields=output.ctx_fields(ctx),
|
|
101
|
+
)
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
@app.command("update")
|
|
105
|
+
def update(
|
|
106
|
+
ctx: typer.Context,
|
|
107
|
+
source_id: int,
|
|
108
|
+
name: str | None = typer.Option(None),
|
|
109
|
+
description: str | None = typer.Option(None),
|
|
110
|
+
config: str | None = typer.Option(None, help="JSON object"),
|
|
111
|
+
json_body: str | None = _JSON_OPT,
|
|
112
|
+
params: list[str] = _PARAMS_OPT,
|
|
113
|
+
dry_run: bool = _DRY_RUN_OPT,
|
|
114
|
+
) -> None:
|
|
115
|
+
"""Update a source's name/description/config."""
|
|
116
|
+
named: dict[str, object] = {
|
|
117
|
+
"name": name,
|
|
118
|
+
"description": description,
|
|
119
|
+
"config": jsonlib.loads(config) if config is not None else None,
|
|
120
|
+
}
|
|
121
|
+
body = validate.build_body(named, json_body, params)
|
|
122
|
+
if dry_run:
|
|
123
|
+
dryrun.run_dry_run(resource="sources", verb="update", body=body)
|
|
124
|
+
output.emit(
|
|
125
|
+
client.request("PATCH", f"/nexla/sources/{source_id}", json=body),
|
|
126
|
+
mode=output.ctx_mode(ctx),
|
|
127
|
+
fields=output.ctx_fields(ctx),
|
|
128
|
+
)
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
@app.command("activate")
|
|
132
|
+
def activate(ctx: typer.Context, source_id: int, dry_run: bool = _DRY_RUN_OPT) -> None:
|
|
133
|
+
"""Activate a paused source."""
|
|
134
|
+
if dry_run:
|
|
135
|
+
dryrun.run_dry_run(resource="sources", verb="activate", body={})
|
|
136
|
+
output.emit(
|
|
137
|
+
client.request("POST", f"/nexla/sources/{source_id}/activate"),
|
|
138
|
+
mode=output.ctx_mode(ctx),
|
|
139
|
+
fields=output.ctx_fields(ctx),
|
|
140
|
+
)
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
@app.command("pause")
|
|
144
|
+
def pause(ctx: typer.Context, source_id: int, dry_run: bool = _DRY_RUN_OPT) -> None:
|
|
145
|
+
"""Pause an active source."""
|
|
146
|
+
if dry_run:
|
|
147
|
+
dryrun.run_dry_run(resource="sources", verb="pause", body={})
|
|
148
|
+
output.emit(
|
|
149
|
+
client.request("POST", f"/nexla/sources/{source_id}/pause"),
|
|
150
|
+
mode=output.ctx_mode(ctx),
|
|
151
|
+
fields=output.ctx_fields(ctx),
|
|
152
|
+
)
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
@app.command("delete")
|
|
156
|
+
def delete(source_id: int, dry_run: bool = _DRY_RUN_OPT) -> None:
|
|
157
|
+
"""Delete a source."""
|
|
158
|
+
if dry_run:
|
|
159
|
+
dryrun.run_dry_run(resource="sources", verb="delete", body={})
|
|
160
|
+
client.request("DELETE", f"/nexla/sources/{source_id}")
|
|
161
|
+
typer.echo(f"deleted source {source_id}")
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
@app.command("sample")
|
|
165
|
+
def sample(
|
|
166
|
+
ctx: typer.Context,
|
|
167
|
+
source_id: int,
|
|
168
|
+
payload: str = typer.Option(..., help="JSON payload to POST"),
|
|
169
|
+
dry_run: bool = _DRY_RUN_OPT,
|
|
170
|
+
) -> None:
|
|
171
|
+
"""Post a sample payload to a webhook source."""
|
|
172
|
+
body = {"payload": jsonlib.loads(payload)}
|
|
173
|
+
if dry_run:
|
|
174
|
+
dryrun.run_dry_run(resource="sources", verb="sample", body=body)
|
|
175
|
+
output.emit(
|
|
176
|
+
client.request("POST", f"/nexla/sources/{source_id}/sample", json=body),
|
|
177
|
+
mode=output.ctx_mode(ctx),
|
|
178
|
+
fields=output.ctx_fields(ctx),
|
|
179
|
+
)
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
@app.command("file-upload")
|
|
183
|
+
def file_upload(
|
|
184
|
+
ctx: typer.Context,
|
|
185
|
+
source_id: int,
|
|
186
|
+
path: list[str] = typer.Option(..., help="Absolute /workspace path; repeatable"),
|
|
187
|
+
dry_run: bool = _DRY_RUN_OPT,
|
|
188
|
+
) -> None:
|
|
189
|
+
"""Push sandbox files to a file-upload source."""
|
|
190
|
+
body = {"files": [{"path": p} for p in path]}
|
|
191
|
+
if dry_run:
|
|
192
|
+
dryrun.run_dry_run(resource="sources", verb="file-upload", body=body)
|
|
193
|
+
output.emit(
|
|
194
|
+
client.request("POST", f"/nexla/sources/{source_id}/file_upload", json=body),
|
|
195
|
+
mode=output.ctx_mode(ctx),
|
|
196
|
+
fields=output.ctx_fields(ctx),
|
|
197
|
+
)
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
"""`nexla tools` — list / get / set-runtime-config / clear-runtime-config / delete.
|
|
2
|
+
|
|
3
|
+
Mirrors ``routers/nexla_agent_api/tools.py``.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import json as jsonlib
|
|
9
|
+
|
|
10
|
+
import typer
|
|
11
|
+
|
|
12
|
+
from .. import client, dryrun, output, validate
|
|
13
|
+
|
|
14
|
+
app = typer.Typer(name="tools", help="Manage Nexla tools.", no_args_is_help=True)
|
|
15
|
+
|
|
16
|
+
_COLUMNS = ["id", "name", "kind", "status", "nexset_id"]
|
|
17
|
+
|
|
18
|
+
_JSON_OPT = typer.Option(
|
|
19
|
+
None, "--json", help="Raw JSON body; merged under named options, over --params"
|
|
20
|
+
)
|
|
21
|
+
_PARAMS_OPT = typer.Option(
|
|
22
|
+
[], "--params", help="key=value body overrides (repeatable); lowest precedence"
|
|
23
|
+
)
|
|
24
|
+
_DRY_RUN_OPT = typer.Option(
|
|
25
|
+
False, "--dry-run", help="Validate locally against the live schema; fire no mutating call"
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@app.command("list")
|
|
30
|
+
def list_(
|
|
31
|
+
ctx: typer.Context, page: int = typer.Option(1), per_page: int = typer.Option(50)
|
|
32
|
+
) -> None:
|
|
33
|
+
"""List tools."""
|
|
34
|
+
if output.ctx_page_all(ctx):
|
|
35
|
+
for item in client.paginate("/nexla/tools", per_page=per_page):
|
|
36
|
+
output.emit(item, mode="ndjson", fields=output.ctx_fields(ctx))
|
|
37
|
+
return
|
|
38
|
+
params = {"page": page, "per_page": per_page}
|
|
39
|
+
output.emit(
|
|
40
|
+
client.request("GET", "/nexla/tools", params=params),
|
|
41
|
+
mode=output.ctx_mode(ctx),
|
|
42
|
+
fields=output.ctx_fields(ctx),
|
|
43
|
+
columns=_COLUMNS,
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
@app.command("get")
|
|
48
|
+
def get(ctx: typer.Context, tool_id: int) -> None:
|
|
49
|
+
"""Get one tool by id."""
|
|
50
|
+
output.emit(
|
|
51
|
+
client.request("GET", f"/nexla/tools/{tool_id}"),
|
|
52
|
+
mode=output.ctx_mode(ctx),
|
|
53
|
+
fields=output.ctx_fields(ctx),
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
@app.command("set-runtime-config")
|
|
58
|
+
def set_runtime_config(
|
|
59
|
+
ctx: typer.Context,
|
|
60
|
+
tool_id: int,
|
|
61
|
+
strategy: str = typer.Option(..., help="'auto' or 'mapped'"),
|
|
62
|
+
connector_name: str = typer.Option(...),
|
|
63
|
+
connector_type: str = typer.Option(...),
|
|
64
|
+
credential_mappings: str | None = typer.Option(
|
|
65
|
+
None, help="JSON array of {user_id, credential_id}; required when strategy=mapped"
|
|
66
|
+
),
|
|
67
|
+
json_body: str | None = _JSON_OPT,
|
|
68
|
+
params: list[str] = _PARAMS_OPT,
|
|
69
|
+
dry_run: bool = _DRY_RUN_OPT,
|
|
70
|
+
) -> None:
|
|
71
|
+
"""Set a tool's credential strategy."""
|
|
72
|
+
named: dict[str, object] = {
|
|
73
|
+
"strategy": strategy,
|
|
74
|
+
"connector_name": connector_name,
|
|
75
|
+
"connector_type": connector_type,
|
|
76
|
+
"credential_mappings": jsonlib.loads(credential_mappings) if credential_mappings else None,
|
|
77
|
+
}
|
|
78
|
+
body = validate.build_body(named, json_body, params)
|
|
79
|
+
if dry_run:
|
|
80
|
+
dryrun.run_dry_run(resource="tools", verb="set-runtime-config", body=body)
|
|
81
|
+
output.emit(
|
|
82
|
+
client.request("PATCH", f"/nexla/tools/{tool_id}/runtime-config", json=body),
|
|
83
|
+
mode=output.ctx_mode(ctx),
|
|
84
|
+
fields=output.ctx_fields(ctx),
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
@app.command("clear-runtime-config")
|
|
89
|
+
def clear_runtime_config(ctx: typer.Context, tool_id: int, dry_run: bool = _DRY_RUN_OPT) -> None:
|
|
90
|
+
"""Clear a tool's runtime config."""
|
|
91
|
+
if dry_run:
|
|
92
|
+
dryrun.run_dry_run(resource="tools", verb="clear-runtime-config", body={})
|
|
93
|
+
output.emit(
|
|
94
|
+
client.request("DELETE", f"/nexla/tools/{tool_id}/runtime-config"),
|
|
95
|
+
mode=output.ctx_mode(ctx),
|
|
96
|
+
fields=output.ctx_fields(ctx),
|
|
97
|
+
)
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
@app.command("delete")
|
|
101
|
+
def delete(tool_id: int, dry_run: bool = _DRY_RUN_OPT) -> None:
|
|
102
|
+
"""Delete a tool."""
|
|
103
|
+
if dry_run:
|
|
104
|
+
dryrun.run_dry_run(resource="tools", verb="delete", body={})
|
|
105
|
+
client.request("DELETE", f"/nexla/tools/{tool_id}")
|
|
106
|
+
typer.echo(f"deleted tool {tool_id}")
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
"""`nexla toolsets` — list / get / create / update / delete / add-nexsets.
|
|
2
|
+
|
|
3
|
+
Mirrors ``routers/nexla_agent_api/toolsets.py``.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import typer
|
|
9
|
+
|
|
10
|
+
from .. import client, dryrun, output, validate
|
|
11
|
+
|
|
12
|
+
app = typer.Typer(name="toolsets", help="Manage Nexla toolsets.", no_args_is_help=True)
|
|
13
|
+
|
|
14
|
+
_COLUMNS = ["id", "name", "status", "mcp_gateway_enabled", "tool_count"]
|
|
15
|
+
|
|
16
|
+
_JSON_OPT = typer.Option(
|
|
17
|
+
None, "--json", help="Raw JSON body; merged under named options, over --params"
|
|
18
|
+
)
|
|
19
|
+
_PARAMS_OPT = typer.Option(
|
|
20
|
+
[], "--params", help="key=value body overrides (repeatable); lowest precedence"
|
|
21
|
+
)
|
|
22
|
+
_DRY_RUN_OPT = typer.Option(
|
|
23
|
+
False, "--dry-run", help="Validate locally against the live schema; fire no mutating call"
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@app.command("list")
|
|
28
|
+
def list_(
|
|
29
|
+
ctx: typer.Context, page: int = typer.Option(1), per_page: int = typer.Option(50)
|
|
30
|
+
) -> None:
|
|
31
|
+
"""List toolsets."""
|
|
32
|
+
if output.ctx_page_all(ctx):
|
|
33
|
+
for item in client.paginate("/nexla/toolsets", per_page=per_page):
|
|
34
|
+
output.emit(item, mode="ndjson", fields=output.ctx_fields(ctx))
|
|
35
|
+
return
|
|
36
|
+
params = {"page": page, "per_page": per_page}
|
|
37
|
+
output.emit(
|
|
38
|
+
client.request("GET", "/nexla/toolsets", params=params),
|
|
39
|
+
mode=output.ctx_mode(ctx),
|
|
40
|
+
fields=output.ctx_fields(ctx),
|
|
41
|
+
columns=_COLUMNS,
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
@app.command("get")
|
|
46
|
+
def get(ctx: typer.Context, toolset_id: int) -> None:
|
|
47
|
+
"""Get one toolset by id."""
|
|
48
|
+
output.emit(
|
|
49
|
+
client.request("GET", f"/nexla/toolsets/{toolset_id}"),
|
|
50
|
+
mode=output.ctx_mode(ctx),
|
|
51
|
+
fields=output.ctx_fields(ctx),
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
@app.command("create")
|
|
56
|
+
def create(
|
|
57
|
+
ctx: typer.Context,
|
|
58
|
+
name: str = typer.Option(...),
|
|
59
|
+
nexset_id: list[int] = typer.Option(..., help="Repeatable; at least one nexset id"),
|
|
60
|
+
description: str | None = typer.Option(None),
|
|
61
|
+
mcp_gateway_enabled: bool = typer.Option(False),
|
|
62
|
+
json_body: str | None = _JSON_OPT,
|
|
63
|
+
params: list[str] = _PARAMS_OPT,
|
|
64
|
+
dry_run: bool = _DRY_RUN_OPT,
|
|
65
|
+
) -> None:
|
|
66
|
+
"""Create a toolset."""
|
|
67
|
+
named: dict[str, object] = {
|
|
68
|
+
"name": name,
|
|
69
|
+
"description": description,
|
|
70
|
+
"nexset_ids": list(nexset_id),
|
|
71
|
+
"mcp_gateway_enabled": mcp_gateway_enabled,
|
|
72
|
+
}
|
|
73
|
+
body = validate.build_body(named, json_body, params)
|
|
74
|
+
if dry_run:
|
|
75
|
+
dryrun.run_dry_run(resource="toolsets", verb="create", body=body)
|
|
76
|
+
output.emit(
|
|
77
|
+
client.request("POST", "/nexla/toolsets", json=body),
|
|
78
|
+
mode=output.ctx_mode(ctx),
|
|
79
|
+
fields=output.ctx_fields(ctx),
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
@app.command("update")
|
|
84
|
+
def update(
|
|
85
|
+
ctx: typer.Context,
|
|
86
|
+
toolset_id: int,
|
|
87
|
+
name: str | None = typer.Option(None),
|
|
88
|
+
description: str | None = typer.Option(None),
|
|
89
|
+
mcp_gateway_enabled: bool | None = typer.Option(None),
|
|
90
|
+
json_body: str | None = _JSON_OPT,
|
|
91
|
+
params: list[str] = _PARAMS_OPT,
|
|
92
|
+
dry_run: bool = _DRY_RUN_OPT,
|
|
93
|
+
) -> None:
|
|
94
|
+
"""Update a toolset."""
|
|
95
|
+
named: dict[str, object] = {
|
|
96
|
+
"name": name,
|
|
97
|
+
"description": description,
|
|
98
|
+
"mcp_gateway_enabled": mcp_gateway_enabled,
|
|
99
|
+
}
|
|
100
|
+
body = validate.build_body(named, json_body, params)
|
|
101
|
+
if dry_run:
|
|
102
|
+
dryrun.run_dry_run(resource="toolsets", verb="update", body=body)
|
|
103
|
+
output.emit(
|
|
104
|
+
client.request("PATCH", f"/nexla/toolsets/{toolset_id}", json=body),
|
|
105
|
+
mode=output.ctx_mode(ctx),
|
|
106
|
+
fields=output.ctx_fields(ctx),
|
|
107
|
+
)
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
@app.command("delete")
|
|
111
|
+
def delete(toolset_id: int, dry_run: bool = _DRY_RUN_OPT) -> None:
|
|
112
|
+
"""Delete a toolset."""
|
|
113
|
+
if dry_run:
|
|
114
|
+
dryrun.run_dry_run(resource="toolsets", verb="delete", body={})
|
|
115
|
+
client.request("DELETE", f"/nexla/toolsets/{toolset_id}")
|
|
116
|
+
typer.echo(f"deleted toolset {toolset_id}")
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
@app.command("add-nexsets")
|
|
120
|
+
def add_nexsets(
|
|
121
|
+
ctx: typer.Context,
|
|
122
|
+
toolset_id: int,
|
|
123
|
+
nexset_id: list[int] = typer.Option(..., help="Repeatable; at least one nexset id"),
|
|
124
|
+
dry_run: bool = _DRY_RUN_OPT,
|
|
125
|
+
) -> None:
|
|
126
|
+
"""Add nexsets to an existing toolset."""
|
|
127
|
+
body = {"nexset_ids": list(nexset_id)}
|
|
128
|
+
if dry_run:
|
|
129
|
+
dryrun.run_dry_run(resource="toolsets", verb="add-nexsets", body=body)
|
|
130
|
+
output.emit(
|
|
131
|
+
client.request("POST", f"/nexla/toolsets/{toolset_id}/nexsets", json=body),
|
|
132
|
+
mode=output.ctx_mode(ctx),
|
|
133
|
+
fields=output.ctx_fields(ctx),
|
|
134
|
+
)
|