kanbaroo-cli 0.2.2__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,323 @@
1
+ """
2
+ Implementation of ``kb epic``.
3
+
4
+ Wraps ``/workspaces/{id}/epics`` and the epic id-addressed endpoints.
5
+ ``show``, ``close``, and ``reopen`` all accept a ``{KEY}-{N}`` handle
6
+ and translate via ``GET /epics/by-key/{human_id}`` (added this cage)
7
+ so the user never has to type a UUID.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ from typing import Any
13
+
14
+ import typer
15
+
16
+ from kanbaroo_cli.client import ApiClient, ApiError, ApiRequestError
17
+ from kanbaroo_cli.context import build_client, require_config
18
+ from kanbaroo_cli.rendering import (
19
+ exit_on_api_error,
20
+ print_json,
21
+ print_table,
22
+ stderr_console,
23
+ stdout_console,
24
+ )
25
+ from kanbaroo_cli.resolvers import (
26
+ require_effective_workspace,
27
+ resolve_epic,
28
+ resolve_workspace,
29
+ try_resolve_other,
30
+ )
31
+ from kanbaroo_cli.similar import fetch_similar_entities, print_similar_entities
32
+
33
+ app = typer.Typer(
34
+ name="epic",
35
+ help="Create, list, and transition epics.",
36
+ no_args_is_help=True,
37
+ )
38
+
39
+
40
+ def _epic_rows(items: list[dict[str, object]]) -> list[list[str]]:
41
+ """
42
+ Format a list of epic bodies into table rows.
43
+ """
44
+ return [
45
+ [
46
+ str(item["human_id"]),
47
+ str(item["title"]),
48
+ str(item["state"]),
49
+ "yes" if item["deleted_at"] else "no",
50
+ ]
51
+ for item in items
52
+ ]
53
+
54
+
55
+ @app.command("list")
56
+ def list_epics(
57
+ workspace: str | None = typer.Option(
58
+ None,
59
+ "--workspace",
60
+ help=(
61
+ "Workspace key or UUID. Falls back to $KANBAROO_WORKSPACE "
62
+ "and default_workspace from config."
63
+ ),
64
+ ),
65
+ include_deleted: bool = typer.Option(
66
+ False,
67
+ "--include-deleted",
68
+ help="Include soft-deleted epics in the listing.",
69
+ ),
70
+ as_json: bool = typer.Option(
71
+ False,
72
+ "--json",
73
+ help="Emit machine-readable JSON instead of a Rich table.",
74
+ ),
75
+ ) -> None:
76
+ """
77
+ List every epic in a workspace.
78
+ """
79
+ config = require_config()
80
+ workspace_ref = require_effective_workspace(workspace, config)
81
+ items: list[dict[str, object]] = []
82
+ with build_client(config) as client:
83
+ try:
84
+ ws = resolve_workspace(client, workspace_ref)
85
+ cursor: str | None = None
86
+ while True:
87
+ params: dict[str, object] = {"limit": 200}
88
+ if include_deleted:
89
+ params["include_deleted"] = True
90
+ if cursor is not None:
91
+ params["cursor"] = cursor
92
+ response = client.get(
93
+ f"/workspaces/{ws['id']}/epics",
94
+ params=params,
95
+ )
96
+ body = response.json()
97
+ items.extend(body["items"])
98
+ cursor = body.get("next_cursor")
99
+ if cursor is None:
100
+ break
101
+ except ApiError as exc:
102
+ exit_on_api_error(exc)
103
+
104
+ if as_json:
105
+ print_json(items)
106
+ return
107
+ print_table(
108
+ columns=["human_id", "title", "state", "deleted"],
109
+ rows=_epic_rows(items),
110
+ title=f"epics in {ws['key']}",
111
+ )
112
+
113
+
114
+ @app.command("create")
115
+ def create_epic(
116
+ workspace: str | None = typer.Option(
117
+ None,
118
+ "--workspace",
119
+ help=(
120
+ "Workspace key or UUID. Falls back to $KANBAROO_WORKSPACE "
121
+ "and default_workspace from config."
122
+ ),
123
+ ),
124
+ title: str = typer.Option(..., "--title", help="Epic title."),
125
+ description: str | None = typer.Option(
126
+ None,
127
+ "--description",
128
+ help="Optional markdown description.",
129
+ ),
130
+ force: bool = typer.Option(
131
+ False,
132
+ "--force",
133
+ help="Skip the duplicate-title prompt and create the epic regardless.",
134
+ ),
135
+ as_json: bool = typer.Option(
136
+ False,
137
+ "--json",
138
+ help="Emit the created epic as JSON instead of a Rich table.",
139
+ ),
140
+ ) -> None:
141
+ """
142
+ Create a new epic in ``workspace``.
143
+
144
+ Mirrors ``kb story create``: a normalised-title check runs against
145
+ the workspace before posting and warns the user about likely
146
+ duplicates. ``--force`` skips the prompt; ``--json`` never prompts
147
+ but folds the matches into a ``warnings`` field.
148
+ """
149
+ config = require_config()
150
+ workspace_ref = require_effective_workspace(workspace, config)
151
+ payload: dict[str, object] = {"title": title}
152
+ if description is not None:
153
+ payload["description"] = description
154
+ similar: list[dict[str, Any]] = []
155
+ with build_client(config) as client:
156
+ try:
157
+ ws = resolve_workspace(client, workspace_ref)
158
+ similar = fetch_similar_entities(
159
+ client,
160
+ workspace_id=str(ws["id"]),
161
+ resource="epics",
162
+ field_name="title",
163
+ value=title,
164
+ )
165
+ if similar and not as_json and not force:
166
+ print_similar_entities(similar, label_key="human_id", entity="epic")
167
+ confirmed = typer.confirm("Create anyway?", default=False)
168
+ if not confirmed:
169
+ stdout_console.print("aborted: existing entity has a similar name")
170
+ raise typer.Exit(code=1)
171
+ response = client.post(
172
+ f"/workspaces/{ws['id']}/epics",
173
+ json=payload,
174
+ )
175
+ except ApiError as exc:
176
+ exit_on_api_error(exc)
177
+ body = response.json()
178
+
179
+ if as_json:
180
+ if similar:
181
+ body = {**body, "warnings": {"similar": [s["id"] for s in similar]}}
182
+ print_json(body)
183
+ return
184
+ print_table(
185
+ columns=["field", "value"],
186
+ rows=[
187
+ ["human_id", body["human_id"]],
188
+ ["title", body["title"]],
189
+ ["state", body["state"]],
190
+ ["id", body["id"]],
191
+ ],
192
+ title=f"created epic {body['human_id']}",
193
+ )
194
+
195
+
196
+ @app.command("show")
197
+ def show_epic(
198
+ ref: str = typer.Argument(..., help="Epic handle (e.g. KAN-4) or UUID."),
199
+ as_json: bool = typer.Option(
200
+ False,
201
+ "--json",
202
+ help="Emit the epic body as JSON instead of a Rich table.",
203
+ ),
204
+ ) -> None:
205
+ """
206
+ Show a single epic by its human id or UUID.
207
+
208
+ A 404 from the epic lookup falls through to a story lookup with
209
+ the same ref; if that hits, the user gets a hint to run
210
+ ``kb story show`` instead of a bare "not found" message.
211
+ """
212
+ config = require_config()
213
+ with build_client(config) as client:
214
+ try:
215
+ body = resolve_epic(client, ref)
216
+ except ApiRequestError as exc:
217
+ if exc.status_code == 404:
218
+ _suggest_alternative(client, ref, missing="epic", alternative="story")
219
+ raise typer.Exit(code=1) from exc
220
+ exit_on_api_error(exc)
221
+ except ApiError as exc:
222
+ exit_on_api_error(exc)
223
+
224
+ if as_json:
225
+ print_json(body)
226
+ return
227
+ print_table(
228
+ columns=["field", "value"],
229
+ rows=[
230
+ ["human_id", body["human_id"]],
231
+ ["title", body["title"]],
232
+ ["state", body["state"]],
233
+ ["description", body["description"] or ""],
234
+ ["id", body["id"]],
235
+ ["version", str(body["version"])],
236
+ ["created_at", body["created_at"]],
237
+ ["updated_at", body["updated_at"]],
238
+ ["deleted_at", body["deleted_at"] or ""],
239
+ ],
240
+ title=f"epic {body['human_id']}",
241
+ )
242
+
243
+
244
+ def _transition(ref: str, action: str, *, as_json: bool) -> None:
245
+ """
246
+ Shared implementation of ``close`` and ``reopen``.
247
+
248
+ Both endpoints require ``If-Match``; we fetch the epic first to
249
+ learn its current version and then POST to the action path.
250
+ """
251
+ config = require_config()
252
+ with build_client(config) as client:
253
+ try:
254
+ epic = resolve_epic(client, ref)
255
+ response = client.post_with_etag(
256
+ f"/epics/{epic['id']}",
257
+ f"/epics/{epic['id']}/{action}",
258
+ )
259
+ except ApiError as exc:
260
+ exit_on_api_error(exc)
261
+ body = response.json()
262
+
263
+ if as_json:
264
+ print_json(body)
265
+ return
266
+ print_table(
267
+ columns=["field", "value"],
268
+ rows=[
269
+ ["human_id", body["human_id"]],
270
+ ["title", body["title"]],
271
+ ["state", body["state"]],
272
+ ["version", str(body["version"])],
273
+ ],
274
+ title=f"{action}d epic {body['human_id']}",
275
+ )
276
+
277
+
278
+ @app.command("close")
279
+ def close_epic(
280
+ ref: str = typer.Argument(..., help="Epic handle (e.g. KAN-4) or UUID."),
281
+ as_json: bool = typer.Option(False, "--json", help="Emit JSON output."),
282
+ ) -> None:
283
+ """
284
+ Close an epic.
285
+ """
286
+ _transition(ref, "close", as_json=as_json)
287
+
288
+
289
+ @app.command("reopen")
290
+ def reopen_epic(
291
+ ref: str = typer.Argument(..., help="Epic handle (e.g. KAN-4) or UUID."),
292
+ as_json: bool = typer.Option(False, "--json", help="Emit JSON output."),
293
+ ) -> None:
294
+ """
295
+ Reopen a closed epic.
296
+ """
297
+ _transition(ref, "reopen", as_json=as_json)
298
+
299
+
300
+ def _suggest_alternative(
301
+ client: ApiClient,
302
+ ref: str,
303
+ *,
304
+ missing: str,
305
+ alternative: str,
306
+ ) -> None:
307
+ """
308
+ Print a Rich error explaining that ``ref`` is the other entity type.
309
+
310
+ Mirrors the helper in ``kanbaroo_cli.commands.story`` so both
311
+ ``show`` commands offer the same "this looks like a story / epic"
312
+ hint when the user picks the wrong one.
313
+ """
314
+ stderr_console.print(
315
+ f"[red]Error (404 not_found):[/red] {missing} {ref!r} not found."
316
+ )
317
+ other = try_resolve_other(client, ref, other=alternative)
318
+ if other is None:
319
+ return
320
+ handle = other.get("human_id", ref)
321
+ stderr_console.print(
322
+ f"{handle} is a {alternative} - try `kb {alternative} show {handle}`."
323
+ )
@@ -0,0 +1,81 @@
1
+ """
2
+ Implementation of ``kb export``.
3
+
4
+ Hits ``GET /api/v1/workspaces/{id}/export`` which is cage K's
5
+ responsibility. The CLI-side implementation here is correct in its
6
+ own right: resolve the workspace, stream the response body to
7
+ ``<output>/<workspace_key>-<timestamp>.tar.gz``, and surface clear
8
+ errors when the endpoint is not yet available.
9
+
10
+ Until the export endpoint lands the server returns 404 (or another
11
+ canonical error envelope) and ``kb export`` prints a friendly
12
+ ``export endpoint not available yet`` message instead of a traceback.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ from datetime import UTC, datetime
18
+ from pathlib import Path
19
+
20
+ import httpx
21
+ import typer
22
+
23
+ from kanbaroo_cli.client import ApiError, ApiRequestError, ApiTransportError
24
+ from kanbaroo_cli.context import build_client, require_config
25
+ from kanbaroo_cli.rendering import (
26
+ exit_on_api_error,
27
+ stderr_console,
28
+ stdout_console,
29
+ )
30
+ from kanbaroo_cli.resolvers import require_effective_workspace, resolve_workspace
31
+
32
+
33
+ def export_command(
34
+ workspace: str | None = typer.Option(
35
+ None,
36
+ "--workspace",
37
+ help=(
38
+ "Workspace key or UUID. Falls back to $KANBAROO_WORKSPACE "
39
+ "and default_workspace from config."
40
+ ),
41
+ ),
42
+ output: Path = typer.Option(
43
+ ...,
44
+ "--output",
45
+ help="Directory to write the exported archive into.",
46
+ ),
47
+ ) -> None:
48
+ """
49
+ Download a workspace export archive into ``output``.
50
+ """
51
+ config = require_config()
52
+ workspace_ref = require_effective_workspace(workspace, config)
53
+ output.mkdir(parents=True, exist_ok=True)
54
+ with build_client(config) as client:
55
+ try:
56
+ ws = resolve_workspace(client, workspace_ref)
57
+ except ApiError as exc:
58
+ exit_on_api_error(exc)
59
+
60
+ timestamp = datetime.now(UTC).strftime("%Y%m%dT%H%M%SZ")
61
+ dest = output / f"{ws['key']}-{timestamp}.tar.gz"
62
+ try:
63
+ response = client.get(f"/workspaces/{ws['id']}/export")
64
+ except ApiRequestError as exc:
65
+ if exc.status_code == 404:
66
+ stderr_console.print(
67
+ "[red]Error:[/red] export endpoint not available yet. "
68
+ "This server does not expose /workspaces/{id}/export; "
69
+ "the feature arrives in a later milestone."
70
+ )
71
+ raise typer.Exit(code=1) from exc
72
+ exit_on_api_error(exc)
73
+ except ApiTransportError as exc:
74
+ exit_on_api_error(exc)
75
+ except httpx.HTTPError as exc:
76
+ stderr_console.print(f"[red]Error:[/red] {exc}")
77
+ raise typer.Exit(code=1) from exc
78
+
79
+ dest.write_bytes(response.content)
80
+
81
+ stdout_console.print(f"wrote [bold]{dest}[/bold].")
@@ -0,0 +1,160 @@
1
+ """
2
+ Implementation of ``kb init``.
3
+
4
+ Creates ``$KANBAROO_CONFIG_DIR`` (defaulting to ``~/.kanbaroo``), runs
5
+ Alembic migrations to stand up the SQLite database, issues the first
6
+ personal API token, writes ``config.toml``, and prints the plaintext
7
+ token exactly once.
8
+
9
+ The command is idempotent-adjacent: running it twice against an existing
10
+ config.toml is an error unless ``--force`` is passed. ``--force`` issues
11
+ a fresh token and rewrites the config but never revokes existing tokens
12
+ in the database; the user is free to revoke old tokens later via
13
+ ``kb token`` (milestone 10).
14
+ """
15
+
16
+ import os
17
+ from pathlib import Path
18
+
19
+ import typer
20
+ from rich.console import Console
21
+ from rich.panel import Panel
22
+ from sqlalchemy.orm import Session, sessionmaker
23
+
24
+ from kanbaroo_core import ActorType
25
+ from kanbaroo_core.auth import create_token
26
+ from kanbaroo_core.db import engine_for_url
27
+ from kanbaroo_core.migrations import upgrade_to_head
28
+
29
+ console = Console()
30
+
31
+
32
+ def _default_config_dir() -> Path:
33
+ """
34
+ Resolve the config directory from ``KANBAROO_CONFIG_DIR`` or fall
35
+ back to ``$HOME/.kanbaroo``.
36
+ """
37
+ override = os.environ.get("KANBAROO_CONFIG_DIR")
38
+ if override:
39
+ return Path(override)
40
+ return Path.home() / ".kanbaroo"
41
+
42
+
43
+ def _default_database_url(config_dir: Path) -> str:
44
+ """
45
+ Resolve the database URL from ``KANBAROO_DATABASE_URL`` or fall
46
+ back to a SQLite file inside ``config_dir``.
47
+ """
48
+ override = os.environ.get("KANBAROO_DATABASE_URL")
49
+ if override:
50
+ return override
51
+ return f"sqlite:///{config_dir / 'kanbaroo.db'}"
52
+
53
+
54
+ def _default_actor_id() -> str:
55
+ """
56
+ Default the initial token's actor id to the OS user, or
57
+ ``"human"`` if ``$USER`` is not set (e.g. minimal containers).
58
+ """
59
+ return os.environ.get("USER") or "human"
60
+
61
+
62
+ def _render_config_toml(*, database_url: str, api_url: str, token: str) -> str:
63
+ """
64
+ Render the on-disk ``config.toml`` contents.
65
+
66
+ Kept deliberately hand-crafted rather than pulling in a TOML writer:
67
+ the file has three scalar keys and the escaping surface is tiny
68
+ (double-quoted strings, backslash-escape internal quotes).
69
+ """
70
+
71
+ def _escape(value: str) -> str:
72
+ return value.replace("\\", "\\\\").replace('"', '\\"')
73
+
74
+ return (
75
+ f'database_url = "{_escape(database_url)}"\n'
76
+ f'api_url = "{_escape(api_url)}"\n'
77
+ f'token = "{_escape(token)}"\n'
78
+ )
79
+
80
+
81
+ def init(
82
+ actor_id: str = typer.Option(
83
+ None,
84
+ "--actor-id",
85
+ help="Label stamped on mutations from this token. Defaults to $USER.",
86
+ ),
87
+ name: str = typer.Option(
88
+ "personal",
89
+ "--name",
90
+ help="Human-readable token name (shown in token lists).",
91
+ ),
92
+ force: bool = typer.Option(
93
+ False,
94
+ "--force",
95
+ help="Overwrite an existing config.toml and issue a new token.",
96
+ ),
97
+ ) -> None:
98
+ """
99
+ Initialise Kanbaroo's local config, database, and first API token.
100
+ """
101
+ config_dir = _default_config_dir()
102
+ database_url = _default_database_url(config_dir)
103
+ resolved_actor_id = actor_id or _default_actor_id()
104
+
105
+ config_dir.mkdir(parents=True, exist_ok=True)
106
+ config_path = config_dir / "config.toml"
107
+
108
+ if config_path.exists() and not force:
109
+ console.print(
110
+ f"[red]Error:[/red] {config_path} already exists. "
111
+ "Re-run with [bold]--force[/bold] to overwrite the config "
112
+ "and issue a new token."
113
+ )
114
+ raise typer.Exit(code=1)
115
+
116
+ upgrade_to_head(database_url)
117
+
118
+ engine = engine_for_url(database_url)
119
+ factory = sessionmaker(bind=engine, expire_on_commit=False)
120
+ session: Session = factory()
121
+ try:
122
+ _, plaintext = create_token(
123
+ session,
124
+ actor_type=ActorType.HUMAN,
125
+ actor_id=resolved_actor_id,
126
+ name=name,
127
+ )
128
+ session.commit()
129
+ finally:
130
+ session.close()
131
+ engine.dispose()
132
+
133
+ api_url = "http://localhost:8080"
134
+ config_path.write_text(
135
+ _render_config_toml(
136
+ database_url=database_url,
137
+ api_url=api_url,
138
+ token=plaintext,
139
+ ),
140
+ encoding="utf-8",
141
+ )
142
+
143
+ warning = (
144
+ "[bold yellow]Save this token now. "
145
+ "It will not be shown again.[/bold yellow]\n\n"
146
+ )
147
+ body = (
148
+ f"[bold]Token:[/bold] [green]{plaintext}[/green]\n"
149
+ f"[bold]Config path:[/bold] {config_path}\n"
150
+ f"[bold]Database URL:[/bold] {database_url}\n"
151
+ f"[bold]Actor:[/bold] human / {resolved_actor_id}\n"
152
+ f"[bold]Token name:[/bold] {name}"
153
+ )
154
+ console.print(
155
+ Panel.fit(
156
+ warning + body,
157
+ title="Kanbaroo initialized",
158
+ border_style="green",
159
+ )
160
+ )
@@ -0,0 +1,133 @@
1
+ """
2
+ Implementation of ``kb server``.
3
+
4
+ Thin wrappers around ``docker compose up -d`` and ``docker compose
5
+ down``. ``--wait`` on ``start`` polls ``/api/v1/workspaces`` until the
6
+ server responds or 30 seconds elapse.
7
+
8
+ We shell out via :func:`subprocess.run` rather than importing a
9
+ docker-compose Python binding because the binding surface is messy and
10
+ users already have ``docker compose`` on PATH as the supported entry
11
+ point per ``docker-compose.yml`` at the project root. When
12
+ ``$KANBAROO_COMPOSE_FILE`` is set we pass ``-f`` so the caller can
13
+ target a non-default file (useful for CI).
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import os
19
+ import subprocess
20
+ import time
21
+ from typing import Any
22
+
23
+ import httpx
24
+ import typer
25
+
26
+ from kanbaroo_cli.client import API_PREFIX
27
+ from kanbaroo_cli.context import require_config_api_only
28
+ from kanbaroo_cli.rendering import stderr_console, stdout_console
29
+
30
+ app = typer.Typer(
31
+ name="server",
32
+ help="Start or stop the Kanbaroo API server via docker compose.",
33
+ no_args_is_help=True,
34
+ )
35
+
36
+ _READY_TIMEOUT_SECONDS = 30.0
37
+ _READY_POLL_SECONDS = 0.5
38
+
39
+
40
+ def _compose_command(subcommand: list[str]) -> list[str]:
41
+ """
42
+ Build the ``docker compose`` command line, injecting
43
+ ``-f $KANBAROO_COMPOSE_FILE`` when the override is set.
44
+ """
45
+ argv: list[str] = ["docker", "compose"]
46
+ compose_file = os.environ.get("KANBAROO_COMPOSE_FILE")
47
+ if compose_file:
48
+ argv.extend(["-f", compose_file])
49
+ argv.extend(subcommand)
50
+ return argv
51
+
52
+
53
+ def _run_compose(subcommand: list[str]) -> None:
54
+ """
55
+ Invoke docker compose with the given subcommand and check the
56
+ result. Any non-zero exit propagates to the user.
57
+ """
58
+ subprocess.run(_compose_command(subcommand), check=True)
59
+
60
+
61
+ def _poll_until_ready(
62
+ *,
63
+ base_url: str,
64
+ token: str,
65
+ timeout_seconds: float = _READY_TIMEOUT_SECONDS,
66
+ ) -> bool:
67
+ """
68
+ Poll ``GET {base_url}{API_PREFIX}/workspaces`` until it responds
69
+ with a 2xx or ``timeout_seconds`` elapses.
70
+
71
+ Returns ``True`` on success, ``False`` on timeout.
72
+ """
73
+ deadline = time.monotonic() + timeout_seconds
74
+ headers: dict[str, Any] = {"Authorization": f"Bearer {token}"}
75
+ url = f"{base_url.rstrip('/')}{API_PREFIX}/workspaces"
76
+ with httpx.Client(timeout=2.0) as probe:
77
+ while time.monotonic() < deadline:
78
+ try:
79
+ response = probe.get(url, headers=headers)
80
+ if 200 <= response.status_code < 300:
81
+ return True
82
+ except httpx.HTTPError:
83
+ pass
84
+ time.sleep(_READY_POLL_SECONDS)
85
+ return False
86
+
87
+
88
+ @app.command("start")
89
+ def start_server(
90
+ wait: bool = typer.Option(
91
+ False,
92
+ "--wait",
93
+ help="Poll the API until it responds or 30 seconds elapse.",
94
+ ),
95
+ ) -> None:
96
+ """
97
+ Start the server (``docker compose up -d``).
98
+ """
99
+ try:
100
+ _run_compose(["up", "-d"])
101
+ except subprocess.CalledProcessError as exc:
102
+ stderr_console.print(
103
+ f"[red]Error:[/red] docker compose up failed (exit {exc.returncode})."
104
+ )
105
+ raise typer.Exit(code=exc.returncode) from exc
106
+
107
+ if wait:
108
+ config = require_config_api_only()
109
+ if not _poll_until_ready(
110
+ base_url=config.api_url,
111
+ token=config.token,
112
+ ):
113
+ stderr_console.print(
114
+ "[red]Error:[/red] server did not respond within 30 seconds."
115
+ )
116
+ raise typer.Exit(code=1)
117
+
118
+ stdout_console.print("server started.")
119
+
120
+
121
+ @app.command("stop")
122
+ def stop_server() -> None:
123
+ """
124
+ Stop the server (``docker compose down``).
125
+ """
126
+ try:
127
+ _run_compose(["down"])
128
+ except subprocess.CalledProcessError as exc:
129
+ stderr_console.print(
130
+ f"[red]Error:[/red] docker compose down failed (exit {exc.returncode})."
131
+ )
132
+ raise typer.Exit(code=exc.returncode) from exc
133
+ stdout_console.print("server stopped.")