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/__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
+ )