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
nexla_cli/mcp_client.py
ADDED
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
"""Thin JSON-RPC client for the Nexla monitoring MCP server (`nexla triage`).
|
|
2
|
+
|
|
3
|
+
Separate from ``client.py`` because it's a different protocol (MCP
|
|
4
|
+
``tools/call`` over a stateless streamable-HTTP transport, no session
|
|
5
|
+
handshake needed against this server) and a different base URL
|
|
6
|
+
(``NEXLA_MONITORING_URL``), but reuses the same bearer token
|
|
7
|
+
(:func:`nexla_cli.client.auth_token`) and the same
|
|
8
|
+
:class:`~nexla_cli.errors.CliError`/``EXIT`` mapping so `triage` commands
|
|
9
|
+
fail the same way every other `nexla` command does.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import json as jsonlib
|
|
15
|
+
import os
|
|
16
|
+
from typing import Any
|
|
17
|
+
|
|
18
|
+
import httpx
|
|
19
|
+
import typer
|
|
20
|
+
|
|
21
|
+
from . import client, dryrun
|
|
22
|
+
from .errors import EXIT, CliError
|
|
23
|
+
|
|
24
|
+
_HEADERS = {"Content-Type": "application/json", "Accept": "application/json, text/event-stream"}
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _base() -> str:
|
|
28
|
+
url = os.environ.get("NEXLA_MONITORING_URL")
|
|
29
|
+
if not url:
|
|
30
|
+
raise CliError(EXIT.CONFIG, "NEXLA_MONITORING_URL is not set")
|
|
31
|
+
return url
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _parse_rpc(r: httpx.Response) -> dict[str, Any]:
|
|
35
|
+
"""Decode a JSON-RPC envelope from either a plain JSON or SSE body.
|
|
36
|
+
|
|
37
|
+
The server always answers with ``content-type: text/event-stream``
|
|
38
|
+
(confirmed live) even for a single, non-streaming reply -- one
|
|
39
|
+
``data: {...}`` line. Handle a bare ``application/json`` body too
|
|
40
|
+
rather than assuming the SSE framing is permanent.
|
|
41
|
+
"""
|
|
42
|
+
ctype = r.headers.get("content-type", "")
|
|
43
|
+
if "text/event-stream" in ctype:
|
|
44
|
+
lines = [ln[len("data:") :].strip() for ln in r.text.splitlines() if ln.startswith("data:")]
|
|
45
|
+
if not lines:
|
|
46
|
+
raise CliError(EXIT.UPSTREAM, "empty response from monitoring MCP server")
|
|
47
|
+
return jsonlib.loads(lines[-1])
|
|
48
|
+
try:
|
|
49
|
+
return r.json()
|
|
50
|
+
except ValueError as e:
|
|
51
|
+
raise CliError(EXIT.UPSTREAM, f"non-JSON response from monitoring MCP server: {e}") from e
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _raise_on_rpc_error(resp: dict[str, Any]) -> None:
|
|
55
|
+
error = resp.get("error")
|
|
56
|
+
if error:
|
|
57
|
+
raise CliError(EXIT.ERROR, error.get("message", "MCP error"))
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _post(payload: dict[str, Any]) -> dict[str, Any]:
|
|
61
|
+
headers = {**_HEADERS, "Authorization": f"Bearer {client.auth_token()}"}
|
|
62
|
+
with httpx.Client(base_url=_base(), timeout=30.0, headers=headers) as c:
|
|
63
|
+
try:
|
|
64
|
+
r = c.post("", json=payload)
|
|
65
|
+
except httpx.HTTPError as e:
|
|
66
|
+
raise CliError(EXIT.UPSTREAM, f"request failed: {e}") from e
|
|
67
|
+
if not r.is_success:
|
|
68
|
+
raise CliError(
|
|
69
|
+
EXIT.from_status(r.status_code), f"HTTP {r.status_code} from monitoring MCP server"
|
|
70
|
+
)
|
|
71
|
+
resp = _parse_rpc(r)
|
|
72
|
+
_raise_on_rpc_error(resp)
|
|
73
|
+
return resp
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def list_tools() -> list[dict[str, Any]]:
|
|
77
|
+
"""Fetch the live tool catalog (name + ``inputSchema``) via `tools/list`."""
|
|
78
|
+
resp = _post({"jsonrpc": "2.0", "id": 1, "method": "tools/list", "params": {}})
|
|
79
|
+
return resp.get("result", {}).get("tools", [])
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def tool_schema(name: str) -> dict[str, Any] | None:
|
|
83
|
+
"""Look up one tool's live ``inputSchema`` by name, or ``None`` if unknown."""
|
|
84
|
+
for tool in list_tools():
|
|
85
|
+
if tool.get("name") == name:
|
|
86
|
+
return tool.get("inputSchema")
|
|
87
|
+
return None
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def call_tool(name: str, arguments: dict[str, Any]) -> Any:
|
|
91
|
+
"""Call one MCP tool and return its decoded JSON payload.
|
|
92
|
+
|
|
93
|
+
Unwraps the MCP envelope (``result.content[0].text``, itself a JSON
|
|
94
|
+
string) down to the plain object every `triage` command renders via
|
|
95
|
+
``output.emit`` -- callers never see JSON-RPC/MCP framing.
|
|
96
|
+
"""
|
|
97
|
+
resp = _post(
|
|
98
|
+
{
|
|
99
|
+
"jsonrpc": "2.0",
|
|
100
|
+
"id": 1,
|
|
101
|
+
"method": "tools/call",
|
|
102
|
+
"params": {"name": name, "arguments": arguments},
|
|
103
|
+
}
|
|
104
|
+
)
|
|
105
|
+
result = resp.get("result", {})
|
|
106
|
+
content = result.get("content") or []
|
|
107
|
+
text = content[0].get("text") if content and isinstance(content[0], dict) else None
|
|
108
|
+
if text is None:
|
|
109
|
+
raise CliError(EXIT.UPSTREAM, "empty tool result from monitoring MCP server")
|
|
110
|
+
try:
|
|
111
|
+
payload = jsonlib.loads(text)
|
|
112
|
+
except jsonlib.JSONDecodeError:
|
|
113
|
+
payload = {"text": text}
|
|
114
|
+
if result.get("isError"):
|
|
115
|
+
message = payload.get("error") if isinstance(payload, dict) else str(payload)
|
|
116
|
+
raise CliError(EXIT.ERROR, message or "tool reported an error")
|
|
117
|
+
return payload
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def run_dry_run(*, tool: str, arguments: dict[str, Any]) -> None:
|
|
121
|
+
"""Validate ``arguments`` against the tool's live ``inputSchema``, then exit.
|
|
122
|
+
|
|
123
|
+
Mirrors ``dryrun.run_dry_run``'s contract (0/valid, 2/invalid,
|
|
124
|
+
same ``{"valid": ..., "body"|"errors": ...}`` shape) but sources its
|
|
125
|
+
schema from this server's own `tools/list` instead of
|
|
126
|
+
`/openapi.json` -- every `triage` tool is a read-only query, so there
|
|
127
|
+
is no mutating call to skip, only a local params check before the
|
|
128
|
+
round trip. Always exits the process; the caller never falls through
|
|
129
|
+
to the real ``call_tool`` after this returns.
|
|
130
|
+
"""
|
|
131
|
+
schema = tool_schema(tool)
|
|
132
|
+
if schema is None:
|
|
133
|
+
raise CliError(EXIT.ERROR, f"unknown monitoring tool '{tool}' (server schema drift?)")
|
|
134
|
+
errors = dryrun.validate_body(schema, arguments)
|
|
135
|
+
if errors:
|
|
136
|
+
typer.echo(jsonlib.dumps({"valid": False, "errors": errors}, indent=2), err=True)
|
|
137
|
+
raise typer.Exit(EXIT.VALIDATION)
|
|
138
|
+
typer.echo(jsonlib.dumps({"valid": True, "body": arguments}, indent=2, default=str))
|
|
139
|
+
raise typer.Exit(EXIT.OK)
|
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
"""Shared live-schema fetch + resource/verb -> route lookup.
|
|
2
|
+
|
|
3
|
+
Both ``nexla schema`` (``schema.py``) and ``--dry-run`` (``dryrun.py``) need
|
|
4
|
+
the same thing: fetch ``/openapi.json`` (unauthenticated), filter to
|
|
5
|
+
``/nexla/*`` paths, and resolve a ``(resource, verb)`` pair to its
|
|
6
|
+
``(method, path, request_schema | None)``. Built once here so neither
|
|
7
|
+
module duplicates the fetch-and-filter logic — if you find yourself
|
|
8
|
+
writing a second ``client.request("GET", "/openapi.json", ...)`` call site
|
|
9
|
+
outside this module, route it through :func:`fetch_openapi` instead.
|
|
10
|
+
|
|
11
|
+
The live doc's ``operationId`` is FastAPI's verbose auto-generated form
|
|
12
|
+
(e.g. ``create_source_nexla_sources_post``), not a clean ``sources.create``
|
|
13
|
+
token (confirmed live 2026-07-08) — :data:`ROUTES` is a hand-maintained
|
|
14
|
+
``(resource, verb) -> (method, path template)`` table instead, mirroring
|
|
15
|
+
the exhaustive resource/verb surface documented in
|
|
16
|
+
``plans/2026-07-07-nexla-agent-overview.md``'s "Resource / verb surface"
|
|
17
|
+
table (17 resources, ~61 routes). Path templates use ``{id}`` (or a named
|
|
18
|
+
placeholder for nested resources) purely as a human-readable token — they
|
|
19
|
+
are matched against the live doc's own ``{...}`` path-parameter names
|
|
20
|
+
positionally (see :func:`resolve`), not by exact placeholder-name string
|
|
21
|
+
match, since the live doc may spell a given path parameter differently
|
|
22
|
+
(e.g. ``{source_id}`` vs ``{id}``).
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
from __future__ import annotations
|
|
26
|
+
|
|
27
|
+
from typing import Any
|
|
28
|
+
|
|
29
|
+
from . import client
|
|
30
|
+
|
|
31
|
+
# (resource, verb) -> (HTTP method, path template). Hand-maintained against
|
|
32
|
+
# the overview plan's "Resource / verb surface" table -- do NOT derive this
|
|
33
|
+
# from `operationId` parsing (see module docstring for why).
|
|
34
|
+
ROUTES: dict[tuple[str, str], tuple[str, str]] = {
|
|
35
|
+
("sources", "list"): ("GET", "/nexla/sources"),
|
|
36
|
+
("sources", "get"): ("GET", "/nexla/sources/{id}"),
|
|
37
|
+
("sources", "create"): ("POST", "/nexla/sources"),
|
|
38
|
+
("sources", "update"): ("PATCH", "/nexla/sources/{id}"),
|
|
39
|
+
("sources", "activate"): ("POST", "/nexla/sources/{id}/activate"),
|
|
40
|
+
("sources", "pause"): ("POST", "/nexla/sources/{id}/pause"),
|
|
41
|
+
("sources", "delete"): ("DELETE", "/nexla/sources/{id}"),
|
|
42
|
+
("sources", "sample"): ("POST", "/nexla/sources/{id}/sample"),
|
|
43
|
+
("sources", "file-upload"): ("POST", "/nexla/sources/{id}/file_upload"),
|
|
44
|
+
("sinks", "list"): ("GET", "/nexla/sinks"),
|
|
45
|
+
("sinks", "get"): ("GET", "/nexla/sinks/{id}"),
|
|
46
|
+
("sinks", "create"): ("POST", "/nexla/sinks"),
|
|
47
|
+
("sinks", "update"): ("PATCH", "/nexla/sinks/{id}"),
|
|
48
|
+
("sinks", "activate"): ("POST", "/nexla/sinks/{id}/activate"),
|
|
49
|
+
("sinks", "pause"): ("POST", "/nexla/sinks/{id}/pause"),
|
|
50
|
+
("sinks", "delete"): ("DELETE", "/nexla/sinks/{id}"),
|
|
51
|
+
("nexsets", "list"): ("GET", "/nexla/nexsets"),
|
|
52
|
+
("nexsets", "get"): ("GET", "/nexla/nexsets/{id}"),
|
|
53
|
+
("nexsets", "transform"): ("POST", "/nexla/nexsets/{id}/transform"),
|
|
54
|
+
("nexsets", "activate"): ("PUT", "/nexla/nexsets/{id}/activate"),
|
|
55
|
+
("credentials", "list"): ("GET", "/nexla/credentials"),
|
|
56
|
+
("credentials", "get"): ("GET", "/nexla/credentials/{id}"),
|
|
57
|
+
("credentials", "create"): ("POST", "/nexla/credentials"),
|
|
58
|
+
("credentials", "update"): ("PATCH", "/nexla/credentials/{id}"),
|
|
59
|
+
("credentials", "delete"): ("DELETE", "/nexla/credentials/{id}"),
|
|
60
|
+
("flows", "list"): ("GET", "/nexla/flows"),
|
|
61
|
+
("flows", "get"): ("GET", "/nexla/flows/{id}"),
|
|
62
|
+
("flows", "activate"): ("PUT", "/nexla/flows/{id}/activate"),
|
|
63
|
+
("flows", "pause"): ("PUT", "/nexla/flows/{id}/pause"),
|
|
64
|
+
("flows", "delete"): ("DELETE", "/nexla/flows/{id}"),
|
|
65
|
+
("transforms", "test"): ("POST", "/nexla/transforms/test"),
|
|
66
|
+
("probe", "run"): ("POST", "/nexla/probe"),
|
|
67
|
+
("connectors", "search"): ("GET", "/nexla/connectors/search"),
|
|
68
|
+
("connectors", "describe"): ("GET", "/nexla/connectors/describe/{name}"),
|
|
69
|
+
("connectors", "describe-credential"): ("GET", "/nexla/connectors/describe/{name}/credential"),
|
|
70
|
+
("connectors", "describe-credential-mode"): (
|
|
71
|
+
"GET",
|
|
72
|
+
"/nexla/connectors/describe/{name}/credential/{auth_mode}",
|
|
73
|
+
),
|
|
74
|
+
("connectors", "describe-source"): ("GET", "/nexla/connectors/describe/{name}/source"),
|
|
75
|
+
("connectors", "describe-source-endpoint"): (
|
|
76
|
+
"GET",
|
|
77
|
+
"/nexla/connectors/describe/{name}/source/{endpoint}",
|
|
78
|
+
),
|
|
79
|
+
("connectors", "describe-sink"): ("GET", "/nexla/connectors/describe/{name}/sink"),
|
|
80
|
+
("connectors", "describe-sink-endpoint"): (
|
|
81
|
+
"GET",
|
|
82
|
+
"/nexla/connectors/describe/{name}/sink/{endpoint}",
|
|
83
|
+
),
|
|
84
|
+
("toolsets", "list"): ("GET", "/nexla/toolsets"),
|
|
85
|
+
("toolsets", "get"): ("GET", "/nexla/toolsets/{id}"),
|
|
86
|
+
("toolsets", "create"): ("POST", "/nexla/toolsets"),
|
|
87
|
+
("toolsets", "update"): ("PATCH", "/nexla/toolsets/{id}"),
|
|
88
|
+
("toolsets", "delete"): ("DELETE", "/nexla/toolsets/{id}"),
|
|
89
|
+
("toolsets", "add-nexsets"): ("POST", "/nexla/toolsets/{id}/nexsets"),
|
|
90
|
+
("tools", "list"): ("GET", "/nexla/tools"),
|
|
91
|
+
("tools", "get"): ("GET", "/nexla/tools/{id}"),
|
|
92
|
+
("tools", "set-runtime-config"): ("PATCH", "/nexla/tools/{id}/runtime-config"),
|
|
93
|
+
("tools", "clear-runtime-config"): ("DELETE", "/nexla/tools/{id}/runtime-config"),
|
|
94
|
+
("tools", "delete"): ("DELETE", "/nexla/tools/{id}"),
|
|
95
|
+
("mcp-servers", "list"): ("GET", "/nexla/toolsets/{toolset_id}/mcp-servers"),
|
|
96
|
+
("mcp-servers", "attach"): ("POST", "/nexla/toolsets/{toolset_id}/mcp-servers"),
|
|
97
|
+
("mcp-servers", "sync"): ("POST", "/nexla/toolsets/{toolset_id}/mcp-servers/{id}/sync"),
|
|
98
|
+
("mcp-servers", "detach"): ("DELETE", "/nexla/toolsets/{toolset_id}/mcp-servers/{id}"),
|
|
99
|
+
("context", "get"): ("GET", "/nexla/context"),
|
|
100
|
+
("orgs", "list"): ("GET", "/nexla/orgs"),
|
|
101
|
+
("orgs", "get"): ("GET", "/nexla/orgs/{id}"),
|
|
102
|
+
("code-containers", "list"): ("GET", "/nexla/code-containers"),
|
|
103
|
+
("metrics", "catalog"): ("GET", "/nexla/metrics/catalog"),
|
|
104
|
+
("metrics", "for-resource"): ("GET", "/nexla/metrics/{resource_type}/{resource_id}"),
|
|
105
|
+
("metrics", "get"): ("GET", "/nexla/metrics/{resource_type}/{resource_id}/{metric_type}"),
|
|
106
|
+
("users", "list"): ("GET", "/nexla/users"),
|
|
107
|
+
("users", "get"): ("GET", "/nexla/users/{id}"),
|
|
108
|
+
("notifications", "list"): ("GET", "/nexla/notifications"),
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def fetch_openapi() -> dict[str, Any]:
|
|
113
|
+
"""Fetch the live, unauthenticated ``/openapi.json`` document.
|
|
114
|
+
|
|
115
|
+
One ``client.request(...)`` call site for the whole CLI — ``schema``
|
|
116
|
+
and ``--dry-run`` both go through this, never a second direct fetch.
|
|
117
|
+
"""
|
|
118
|
+
spec: dict[str, Any] = client.request("GET", "/openapi.json", require_auth=False)
|
|
119
|
+
return spec
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def nexla_paths(spec: dict[str, Any]) -> dict[str, Any]:
|
|
123
|
+
"""Filter ``spec['paths']`` down to the ``/nexla/*`` subset."""
|
|
124
|
+
paths: dict[str, Any] = spec.get("paths", {})
|
|
125
|
+
return {p: m for p, m in paths.items() if p.startswith("/nexla")}
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def _placeholder_shape(path: str) -> tuple[str, int]:
|
|
129
|
+
"""Reduce a path to (literal-segments-joined, placeholder-count).
|
|
130
|
+
|
|
131
|
+
Used to match our hand-maintained ``ROUTES`` template against the live
|
|
132
|
+
doc's actual path string regardless of the exact placeholder name the
|
|
133
|
+
live doc happens to use (e.g. ``{id}`` vs ``{source_id}``) — only the
|
|
134
|
+
literal segments and the *position*/count of ``{...}`` placeholders
|
|
135
|
+
need to agree.
|
|
136
|
+
"""
|
|
137
|
+
segments = path.strip("/").split("/")
|
|
138
|
+
literal = "/".join(s if not (s.startswith("{") and s.endswith("}")) else "\0" for s in segments)
|
|
139
|
+
count = sum(1 for s in segments if s.startswith("{") and s.endswith("}"))
|
|
140
|
+
return literal, count
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def resolve(spec: dict[str, Any], resource: str, verb: str) -> dict[str, Any] | None:
|
|
144
|
+
"""Resolve ``(resource, verb)`` to its live path item + method.
|
|
145
|
+
|
|
146
|
+
Returns ``{"method": ..., "path": ..., "operation": <path-item's
|
|
147
|
+
method entry from the live doc>}`` or ``None`` if the (resource, verb)
|
|
148
|
+
pair isn't in :data:`ROUTES`, or the templated path can't be matched
|
|
149
|
+
against any path actually present in the live doc (drift between this
|
|
150
|
+
table and the deployed API).
|
|
151
|
+
"""
|
|
152
|
+
key = (resource, verb)
|
|
153
|
+
if key not in ROUTES:
|
|
154
|
+
return None
|
|
155
|
+
method, template = ROUTES[key]
|
|
156
|
+
want_shape = _placeholder_shape(template)
|
|
157
|
+
paths = nexla_paths(spec)
|
|
158
|
+
for live_path, methods in paths.items():
|
|
159
|
+
if _placeholder_shape(live_path) != want_shape:
|
|
160
|
+
continue
|
|
161
|
+
operation = methods.get(method.lower())
|
|
162
|
+
if operation is not None:
|
|
163
|
+
return {"method": method, "path": live_path, "operation": operation}
|
|
164
|
+
return None
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def request_schema(spec: dict[str, Any], ref: str) -> dict[str, Any]:
|
|
168
|
+
"""Dereference a single-level ``#/components/schemas/<Name>`` ``$ref``.
|
|
169
|
+
|
|
170
|
+
Only single-level dereferencing is implemented — the generated
|
|
171
|
+
schemas observed on the live API are flat with ``anyOf``-for-optional
|
|
172
|
+
fields rather than deeply nested ``$ref`` chains (confirmed by
|
|
173
|
+
inspecting ``CreateSourceIn`` this session). If a future schema nests
|
|
174
|
+
a ``$ref`` inside a property, this returns that property's raw
|
|
175
|
+
``{"$ref": ...}`` object unresolved rather than silently guessing —
|
|
176
|
+
extend this function if/when that shape shows up for real.
|
|
177
|
+
"""
|
|
178
|
+
name = ref.rsplit("/", 1)[-1]
|
|
179
|
+
schemas: dict[str, Any] = spec.get("components", {}).get("schemas", {})
|
|
180
|
+
result: dict[str, Any] = schemas.get(name, {})
|
|
181
|
+
return result
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def request_body_schema(spec: dict[str, Any], operation: dict[str, Any]) -> dict[str, Any] | None:
|
|
185
|
+
"""Extract + dereference an operation's JSON request-body schema, if any."""
|
|
186
|
+
body = operation.get("requestBody")
|
|
187
|
+
if not isinstance(body, dict):
|
|
188
|
+
return None
|
|
189
|
+
content = body.get("content", {}).get("application/json", {})
|
|
190
|
+
schema = content.get("schema")
|
|
191
|
+
if not isinstance(schema, dict):
|
|
192
|
+
return None
|
|
193
|
+
ref = schema.get("$ref")
|
|
194
|
+
if isinstance(ref, str):
|
|
195
|
+
return request_schema(spec, ref)
|
|
196
|
+
return schema
|
nexla_cli/output.py
ADDED
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
"""Human-readable table/kv rendering plus agent-facing json/ndjson output.
|
|
2
|
+
|
|
3
|
+
Deliberately kept to stdlib string formatting — no table library (rung 2/5
|
|
4
|
+
is enough here).
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import json as jsonlib
|
|
10
|
+
import os
|
|
11
|
+
import sys
|
|
12
|
+
from typing import Any
|
|
13
|
+
|
|
14
|
+
import typer
|
|
15
|
+
|
|
16
|
+
from .sanitize import sanitize
|
|
17
|
+
|
|
18
|
+
_DEFAULT_MAX_COL_WIDTH = 40
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def resolve_mode(flag: str | None) -> str:
|
|
22
|
+
"""Resolve the active output mode: explicit flag > env > TTY autodetect."""
|
|
23
|
+
if flag:
|
|
24
|
+
return flag.lower()
|
|
25
|
+
env = os.environ.get("NEXLA_OUTPUT") or os.environ.get("OUTPUT_FORMAT")
|
|
26
|
+
if env:
|
|
27
|
+
return env.lower()
|
|
28
|
+
# ponytail: the whole human/agent split hinges on this one line.
|
|
29
|
+
return "table" if sys.stdout.isatty() else "json"
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def ctx_mode(ctx: typer.Context) -> str:
|
|
33
|
+
"""Resolve the active output mode from the global callback's ``ctx.obj``."""
|
|
34
|
+
obj = ctx.obj or {}
|
|
35
|
+
return resolve_mode(obj.get("mode"))
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def ctx_fields(ctx: typer.Context) -> list[str] | None:
|
|
39
|
+
"""Read the ``--fields`` mask stashed on ``ctx.obj`` by the global callback."""
|
|
40
|
+
obj = ctx.obj or {}
|
|
41
|
+
fields: list[str] | None = obj.get("fields")
|
|
42
|
+
return fields
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def ctx_page_all(ctx: typer.Context) -> bool:
|
|
46
|
+
"""Read the ``--page-all`` flag stashed on ``ctx.obj`` by the global callback."""
|
|
47
|
+
obj = ctx.obj or {}
|
|
48
|
+
return bool(obj.get("page_all"))
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _mask(data: Any, fields: list[str]) -> Any:
|
|
52
|
+
def keep(d: dict[str, Any]) -> dict[str, Any]:
|
|
53
|
+
return {k: d.get(k) for k in fields}
|
|
54
|
+
|
|
55
|
+
if isinstance(data, list):
|
|
56
|
+
return [keep(d) if isinstance(d, dict) else d for d in data]
|
|
57
|
+
if isinstance(data, dict):
|
|
58
|
+
return keep(data)
|
|
59
|
+
return data
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def emit(
|
|
63
|
+
data: Any,
|
|
64
|
+
*,
|
|
65
|
+
mode: str = "table",
|
|
66
|
+
columns: list[str] | None = None,
|
|
67
|
+
fields: list[str] | None = None,
|
|
68
|
+
) -> None:
|
|
69
|
+
"""Render ``data`` as a table/kv block (human), or json/ndjson (agent).
|
|
70
|
+
|
|
71
|
+
List endpoints return the ``Page[T]`` envelope
|
|
72
|
+
(``{items, page, per_page, next_page, truncated}``), never a bare
|
|
73
|
+
array — unwrap ``items`` before branching on type or masking,
|
|
74
|
+
otherwise every ``list`` command would render/mask the envelope dict
|
|
75
|
+
as a single row. In ``json`` mode the envelope (with masked items
|
|
76
|
+
spliced back in) is preserved so an agent can still see pagination
|
|
77
|
+
metadata.
|
|
78
|
+
|
|
79
|
+
Every value is run through :func:`nexla_cli.sanitize.sanitize` before
|
|
80
|
+
rendering, in every mode — API responses are untrusted data, and an
|
|
81
|
+
agent parsing JSON is exactly as exposed to a hidden zero-width
|
|
82
|
+
character or an embedded ANSI escape as a human reading a table.
|
|
83
|
+
"""
|
|
84
|
+
if data is None:
|
|
85
|
+
return
|
|
86
|
+
data = sanitize(data)
|
|
87
|
+
envelope: dict[str, Any] | None = None
|
|
88
|
+
if isinstance(data, dict) and "items" in data:
|
|
89
|
+
envelope = data
|
|
90
|
+
data = data["items"]
|
|
91
|
+
if fields:
|
|
92
|
+
data = _mask(data, fields)
|
|
93
|
+
if mode == "ndjson":
|
|
94
|
+
for row in data if isinstance(data, list) else [data]:
|
|
95
|
+
typer.echo(jsonlib.dumps(row, default=str))
|
|
96
|
+
elif mode == "json":
|
|
97
|
+
payload: Any = {**envelope, "items": data} if envelope is not None else data
|
|
98
|
+
typer.echo(jsonlib.dumps(payload, indent=2, default=str))
|
|
99
|
+
elif isinstance(data, list):
|
|
100
|
+
_table(data, columns)
|
|
101
|
+
else:
|
|
102
|
+
_kv(data)
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def _stringify(value: Any) -> str:
|
|
106
|
+
if value is None:
|
|
107
|
+
return ""
|
|
108
|
+
if isinstance(value, bool):
|
|
109
|
+
return "true" if value else "false"
|
|
110
|
+
return str(value)
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def _truncate(value: str, width: int) -> str:
|
|
114
|
+
if len(value) <= width:
|
|
115
|
+
return value
|
|
116
|
+
return value[: width - 1] + "…"
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def _table(rows: list[Any], columns: list[str] | None) -> None:
|
|
120
|
+
if not rows:
|
|
121
|
+
typer.echo("(no results)")
|
|
122
|
+
return
|
|
123
|
+
dict_rows = [r for r in rows if isinstance(r, dict)]
|
|
124
|
+
if not dict_rows:
|
|
125
|
+
for r in rows:
|
|
126
|
+
typer.echo(_stringify(r))
|
|
127
|
+
return
|
|
128
|
+
cols = columns or list(dict_rows[0].keys())
|
|
129
|
+
str_rows = [[_truncate(_stringify(r.get(c)), _DEFAULT_MAX_COL_WIDTH) for c in cols] for r in dict_rows]
|
|
130
|
+
widths = [max(len(c), *(len(row[i]) for row in str_rows)) for i, c in enumerate(cols)]
|
|
131
|
+
header = " ".join(c.upper().ljust(widths[i]) for i, c in enumerate(cols))
|
|
132
|
+
typer.echo(header)
|
|
133
|
+
for row in str_rows:
|
|
134
|
+
typer.echo(" ".join(cell.ljust(widths[i]) for i, cell in enumerate(row)))
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def _kv(data: Any) -> None:
|
|
138
|
+
if not isinstance(data, dict):
|
|
139
|
+
typer.echo(_stringify(data))
|
|
140
|
+
return
|
|
141
|
+
if not data:
|
|
142
|
+
typer.echo("(empty)")
|
|
143
|
+
return
|
|
144
|
+
width = max(len(str(k)) for k in data)
|
|
145
|
+
for k, v in data.items():
|
|
146
|
+
typer.echo(f"{str(k).ljust(width)} : {_stringify(v)}")
|
nexla_cli/py.typed
ADDED
|
File without changes
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""One module per `/nexla/*` resource, each a thin typer command group."""
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
"""`nexla code-containers` — thin, 501 passthrough.
|
|
2
|
+
|
|
3
|
+
Mirrors ``routers/nexla_agent_api/code_containers.py``. Scaffolded for a
|
|
4
|
+
consistent help tree / stable exit code; nothing real behind it yet.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import typer
|
|
10
|
+
|
|
11
|
+
from .. import client, output
|
|
12
|
+
|
|
13
|
+
app = typer.Typer(name="code-containers", help="Manage Nexla code containers (not implemented in v1).", no_args_is_help=True)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@app.command("list")
|
|
17
|
+
def list_(ctx: typer.Context) -> None:
|
|
18
|
+
"""List code containers. (Not implemented in v1.)"""
|
|
19
|
+
output.emit(
|
|
20
|
+
client.request("GET", "/nexla/code-containers"),
|
|
21
|
+
mode=output.ctx_mode(ctx),
|
|
22
|
+
fields=output.ctx_fields(ctx),
|
|
23
|
+
)
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
"""`nexla connectors` — search + hierarchical describe.
|
|
2
|
+
|
|
3
|
+
No plain list/get — this is a describe/search-only surface. Mirrors
|
|
4
|
+
``routers/nexla_agent_api/connectors.py``.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import typer
|
|
10
|
+
|
|
11
|
+
from .. import client, output, validate
|
|
12
|
+
|
|
13
|
+
app = typer.Typer(name="connectors", help="Search and describe Nexla connectors.", no_args_is_help=True)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@app.command("search")
|
|
17
|
+
def search(
|
|
18
|
+
ctx: typer.Context,
|
|
19
|
+
query: str | None = typer.Argument(None, help="Search term, e.g. 's3'"),
|
|
20
|
+
kind: str | None = typer.Option(None),
|
|
21
|
+
supports: str | None = typer.Option(None, help="'credential', 'source', or 'sink'"),
|
|
22
|
+
include_unsupported: bool = typer.Option(False),
|
|
23
|
+
limit: int = typer.Option(25),
|
|
24
|
+
) -> None:
|
|
25
|
+
"""Search the connector catalog."""
|
|
26
|
+
params: dict[str, object] = {"include_unsupported": include_unsupported, "limit": limit}
|
|
27
|
+
if query is not None:
|
|
28
|
+
params["q"] = query
|
|
29
|
+
if kind is not None:
|
|
30
|
+
params["kind"] = kind
|
|
31
|
+
if supports is not None:
|
|
32
|
+
params["supports"] = supports
|
|
33
|
+
output.emit(
|
|
34
|
+
client.request("GET", "/nexla/connectors/search", params=params),
|
|
35
|
+
mode=output.ctx_mode(ctx),
|
|
36
|
+
fields=output.ctx_fields(ctx),
|
|
37
|
+
columns=["name", "display_name", "kind", "supported", "score"],
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
@app.command("describe")
|
|
42
|
+
def describe(
|
|
43
|
+
ctx: typer.Context,
|
|
44
|
+
name: str,
|
|
45
|
+
include: str | None = typer.Option(None, help="Comma-separated subset of credential,source,sink"),
|
|
46
|
+
) -> None:
|
|
47
|
+
"""Describe a connector's capabilities."""
|
|
48
|
+
name = validate.resource_id(name)
|
|
49
|
+
params: dict[str, object] = {}
|
|
50
|
+
if include is not None:
|
|
51
|
+
params["include"] = include
|
|
52
|
+
output.emit(
|
|
53
|
+
client.request("GET", f"/nexla/connectors/describe/{name}", params=params),
|
|
54
|
+
mode=output.ctx_mode(ctx),
|
|
55
|
+
fields=output.ctx_fields(ctx),
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
@app.command("describe-credential")
|
|
60
|
+
def describe_credential(ctx: typer.Context, name: str) -> None:
|
|
61
|
+
"""List a connector's auth modes."""
|
|
62
|
+
name = validate.resource_id(name)
|
|
63
|
+
output.emit(
|
|
64
|
+
client.request("GET", f"/nexla/connectors/describe/{name}/credential"),
|
|
65
|
+
mode=output.ctx_mode(ctx),
|
|
66
|
+
fields=output.ctx_fields(ctx),
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
@app.command("describe-credential-mode")
|
|
71
|
+
def describe_credential_mode(ctx: typer.Context, name: str, auth_mode: str) -> None:
|
|
72
|
+
"""Describe a specific auth mode's required fields."""
|
|
73
|
+
name = validate.resource_id(name)
|
|
74
|
+
auth_mode = validate.resource_id(auth_mode)
|
|
75
|
+
output.emit(
|
|
76
|
+
client.request("GET", f"/nexla/connectors/describe/{name}/credential/{auth_mode}"),
|
|
77
|
+
mode=output.ctx_mode(ctx),
|
|
78
|
+
fields=output.ctx_fields(ctx),
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
@app.command("describe-source")
|
|
83
|
+
def describe_source(ctx: typer.Context, name: str) -> None:
|
|
84
|
+
"""Describe how to create a source for this connector."""
|
|
85
|
+
name = validate.resource_id(name)
|
|
86
|
+
output.emit(
|
|
87
|
+
client.request("GET", f"/nexla/connectors/describe/{name}/source"),
|
|
88
|
+
mode=output.ctx_mode(ctx),
|
|
89
|
+
fields=output.ctx_fields(ctx),
|
|
90
|
+
)
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
@app.command("describe-source-endpoint")
|
|
94
|
+
def describe_source_endpoint(ctx: typer.Context, name: str, endpoint: str) -> None:
|
|
95
|
+
"""Describe a specific API-connector source endpoint."""
|
|
96
|
+
name = validate.resource_id(name)
|
|
97
|
+
endpoint = validate.resource_id(endpoint)
|
|
98
|
+
output.emit(
|
|
99
|
+
client.request("GET", f"/nexla/connectors/describe/{name}/source/{endpoint}"),
|
|
100
|
+
mode=output.ctx_mode(ctx),
|
|
101
|
+
fields=output.ctx_fields(ctx),
|
|
102
|
+
)
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
@app.command("describe-sink")
|
|
106
|
+
def describe_sink(ctx: typer.Context, name: str) -> None:
|
|
107
|
+
"""Describe how to create a sink for this connector."""
|
|
108
|
+
name = validate.resource_id(name)
|
|
109
|
+
output.emit(
|
|
110
|
+
client.request("GET", f"/nexla/connectors/describe/{name}/sink"),
|
|
111
|
+
mode=output.ctx_mode(ctx),
|
|
112
|
+
fields=output.ctx_fields(ctx),
|
|
113
|
+
)
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
@app.command("describe-sink-endpoint")
|
|
117
|
+
def describe_sink_endpoint(ctx: typer.Context, name: str, endpoint: str) -> None:
|
|
118
|
+
"""Describe a specific API-connector sink endpoint."""
|
|
119
|
+
name = validate.resource_id(name)
|
|
120
|
+
endpoint = validate.resource_id(endpoint)
|
|
121
|
+
output.emit(
|
|
122
|
+
client.request("GET", f"/nexla/connectors/describe/{name}/sink/{endpoint}"),
|
|
123
|
+
mode=output.ctx_mode(ctx),
|
|
124
|
+
fields=output.ctx_fields(ctx),
|
|
125
|
+
)
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
"""`nexla context` — consolidated flows/sources/nexsets/credentials/connectors.
|
|
2
|
+
|
|
3
|
+
Mirrors ``routers/nexla_agent_api/context.py``. Single ``get`` command.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import typer
|
|
9
|
+
|
|
10
|
+
from .. import client, output
|
|
11
|
+
|
|
12
|
+
app = typer.Typer(name="context", help="Fetch consolidated Nexla context.", no_args_is_help=True)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@app.command("get")
|
|
16
|
+
def get(ctx: typer.Context) -> None:
|
|
17
|
+
"""Fetch flows/credentials/sources/nexsets/catalog for @-mentions."""
|
|
18
|
+
output.emit(
|
|
19
|
+
client.request("GET", "/nexla/context"),
|
|
20
|
+
mode=output.ctx_mode(ctx),
|
|
21
|
+
fields=output.ctx_fields(ctx),
|
|
22
|
+
)
|