tai42-cli 1.0.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.
Files changed (47) hide show
  1. tai42_cli/__init__.py +0 -0
  2. tai42_cli/app.py +281 -0
  3. tai42_cli/client.py +273 -0
  4. tai42_cli/commands/__init__.py +5 -0
  5. tai42_cli/commands/_common.py +351 -0
  6. tai42_cli/commands/agents.py +87 -0
  7. tai42_cli/commands/auth.py +70 -0
  8. tai42_cli/commands/backup.py +76 -0
  9. tai42_cli/commands/channels.py +31 -0
  10. tai42_cli/commands/checkpoints.py +29 -0
  11. tai42_cli/commands/config.py +375 -0
  12. tai42_cli/commands/connectors.py +152 -0
  13. tai42_cli/commands/conversations.py +320 -0
  14. tai42_cli/commands/extensions.py +30 -0
  15. tai42_cli/commands/fleet.py +123 -0
  16. tai42_cli/commands/hooks.py +258 -0
  17. tai42_cli/commands/interactions.py +67 -0
  18. tai42_cli/commands/keys.py +233 -0
  19. tai42_cli/commands/manifest.py +76 -0
  20. tai42_cli/commands/mcp.py +162 -0
  21. tai42_cli/commands/notifications.py +105 -0
  22. tai42_cli/commands/obs.py +48 -0
  23. tai42_cli/commands/plugins.py +267 -0
  24. tai42_cli/commands/presets.py +279 -0
  25. tai42_cli/commands/resources.py +60 -0
  26. tai42_cli/commands/roles.py +155 -0
  27. tai42_cli/commands/schedules.py +94 -0
  28. tai42_cli/commands/scopes.py +139 -0
  29. tai42_cli/commands/storage.py +128 -0
  30. tai42_cli/commands/sub_mcp.py +61 -0
  31. tai42_cli/commands/system.py +36 -0
  32. tai42_cli/commands/templates.py +145 -0
  33. tai42_cli/commands/tool_meta.py +194 -0
  34. tai42_cli/commands/tools.py +245 -0
  35. tai42_cli/commands/traces.py +95 -0
  36. tai42_cli/completion.py +45 -0
  37. tai42_cli/context.py +121 -0
  38. tai42_cli/py.typed +0 -0
  39. tai42_cli/render.py +94 -0
  40. tai42_cli/version.py +36 -0
  41. tai42_cli-1.0.0.dist-info/METADATA +123 -0
  42. tai42_cli-1.0.0.dist-info/RECORD +47 -0
  43. tai42_cli-1.0.0.dist-info/WHEEL +5 -0
  44. tai42_cli-1.0.0.dist-info/entry_points.txt +3 -0
  45. tai42_cli-1.0.0.dist-info/licenses/LICENSE +202 -0
  46. tai42_cli-1.0.0.dist-info/licenses/NOTICE +5 -0
  47. tai42_cli-1.0.0.dist-info/top_level.txt +1 -0
