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,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
+ )