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/__init__.py
ADDED
|
@@ -0,0 +1,247 @@
|
|
|
1
|
+
"""`nexla` — CLI client for the `/nexla/*` agent API.
|
|
2
|
+
|
|
3
|
+
Set ``NEXLA_API_URL`` and ``NEXLA_TOKEN`` in the environment (or run
|
|
4
|
+
``nexla login --service-key ...`` to obtain a token first).
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import functools
|
|
10
|
+
import json as jsonlib
|
|
11
|
+
import sys
|
|
12
|
+
from collections.abc import Callable
|
|
13
|
+
from typing import Any
|
|
14
|
+
|
|
15
|
+
import typer
|
|
16
|
+
|
|
17
|
+
# Typer 0.12+ vendors its own private click fork rather than depending on
|
|
18
|
+
# the standalone `click` package (confirmed: `click` isn't even installed
|
|
19
|
+
# in this venv) -- these are the exception types Click itself raises while
|
|
20
|
+
# *parsing* argv, before any command callback runs, so they can't be
|
|
21
|
+
# caught by `_wrap_cli_error` (which only wraps callback bodies). Imported
|
|
22
|
+
# from the private `_click` module because Typer doesn't re-export them
|
|
23
|
+
# publicly; there is no other way to intercept a parse-time usage error.
|
|
24
|
+
from typer._click.exceptions import NoArgsIsHelpError, UsageError
|
|
25
|
+
|
|
26
|
+
from . import login as login_module
|
|
27
|
+
from . import output as output_module
|
|
28
|
+
from .errors import CliError
|
|
29
|
+
from .resources import (
|
|
30
|
+
code_containers,
|
|
31
|
+
connectors,
|
|
32
|
+
context,
|
|
33
|
+
credentials,
|
|
34
|
+
flows,
|
|
35
|
+
mcp_servers,
|
|
36
|
+
metrics,
|
|
37
|
+
nexsets,
|
|
38
|
+
notifications,
|
|
39
|
+
orgs,
|
|
40
|
+
probe,
|
|
41
|
+
sinks,
|
|
42
|
+
sources,
|
|
43
|
+
tools,
|
|
44
|
+
toolsets,
|
|
45
|
+
transforms,
|
|
46
|
+
triage,
|
|
47
|
+
users,
|
|
48
|
+
)
|
|
49
|
+
from .schema import schema_app
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _output_flag_from_argv() -> str | None:
|
|
53
|
+
"""Scan ``sys.argv`` for an explicit ``--output``/``-o`` value.
|
|
54
|
+
|
|
55
|
+
``_wrap_cli_error`` wraps each command callback directly, so it runs
|
|
56
|
+
*before* Typer builds ``ctx`` — it cannot read ``ctx.obj`` the way
|
|
57
|
+
command bodies can (see ``output.ctx_mode``). Falling back to scanning
|
|
58
|
+
``sys.argv`` directly is the only way to honor an explicit flag at this
|
|
59
|
+
point; ``NEXLA_OUTPUT``/``OUTPUT_FORMAT`` env and TTY autodetection
|
|
60
|
+
still work normally via ``output.resolve_mode``.
|
|
61
|
+
"""
|
|
62
|
+
argv = sys.argv[1:]
|
|
63
|
+
for i, arg in enumerate(argv):
|
|
64
|
+
if arg in ("--output", "-o") and i + 1 < len(argv):
|
|
65
|
+
return argv[i + 1]
|
|
66
|
+
for prefix in ("--output=", "-o="):
|
|
67
|
+
if arg.startswith(prefix):
|
|
68
|
+
return arg[len(prefix) :]
|
|
69
|
+
return None
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _wrap_cli_error[F: Callable[..., Any]](fn: F) -> F:
|
|
73
|
+
"""Map a :class:`CliError` raised by a command into ``typer.Exit``.
|
|
74
|
+
|
|
75
|
+
Typer 0.12+ vendors its own private click fork (``typer._click``), so a
|
|
76
|
+
``CliError`` subclassing the standalone ``click`` package's
|
|
77
|
+
``ClickException`` is *not* recognized by Typer's exception handling —
|
|
78
|
+
it would propagate as a generic exception and every failure would map
|
|
79
|
+
to exit code 1. Wrapping each command callback here keeps the mapping
|
|
80
|
+
correct regardless of which click Typer happens to vendor.
|
|
81
|
+
|
|
82
|
+
In ``json``/``ndjson`` output mode, the caught error is emitted to
|
|
83
|
+
stderr as ``{"error": e.envelope, "detail": e.message}``-shaped JSON
|
|
84
|
+
instead of the plain ``error: {message}`` line, so an agent parsing
|
|
85
|
+
stderr doesn't have to special-case error output. Exit code mapping is
|
|
86
|
+
unchanged either way.
|
|
87
|
+
"""
|
|
88
|
+
|
|
89
|
+
@functools.wraps(fn)
|
|
90
|
+
def wrapper(*args: Any, **kwargs: Any) -> Any:
|
|
91
|
+
try:
|
|
92
|
+
return fn(*args, **kwargs)
|
|
93
|
+
except CliError as e:
|
|
94
|
+
mode = output_module.resolve_mode(_output_flag_from_argv())
|
|
95
|
+
if mode in ("json", "ndjson"):
|
|
96
|
+
envelope = {"error": e.envelope, "detail": e.message}
|
|
97
|
+
typer.echo(jsonlib.dumps(envelope, default=str), err=True)
|
|
98
|
+
else:
|
|
99
|
+
typer.echo(f"error: {e.message}", err=True)
|
|
100
|
+
raise typer.Exit(e.code) from e
|
|
101
|
+
|
|
102
|
+
return wrapper # type: ignore[return-value]
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def _wrap_app_commands(sub_app: typer.Typer) -> None:
|
|
106
|
+
for cmd_info in sub_app.registered_commands:
|
|
107
|
+
if cmd_info.callback is not None:
|
|
108
|
+
cmd_info.callback = _wrap_cli_error(cmd_info.callback)
|
|
109
|
+
# `schema_app` (Phase 3) has no `registered_commands` at all -- its only
|
|
110
|
+
# entry point is a `@schema_app.callback(invoke_without_command=True)`,
|
|
111
|
+
# which Typer stores separately as `registered_callback`. Wrap that too
|
|
112
|
+
# so a `CliError` raised from a callback-only sub-app still maps to the
|
|
113
|
+
# right exit code instead of falling through as a generic exception.
|
|
114
|
+
if sub_app.registered_callback is not None and sub_app.registered_callback.callback is not None:
|
|
115
|
+
sub_app.registered_callback.callback = _wrap_cli_error(sub_app.registered_callback.callback)
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
app = typer.Typer(
|
|
119
|
+
name="nexla",
|
|
120
|
+
no_args_is_help=True,
|
|
121
|
+
help="Nexla agent CLI. Set NEXLA_API_URL and NEXLA_TOKEN.",
|
|
122
|
+
)
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
@app.callback()
|
|
126
|
+
def _root(
|
|
127
|
+
ctx: typer.Context,
|
|
128
|
+
output: str | None = typer.Option(
|
|
129
|
+
None, "--output", "-o", help="table|json|ndjson (default: table on a TTY, json otherwise)"
|
|
130
|
+
),
|
|
131
|
+
fields: str | None = typer.Option(
|
|
132
|
+
None, "--fields", help="Comma-separated field mask, e.g. 'id,name'"
|
|
133
|
+
),
|
|
134
|
+
page_all: bool = typer.Option(
|
|
135
|
+
False, "--page-all", help="Stream every page as NDJSON instead of one page as a table/JSON"
|
|
136
|
+
),
|
|
137
|
+
) -> None:
|
|
138
|
+
"""Global output options, available to every subcommand except `login`.
|
|
139
|
+
|
|
140
|
+
Stashed on ``ctx.obj`` rather than re-declared per command. `login`
|
|
141
|
+
doesn't read ``ctx.obj`` and is unaffected by these flags — it always
|
|
142
|
+
writes the bare token to stdout by design.
|
|
143
|
+
"""
|
|
144
|
+
ctx.obj = {
|
|
145
|
+
"mode": output,
|
|
146
|
+
"fields": fields.split(",") if fields else None,
|
|
147
|
+
"page_all": page_all,
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
app.command("login")(_wrap_cli_error(login_module.login))
|
|
152
|
+
|
|
153
|
+
for _mod in (
|
|
154
|
+
sources,
|
|
155
|
+
sinks,
|
|
156
|
+
nexsets,
|
|
157
|
+
credentials,
|
|
158
|
+
flows,
|
|
159
|
+
transforms,
|
|
160
|
+
connectors,
|
|
161
|
+
probe,
|
|
162
|
+
toolsets,
|
|
163
|
+
tools,
|
|
164
|
+
mcp_servers,
|
|
165
|
+
context,
|
|
166
|
+
orgs,
|
|
167
|
+
code_containers,
|
|
168
|
+
metrics,
|
|
169
|
+
users,
|
|
170
|
+
notifications,
|
|
171
|
+
triage,
|
|
172
|
+
):
|
|
173
|
+
_wrap_app_commands(_mod.app)
|
|
174
|
+
app.add_typer(_mod.app)
|
|
175
|
+
|
|
176
|
+
_wrap_app_commands(schema_app)
|
|
177
|
+
app.add_typer(schema_app)
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
_GLOBAL_FLAGS_WITH_VALUE = ("--output", "-o", "--fields")
|
|
181
|
+
_GLOBAL_FLAGS_BOOL = ("--page-all",)
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def _reorder_global_flags(argv: list[str]) -> list[str]:
|
|
185
|
+
"""Let ``--output``/``--fields``/``--page-all`` appear anywhere in argv.
|
|
186
|
+
|
|
187
|
+
Typer/Click only recognize a parent group's options before the
|
|
188
|
+
subcommand name (``nexla --output json sources list``, not
|
|
189
|
+
``nexla sources list --output json``) — every subcommand tree behaves
|
|
190
|
+
this way (docker, kubectl, git). Rather than duplicating these three
|
|
191
|
+
options onto all 17 resource modules, hoist them to the front of argv
|
|
192
|
+
before Typer ever parses it, so users can put them wherever feels
|
|
193
|
+
natural.
|
|
194
|
+
"""
|
|
195
|
+
front: list[str] = []
|
|
196
|
+
rest: list[str] = []
|
|
197
|
+
i = 0
|
|
198
|
+
while i < len(argv):
|
|
199
|
+
arg = argv[i]
|
|
200
|
+
if arg in _GLOBAL_FLAGS_BOOL:
|
|
201
|
+
front.append(arg)
|
|
202
|
+
i += 1
|
|
203
|
+
elif arg in _GLOBAL_FLAGS_WITH_VALUE and i + 1 < len(argv):
|
|
204
|
+
front.extend([arg, argv[i + 1]])
|
|
205
|
+
i += 2
|
|
206
|
+
elif any(arg.startswith(f"{flag}=") for flag in _GLOBAL_FLAGS_WITH_VALUE):
|
|
207
|
+
front.append(arg)
|
|
208
|
+
i += 1
|
|
209
|
+
else:
|
|
210
|
+
rest.append(arg)
|
|
211
|
+
i += 1
|
|
212
|
+
return front + rest
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def main() -> None:
|
|
216
|
+
"""Console-script entrypoint (``nexla = "nexla_cli:main"``).
|
|
217
|
+
|
|
218
|
+
Runs the Typer app with ``standalone_mode=False`` so a Click-level
|
|
219
|
+
parse error (e.g. ``--params`` on a command that never defined it) can
|
|
220
|
+
be caught here and get a hint pointing at ``--help``, instead of
|
|
221
|
+
Click's own generic "No such option" with nothing pointing you toward
|
|
222
|
+
the fix. Under ``standalone_mode=False``, Click returns the exit code
|
|
223
|
+
from ``app()`` instead of calling ``sys.exit`` itself (confirmed this
|
|
224
|
+
session) -- every existing ``CliError``-mapped exit code (via
|
|
225
|
+
``_wrap_cli_error`` -> ``typer.Exit``) still comes back this way
|
|
226
|
+
unaffected; only genuine argv-parsing errors raise here.
|
|
227
|
+
"""
|
|
228
|
+
sys.argv[1:] = _reorder_global_flags(sys.argv[1:])
|
|
229
|
+
try:
|
|
230
|
+
code = app(standalone_mode=False)
|
|
231
|
+
except NoArgsIsHelpError as e:
|
|
232
|
+
# Typer/Click already echoed the help text to stdout before this
|
|
233
|
+
# exception ever reaches us (confirmed this session) -- calling
|
|
234
|
+
# `.show()` here would print it a second time, and its override
|
|
235
|
+
# writes to stderr, moving that second copy off the stream a
|
|
236
|
+
# script piping bare `nexla`/`nexla <group>` output would expect.
|
|
237
|
+
raise SystemExit(e.exit_code) from None
|
|
238
|
+
except UsageError as e:
|
|
239
|
+
e.show()
|
|
240
|
+
typer.echo(
|
|
241
|
+
"Run 'nexla <command> --help' to see its exact options.", err=True
|
|
242
|
+
)
|
|
243
|
+
raise SystemExit(e.exit_code) from None
|
|
244
|
+
raise SystemExit(code or 0)
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
__all__ = ["app", "main"]
|
nexla_cli/client.py
ADDED
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
"""Thin, synchronous httpx wrapper shared by every CLI command.
|
|
2
|
+
|
|
3
|
+
The CLI is short-lived (one process per invocation) so there's no need for
|
|
4
|
+
an async client or connection pooling across calls. Mirrors the API's own
|
|
5
|
+
``{detail: {error, detail, nexla_request_id}}`` failure envelope and maps
|
|
6
|
+
failures to :class:`nexla_cli.errors.CliError` / exit codes instead of
|
|
7
|
+
raising ``httpx`` or FastAPI exceptions.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import os
|
|
13
|
+
from collections.abc import Iterator
|
|
14
|
+
from typing import Any
|
|
15
|
+
|
|
16
|
+
import httpx
|
|
17
|
+
|
|
18
|
+
from .errors import EXIT, CliError
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _base() -> str:
|
|
22
|
+
url = os.environ.get("NEXLA_API_URL")
|
|
23
|
+
if not url:
|
|
24
|
+
raise CliError(EXIT.CONFIG, "NEXLA_API_URL is not set")
|
|
25
|
+
return url.rstrip("/")
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _token() -> str:
|
|
29
|
+
tok = os.environ.get("NEXLA_TOKEN")
|
|
30
|
+
if not tok:
|
|
31
|
+
raise CliError(EXIT.CONFIG, "NEXLA_TOKEN is not set")
|
|
32
|
+
return tok
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def auth_token() -> str:
|
|
36
|
+
"""Public accessor for the bearer token, for other transports (e.g. mcp_client)."""
|
|
37
|
+
return _token()
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _envelope(r: httpx.Response) -> dict[str, Any]:
|
|
41
|
+
"""Extract the ``{error, detail, nexla_request_id}`` failure envelope.
|
|
42
|
+
|
|
43
|
+
``/nexla/*`` routes raise ``HTTPException(detail=...)``, which FastAPI
|
|
44
|
+
serializes as ``{"detail": <whatever was passed>}``. ``detail`` may
|
|
45
|
+
itself be a dict (our common shape), a plain string, or (for 422s) a
|
|
46
|
+
list of Pydantic error objects.
|
|
47
|
+
"""
|
|
48
|
+
try:
|
|
49
|
+
body = r.json()
|
|
50
|
+
except ValueError:
|
|
51
|
+
return {"error": r.text[:512]}
|
|
52
|
+
if not isinstance(body, dict):
|
|
53
|
+
return {"error": str(body)[:512]}
|
|
54
|
+
detail = body.get("detail", body)
|
|
55
|
+
if isinstance(detail, dict):
|
|
56
|
+
return detail
|
|
57
|
+
return {"error": str(detail)[:512] if detail is not None else r.text[:512]}
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _message_from_envelope(envelope: dict[str, Any], *, fallback: str) -> str:
|
|
61
|
+
for key in ("detail", "error"):
|
|
62
|
+
val = envelope.get(key)
|
|
63
|
+
if isinstance(val, str) and val:
|
|
64
|
+
return val
|
|
65
|
+
return fallback
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def request(
|
|
69
|
+
method: str,
|
|
70
|
+
path: str,
|
|
71
|
+
*,
|
|
72
|
+
params: dict[str, Any] | None = None,
|
|
73
|
+
json: Any = None,
|
|
74
|
+
require_auth: bool = True,
|
|
75
|
+
) -> Any:
|
|
76
|
+
"""Fire one HTTP request against ``NEXLA_API_URL`` and return decoded JSON.
|
|
77
|
+
|
|
78
|
+
Raises :class:`CliError` (never a raw ``httpx``/network exception) on
|
|
79
|
+
any non-2xx response, with ``EXIT.from_status`` mapping the upstream
|
|
80
|
+
status code to a stable CLI exit code.
|
|
81
|
+
"""
|
|
82
|
+
headers: dict[str, str] = {}
|
|
83
|
+
if require_auth:
|
|
84
|
+
headers["Authorization"] = f"Bearer {_token()}"
|
|
85
|
+
|
|
86
|
+
with httpx.Client(base_url=_base(), timeout=30.0, headers=headers) as c:
|
|
87
|
+
try:
|
|
88
|
+
r = c.request(method, path, params=params, json=json)
|
|
89
|
+
except httpx.HTTPError as e:
|
|
90
|
+
raise CliError(EXIT.UPSTREAM, f"request failed: {e}") from e
|
|
91
|
+
|
|
92
|
+
if r.is_success:
|
|
93
|
+
if r.status_code == 204 or not r.content:
|
|
94
|
+
return None
|
|
95
|
+
try:
|
|
96
|
+
return r.json()
|
|
97
|
+
except ValueError:
|
|
98
|
+
return None
|
|
99
|
+
|
|
100
|
+
envelope = _envelope(r)
|
|
101
|
+
message = _message_from_envelope(envelope, fallback=f"HTTP {r.status_code}")
|
|
102
|
+
raise CliError(EXIT.from_status(r.status_code), message, envelope=envelope)
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def paginate(
|
|
106
|
+
path: str,
|
|
107
|
+
*,
|
|
108
|
+
params: dict[str, Any] | None = None,
|
|
109
|
+
per_page: int = 100,
|
|
110
|
+
) -> Iterator[Any]:
|
|
111
|
+
"""Stream every item across all pages without ever buffering the full set.
|
|
112
|
+
|
|
113
|
+
Drives the loop off the ``Page[T]`` envelope's own ``next_page`` key
|
|
114
|
+
(falsy means done) rather than a length heuristic on ``items`` — a
|
|
115
|
+
``len(batch) < per_page`` check is wrong (a last page can legitimately
|
|
116
|
+
be exactly ``per_page`` long) and was flagged as a bug in review.
|
|
117
|
+
Forwards the full ``params`` dict on every call, only overriding
|
|
118
|
+
``page``/``per_page``, so resource-specific filters (e.g. ``sources
|
|
119
|
+
list``'s ``connector``/``flow_id``) stay applied under ``--page-all``.
|
|
120
|
+
"""
|
|
121
|
+
page: int = 1
|
|
122
|
+
while True:
|
|
123
|
+
data = request(
|
|
124
|
+
"GET", path, params={**(params or {}), "page": page, "per_page": per_page}
|
|
125
|
+
)
|
|
126
|
+
yield from data.get("items", [])
|
|
127
|
+
next_page = data.get("next_page")
|
|
128
|
+
if not next_page:
|
|
129
|
+
return
|
|
130
|
+
page = next_page
|
nexla_cli/dryrun.py
ADDED
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
"""``--dry-run`` — hand-rolled structural validation, zero mutating calls.
|
|
2
|
+
|
|
3
|
+
Per the plan's resolved open question (§3 of
|
|
4
|
+
``plans/2026-07-07-nexla-agent-phase-3-schema-safety-docs.md``): a full
|
|
5
|
+
JSON-Schema validator (the ``jsonschema`` package) is not worth a new
|
|
6
|
+
runtime dependency for schemas this flat. :func:`validate_body` checks two
|
|
7
|
+
things only:
|
|
8
|
+
|
|
9
|
+
- every key in ``schema["required"]`` is present in ``body``;
|
|
10
|
+
- for keys present in both, a rough type check of ``schema["properties"]``
|
|
11
|
+
``"type"`` (``string``/``integer``/``boolean``/``object``/``array``/
|
|
12
|
+
``null``) against the Python value's type, including the
|
|
13
|
+
``anyOf: [{type: X}, {type: "null"}]`` optional-field shape the live
|
|
14
|
+
API's generated schemas use for optional fields.
|
|
15
|
+
|
|
16
|
+
Deliberately **not** implemented: ``pattern``/``format``/``enum``/
|
|
17
|
+
``minimum``/nested-object validation — the real API still fully validates
|
|
18
|
+
on any non-dry-run call, so an over-eager local validator would only give
|
|
19
|
+
a false sense of safety for constraints this module doesn't check.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
from __future__ import annotations
|
|
23
|
+
|
|
24
|
+
import json as jsonlib
|
|
25
|
+
from typing import Any
|
|
26
|
+
|
|
27
|
+
import typer
|
|
28
|
+
|
|
29
|
+
from . import openapi_client
|
|
30
|
+
from .errors import EXIT, CliError
|
|
31
|
+
|
|
32
|
+
_JSON_TYPE_TO_PY: dict[str, type | tuple[type, ...]] = {
|
|
33
|
+
"string": str,
|
|
34
|
+
"integer": int,
|
|
35
|
+
"number": (int, float),
|
|
36
|
+
"boolean": bool,
|
|
37
|
+
"object": dict,
|
|
38
|
+
"array": list,
|
|
39
|
+
"null": type(None),
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _allowed_types(prop_schema: dict[str, Any]) -> list[str]:
|
|
44
|
+
"""Collect the JSON-Schema ``type``(s) a property may take.
|
|
45
|
+
|
|
46
|
+
Handles a bare ``"type": "X"``, and the ``anyOf: [{type: X}, {type:
|
|
47
|
+
"null"}]`` shape the live API's generated schemas use for optional
|
|
48
|
+
fields — anything else in ``anyOf`` (a nested ``$ref``, for instance)
|
|
49
|
+
is skipped rather than guessed at, matching this module's documented
|
|
50
|
+
scope (rough type check only, not general schema resolution).
|
|
51
|
+
"""
|
|
52
|
+
types: list[str] = []
|
|
53
|
+
if "type" in prop_schema and isinstance(prop_schema["type"], str):
|
|
54
|
+
types.append(prop_schema["type"])
|
|
55
|
+
for branch in prop_schema.get("anyOf", []):
|
|
56
|
+
if isinstance(branch, dict) and isinstance(branch.get("type"), str):
|
|
57
|
+
types.append(branch["type"])
|
|
58
|
+
return types
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def validate_body(schema: dict[str, Any], body: dict[str, Any]) -> list[str]:
|
|
62
|
+
"""Return human-readable validation errors; empty list means valid."""
|
|
63
|
+
errors: list[str] = []
|
|
64
|
+
required = schema.get("required", [])
|
|
65
|
+
for key in required:
|
|
66
|
+
if key not in body or body[key] is None:
|
|
67
|
+
errors.append(f"missing required field: {key}")
|
|
68
|
+
|
|
69
|
+
properties: dict[str, Any] = schema.get("properties", {})
|
|
70
|
+
for key, value in body.items():
|
|
71
|
+
prop_schema = properties.get(key)
|
|
72
|
+
if not isinstance(prop_schema, dict):
|
|
73
|
+
continue
|
|
74
|
+
allowed = [t for t in _allowed_types(prop_schema) if t in _JSON_TYPE_TO_PY]
|
|
75
|
+
if not allowed:
|
|
76
|
+
continue
|
|
77
|
+
py_types: tuple[type, ...] = ()
|
|
78
|
+
for json_t in allowed:
|
|
79
|
+
mapped = _JSON_TYPE_TO_PY[json_t]
|
|
80
|
+
py_types += mapped if isinstance(mapped, tuple) else (mapped,)
|
|
81
|
+
if not isinstance(value, py_types):
|
|
82
|
+
errors.append(
|
|
83
|
+
f"field '{key}' expected type {'/'.join(allowed)}, got {type(value).__name__}"
|
|
84
|
+
)
|
|
85
|
+
return errors
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def run_dry_run(*, resource: str, verb: str, body: dict[str, Any]) -> None:
|
|
89
|
+
"""Fetch the matching schema, validate ``body``, print result, and exit.
|
|
90
|
+
|
|
91
|
+
Called by a mutating command's ``--dry-run`` branch *before* it fires
|
|
92
|
+
its real ``client.request(...)`` call. Always exits the process (0 for
|
|
93
|
+
valid, 2 for invalid) — the caller never falls through to the real
|
|
94
|
+
mutating call after this returns, because it never returns.
|
|
95
|
+
"""
|
|
96
|
+
spec = openapi_client.fetch_openapi()
|
|
97
|
+
match = openapi_client.resolve(spec, resource, verb)
|
|
98
|
+
if match is None:
|
|
99
|
+
# Route not found at all -- our hand-maintained ROUTES table has
|
|
100
|
+
# drifted from the live API, or this (resource, verb) pair was
|
|
101
|
+
# never wired up. Conservative fallback per the stuck-resource
|
|
102
|
+
# policy: refuse rather than guess.
|
|
103
|
+
raise CliError(EXIT.ERROR, "dry-run not supported for this command yet")
|
|
104
|
+
|
|
105
|
+
body_schema = openapi_client.request_body_schema(spec, match["operation"])
|
|
106
|
+
if body_schema is None:
|
|
107
|
+
# The route exists but genuinely takes no request body (e.g.
|
|
108
|
+
# `activate`/`pause`/`delete`/`sync`/`detach`) -- nothing to
|
|
109
|
+
# validate, so the body is trivially valid.
|
|
110
|
+
typer.echo(jsonlib.dumps({"valid": True, "body": body}, indent=2, default=str))
|
|
111
|
+
raise typer.Exit(EXIT.OK)
|
|
112
|
+
|
|
113
|
+
errors = validate_body(body_schema, body)
|
|
114
|
+
if errors:
|
|
115
|
+
typer.echo(jsonlib.dumps({"valid": False, "errors": errors}, indent=2), err=True)
|
|
116
|
+
raise typer.Exit(EXIT.VALIDATION)
|
|
117
|
+
|
|
118
|
+
typer.echo(jsonlib.dumps({"valid": True, "body": body}, indent=2, default=str))
|
|
119
|
+
raise typer.Exit(EXIT.OK)
|
nexla_cli/errors.py
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
"""Exit-code taxonomy + the CLI's structured error exception.
|
|
2
|
+
|
|
3
|
+
Agents branch on the numeric exit code, not on message text — keep this
|
|
4
|
+
table stable and documented in ``--help`` epilogs.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class EXIT:
|
|
11
|
+
"""Stable exit-code taxonomy for the `nexla` CLI."""
|
|
12
|
+
|
|
13
|
+
OK = 0
|
|
14
|
+
ERROR = 1 # generic / unexpected
|
|
15
|
+
VALIDATION = 2 # bad local input (Phase 2 validation, dry-run failures)
|
|
16
|
+
CONFIG = 3 # missing env / not configured
|
|
17
|
+
AUTH = 4 # 401/403 from the API
|
|
18
|
+
NOT_FOUND = 5 # 404
|
|
19
|
+
UPSTREAM = 6 # 5xx / 502 gateway
|
|
20
|
+
|
|
21
|
+
@staticmethod
|
|
22
|
+
def from_status(code: int) -> int:
|
|
23
|
+
if code in (401, 403):
|
|
24
|
+
return EXIT.AUTH
|
|
25
|
+
if code == 404:
|
|
26
|
+
return EXIT.NOT_FOUND
|
|
27
|
+
if code >= 500:
|
|
28
|
+
return EXIT.UPSTREAM
|
|
29
|
+
return EXIT.ERROR
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class CliError(Exception):
|
|
33
|
+
"""Raised anywhere in the CLI to signal a specific exit code + message.
|
|
34
|
+
|
|
35
|
+
Every ``app.command()`` callback is wrapped (see
|
|
36
|
+
``cli/__init__.py::_wrap_cli_error``) so this maps to ``typer.Exit(code)``
|
|
37
|
+
with the message on stderr, regardless of entrypoint (installed console
|
|
38
|
+
script or ``CliRunner.invoke`` in tests).
|
|
39
|
+
"""
|
|
40
|
+
|
|
41
|
+
def __init__(self, code: int, message: str, *, envelope: dict | None = None) -> None:
|
|
42
|
+
super().__init__(message)
|
|
43
|
+
self.code = code
|
|
44
|
+
self.message = message
|
|
45
|
+
self.envelope = envelope
|
nexla_cli/login.py
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
"""`nexla login` — exchange a Nexla service key for a session bearer.
|
|
2
|
+
|
|
3
|
+
Not a ``resources/`` module: hits the bare API root (``POST /login``, no
|
|
4
|
+
``/nexla`` prefix) — matches ``routers/auth.py::LoginRequest``/``LoginResponse``.
|
|
5
|
+
Registered as a single top-level command, not a sub-typer group.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import os
|
|
11
|
+
|
|
12
|
+
import typer
|
|
13
|
+
|
|
14
|
+
from . import client
|
|
15
|
+
from .sanitize import sanitize
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def login(
|
|
19
|
+
service_key: str = typer.Option(..., "--service-key", help="Nexla service key"),
|
|
20
|
+
api_url: str | None = typer.Option(
|
|
21
|
+
None, "--api-url", help="Override NEXLA_API_URL for this call"
|
|
22
|
+
),
|
|
23
|
+
) -> None:
|
|
24
|
+
"""Exchange a service key for a bearer token.
|
|
25
|
+
|
|
26
|
+
Prints the access token to stdout only, so
|
|
27
|
+
``export NEXLA_TOKEN=$(nexla login --service-key ...)`` works.
|
|
28
|
+
Everything else (expiry, user, org) goes to stderr.
|
|
29
|
+
"""
|
|
30
|
+
if api_url:
|
|
31
|
+
os.environ["NEXLA_API_URL"] = api_url
|
|
32
|
+
|
|
33
|
+
resp = client.request("POST", "/login", json={"service_key": service_key}, require_auth=False)
|
|
34
|
+
# The token itself is never sanitized — it's a literal secret value
|
|
35
|
+
# that must round-trip exactly for `export NEXLA_TOKEN=$(...)` to work.
|
|
36
|
+
typer.echo(resp["access_token"])
|
|
37
|
+
user = sanitize(resp["user"])
|
|
38
|
+
org = sanitize(resp["org"])
|
|
39
|
+
typer.echo(
|
|
40
|
+
f"expires_at={resp['expires_at']} user={user['email']} org={org['name']}",
|
|
41
|
+
err=True,
|
|
42
|
+
)
|