tai42_cli/__init__.py ADDED
File without changes
tai42_cli/app.py ADDED
@@ -0,0 +1,281 @@
1
+ """The unified ``tai`` command.
2
+
3
+ One Typer app exposes the remote command groups — thin clients over a tai42
4
+ server's ``/api/*`` routes — plus the CLI-native ``completion`` and ``version``
5
+ commands. The console entry point ``app`` is the compiled click group.
6
+
7
+ Server-side and local commands (``serve``, ``db``, ``doctor``, ``backend`` …)
8
+ are contributed by the server package through the ``tai.commands`` entry-point
9
+ group: each entry point loads to a ``register(group: click.Group) -> None``
10
+ callable that mounts its commands onto the compiled root group. When only this
11
+ client is installed those commands are simply absent; the remote surface stands
12
+ on its own.
13
+
14
+ Registration lives here so each remote command module only fills its own Typer
15
+ app without editing this file. An extension's ``register`` receives the compiled
16
+ click group and may use click's own API on it, plus the two exported helpers:
17
+ :func:`inject_json_flag` (give a Typer-backed leaf the trailing ``--json`` form)
18
+ and :func:`mount_launcher` (mount a click launcher command under a name). The
19
+ seams the command bodies build on are the
20
+ :class:`~tai42_cli.context.AppContext` on the Typer context (for a configured
21
+ client), :class:`~tai42_cli.client.ApiClient`, and the
22
+ :mod:`tai42_cli.render` helpers.
23
+ """
24
+
25
+ import os
26
+ from importlib.metadata import entry_points
27
+ from typing import Any, cast
28
+
29
+ import click
30
+ import typer
31
+ from dotenv import load_dotenv
32
+ from typer.core import TyperGroup, TyperOption
33
+ from typer.main import get_command
34
+
35
+ from tai42_cli import completion, version
36
+ from tai42_cli.client import ApiError
37
+ from tai42_cli.commands import (
38
+ agents,
39
+ auth,
40
+ backup,
41
+ channels,
42
+ checkpoints,
43
+ config,
44
+ connectors,
45
+ conversations,
46
+ extensions,
47
+ fleet,
48
+ hooks,
49
+ interactions,
50
+ keys,
51
+ manifest,
52
+ mcp,
53
+ notifications,
54
+ obs,
55
+ plugins,
56
+ presets,
57
+ resources,
58
+ roles,
59
+ schedules,
60
+ scopes,
61
+ storage,
62
+ sub_mcp,
63
+ system,
64
+ templates,
65
+ tool_meta,
66
+ tools,
67
+ traces,
68
+ )
69
+ from tai42_cli.context import AppContext
70
+
71
+ # The entry-point group a server package publishes to contribute its local and
72
+ # runtime commands (``serve``/``db``/``doctor``/``backend`` …) to this CLI.
73
+ _EXTENSION_GROUP = "tai.commands"
74
+
75
+
76
+ class TaiCLIGroup(TyperGroup):
77
+ """Root group that bridges standard-``click`` outcomes into Typer's runner.
78
+
79
+ Typer bundles its own vendored copy of click, so Typer's runner only
80
+ recognises its vendored exception types. Contributed launcher commands (and
81
+ the native command modules) raise standard-``click`` exceptions, and remote
82
+ commands raise :class:`ApiError`. This override translates all of those into
83
+ Typer's own control-flow exceptions so they render cleanly — a server/usage
84
+ message on stderr with a non-zero exit — instead of a traceback."""
85
+
86
+ def invoke(self, ctx: Any) -> Any:
87
+ try:
88
+ return super().invoke(ctx)
89
+ except ApiError as exc:
90
+ typer.echo(f"Error: {exc}", err=True)
91
+ raise typer.Exit(1) from exc
92
+ except click.exceptions.Exit as exc:
93
+ raise typer.Exit(exc.exit_code) from exc
94
+ except click.exceptions.Abort as exc:
95
+ raise typer.Abort() from exc
96
+ except click.ClickException as exc:
97
+ typer.echo(f"Error: {exc.format_message()}", err=True)
98
+ raise typer.Exit(exc.exit_code) from exc
99
+
100
+
101
+ def _apply_json_flag(ctx: click.Context, _param: click.Parameter, value: bool | None) -> bool | None:
102
+ """Merge a per-subcommand ``--json/--no-json`` into the shared context.
103
+
104
+ The root callback owns the flag-first form (``tai --json <cmd>``); this lets the
105
+ same flag ride AFTER the subcommand (``tai <cmd> --json``). ``value`` is ``None``
106
+ unless the flag was actually passed, so the root callback's setting stands when
107
+ the trailing flag is absent."""
108
+ if value is not None and isinstance(ctx.obj, AppContext):
109
+ ctx.obj.json_output = value
110
+ return value
111
+
112
+
113
+ def _json_flag_option() -> TyperOption:
114
+ """A fresh trailing ``--json/--no-json`` flag that merges into the context.
115
+
116
+ Built as a :class:`TyperOption` so its parse handling matches the Typer context
117
+ the compiled commands run under (a raw ``click.Option`` reads a context attribute
118
+ Typer's vendored context does not carry)."""
119
+ return TyperOption(
120
+ param_decls=["--json/--no-json"],
121
+ is_flag=True,
122
+ default=None,
123
+ expose_value=False,
124
+ callback=_apply_json_flag,
125
+ help="Emit raw JSON instead of human tables.",
126
+ )
127
+
128
+
129
+ def inject_json_flag(command: click.Command) -> None:
130
+ """Give a compiled command the trailing ``--json/--no-json`` form.
131
+
132
+ A group recurses into every subcommand so a leaf reached as ``tai <group> <cmd>``
133
+ accepts the flag in its own right (``tai tools list --json``), matching the root
134
+ callback's flag-first form; a single leaf command gets the flag directly. The flag
135
+ is only meaningful once, so groups keep the root form and only leaf commands carry
136
+ the trailing one.
137
+
138
+ A group is detected by its ``commands`` mapping rather than by ``isinstance`` —
139
+ Typer's compiled sub-groups are its own vendored ``Group`` type, not a
140
+ :class:`click.Group` subclass. Extensions apply this to their own Typer-backed
141
+ leaves after mounting them."""
142
+ subcommands = getattr(command, "commands", None)
143
+ if subcommands is not None:
144
+ for sub in subcommands.values():
145
+ inject_json_flag(cast(click.Command, sub))
146
+ else:
147
+ command.params.append(cast(click.Parameter, _json_flag_option()))
148
+
149
+
150
+ def mount_launcher(group: click.Group, launcher: click.Command, name: str) -> None:
151
+ """Mount a click launcher command under its ``tai`` subcommand name.
152
+
153
+ The command's own ``name`` is the canonical subcommand name now, so it is set
154
+ here (the rich help lists commands by that name, not by the registration key).
155
+ Extensions use this to mount their raw click launcher commands onto the root
156
+ group."""
157
+ launcher.name = name
158
+ group.add_command(launcher)
159
+
160
+
161
+ cli_app = typer.Typer(
162
+ name="tai",
163
+ cls=TaiCLIGroup,
164
+ help="Operate a tai42 server from the terminal.",
165
+ no_args_is_help=True,
166
+ add_completion=False,
167
+ )
168
+
169
+
170
+ @cli_app.callback()
171
+ def main(
172
+ ctx: typer.Context,
173
+ json_output: bool = typer.Option(
174
+ False,
175
+ "--json/--no-json",
176
+ help="Emit raw JSON instead of human tables.",
177
+ ),
178
+ server: str | None = typer.Option(
179
+ None,
180
+ "--server",
181
+ metavar="URL",
182
+ help="Server base URL. Resolved: this flag -> TAI_SERVER_URL -> config.toml -> local default.",
183
+ ),
184
+ api_key_stdin: bool = typer.Option(
185
+ False,
186
+ "--api-key-stdin",
187
+ help=(
188
+ "Read the API key as one line from stdin. Resolved: this flag -> TAI_API_KEY -> "
189
+ "config.toml -> interactive prompt. There is no --api-key VALUE flag (a value leaks "
190
+ "via ps and shell history)."
191
+ ),
192
+ ),
193
+ ) -> None:
194
+ # Bootstrap a local ``.env`` once for the whole CLI so every subcommand — the
195
+ # remote client and any contributed launcher alike — sees it. ``TAI_CONFIG_MODE``
196
+ # is read straight from the environment (default ``file``), normalized and
197
+ # validated here so this client needs no server settings module. Under ``k8s`` the
198
+ # platform injects env directly and a local ``.env`` must not shadow it, so the
199
+ # load is skipped.
200
+ raw_mode = os.environ.get("TAI_CONFIG_MODE", "file")
201
+ config_mode = raw_mode.strip().lower()
202
+ if config_mode not in ("file", "k8s"):
203
+ raise typer.BadParameter(
204
+ f"Invalid TAI_CONFIG_MODE={raw_mode!r}. Must be one of: file, k8s",
205
+ param_hint="TAI_CONFIG_MODE",
206
+ )
207
+ if config_mode != "k8s":
208
+ load_dotenv()
209
+ ctx.obj = AppContext(json_output=json_output, server_override=server, api_key_stdin=api_key_stdin)
210
+
211
+
212
+ # Remote command groups — thin clients over the ``/api/*`` routes.
213
+ _REMOTE_GROUPS: list[tuple[typer.Typer, str]] = [
214
+ (tools.app, "tools"),
215
+ (presets.app, "presets"),
216
+ (agents.app, "agents"),
217
+ (extensions.app, "extensions"),
218
+ (connectors.app, "connectors"),
219
+ (conversations.app, "conversations"),
220
+ (hooks.app, "hooks"),
221
+ (channels.app, "channels"),
222
+ (checkpoints.app, "checkpoints"),
223
+ (notifications.app, "notifications"),
224
+ (storage.app, "storage"),
225
+ (resources.app, "resources"),
226
+ (fleet.app, "fleet"),
227
+ (manifest.app, "manifest"),
228
+ (mcp.app, "mcp"),
229
+ (sub_mcp.app, "sub-mcp"),
230
+ (templates.app, "templates"),
231
+ (config.app, "config"),
232
+ (keys.app, "keys"),
233
+ (scopes.app, "scopes"),
234
+ (roles.app, "roles"),
235
+ (auth.app, "auth"),
236
+ (backup.app, "backup"),
237
+ (schedules.app, "schedules"),
238
+ (obs.app, "obs"),
239
+ (plugins.app, "plugins"),
240
+ (traces.app, "traces"),
241
+ (interactions.app, "interactions"),
242
+ (system.app, "system"),
243
+ (tool_meta.app, "tool-meta"),
244
+ ]
245
+ for group_app, group_name in _REMOTE_GROUPS:
246
+ cli_app.add_typer(group_app, name=group_name)
247
+
248
+ # CLI-native pieces the remote client owns outright.
249
+ cli_app.add_typer(completion.app, name="completion")
250
+ cli_app.command(name="version")(version.version)
251
+
252
+
253
+ def _discover_extensions(root: click.Group) -> None:
254
+ """Load every ``tai.commands`` entry point and let it register its commands.
255
+
256
+ Each entry point loads to a ``register(group: click.Group) -> None`` callable and
257
+ is invoked with the compiled root group. Entry points are processed in sorted
258
+ entry-point-name order so contributed help order is deterministic. A failing load
259
+ or a failing ``register`` propagates — a broken contribution is never swallowed."""
260
+ discovered = sorted(entry_points(group=_EXTENSION_GROUP), key=lambda ep: ep.name)
261
+ for ep in discovered:
262
+ register = ep.load()
263
+ register(root)
264
+
265
+
266
+ def _build_app() -> click.Group:
267
+ """Compile the Typer app to a click group, give every native leaf the trailing
268
+ ``--json`` form, then let installed extensions contribute their commands."""
269
+ command = cast(click.Group, get_command(cli_app))
270
+ # Inject the trailing ``--json`` form onto the native tree BEFORE extensions run;
271
+ # each extension applies :func:`inject_json_flag` to its own Typer-backed leaves.
272
+ inject_json_flag(command)
273
+ _discover_extensions(command)
274
+ return command
275
+
276
+
277
+ app = _build_app()
278
+
279
+
280
+ if __name__ == "__main__":
281
+ app()
tai42_cli/client.py ADDED
@@ -0,0 +1,273 @@
1
+ """Shared HTTP client for the ``tai`` CLI's remote commands.
2
+
3
+ Every remote command talks to the server's ``/api/*`` surface — the SAME
4
+ routes the Studio calls — through this one client, so the envelope contract and
5
+ authentication live in exactly one place.
6
+
7
+ The wire contract:
8
+
9
+ * the api key travels in the ``x-api-key`` header;
10
+ * a success body is ``{"data": ...}`` and is unwrapped to the inner value;
11
+ * a failure body is ``{"error": "<message>"}`` and, together with the HTTP
12
+ status, is raised as a typed :class:`ApiError` (401/404/409/400 each get their
13
+ own subclass so callers can distinguish them);
14
+ * streaming runs are consumed frame by frame off an SSE response — never
15
+ buffered whole.
16
+ """
17
+
18
+ import json
19
+ from collections.abc import Iterator, Mapping
20
+ from typing import Any
21
+
22
+ import httpx
23
+
24
+
25
+ class ApiError(Exception):
26
+ """A non-2xx response from the server API.
27
+
28
+ ``message`` is the server's ``{"error": ...}`` text where present, else the
29
+ raw response body. ``status_code`` is the HTTP status.
30
+ """
31
+
32
+ def __init__(self, message: str, *, status_code: int) -> None:
33
+ super().__init__(message)
34
+ self.message = message
35
+ self.status_code = status_code
36
+
37
+
38
+ class AuthError(ApiError):
39
+ """A 401 — the request carried no API key the server would accept."""
40
+
41
+
42
+ class NotFoundError(ApiError):
43
+ """A 404 — the addressed resource does not exist."""
44
+
45
+
46
+ class ConflictError(ApiError):
47
+ """A 409 — the request conflicts with the current server state."""
48
+
49
+
50
+ class BadRequestError(ApiError):
51
+ """A 400 — the server rejected the request payload."""
52
+
53
+
54
+ _STATUS_ERRORS: dict[int, type[ApiError]] = {
55
+ 400: BadRequestError,
56
+ 401: AuthError,
57
+ 404: NotFoundError,
58
+ 409: ConflictError,
59
+ }
60
+
61
+
62
+ def _server_message(response: httpx.Response) -> str | None:
63
+ """The ``{"error": ...}`` message from a response body, or ``None`` when the
64
+ body is not the JSON error envelope."""
65
+ try:
66
+ body = response.json()
67
+ except (json.JSONDecodeError, ValueError):
68
+ return None
69
+ if isinstance(body, Mapping):
70
+ error = body.get("error")
71
+ if isinstance(error, str):
72
+ return error
73
+ return None
74
+
75
+
76
+ def _error_from_response(response: httpx.Response) -> ApiError:
77
+ status = response.status_code
78
+ message = _server_message(response) or response.text.strip()
79
+ if status == 401:
80
+ detail = message or "no valid API key was accepted by the server"
81
+ return AuthError(f"not authenticated: {detail}", status_code=401)
82
+ if not message:
83
+ message = f"HTTP {status}"
84
+ error_cls = _STATUS_ERRORS.get(status, ApiError)
85
+ return error_cls(message, status_code=status)
86
+
87
+
88
+ def _unwrap(response: httpx.Response) -> Any:
89
+ """Return the ``data`` payload of a success response, raising on any failure
90
+ status or a malformed success envelope."""
91
+ if response.status_code >= 400:
92
+ raise _error_from_response(response)
93
+ if response.status_code == 204 or not response.content:
94
+ return None
95
+ try:
96
+ body = response.json()
97
+ except (json.JSONDecodeError, ValueError) as exc:
98
+ # A 2xx whose body is not JSON (a proxy's HTML page, say) is as malformed as
99
+ # a missing ``data`` key — surface it as the same typed error, not a raw decode.
100
+ raise ApiError(
101
+ f"malformed success envelope (body is not JSON): {response.text!r}",
102
+ status_code=response.status_code,
103
+ ) from exc
104
+ if not isinstance(body, Mapping) or "data" not in body:
105
+ raise ApiError(
106
+ f"malformed success envelope (expected a 'data' key): {body!r}",
107
+ status_code=response.status_code,
108
+ )
109
+ return body["data"]
110
+
111
+
112
+ def iter_sse_data(lines: Iterator[str]) -> Iterator[tuple[str | None, str]]:
113
+ """Yield ``(event, data)`` for each SSE frame from a line iterator.
114
+
115
+ The server emits one JSON object per frame as a single ``data:`` line
116
+ terminated by a blank line. A frame's type may ride INSIDE that JSON (the
117
+ agents/runs stream sends no ``event:`` line) OR arrive OUT OF BAND on an
118
+ ``event:`` line (the interactions stream), so the parser surfaces the
119
+ ``event:`` value when present and yields ``None`` for it otherwise.
120
+ Comment/keepalive lines (a leading ``:``) and any other SSE fields are
121
+ ignored; multi-line ``data:`` values are joined with newlines.
122
+ """
123
+ event: str | None = None
124
+ data_parts: list[str] = []
125
+ for line in lines:
126
+ if line == "":
127
+ if data_parts:
128
+ yield event, "\n".join(data_parts)
129
+ event = None
130
+ data_parts = []
131
+ continue
132
+ if line.startswith(":"):
133
+ continue
134
+ field, _, value = line.partition(":")
135
+ value = value[1:] if value.startswith(" ") else value
136
+ if field == "data":
137
+ data_parts.append(value)
138
+ elif field == "event":
139
+ event = value
140
+ if data_parts:
141
+ yield event, "\n".join(data_parts)
142
+
143
+
144
+ class ApiClient:
145
+ """A thin httpx wrapper that owns the api-key header and the envelope.
146
+
147
+ Construct with a ``base_url`` and an ``api_key``; inject ``transport`` in tests
148
+ to serve responses from a fake without any network. ``api_key=None`` builds an
149
+ ANONYMOUS client that sends no credential header — the one public door the CLI
150
+ calls (``tai auth claim`` exchanging a claim token, which the caller has no key for
151
+ yet); every other command passes a real key.
152
+ """
153
+
154
+ def __init__(
155
+ self,
156
+ base_url: str,
157
+ api_key: str | None,
158
+ *,
159
+ transport: httpx.BaseTransport | None = None,
160
+ timeout: float = 30.0,
161
+ ) -> None:
162
+ # No key → no ``x-api-key`` header at all, so a public route is never handed a
163
+ # stale/wrong credential (which its always-public middleware would ignore, but a
164
+ # protected route would 401 on).
165
+ headers = {"x-api-key": api_key} if api_key is not None else {}
166
+ self._client = httpx.Client(
167
+ base_url=base_url,
168
+ timeout=timeout,
169
+ transport=transport,
170
+ headers=headers,
171
+ )
172
+
173
+ def __enter__(self) -> "ApiClient":
174
+ return self
175
+
176
+ def __exit__(self, *exc_info: object) -> None:
177
+ self.close()
178
+
179
+ def close(self) -> None:
180
+ self._client.close()
181
+
182
+ def request(
183
+ self,
184
+ method: str,
185
+ path: str,
186
+ *,
187
+ json: Any | None = None,
188
+ params: Mapping[str, Any] | None = None,
189
+ ) -> Any:
190
+ response = self._client.request(method, path, json=json, params=params)
191
+ return _unwrap(response)
192
+
193
+ def request_raw(
194
+ self,
195
+ method: str,
196
+ path: str,
197
+ *,
198
+ json: Any | None = None,
199
+ params: Mapping[str, Any] | None = None,
200
+ ) -> httpx.Response:
201
+ """Perform the auth'd request and return the RAW response, unwrapping no
202
+ envelope, but still raising the typed error on a failure status.
203
+
204
+ The download routes (a bare backup document, a CSV/JSON export) answer
205
+ outside the ``{"data": ...}`` envelope, so their callers read the body
206
+ directly while keeping this module's auth, base URL, and typed errors.
207
+ """
208
+ response = self._client.request(method, path, json=json, params=params)
209
+ if response.status_code >= 400:
210
+ raise _error_from_response(response)
211
+ return response
212
+
213
+ def get(self, path: str, *, params: Mapping[str, Any] | None = None) -> Any:
214
+ return self.request("GET", path, params=params)
215
+
216
+ def post(
217
+ self,
218
+ path: str,
219
+ *,
220
+ json: Any | None = None,
221
+ params: Mapping[str, Any] | None = None,
222
+ ) -> Any:
223
+ return self.request("POST", path, json=json, params=params)
224
+
225
+ def patch(
226
+ self,
227
+ path: str,
228
+ *,
229
+ json: Any | None = None,
230
+ params: Mapping[str, Any] | None = None,
231
+ ) -> Any:
232
+ return self.request("PATCH", path, json=json, params=params)
233
+
234
+ def put(
235
+ self,
236
+ path: str,
237
+ *,
238
+ json: Any | None = None,
239
+ params: Mapping[str, Any] | None = None,
240
+ ) -> Any:
241
+ return self.request("PUT", path, json=json, params=params)
242
+
243
+ def delete(self, path: str, *, params: Mapping[str, Any] | None = None) -> Any:
244
+ return self.request("DELETE", path, params=params)
245
+
246
+ def stream(
247
+ self,
248
+ method: str,
249
+ path: str,
250
+ *,
251
+ json: Any | None = None,
252
+ params: Mapping[str, Any] | None = None,
253
+ ) -> Iterator[tuple[str | None, str]]:
254
+ """Yield each SSE frame as ``(event, data)`` from a streaming run, incrementally.
255
+
256
+ The response body is consumed frame by frame — a run's output reaches
257
+ the caller as it arrives, never after the whole run completes. ``event``
258
+ is the frame's out-of-band ``event:`` type when the server sends one (the
259
+ interactions stream) and ``None`` otherwise (the runs stream, whose type
260
+ rides inside the JSON ``data``). A failure status raises the typed error
261
+ before any frame is yielded.
262
+ """
263
+ with self._client.stream(
264
+ method,
265
+ path,
266
+ json=json,
267
+ params=params,
268
+ headers={"accept": "text/event-stream"},
269
+ ) as response:
270
+ if response.status_code >= 400:
271
+ response.read()
272
+ raise _error_from_response(response)
273
+ yield from iter_sse_data(response.iter_lines())
@@ -0,0 +1,5 @@
1
+ """Remote command groups — thin clients over a tai42 server's ``/api/*`` routes.
2
+
3
+ Each module exposes a ``typer.Typer`` app named ``app`` that the root CLI
4
+ registers as a subcommand group. The command bodies are added per domain.
5
+ """