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,130 @@
|
|
|
1
|
+
"""`nexla credentials` — list / get / create / update / delete.
|
|
2
|
+
|
|
3
|
+
Mirrors ``routers/nexla_agent_api/credentials.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="credentials", help="Manage Nexla credentials.", no_args_is_help=True)
|
|
15
|
+
|
|
16
|
+
_COLUMNS = ["id", "name", "connector", "kind", "auth_mode"]
|
|
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,
|
|
32
|
+
connector: str | None = typer.Option(None),
|
|
33
|
+
kind: str | None = typer.Option(None),
|
|
34
|
+
access_role: str = typer.Option("collaborator"),
|
|
35
|
+
page: int = typer.Option(1),
|
|
36
|
+
per_page: int = typer.Option(50),
|
|
37
|
+
) -> None:
|
|
38
|
+
"""List credentials."""
|
|
39
|
+
params: dict[str, object] = {"access_role": access_role, "page": page, "per_page": per_page}
|
|
40
|
+
if connector is not None:
|
|
41
|
+
params["connector"] = connector
|
|
42
|
+
if kind is not None:
|
|
43
|
+
params["kind"] = kind
|
|
44
|
+
if output.ctx_page_all(ctx):
|
|
45
|
+
filters = {k: v for k, v in params.items() if k not in ("page", "per_page")}
|
|
46
|
+
for item in client.paginate("/nexla/credentials", params=filters, per_page=per_page):
|
|
47
|
+
output.emit(item, mode="ndjson", fields=output.ctx_fields(ctx))
|
|
48
|
+
return
|
|
49
|
+
output.emit(
|
|
50
|
+
client.request("GET", "/nexla/credentials", params=params),
|
|
51
|
+
mode=output.ctx_mode(ctx),
|
|
52
|
+
fields=output.ctx_fields(ctx),
|
|
53
|
+
columns=_COLUMNS,
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
@app.command("get")
|
|
58
|
+
def get(ctx: typer.Context, credential_id: int) -> None:
|
|
59
|
+
"""Get one credential by id."""
|
|
60
|
+
output.emit(
|
|
61
|
+
client.request("GET", f"/nexla/credentials/{credential_id}"),
|
|
62
|
+
mode=output.ctx_mode(ctx),
|
|
63
|
+
fields=output.ctx_fields(ctx),
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
@app.command("create")
|
|
68
|
+
def create(
|
|
69
|
+
ctx: typer.Context,
|
|
70
|
+
name: str = typer.Option(...),
|
|
71
|
+
connector: str = typer.Option(...),
|
|
72
|
+
description: str | None = typer.Option(None),
|
|
73
|
+
auth_mode: str | None = typer.Option(None),
|
|
74
|
+
config: str = typer.Option("{}", help="JSON object of connector-specific fields"),
|
|
75
|
+
json_body: str | None = _JSON_OPT,
|
|
76
|
+
params: list[str] = _PARAMS_OPT,
|
|
77
|
+
dry_run: bool = _DRY_RUN_OPT,
|
|
78
|
+
) -> None:
|
|
79
|
+
"""Create a credential."""
|
|
80
|
+
named: dict[str, object] = {
|
|
81
|
+
"name": name,
|
|
82
|
+
"connector": connector,
|
|
83
|
+
"description": description,
|
|
84
|
+
"auth_mode": auth_mode,
|
|
85
|
+
"config": jsonlib.loads(config) if config != "{}" else None,
|
|
86
|
+
}
|
|
87
|
+
body = validate.build_body(named, json_body, params)
|
|
88
|
+
if dry_run:
|
|
89
|
+
dryrun.run_dry_run(resource="credentials", verb="create", body=body)
|
|
90
|
+
output.emit(
|
|
91
|
+
client.request("POST", "/nexla/credentials", json=body),
|
|
92
|
+
mode=output.ctx_mode(ctx),
|
|
93
|
+
fields=output.ctx_fields(ctx),
|
|
94
|
+
)
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
@app.command("update")
|
|
98
|
+
def update(
|
|
99
|
+
ctx: typer.Context,
|
|
100
|
+
credential_id: int,
|
|
101
|
+
name: str | None = typer.Option(None),
|
|
102
|
+
description: str | None = typer.Option(None),
|
|
103
|
+
config: str | None = typer.Option(None, help="JSON object"),
|
|
104
|
+
json_body: str | None = _JSON_OPT,
|
|
105
|
+
params: list[str] = _PARAMS_OPT,
|
|
106
|
+
dry_run: bool = _DRY_RUN_OPT,
|
|
107
|
+
) -> None:
|
|
108
|
+
"""Update a credential."""
|
|
109
|
+
named: dict[str, object] = {
|
|
110
|
+
"name": name,
|
|
111
|
+
"description": description,
|
|
112
|
+
"config": jsonlib.loads(config) if config is not None else None,
|
|
113
|
+
}
|
|
114
|
+
body = validate.build_body(named, json_body, params)
|
|
115
|
+
if dry_run:
|
|
116
|
+
dryrun.run_dry_run(resource="credentials", verb="update", body=body)
|
|
117
|
+
output.emit(
|
|
118
|
+
client.request("PATCH", f"/nexla/credentials/{credential_id}", json=body),
|
|
119
|
+
mode=output.ctx_mode(ctx),
|
|
120
|
+
fields=output.ctx_fields(ctx),
|
|
121
|
+
)
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
@app.command("delete")
|
|
125
|
+
def delete(credential_id: int, dry_run: bool = _DRY_RUN_OPT) -> None:
|
|
126
|
+
"""Delete a credential."""
|
|
127
|
+
if dry_run:
|
|
128
|
+
dryrun.run_dry_run(resource="credentials", verb="delete", body={})
|
|
129
|
+
client.request("DELETE", f"/nexla/credentials/{credential_id}")
|
|
130
|
+
typer.echo(f"deleted credential {credential_id}")
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
"""`nexla flows` — list / get / activate / pause / delete.
|
|
2
|
+
|
|
3
|
+
Mirrors ``routers/nexla_agent_api/flows.py``. No create/update — flows are
|
|
4
|
+
assembled implicitly from source/nexset/sink creation.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import typer
|
|
10
|
+
|
|
11
|
+
from .. import client, dryrun, output
|
|
12
|
+
|
|
13
|
+
app = typer.Typer(name="flows", help="Manage Nexla flows.", no_args_is_help=True)
|
|
14
|
+
|
|
15
|
+
_COLUMNS = ["id", "name", "status", "source_connector"]
|
|
16
|
+
|
|
17
|
+
_DRY_RUN_OPT = typer.Option(
|
|
18
|
+
False, "--dry-run", help="Validate locally against the live schema; fire no mutating call"
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@app.command("list")
|
|
23
|
+
def list_(
|
|
24
|
+
ctx: typer.Context,
|
|
25
|
+
status_: str | None = typer.Option(None, "--status"),
|
|
26
|
+
q: str | None = typer.Option(None),
|
|
27
|
+
access_role: str = typer.Option("collaborator"),
|
|
28
|
+
page: int = typer.Option(1),
|
|
29
|
+
per_page: int = typer.Option(50),
|
|
30
|
+
) -> None:
|
|
31
|
+
"""List flows."""
|
|
32
|
+
params: dict[str, object] = {"access_role": access_role, "page": page, "per_page": per_page}
|
|
33
|
+
if status_ is not None:
|
|
34
|
+
params["status"] = status_
|
|
35
|
+
if q is not None:
|
|
36
|
+
params["q"] = q
|
|
37
|
+
if output.ctx_page_all(ctx):
|
|
38
|
+
filters = {k: v for k, v in params.items() if k not in ("page", "per_page")}
|
|
39
|
+
for item in client.paginate("/nexla/flows", params=filters, per_page=per_page):
|
|
40
|
+
output.emit(item, mode="ndjson", fields=output.ctx_fields(ctx))
|
|
41
|
+
return
|
|
42
|
+
output.emit(
|
|
43
|
+
client.request("GET", "/nexla/flows", params=params),
|
|
44
|
+
mode=output.ctx_mode(ctx),
|
|
45
|
+
fields=output.ctx_fields(ctx),
|
|
46
|
+
columns=_COLUMNS,
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
@app.command("get")
|
|
51
|
+
def get(ctx: typer.Context, flow_id: int) -> None:
|
|
52
|
+
"""Get one flow, including its full DAG."""
|
|
53
|
+
output.emit(
|
|
54
|
+
client.request("GET", f"/nexla/flows/{flow_id}"),
|
|
55
|
+
mode=output.ctx_mode(ctx),
|
|
56
|
+
fields=output.ctx_fields(ctx),
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
@app.command("activate")
|
|
61
|
+
def activate(ctx: typer.Context, flow_id: int, dry_run: bool = _DRY_RUN_OPT) -> None:
|
|
62
|
+
"""Activate a flow."""
|
|
63
|
+
if dry_run:
|
|
64
|
+
dryrun.run_dry_run(resource="flows", verb="activate", body={})
|
|
65
|
+
output.emit(
|
|
66
|
+
client.request("PUT", f"/nexla/flows/{flow_id}/activate"),
|
|
67
|
+
mode=output.ctx_mode(ctx),
|
|
68
|
+
fields=output.ctx_fields(ctx),
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
@app.command("pause")
|
|
73
|
+
def pause(ctx: typer.Context, flow_id: int, dry_run: bool = _DRY_RUN_OPT) -> None:
|
|
74
|
+
"""Pause a flow."""
|
|
75
|
+
if dry_run:
|
|
76
|
+
dryrun.run_dry_run(resource="flows", verb="pause", body={})
|
|
77
|
+
output.emit(
|
|
78
|
+
client.request("PUT", f"/nexla/flows/{flow_id}/pause"),
|
|
79
|
+
mode=output.ctx_mode(ctx),
|
|
80
|
+
fields=output.ctx_fields(ctx),
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
@app.command("delete")
|
|
85
|
+
def delete(flow_id: int, dry_run: bool = _DRY_RUN_OPT) -> None:
|
|
86
|
+
"""Delete a flow."""
|
|
87
|
+
if dry_run:
|
|
88
|
+
dryrun.run_dry_run(resource="flows", verb="delete", body={})
|
|
89
|
+
client.request("DELETE", f"/nexla/flows/{flow_id}")
|
|
90
|
+
typer.echo(f"deleted flow {flow_id}")
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
"""`nexla mcp-servers` — external MCP servers nested under a toolset.
|
|
2
|
+
|
|
3
|
+
Mirrors ``routers/nexla_agent_api/mcp_servers.py``
|
|
4
|
+
(``/nexla/toolsets/{toolset_id}/mcp-servers/*``). No update — attach again
|
|
5
|
+
with a new name or detach + reattach.
|
|
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
|
+
|
|
16
|
+
app = typer.Typer(
|
|
17
|
+
name="mcp-servers", help="Manage external MCP servers on a toolset's gateway.", no_args_is_help=True
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
_COLUMNS = ["id", "toolset_id", "server_name", "server_url", "status", "tool_count"]
|
|
21
|
+
|
|
22
|
+
_JSON_OPT = typer.Option(
|
|
23
|
+
None, "--json", help="Raw JSON body; merged under named options, over --params"
|
|
24
|
+
)
|
|
25
|
+
_PARAMS_OPT = typer.Option(
|
|
26
|
+
[], "--params", help="key=value body overrides (repeatable); lowest precedence"
|
|
27
|
+
)
|
|
28
|
+
_DRY_RUN_OPT = typer.Option(
|
|
29
|
+
False, "--dry-run", help="Validate locally against the live schema; fire no mutating call"
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@app.command("list")
|
|
34
|
+
def list_(ctx: typer.Context, toolset_id: int) -> None:
|
|
35
|
+
"""List MCP servers on a toolset."""
|
|
36
|
+
# No page/per_page on this endpoint — always a single response, not a
|
|
37
|
+
# Page[T] envelope, so --page-all does not apply here.
|
|
38
|
+
output.emit(
|
|
39
|
+
client.request("GET", f"/nexla/toolsets/{toolset_id}/mcp-servers"),
|
|
40
|
+
mode=output.ctx_mode(ctx),
|
|
41
|
+
fields=output.ctx_fields(ctx),
|
|
42
|
+
columns=_COLUMNS,
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
@app.command("attach")
|
|
47
|
+
def attach(
|
|
48
|
+
ctx: typer.Context,
|
|
49
|
+
toolset_id: int,
|
|
50
|
+
server_name: str = typer.Option(...),
|
|
51
|
+
server_url: str = typer.Option(...),
|
|
52
|
+
server_description: str | None = typer.Option(None),
|
|
53
|
+
auth_type: str | None = typer.Option(None, help="'bearer' | 'basic' | 'headers' | 'none'"),
|
|
54
|
+
auth_value: str | None = typer.Option(
|
|
55
|
+
None, help="Bearer/basic token string, or JSON object for 'headers'"
|
|
56
|
+
),
|
|
57
|
+
tool_filter: str | None = typer.Option(None, help="JSON object {mode, tool_names, patterns}"),
|
|
58
|
+
json_body: str | None = _JSON_OPT,
|
|
59
|
+
params: list[str] = _PARAMS_OPT,
|
|
60
|
+
dry_run: bool = _DRY_RUN_OPT,
|
|
61
|
+
) -> None:
|
|
62
|
+
"""Attach an external MCP server to a toolset."""
|
|
63
|
+
named: dict[str, object] = {
|
|
64
|
+
"server_name": server_name,
|
|
65
|
+
"server_description": server_description,
|
|
66
|
+
"server_url": server_url,
|
|
67
|
+
}
|
|
68
|
+
if auth_type is not None:
|
|
69
|
+
value: object = auth_value
|
|
70
|
+
if auth_type == "headers" and auth_value is not None:
|
|
71
|
+
value = jsonlib.loads(auth_value)
|
|
72
|
+
named["auth"] = {"type": auth_type, "value": value}
|
|
73
|
+
if tool_filter is not None:
|
|
74
|
+
named["tool_filter"] = jsonlib.loads(tool_filter)
|
|
75
|
+
body = validate.build_body(named, json_body, params)
|
|
76
|
+
if dry_run:
|
|
77
|
+
dryrun.run_dry_run(resource="mcp-servers", verb="attach", body=body)
|
|
78
|
+
output.emit(
|
|
79
|
+
client.request("POST", f"/nexla/toolsets/{toolset_id}/mcp-servers", json=body),
|
|
80
|
+
mode=output.ctx_mode(ctx),
|
|
81
|
+
fields=output.ctx_fields(ctx),
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
@app.command("sync")
|
|
86
|
+
def sync(ctx: typer.Context, toolset_id: int, server_id: int, dry_run: bool = _DRY_RUN_OPT) -> None:
|
|
87
|
+
"""Trigger discovery/indexing on an attached MCP server."""
|
|
88
|
+
if dry_run:
|
|
89
|
+
dryrun.run_dry_run(resource="mcp-servers", verb="sync", body={})
|
|
90
|
+
output.emit(
|
|
91
|
+
client.request("POST", f"/nexla/toolsets/{toolset_id}/mcp-servers/{server_id}/sync"),
|
|
92
|
+
mode=output.ctx_mode(ctx),
|
|
93
|
+
fields=output.ctx_fields(ctx),
|
|
94
|
+
)
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
@app.command("detach")
|
|
98
|
+
def detach(toolset_id: int, server_id: int, dry_run: bool = _DRY_RUN_OPT) -> None:
|
|
99
|
+
"""Detach an MCP server from a toolset."""
|
|
100
|
+
if dry_run:
|
|
101
|
+
dryrun.run_dry_run(resource="mcp-servers", verb="detach", body={})
|
|
102
|
+
client.request("DELETE", f"/nexla/toolsets/{toolset_id}/mcp-servers/{server_id}")
|
|
103
|
+
typer.echo(f"detached mcp server {server_id} from toolset {toolset_id}")
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
"""`nexla metrics` — thin, 501 passthrough.
|
|
2
|
+
|
|
3
|
+
Mirrors ``routers/nexla_agent_api/metrics.py``.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import typer
|
|
9
|
+
|
|
10
|
+
from .. import client, output, validate
|
|
11
|
+
|
|
12
|
+
app = typer.Typer(name="metrics", help="Query Nexla metrics (not implemented in v1).", no_args_is_help=True)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@app.command("catalog")
|
|
16
|
+
def catalog(ctx: typer.Context) -> None:
|
|
17
|
+
"""List available metric types. (Not implemented in v1.)"""
|
|
18
|
+
output.emit(
|
|
19
|
+
client.request("GET", "/nexla/metrics/catalog"),
|
|
20
|
+
mode=output.ctx_mode(ctx),
|
|
21
|
+
fields=output.ctx_fields(ctx),
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@app.command("for-resource")
|
|
26
|
+
def for_resource(ctx: typer.Context, resource_type: str, resource_id: str) -> None:
|
|
27
|
+
"""Get all metrics for a resource. (Not implemented in v1.)"""
|
|
28
|
+
resource_type = validate.resource_id(resource_type)
|
|
29
|
+
resource_id = validate.resource_id(resource_id)
|
|
30
|
+
output.emit(
|
|
31
|
+
client.request("GET", f"/nexla/metrics/{resource_type}/{resource_id}"),
|
|
32
|
+
mode=output.ctx_mode(ctx),
|
|
33
|
+
fields=output.ctx_fields(ctx),
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@app.command("get")
|
|
38
|
+
def get(ctx: typer.Context, resource_type: str, resource_id: str, metric_type: str) -> None:
|
|
39
|
+
"""Get one metric for a resource. (Not implemented in v1.)"""
|
|
40
|
+
resource_type = validate.resource_id(resource_type)
|
|
41
|
+
resource_id = validate.resource_id(resource_id)
|
|
42
|
+
metric_type = validate.resource_id(metric_type)
|
|
43
|
+
output.emit(
|
|
44
|
+
client.request("GET", f"/nexla/metrics/{resource_type}/{resource_id}/{metric_type}"),
|
|
45
|
+
mode=output.ctx_mode(ctx),
|
|
46
|
+
fields=output.ctx_fields(ctx),
|
|
47
|
+
)
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
"""`nexla nexsets` — list / get / transform / activate.
|
|
2
|
+
|
|
3
|
+
No update/delete — nexsets are source-spawned or transform-derived only.
|
|
4
|
+
Mirrors ``routers/nexla_agent_api/nexsets.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="nexsets", help="Manage Nexla nexsets.", no_args_is_help=True)
|
|
16
|
+
|
|
17
|
+
_COLUMNS = ["id", "name", "status", "flow_id", "parent_nexset_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
|
+
flow_id: int | None = typer.Option(None),
|
|
34
|
+
parent_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 nexsets."""
|
|
40
|
+
params: dict[str, object] = {"access_role": access_role, "page": page, "per_page": per_page}
|
|
41
|
+
if flow_id is not None:
|
|
42
|
+
params["flow_id"] = flow_id
|
|
43
|
+
if parent_id is not None:
|
|
44
|
+
params["parent_id"] = parent_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/nexsets", 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/nexsets", 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, nexset_id: int) -> None:
|
|
60
|
+
"""Get one nexset, including samples and schema."""
|
|
61
|
+
output.emit(
|
|
62
|
+
client.request("GET", f"/nexla/nexsets/{nexset_id}"),
|
|
63
|
+
mode=output.ctx_mode(ctx),
|
|
64
|
+
fields=output.ctx_fields(ctx),
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
@app.command("transform")
|
|
69
|
+
def transform(
|
|
70
|
+
ctx: typer.Context,
|
|
71
|
+
parent_id: int,
|
|
72
|
+
name: str = typer.Option(...),
|
|
73
|
+
language: str = typer.Option(..., help="'python' or 'sql'"),
|
|
74
|
+
code: str = typer.Option(...),
|
|
75
|
+
description: str | None = typer.Option(None),
|
|
76
|
+
options: str | None = typer.Option(None, help="JSON object (SQL only)"),
|
|
77
|
+
auto_activate: bool = typer.Option(True),
|
|
78
|
+
json_body: str | None = _JSON_OPT,
|
|
79
|
+
params: list[str] = _PARAMS_OPT,
|
|
80
|
+
dry_run: bool = _DRY_RUN_OPT,
|
|
81
|
+
) -> None:
|
|
82
|
+
"""Derive a child nexset via a Python/SQL transform."""
|
|
83
|
+
named: dict[str, object] = {
|
|
84
|
+
"name": name,
|
|
85
|
+
"description": description,
|
|
86
|
+
"language": language,
|
|
87
|
+
"code": code,
|
|
88
|
+
"options": jsonlib.loads(options) if options is not None else None,
|
|
89
|
+
"auto_activate": auto_activate,
|
|
90
|
+
}
|
|
91
|
+
body = validate.build_body(named, json_body, params)
|
|
92
|
+
if dry_run:
|
|
93
|
+
dryrun.run_dry_run(resource="nexsets", verb="transform", body=body)
|
|
94
|
+
output.emit(
|
|
95
|
+
client.request("POST", f"/nexla/nexsets/{parent_id}/transform", json=body),
|
|
96
|
+
mode=output.ctx_mode(ctx),
|
|
97
|
+
fields=output.ctx_fields(ctx),
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
@app.command("activate")
|
|
102
|
+
def activate(ctx: typer.Context, nexset_id: int, dry_run: bool = _DRY_RUN_OPT) -> None:
|
|
103
|
+
"""Activate a nexset."""
|
|
104
|
+
if dry_run:
|
|
105
|
+
dryrun.run_dry_run(resource="nexsets", verb="activate", body={})
|
|
106
|
+
output.emit(
|
|
107
|
+
client.request("PUT", f"/nexla/nexsets/{nexset_id}/activate"),
|
|
108
|
+
mode=output.ctx_mode(ctx),
|
|
109
|
+
fields=output.ctx_fields(ctx),
|
|
110
|
+
)
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
"""`nexla notifications` — thin, 501 passthrough.
|
|
2
|
+
|
|
3
|
+
Mirrors ``routers/nexla_agent_api/notifications.py``.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import typer
|
|
9
|
+
|
|
10
|
+
from .. import client, output
|
|
11
|
+
|
|
12
|
+
app = typer.Typer(
|
|
13
|
+
name="notifications", help="List notifications (not implemented in v1).", no_args_is_help=True
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@app.command("list")
|
|
18
|
+
def list_(ctx: typer.Context) -> None:
|
|
19
|
+
"""List notifications. (Not implemented in v1.)"""
|
|
20
|
+
output.emit(
|
|
21
|
+
client.request("GET", "/nexla/notifications"),
|
|
22
|
+
mode=output.ctx_mode(ctx),
|
|
23
|
+
fields=output.ctx_fields(ctx),
|
|
24
|
+
)
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
"""`nexla orgs` — list (real) / get (501).
|
|
2
|
+
|
|
3
|
+
Mirrors ``routers/nexla_agent_api/orgs.py``. ``list`` proxies a real,
|
|
4
|
+
working endpoint (a bare array, not a ``Page[T]`` envelope) and gets the
|
|
5
|
+
same full treatment as any other resource for ``--output``/``--fields``;
|
|
6
|
+
only ``get`` 501s.
|
|
7
|
+
|
|
8
|
+
**``--page-all`` is explicitly rejected here**, not silently ignored.
|
|
9
|
+
``list`` takes no ``page``/``per_page`` params and the upstream endpoint
|
|
10
|
+
returns a bare array with no ``items``/``next_page`` keys, so
|
|
11
|
+
``client.paginate()`` cannot be wired against it (it would try to read
|
|
12
|
+
those keys off a list and break). Per the phase-2 plan's stuck-resource
|
|
13
|
+
policy (pick the more conservative option when forced to choose between
|
|
14
|
+
reject-with-error and silent-ignore), an explicit ``CliError`` is raised
|
|
15
|
+
instead of quietly doing the wrong thing.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import typer
|
|
21
|
+
|
|
22
|
+
from .. import client, output
|
|
23
|
+
from ..errors import EXIT, CliError
|
|
24
|
+
|
|
25
|
+
app = typer.Typer(name="orgs", help="Manage Nexla orgs.", no_args_is_help=True)
|
|
26
|
+
|
|
27
|
+
_COLUMNS = ["id", "name"]
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@app.command("list")
|
|
31
|
+
def list_(ctx: typer.Context) -> None:
|
|
32
|
+
"""List orgs."""
|
|
33
|
+
if output.ctx_page_all(ctx):
|
|
34
|
+
raise CliError(
|
|
35
|
+
EXIT.VALIDATION,
|
|
36
|
+
"orgs list does not support --page-all (bare-array endpoint, no next_page)",
|
|
37
|
+
)
|
|
38
|
+
output.emit(
|
|
39
|
+
client.request("GET", "/nexla/orgs"),
|
|
40
|
+
mode=output.ctx_mode(ctx),
|
|
41
|
+
fields=output.ctx_fields(ctx),
|
|
42
|
+
columns=_COLUMNS,
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
@app.command("get")
|
|
47
|
+
def get(ctx: typer.Context, org_id: int) -> None:
|
|
48
|
+
"""Get one org by id."""
|
|
49
|
+
output.emit(
|
|
50
|
+
client.request("GET", f"/nexla/orgs/{org_id}"),
|
|
51
|
+
mode=output.ctx_mode(ctx),
|
|
52
|
+
fields=output.ctx_fields(ctx),
|
|
53
|
+
)
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
"""`nexla probe` — probe a credential or inline config.
|
|
2
|
+
|
|
3
|
+
Mirrors ``routers/nexla_agent_api/probe.py``: one action-discriminated
|
|
4
|
+
endpoint covering validate/tree/sample. Exactly one of
|
|
5
|
+
``--credential-id``/``--connector`` must be given.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import json as jsonlib
|
|
11
|
+
import re
|
|
12
|
+
|
|
13
|
+
import typer
|
|
14
|
+
|
|
15
|
+
from .. import client, output
|
|
16
|
+
from ..errors import CliError
|
|
17
|
+
|
|
18
|
+
app = typer.Typer(name="probe", help="Probe a Nexla credential or inline config.", no_args_is_help=True)
|
|
19
|
+
|
|
20
|
+
# ``--params`` is a free-form, kind-discriminated bag on the server side --
|
|
21
|
+
# the shape that's valid depends entirely on the connector's `kind`
|
|
22
|
+
# (surfaced by `nexla connectors search <name>`/`describe <name>`), and
|
|
23
|
+
# nothing in the live OpenAPI schema documents it (it's typed as a bare
|
|
24
|
+
# `additionalProperties: true` object there). See AGENTS.md/SKILL.md
|
|
25
|
+
# "probe params shape depends on connector kind" for the authoritative,
|
|
26
|
+
# longer version of this -- keep the two in sync if this changes.
|
|
27
|
+
_PARAMS_HELP = (
|
|
28
|
+
"JSON object of action params; shape depends on the connector's `kind` "
|
|
29
|
+
"(see `nexla connectors search <name>` for kind). api: "
|
|
30
|
+
'{"endpoint": "<connector>.<endpoint_id>", "config": {...}} (get '
|
|
31
|
+
"endpoint/field names from `describe-source`/`describe-source-endpoint`). "
|
|
32
|
+
'db: {"db_query_mode": "Default", "table": ..., "database": ...} or '
|
|
33
|
+
'{"db_query_mode": "Query", "query": ...}. file: {"path": ...} (required). '
|
|
34
|
+
'rest: {"url": ..., "method": "GET", "response.data.path": ...} or the '
|
|
35
|
+
'full passthrough {"rest.iterations": [...]}.'
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
# Java NPE-style messages the upstream probe service returns verbatim
|
|
39
|
+
# (e.g. `Cannot read field "template" because "varInfo" is null`) when
|
|
40
|
+
# `--params` doesn't match the connector's `kind` -- these pass through
|
|
41
|
+
# `client.request` as an opaque 500 with no indication of the real cause.
|
|
42
|
+
# Detecting the shape (rather than one exact message) survives the field
|
|
43
|
+
# name varying per connector/kind.
|
|
44
|
+
_NPE_SIGNATURE = re.compile(r"cannot read (field|property|array)|nullpointerexception", re.IGNORECASE)
|
|
45
|
+
_NPE_HINT = (
|
|
46
|
+
"this looks like an upstream Java NPE, which usually means --params "
|
|
47
|
+
"doesn't match the connector's expected shape for its kind (api/db/file/"
|
|
48
|
+
"rest) -- run `nexla probe run --help` for the shape per kind"
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _annotate_npe(e: CliError, action: str) -> CliError:
|
|
53
|
+
"""Append a shape-mismatch hint to `sample`/`tree` 500s that look like the
|
|
54
|
+
upstream NPE signature, without changing the exit code or envelope."""
|
|
55
|
+
if action not in ("sample", "tree"):
|
|
56
|
+
return e
|
|
57
|
+
envelope = e.envelope or {}
|
|
58
|
+
message = envelope.get("errorMessage") or envelope.get("error")
|
|
59
|
+
if not isinstance(message, str) or not _NPE_SIGNATURE.search(message):
|
|
60
|
+
return e
|
|
61
|
+
return CliError(e.code, f"{e.message}\nhint: {_NPE_HINT}", envelope=e.envelope)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
@app.command("run")
|
|
65
|
+
def run(
|
|
66
|
+
ctx: typer.Context,
|
|
67
|
+
action: str = typer.Option(..., help="'validate', 'tree', or 'sample'"),
|
|
68
|
+
credential_id: int | None = typer.Option(None),
|
|
69
|
+
connector: str | None = typer.Option(None, help="Inline credential: connector name"),
|
|
70
|
+
auth_mode: str | None = typer.Option(None, help="Inline credential: auth mode"),
|
|
71
|
+
config: str = typer.Option("{}", help="Inline credential: JSON config"),
|
|
72
|
+
params: str = typer.Option("{}", help=_PARAMS_HELP),
|
|
73
|
+
) -> None:
|
|
74
|
+
"""Validate a credential, or sample/tree an endpoint."""
|
|
75
|
+
if (credential_id is None) == (connector is None):
|
|
76
|
+
raise typer.BadParameter("specify exactly one of --credential-id or --connector")
|
|
77
|
+
body: dict[str, object] = {"action": action, "params": jsonlib.loads(params)}
|
|
78
|
+
if credential_id is not None:
|
|
79
|
+
body["credential_id"] = credential_id
|
|
80
|
+
else:
|
|
81
|
+
body["credential"] = {
|
|
82
|
+
"connector": connector,
|
|
83
|
+
"auth_mode": auth_mode,
|
|
84
|
+
"config": jsonlib.loads(config),
|
|
85
|
+
}
|
|
86
|
+
try:
|
|
87
|
+
response = client.request("POST", "/nexla/probe", json=body)
|
|
88
|
+
except CliError as e:
|
|
89
|
+
raise _annotate_npe(e, action) from e
|
|
90
|
+
output.emit(
|
|
91
|
+
response,
|
|
92
|
+
mode=output.ctx_mode(ctx),
|
|
93
|
+
fields=output.ctx_fields(ctx),
|
|
94
|
+
)
|