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