mad-cli 0.4.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 (54) hide show
  1. mad_cli/__init__.py +3 -0
  2. mad_cli/__main__.py +6 -0
  3. mad_cli/app.py +77 -0
  4. mad_cli/commands/__init__.py +5 -0
  5. mad_cli/commands/_adapt.py +41 -0
  6. mad_cli/commands/_common.py +12 -0
  7. mad_cli/commands/config.py +94 -0
  8. mad_cli/commands/install.py +504 -0
  9. mad_cli/commands/instances.py +102 -0
  10. mad_cli/commands/keys.py +126 -0
  11. mad_cli/commands/lifecycle.py +69 -0
  12. mad_cli/commands/profiles.py +238 -0
  13. mad_cli/commands/service.py +220 -0
  14. mad_cli/commands/versions.py +61 -0
  15. mad_cli/core/__init__.py +4 -0
  16. mad_cli/core/claude_creds.py +31 -0
  17. mad_cli/core/compose.py +145 -0
  18. mad_cli/core/docker_check.py +89 -0
  19. mad_cli/core/envfile.py +140 -0
  20. mad_cli/core/instance.py +110 -0
  21. mad_cli/core/keyspec.py +98 -0
  22. mad_cli/core/paths.py +40 -0
  23. mad_cli/core/profiles.py +93 -0
  24. mad_cli/core/pypi.py +29 -0
  25. mad_cli/core/templates.py +91 -0
  26. mad_cli/core/usecases/__init__.py +11 -0
  27. mad_cli/core/usecases/adopt.py +55 -0
  28. mad_cli/core/usecases/configvals.py +94 -0
  29. mad_cli/core/usecases/errors.py +57 -0
  30. mad_cli/core/usecases/install.py +263 -0
  31. mad_cli/core/usecases/instances.py +156 -0
  32. mad_cli/core/usecases/keys.py +169 -0
  33. mad_cli/core/usecases/lifecycle.py +76 -0
  34. mad_cli/core/usecases/service.py +269 -0
  35. mad_cli/core/usecases/versions.py +126 -0
  36. mad_cli/py.typed +0 -0
  37. mad_cli/server/__init__.py +13 -0
  38. mad_cli/server/app.py +260 -0
  39. mad_cli/server/auth.py +41 -0
  40. mad_cli/server/models.py +156 -0
  41. mad_cli/templates/Dockerfile.tmpl +66 -0
  42. mad_cli/templates/__init__.py +6 -0
  43. mad_cli/templates/com.mad-core.mad-cli.plist.tmpl +28 -0
  44. mad_cli/templates/compose.yml.tmpl +29 -0
  45. mad_cli/templates/entrypoint.sh.tmpl +11 -0
  46. mad_cli/templates/mad-cli.service.tmpl +15 -0
  47. mad_cli/ui/__init__.py +5 -0
  48. mad_cli/ui/console.py +65 -0
  49. mad_cli/ui/prompts.py +83 -0
  50. mad_cli-0.4.0.dist-info/METADATA +167 -0
  51. mad_cli-0.4.0.dist-info/RECORD +54 -0
  52. mad_cli-0.4.0.dist-info/WHEEL +4 -0
  53. mad_cli-0.4.0.dist-info/entry_points.txt +2 -0
  54. mad_cli-0.4.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,269 @@
