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.
@@ -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
+ )