1
+ """Service-mode use cases: the API token, the dedicated server venv and the
2
+ systemd unit / launchd plist rendering.
3
+
4
+ The base CLI is a two-dependency package, so ``mad serve`` / ``mad service`` must
5
+ not assume the ``server`` extra is importable. When it is not, ``mad service
6
+ install`` bootstraps a dedicated virtualenv under ``config_root()/server-venv`` and
7
+ installs ``mad-cli[server]`` into it (pinned to the running CLI's version, or from
8
+ a local wheel via ``--wheel``); the rendered unit/plist then points ``ExecStart``
9
+ at ``<venv>/bin/mad serve``. When the extra *is* importable, the current
10
+ interpreter's ``mad`` is used and no venv is created.
11
+
12
+ Everything here is framework-free (no typer / rich / fastapi). Activating the unit
13
+ (``systemctl`` / ``launchctl``) is the caller's business; this module only renders
14
+ files and provisions the runtime.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import importlib.util
20
+ import os
21
+ import secrets
22
+ import shlex
23
+ import string
24
+ import subprocess
25
+ import sys
26
+ from dataclasses import dataclass
27
+ from importlib import resources
28
+ from pathlib import Path
29
+ from xml.sax.saxutils import escape as xml_escape
30
+
31
+ from mad_cli import __version__
32
+ from mad_cli.core.paths import config_root
33
+ from mad_cli.core.usecases.errors import PreconditionError
34
+
35
+ DEFAULT_HOST = "127.0.0.1"
36
+ DEFAULT_PORT = 7373
37
+ LOOPBACK_HOSTS = frozenset({"127.0.0.1", "::1", "localhost"})
38
+
39
+ LAUNCHD_LABEL = "com.mad-core.mad-cli"
40
+ SYSTEMD_UNIT_NAME = "mad-cli.service"
41
+ PLIST_NAME = f"{LAUNCHD_LABEL}.plist"
42
+
43
+ _SERVER_MODULES = ("fastapi", "uvicorn")
44
+
45
+
46
+ # ── API token ────────────────────────────────────────────────────────────────
47
+ def api_token_path() -> Path:
48
+ """Path of the bearer token file (``config_root()/api-token``)."""
49
+ return config_root() / "api-token"
50
+
51
+
52
+ def read_api_token() -> str | None:
53
+ """Return the stored bearer token, or ``None`` when it does not exist."""
54
+ path = api_token_path()
55
+ if not path.is_file():
56
+ return None
57
+ token = path.read_text(encoding="utf-8").strip()
58
+ return token or None
59
+
60
+
61
+ def ensure_api_token() -> str:
62
+ """Return the bearer token, generating a fresh 0600 one on first use."""
63
+ existing = read_api_token()
64
+ if existing is not None:
65
+ return existing
66
+ token = secrets.token_urlsafe(32)
67
+ path = api_token_path()
68
+ path.parent.mkdir(parents=True, exist_ok=True)
69
+ path.write_text(token + "\n", encoding="utf-8")
70
+ path.chmod(0o600)
71
+ return token
72
+
73
+
74
+ # ── server-extra detection & the dedicated venv ──────────────────────────────
75
+ def server_deps_available() -> bool:
76
+ """True when the ``server`` extra (fastapi + uvicorn) is importable here."""
77
+ return all(importlib.util.find_spec(mod) is not None for mod in _SERVER_MODULES)
78
+
79
+
80
+ def server_venv_dir() -> Path:
81
+ """The dedicated server virtualenv directory (``config_root()/server-venv``)."""
82
+ return config_root() / "server-venv"
83
+
84
+
85
+ def _venv_bin_dir(venv: Path) -> Path:
86
+ return venv / ("Scripts" if os.name == "nt" else "bin")
87
+
88
+
89
+ def server_venv_mad() -> Path:
90
+ """The ``mad`` console script inside the dedicated server venv."""
91
+ exe = "mad.exe" if os.name == "nt" else "mad"
92
+ return _venv_bin_dir(server_venv_dir()) / exe
93
+
94
+
95
+ def server_venv_exists() -> bool:
96
+ """True when the dedicated server venv has a usable ``mad`` binary."""
97
+ return server_venv_mad().exists()
98
+
99
+
100
+ def _run(cmd: list[str]) -> None:
101
+ """Run ``cmd``; raise :class:`PreconditionError` with captured output on failure."""
102
+ try:
103
+ result = subprocess.run(cmd, capture_output=True, text=True, check=False)
104
+ except OSError as exc:
105
+ raise PreconditionError(f"failed to run {' '.join(cmd)}: {exc}") from exc
106
+ if result.returncode != 0:
107
+ detail = (result.stderr or result.stdout or "").strip()
108
+ raise PreconditionError(
109
+ f"`{' '.join(cmd)}` failed (exit {result.returncode})"
110
+ + (f": {detail}" if detail else "")
111
+ )
112
+
113
+
114
+ def bootstrap_server_venv(*, wheel: Path | None = None) -> Path:
115
+ """Create ``config_root()/server-venv`` and install ``mad-cli[server]`` into it.
116
+
117
+ From ``wheel`` (a local wheel/sdist, extras appended) when given — no network,
118
+ exact artifact — otherwise ``mad-cli[server]==<running version>`` from PyPI. A
119
+ PyPI failure raises :class:`PreconditionError` hinting at ``--wheel``.
120
+ """
121
+ venv_dir = server_venv_dir()
122
+ _run([sys.executable, "-m", "venv", str(venv_dir)])
123
+ pip = str(_venv_bin_dir(venv_dir) / ("pip.exe" if os.name == "nt" else "pip"))
124
+ target = f"{wheel}[server]" if wheel is not None else f"mad-cli[server]=={__version__}"
125
+ try:
126
+ _run([pip, "install", target])
127
+ except PreconditionError:
128
+ if wheel is None:
129
+ raise PreconditionError(
130
+ "could not install 'mad-cli[server]' from PyPI into the server venv; "
131
+ "retry with --wheel PATH pointing at a local wheel or sdist"
132
+ ) from None
133
+ raise
134
+ return venv_dir
135
+
136
+
137
+ def server_venv_version() -> str | None:
138
+ """Return the ``mad_cli`` version installed in the server venv, or ``None``."""
139
+ python = _venv_bin_dir(server_venv_dir()) / ("python.exe" if os.name == "nt" else "python")
140
+ if not python.exists():
141
+ return None
142
+ try:
143
+ out = subprocess.run(
144
+ [str(python), "-c", "import mad_cli; print(mad_cli.__version__)"],
145
+ capture_output=True,
146
+ text=True,
147
+ check=False,
148
+ )
149
+ except OSError:
150
+ return None
151
+ if out.returncode != 0:
152
+ return None
153
+ return (out.stdout or "").strip() or None
154
+
155
+
156
+ # ── launcher resolution ──────────────────────────────────────────────────────
157
+ def _mad_launcher() -> list[str]:
158
+ """Robustly resolve the current interpreter's ``mad`` launcher argv.
159
+
160
+ Prefers the console script next to ``sys.executable`` (works inside a venv
161
+ regardless of ``PATH``), then ``PATH``, falling back to ``python -m mad_cli``.
162
+ """
163
+ import shutil
164
+
165
+ candidate = Path(sys.executable).with_name("mad.exe" if os.name == "nt" else "mad")
166
+ if candidate.exists():
167
+ return [str(candidate)]
168
+ found = shutil.which("mad")
169
+ if found:
170
+ return [found]
171
+ return [sys.executable, "-m", "mad_cli"]
172
+
173
+
174
+ def ensure_server_runtime(*, wheel: Path | None = None) -> tuple[list[str], bool]:
175
+ """Return ``(mad launcher argv, bootstrapped)`` for a server-capable ``mad``.
176
+
177
+ With ``wheel`` given, or when the ``server`` extra is not importable in the
178
+ current interpreter, a dedicated venv is provisioned and its ``mad`` is
179
+ returned; otherwise the current interpreter's ``mad`` is used unchanged.
180
+ """
181
+ if wheel is None and server_deps_available():
182
+ return _mad_launcher(), False
183
+ bootstrap_server_venv(wheel=wheel)
184
+ return [str(server_venv_mad())], True
185
+
186
+
187
+ def serve_argv(launcher: list[str], host: str, port: int) -> list[str]:
188
+ """Build the ``mad serve`` argv from a launcher and bind address."""
189
+ return [*launcher, "serve", "--host", host, "--port", str(port)]
190
+
191
+
192
+ def is_loopback(host: str) -> bool:
193
+ """True when ``host`` binds only the loopback interface."""
194
+ return host in LOOPBACK_HOSTS
195
+
196
+
197
+ # ── unit / plist rendering ───────────────────────────────────────────────────
198
+ def _load_template(name: str) -> string.Template:
199
+ text = resources.files("mad_cli.templates").joinpath(name).read_text(encoding="utf-8")
200
+ return string.Template(text)
201
+
202
+
203
+ def render_systemd_unit(*, exec_args: list[str], config_dir: Path) -> str:
204
+ """Render the systemd **user** unit. ``ExecStart`` is a single command line."""
205
+ return _load_template("mad-cli.service.tmpl").substitute(
206
+ exec_start=shlex.join(exec_args),
207
+ config_dir=str(config_dir),
208
+ )
209
+
210
+
211
+ def render_launchd_plist(*, exec_args: list[str], config_dir: Path, log_path: Path) -> str:
212
+ """Render the launchd LaunchAgent plist (``ProgramArguments`` as a string array)."""
213
+ program_arguments = "\n".join(
214
+ f" <string>{xml_escape(arg)}</string>" for arg in exec_args
215
+ )
216
+ return _load_template(f"{PLIST_NAME}.tmpl").substitute(
217
+ label=LAUNCHD_LABEL,
218
+ program_arguments=program_arguments,
219
+ config_dir=str(config_dir),
220
+ log_path=str(log_path),
221
+ )
222
+
223
+
224
+ def systemd_unit_path() -> Path:
225
+ """Default install path of the systemd user unit."""
226
+ return Path.home() / ".config" / "systemd" / "user" / SYSTEMD_UNIT_NAME
227
+
228
+
229
+ def launchd_plist_path() -> Path:
230
+ """Default install path of the launchd LaunchAgent plist."""
231
+ return Path.home() / "Library" / "LaunchAgents" / PLIST_NAME
232
+
233
+
234
+ def default_log_path() -> Path:
235
+ """Default log file for the launchd service."""
236
+ return config_root() / "mad-cli.serve.log"
237
+
238
+
239
+ @dataclass(frozen=True)
240
+ class RenderedService:
241
+ platform: str # "linux" | "darwin"
242
+ default_path: Path
243
+ content: str
244
+ exec_args: list[str]
245
+
246
+
247
+ def render_service(
248
+ *,
249
+ platform: str,
250
+ exec_args: list[str],
251
+ config_dir: Path,
252
+ ) -> RenderedService:
253
+ """Render the platform's service file and report its default install path.
254
+
255
+ Raises :class:`PreconditionError` on an unsupported platform (only Linux
256
+ systemd-user and macOS launchd are supported).
257
+ """
258
+ if platform == "linux":
259
+ content = render_systemd_unit(exec_args=exec_args, config_dir=config_dir)
260
+ return RenderedService("linux", systemd_unit_path(), content, exec_args)
261
+ if platform == "darwin":
262
+ content = render_launchd_plist(
263
+ exec_args=exec_args, config_dir=config_dir, log_path=default_log_path()
264
+ )
265
+ return RenderedService("darwin", launchd_plist_path(), content, exec_args)
266
+ raise PreconditionError(
267
+ f"unsupported platform {platform!r}: `mad service` supports Linux (systemd) and macOS "
268
+ "(launchd) only. Use `mad serve` to run in the foreground."
269
+ )
@@ -0,0 +1,126 @@
1
+ """Version-reporting and update use cases.
2
+
3
+ ``versions`` reports, per instance, the pinned / installed / latest-on-PyPI
4
+ versions and whether an update is available. ``update`` re-pins ``MAD_VERSION``
5
+ and rebuilds from scratch (synchronous in the MVP). Shared by ``mad versions`` /
6
+ ``mad update`` and the ``/v1/instances/{name}/versions`` + ``/update`` routes.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from dataclasses import dataclass
12
+
13
+ from mad_cli.core import pypi
14
+ from mad_cli.core.compose import ComposeError, ComposeRunner
15
+ from mad_cli.core.instance import Instance, InstanceNotFoundError, discover_instances, get_instance
16
+ from mad_cli.core.templates import EDGE_PACKAGE
17
+ from mad_cli.core.usecases.errors import NotFoundError
18
+
19
+ NOT_RUNNING = "not running"
20
+ UNKNOWN = "?"
21
+
22
+
23
+ def _edge_package(instance: Instance) -> str:
24
+ """The PyPI package to check: ``MAD_EDGE_PACKAGE`` from the .env, else default."""
25
+ return instance.env.get("MAD_EDGE_PACKAGE") or EDGE_PACKAGE
26
+
27
+
28
+ def _installed_version(instance: Instance) -> str:
29
+ """Version reported by ``mad`` inside the container, or ``"not running"``."""
30
+ try:
31
+ out = ComposeRunner(instance).exec(["python", "-c", "import mad; print(mad.__version__)"])
32
+ except ComposeError:
33
+ return NOT_RUNNING
34
+ return out.strip() or NOT_RUNNING
35
+
36
+
37
+ def _version_tuple(version: str) -> tuple[int, ...]:
38
+ """Best-effort ordering tuple: each dotted segment's leading digit run."""
39
+ parts: list[int] = []
40
+ for chunk in version.split("."):
41
+ digits = ""
42
+ for char in chunk:
43
+ if char.isdigit():
44
+ digits += char
45
+ else:
46
+ break
47
+ parts.append(int(digits) if digits else 0)
48
+ return tuple(parts)
49
+
50
+
51
+ def _update_status(installed: str, latest: str | None) -> str:
52
+ if latest is None or installed in (NOT_RUNNING, UNKNOWN):
53
+ return UNKNOWN
54
+ if _version_tuple(installed) >= _version_tuple(latest):
55
+ return "up to date"
56
+ return "update available"
57
+
58
+
59
+ @dataclass(frozen=True)
60
+ class VersionRow:
61
+ name: str
62
+ legacy: bool
63
+ pinned: str # the pin, or "latest"
64
+ installed: str
65
+ latest: str # latest on PyPI, or "?"
66
+ update: str # up to date / update available / ?
67
+
68
+
69
+ def versions(name: str | None) -> list[VersionRow]:
70
+ """Report version state for ``name`` (or every instance when ``None``).
71
+
72
+ Raises :class:`NotFoundError` for a named instance that does not exist; an
73
+ empty list means no instances are configured.
74
+ """
75
+ if name is not None:
76
+ try:
77
+ targets = [get_instance(name)]
78
+ except InstanceNotFoundError as exc:
79
+ raise NotFoundError(f"instance {name!r} not found") from exc
80
+ else:
81
+ targets = discover_instances()
82
+
83
+ rows: list[VersionRow] = []
84
+ latest_cache: dict[str, str | None] = {}
85
+ for inst in targets:
86
+ installed = _installed_version(inst)
87
+ package = _edge_package(inst)
88
+ if package not in latest_cache:
89
+ latest_cache[package] = pypi.latest_version(package)
90
+ latest = latest_cache[package]
91
+ rows.append(
92
+ VersionRow(
93
+ name=inst.name,
94
+ legacy=bool(getattr(inst, "legacy", False)),
95
+ pinned=inst.version_pin or "latest",
96
+ installed=installed,
97
+ latest=latest if latest is not None else UNKNOWN,
98
+ update=_update_status(installed, latest),
99
+ )
100
+ )
101
+ return rows
102
+
103
+
104
+ @dataclass(frozen=True)
105
+ class UpdateResult:
106
+ instance: Instance
107
+ target: str # the pin applied, or "latest"
108
+ healthy: bool
109
+
110
+
111
+ def update(instance: Instance, version: str | None) -> UpdateResult:
112
+ """Re-pin ``MAD_VERSION`` and rebuild the instance from scratch.
113
+
114
+ The returned ``healthy`` flag lets the adapter decide how to signal a
115
+ not-yet-healthy rebuild (the CLI exits non-zero; the API reports it in the
116
+ body).
117
+ """
118
+ pin = version or ""
119
+ instance.env.set("MAD_VERSION", pin)
120
+ instance.env.save(instance.env_file)
121
+
122
+ runner = ComposeRunner(instance)
123
+ runner.build(no_cache=True)
124
+ runner.up()
125
+ healthy = runner.wait_healthy()
126
+ return UpdateResult(instance=instance, target=pin or "latest", healthy=healthy)
mad_cli/py.typed ADDED
File without changes
@@ -0,0 +1,13 @@
1
+ """The optional local HTTP API (the ``server`` extra).
2
+
3
+ Nothing here is imported by the base CLI (``mad_cli.app``); it is reached only via
4
+ ``mad serve`` behind an import guard, so ``pip install mad-cli`` never pulls in
5
+ FastAPI/uvicorn. The routes are thin adapters over :mod:`mad_cli.core.usecases`,
6
+ exactly like the Typer commands, so the CLI and the API can never drift.
7
+
8
+ See :func:`mad_cli.server.app.create_app` for the FastAPI factory.
9
+ """
10
+
11
+ from mad_cli.server.app import create_app
12
+
13
+ __all__ = ["create_app"]
mad_cli/server/app.py ADDED
@@ -0,0 +1,260 @@
1
+ """FastAPI application factory for the v1 local API.
2
+
3
+ Every ``/v1`` route is a thin adapter over :mod:`mad_cli.core.usecases` — the same
4
+ functions the Typer commands call — guarded by the bearer-token dependency. The
5
+ use-case error vocabulary is mapped to HTTP status codes by a single exception
6
+ handler, so the routes stay declarative.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import os
12
+ from pathlib import Path
13
+
14
+ from fastapi import APIRouter, Depends, FastAPI, Request
15
+ from fastapi.responses import JSONResponse
16
+
17
+ from mad_cli import __version__
18
+ from mad_cli.core.envfile import EnvFile
19
+ from mad_cli.core.instance import Instance, InstanceNotFoundError, get_instance
20
+ from mad_cli.core.templates import EDGE_PACKAGE
21
+ from mad_cli.core.usecases import adopt as uc_adopt
22
+ from mad_cli.core.usecases import configvals as uc_config
23
+ from mad_cli.core.usecases import install as uc_install
24
+ from mad_cli.core.usecases import instances as uc_instances
25
+ from mad_cli.core.usecases import keys as uc_keys
26
+ from mad_cli.core.usecases import lifecycle as uc_lifecycle
27
+ from mad_cli.core.usecases import versions as uc_versions
28
+ from mad_cli.core.usecases.errors import NotFoundError, UseCaseError, http_status_for
29
+ from mad_cli.server import models as m
30
+ from mad_cli.server.auth import require_token
31
+
32
+
33
+ def _host_id(getter_name: str) -> int:
34
+ getter = getattr(os, getter_name, None)
35
+ return getter() if getter is not None else 1000
36
+
37
+
38
+ def _instance(name: str) -> Instance:
39
+ """Resolve a path ``{name}`` to an :class:`Instance` or raise 404 via the handler."""
40
+ try:
41
+ return get_instance(name)
42
+ except InstanceNotFoundError:
43
+ raise NotFoundError(f"instance {name!r} not found") from None
44
+
45
+
46
+ def _build_router() -> APIRouter:
47
+ router = APIRouter(prefix="/v1", dependencies=[Depends(require_token)])
48
+
49
+ # ── instances ────────────────────────────────────────────────────────────
50
+ @router.get("/instances", response_model=list[m.InstanceSummaryModel], tags=["instances"])
51
+ def list_instances() -> list[m.InstanceSummaryModel]:
52
+ return [
53
+ m.InstanceSummaryModel(
54
+ name=row.name,
55
+ legacy=row.legacy,
56
+ port=row.port,
57
+ state=row.state,
58
+ health=row.health,
59
+ version=row.version,
60
+ )
61
+ for row in uc_instances.list_instances()
62
+ ]
63
+
64
+ @router.post(
65
+ "/instances",
66
+ response_model=m.InstallResponse,
67
+ status_code=201,
68
+ tags=["instances"],
69
+ )
70
+ def install_instance(req: m.InstallRequest) -> m.InstallResponse:
71
+ scratch = EnvFile.empty()
72
+ for ident, value in req.extra_keys.items():
73
+ uc_install.apply_extra_key(scratch, ident, value)
74
+ extra_env = {var: scratch.get(var) or "" for var in scratch.keys()} # noqa: SIM118
75
+ params = uc_install.InstallParams(
76
+ name=req.name,
77
+ port=req.port,
78
+ data_path=Path(req.data_path),
79
+ timeout_s=req.timeout_s,
80
+ github_token=req.github_token,
81
+ puid=_host_id("getuid"),
82
+ pgid=_host_id("getgid"),
83
+ git_name=req.git_name,
84
+ git_email=req.git_email,
85
+ claude_token=req.claude_token,
86
+ anthropic_api_key=req.anthropic_api_key,
87
+ extra_env=extra_env,
88
+ retention_days=req.retention_days,
89
+ mcp_allowed_hosts=req.mcp_allowed_hosts,
90
+ edge_package=req.edge_package or EDGE_PACKAGE,
91
+ edge_version=req.edge_version,
92
+ start=req.start,
93
+ )
94
+ result = uc_install.install(params)
95
+ return m.InstallResponse(
96
+ name=result.name,
97
+ config_dir=str(result.config_dir),
98
+ data_dir=str(result.data_dir),
99
+ port=result.port,
100
+ started=result.started,
101
+ healthy=result.healthy,
102
+ url=result.url,
103
+ )
104
+
105
+ @router.get("/instances/{name}", response_model=m.InstanceInfoModel, tags=["instances"])
106
+ def instance_info(name: str) -> m.InstanceInfoModel:
107
+ info = uc_instances.instance_info(name)
108
+ return m.InstanceInfoModel(
109
+ name=info.name,
110
+ legacy=info.legacy,
111
+ config_dir=str(info.config_dir),
112
+ compose_file=str(info.compose_file),
113
+ data_path=str(info.data_path) if info.data_path else None,
114
+ port=info.port,
115
+ version=info.version,
116
+ env=[m.EnvItemModel(key=i.key, value=i.display(), secret=i.secret) for i in info.env],
117
+ )
118
+
119
+ # ── lifecycle ─────────────────────────────────────────────────────────────
120
+ @router.post("/instances/{name}/start", response_model=m.StartResponse, tags=["lifecycle"])
121
+ def start(name: str) -> m.StartResponse:
122
+ inst = _instance(name)
123
+ res = uc_lifecycle.start(inst)
124
+ return m.StartResponse(name=inst.name, healthy=res.healthy, url=res.url)
125
+
126
+ @router.post("/instances/{name}/stop", response_model=m.ActionResponse, tags=["lifecycle"])
127
+ def stop(name: str) -> m.ActionResponse:
128
+ inst = _instance(name)
129
+ uc_lifecycle.stop(inst)
130
+ return m.ActionResponse(name=inst.name, action="stop")
131
+
132
+ @router.post("/instances/{name}/restart", response_model=m.ActionResponse, tags=["lifecycle"])
133
+ def restart(name: str) -> m.ActionResponse:
134
+ inst = _instance(name)
135
+ uc_lifecycle.restart(inst)
136
+ return m.ActionResponse(name=inst.name, action="restart")
137
+
138
+ @router.get("/instances/{name}/status", response_model=m.StatusResponse, tags=["lifecycle"])
139
+ def status(name: str) -> m.StatusResponse:
140
+ inst = _instance(name)
141
+ res = uc_lifecycle.status(inst)
142
+ return m.StatusResponse(name=inst.name, health=res.health, url=res.url, ps=res.ps_text)
143
+
144
+ # ── config ────────────────────────────────────────────────────────────────
145
+ @router.get("/instances/{name}/config", response_model=list[m.EnvItemModel], tags=["config"])
146
+ def get_config(name: str) -> list[m.EnvItemModel]:
147
+ inst = _instance(name)
148
+ return [
149
+ m.EnvItemModel(key=i.key, value=i.display(), secret=i.secret)
150
+ for i in uc_config.list_config(inst)
151
+ ]
152
+
153
+ @router.put(
154
+ "/instances/{name}/config/{key}", response_model=m.SetConfigResponse, tags=["config"]
155
+ )
156
+ def set_config(name: str, key: str, body: m.SetConfigRequest) -> m.SetConfigResponse:
157
+ inst = _instance(name)
158
+ item, compose_baked = uc_config.set_config(inst, key, body.value)
159
+ return m.SetConfigResponse(
160
+ key=item.key, value=item.display(), secret=item.secret, compose_baked=compose_baked
161
+ )
162
+
163
+ @router.delete(
164
+ "/instances/{name}/config/{key}", response_model=m.ActionResponse, tags=["config"]
165
+ )
166
+ def unset_config(name: str, key: str) -> m.ActionResponse:
167
+ inst = _instance(name)
168
+ existed = uc_config.unset_config(inst, key)
169
+ if not existed:
170
+ raise NotFoundError(f"{key} is not set on {inst.name}")
171
+ return m.ActionResponse(name=inst.name, action=f"unset {key}")
172
+
173
+ # ── keys ──────────────────────────────────────────────────────────────────
174
+ @router.get("/instances/{name}/keys", response_model=m.KeysResponse, tags=["keys"])
175
+ def list_keys(name: str) -> m.KeysResponse:
176
+ inst = _instance(name)
177
+ view = uc_keys.list_keys(inst)
178
+ return m.KeysResponse(
179
+ builtins=[
180
+ m.BuiltinKeyModel(
181
+ id=s.id, env_vars=list(s.env_vars), is_set=s.is_set, value=s.masked
182
+ )
183
+ for s in view.builtins
184
+ ],
185
+ custom=[m.CustomSecretModel(key=c.key, value=c.masked) for c in view.custom],
186
+ )
187
+
188
+ @router.put("/instances/{name}/keys/{key_id}", response_model=m.SetKeyResponse, tags=["keys"])
189
+ def set_key(name: str, key_id: str, body: m.SetKeyRequest) -> m.SetKeyResponse:
190
+ inst = _instance(name)
191
+ res = uc_keys.set_key(inst, key_id, body.value)
192
+ return m.SetKeyResponse(
193
+ id=res.id,
194
+ env_vars=list(res.env_vars),
195
+ builtin=res.builtin,
196
+ credentials_written=res.credentials_path is not None,
197
+ )
198
+
199
+ @router.delete(
200
+ "/instances/{name}/keys/{key_id}", response_model=m.ActionResponse, tags=["keys"]
201
+ )
202
+ def remove_key(name: str, key_id: str) -> m.ActionResponse:
203
+ inst = _instance(name)
204
+ res = uc_keys.remove_key(inst, key_id)
205
+ if not res.existed:
206
+ raise NotFoundError(f"{res.id} is not set on {inst.name}")
207
+ return m.ActionResponse(name=inst.name, action=f"remove {res.id}")
208
+
209
+ # ── versions ──────────────────────────────────────────────────────────────
210
+ @router.get("/instances/{name}/versions", response_model=m.VersionRowModel, tags=["versions"])
211
+ def versions(name: str) -> m.VersionRowModel:
212
+ rows = uc_versions.versions(name)
213
+ row = rows[0]
214
+ return m.VersionRowModel(
215
+ name=row.name,
216
+ legacy=row.legacy,
217
+ pinned=row.pinned,
218
+ installed=row.installed,
219
+ latest=row.latest,
220
+ update=row.update,
221
+ )
222
+
223
+ @router.post("/instances/{name}/update", response_model=m.UpdateResponse, tags=["versions"])
224
+ def update(name: str, body: m.UpdateRequest) -> m.UpdateResponse:
225
+ inst = _instance(name)
226
+ res = uc_versions.update(inst, body.version)
227
+ return m.UpdateResponse(name=inst.name, target=res.target, healthy=res.healthy)
228
+
229
+ # ── adopt ─────────────────────────────────────────────────────────────────
230
+ @router.post("/adopt", response_model=m.AdoptResponse, tags=["adopt"])
231
+ def adopt() -> m.AdoptResponse:
232
+ plan = uc_adopt.plan_adopt()
233
+ if plan is None:
234
+ return m.AdoptResponse(adopted=False)
235
+ uc_adopt.apply_adopt(plan)
236
+ return m.AdoptResponse(
237
+ adopted=True, name=plan.name, target=str(plan.target), moved=plan.movable
238
+ )
239
+
240
+ return router
241
+
242
+
243
+ def create_app() -> FastAPI:
244
+ """Build the FastAPI application (the OpenAPI schema is generated from it)."""
245
+ app = FastAPI(
246
+ title="mad-cli local API",
247
+ version=__version__,
248
+ summary="Operator API mirroring the mad CLI (instances, lifecycle, config, keys).",
249
+ )
250
+
251
+ @app.exception_handler(UseCaseError)
252
+ async def _handle_usecase_error(request: Request, exc: UseCaseError) -> JSONResponse:
253
+ return JSONResponse(status_code=http_status_for(exc), content={"detail": str(exc)})
254
+
255
+ @app.get("/health", response_model=m.HealthResponse, tags=["meta"])
256
+ def health() -> m.HealthResponse:
257
+ return m.HealthResponse(status="ok", version=__version__)
258
+
259
+ app.include_router(_build_router())
260
+ return app