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,126 @@
1
+ """``mad keys set|list|remove`` — manage credentials / API keys in ``.env``.
2
+
3
+ Thin adapter over :mod:`mad_cli.core.usecases.keys`. A key is a *builtin*
4
+ (``github``, ``claude-oauth``, …), whose value fans out to one or more env vars,
5
+ or a raw *custom* ``[A-Z][A-Z0-9_]*`` variable. ``claude-oauth`` also materialises
6
+ the container credentials file. Values are always masked when shown.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import typer
12
+ from rich.table import Table
13
+
14
+ from mad_cli.commands._adapt import fail, resolve_or_die
15
+ from mad_cli.core.instance import Instance
16
+ from mad_cli.core.usecases import keys as uc
17
+ from mad_cli.core.usecases.errors import UseCaseError, ValidationError
18
+ from mad_cli.ui.console import console, error, info, ok, warn
19
+ from mad_cli.ui.prompts import PromptRequiredError, ask
20
+
21
+ keys_app = typer.Typer(
22
+ no_args_is_help=True,
23
+ add_completion=False,
24
+ help="Manage API keys and tokens stored in an instance's .env.",
25
+ )
26
+
27
+ _INSTANCE_OPTION = typer.Option(
28
+ None, "--instance", "-i", help="Instance name (optional when exactly one instance exists)."
29
+ )
30
+
31
+
32
+ def _restart_hint(instance: Instance) -> None:
33
+ info(f"Restart the instance to apply: mad restart {instance.name}")
34
+
35
+
36
+ def _prompt_value(prompt: str, *, secret: bool) -> str:
37
+ try:
38
+ return ask(prompt, secret=secret)
39
+ except PromptRequiredError as exc:
40
+ error("A value is required. Pass it as an argument or run in an interactive terminal.")
41
+ raise typer.Exit(1) from exc
42
+
43
+
44
+ @keys_app.command("set")
45
+ def set_key(
46
+ key: str = typer.Argument(
47
+ ..., help="Builtin key id (e.g. github, claude-oauth) or a custom VAR name."
48
+ ),
49
+ value: str | None = typer.Argument(None, help="Value to store. Omit to be prompted."),
50
+ instance: str | None = _INSTANCE_OPTION,
51
+ ) -> None:
52
+ """Store a builtin key (fanned out to its env vars) or a custom variable."""
53
+ inst = resolve_or_die(instance)
54
+ if value is None:
55
+ try:
56
+ prompt, secret = uc.key_prompt(key)
57
+ except ValidationError as exc:
58
+ fail(exc)
59
+ value = _prompt_value(prompt, secret=secret)
60
+
61
+ try:
62
+ res = uc.set_key(inst, key, value)
63
+ except UseCaseError as exc:
64
+ fail(exc)
65
+
66
+ if res.credentials_path is not None:
67
+ ok(f"Claude credentials → {res.credentials_path}")
68
+ if res.builtin:
69
+ ok(f"Set {res.id} ({', '.join(res.env_vars)}) on {inst.name}.")
70
+ else:
71
+ ok(f"Set {res.id} on {inst.name}.")
72
+ _restart_hint(inst)
73
+
74
+
75
+ @keys_app.command("list")
76
+ def list_keys(instance: str | None = _INSTANCE_OPTION) -> None:
77
+ """Show which builtin keys are set (masked) plus any custom secret vars."""
78
+ inst = resolve_or_die(instance)
79
+ view = uc.list_keys(inst)
80
+
81
+ table = Table(title=f"Keys — {inst.name}")
82
+ table.add_column("Key", style="bold cyan", no_wrap=True)
83
+ table.add_column("Env vars")
84
+ table.add_column("Status")
85
+ table.add_column("Value")
86
+ for status in view.builtins:
87
+ table.add_row(
88
+ status.id,
89
+ ", ".join(status.env_vars),
90
+ "set" if status.is_set else "unset",
91
+ status.masked,
92
+ )
93
+ console.print(table)
94
+
95
+ if view.custom:
96
+ ctable = Table(title="Custom secrets")
97
+ ctable.add_column("Env var", style="bold", no_wrap=True)
98
+ ctable.add_column("Value")
99
+ for secret in view.custom:
100
+ ctable.add_row(secret.key, secret.masked)
101
+ console.print(ctable)
102
+
103
+
104
+ @keys_app.command("remove")
105
+ def remove_key(
106
+ key: str = typer.Argument(..., help="Builtin key id or custom VAR name to remove."),
107
+ instance: str | None = _INSTANCE_OPTION,
108
+ ) -> None:
109
+ """Remove a builtin key's env vars (or a custom variable) from ``.env``."""
110
+ inst = resolve_or_die(instance)
111
+ try:
112
+ res = uc.remove_key(inst, key)
113
+ except UseCaseError as exc:
114
+ fail(exc)
115
+
116
+ if not res.existed:
117
+ warn(f"{res.id} is not set on {inst.name}; nothing to remove.")
118
+ return
119
+
120
+ if res.builtin:
121
+ ok(f"Removed {res.id} ({', '.join(res.env_vars)}) from {inst.name}.")
122
+ else:
123
+ ok(f"Removed {res.id} from {inst.name}.")
124
+ if res.credentials_left is not None:
125
+ warn(f"Claude credentials file left in place on disk: {res.credentials_left}")
126
+ _restart_hint(inst)
@@ -0,0 +1,69 @@
1
+ """``mad start|stop|restart|status|logs|shell [INSTANCE]`` — container lifecycle.
2
+
3
+ Thin adapter over :mod:`mad_cli.core.usecases.lifecycle`. ``INSTANCE`` is optional:
4
+ when omitted it resolves to the single configured instance; with zero the user is
5
+ pointed at ``mad install``, with several the ambiguity is reported. ``logs`` and
6
+ ``shell`` attach the caller's TTY and stay here (no HTTP equivalent).
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import typer
12
+
13
+ from mad_cli.commands._adapt import resolve_or_die
14
+ from mad_cli.core.compose import ComposeRunner
15
+ from mad_cli.core.usecases import lifecycle as uc
16
+ from mad_cli.ui.console import console, header, info, ok, run_step, warn
17
+
18
+ _INSTANCE_ARG = typer.Argument(
19
+ None, help="Instance name (optional when exactly one instance exists)."
20
+ )
21
+
22
+
23
+ def start(instance: str | None = _INSTANCE_ARG) -> None:
24
+ """Build if needed, start the instance, and wait for it to become healthy."""
25
+ inst = resolve_or_die(instance)
26
+ header(f"Starting {inst.name}")
27
+ res = run_step("Building and starting…", lambda: uc.start(inst))
28
+ if res.healthy:
29
+ ok(f"{inst.name} is up — {res.url}" if res.url else f"{inst.name} is up.")
30
+ else:
31
+ warn(f"{inst.name} started but is not healthy yet. Check `mad status` and `mad logs`.")
32
+
33
+
34
+ def stop(instance: str | None = _INSTANCE_ARG) -> None:
35
+ """Stop the instance and remove its containers."""
36
+ inst = resolve_or_die(instance)
37
+ run_step(f"Stopping {inst.name}…", lambda: uc.stop(inst))
38
+ ok(f"{inst.name} stopped.")
39
+
40
+
41
+ def restart(instance: str | None = _INSTANCE_ARG) -> None:
42
+ """Restart the instance's containers."""
43
+ inst = resolve_or_die(instance)
44
+ run_step(f"Restarting {inst.name}…", lambda: uc.restart(inst))
45
+ ok(f"{inst.name} restarted.")
46
+
47
+
48
+ def status(instance: str | None = _INSTANCE_ARG) -> None:
49
+ """Show container state, a health summary and the instance URL."""
50
+ inst = resolve_or_die(instance)
51
+ header(f"Status — {inst.name}")
52
+ res = uc.status(inst)
53
+ if res.ps_text.strip():
54
+ console.print(res.ps_text)
55
+ info(f"Health: {res.health}")
56
+ if res.url:
57
+ info(f"URL: {res.url}")
58
+
59
+
60
+ def logs(instance: str | None = _INSTANCE_ARG) -> None:
61
+ """Follow the instance's container logs."""
62
+ inst = resolve_or_die(instance)
63
+ ComposeRunner(inst).logs(follow=True)
64
+
65
+
66
+ def shell(instance: str | None = _INSTANCE_ARG) -> None:
67
+ """Open an interactive shell inside the running container."""
68
+ inst = resolve_or_die(instance)
69
+ ComposeRunner(inst).shell()
@@ -0,0 +1,238 @@
1
+ """``mad profiles create|list|show|delete|apply`` — named environment profiles.
2
+
3
+ A *profile* is a reusable, named set of ``.env`` values (credentials + tuning,
4
+ never instance identity) stored under ``config_root()/profiles/<name>.env``. Use
5
+ it to stamp consistent config across instances: ``mad profiles apply`` overlays a
6
+ profile onto an existing instance's ``.env``, and ``mad install --profile`` feeds
7
+ a profile's values as the wizard's defaults.
8
+
9
+ The engine lives in :mod:`mad_cli.core.profiles`; this module is the Typer
10
+ surface, so it never touches the filesystem directly.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import re
16
+ import sys
17
+
18
+ import typer
19
+ from rich.table import Table
20
+
21
+ from mad_cli.commands._common import is_secret_key
22
+ from mad_cli.core.envfile import EnvFile
23
+ from mad_cli.core.instance import InstanceNotFoundError, get_instance
24
+ from mad_cli.core.keyspec import mask
25
+ from mad_cli.core.profiles import (
26
+ IDENTITY_KEYS,
27
+ ProfileNotFoundError,
28
+ delete_profile,
29
+ list_profiles,
30
+ load_profile,
31
+ save_profile,
32
+ )
33
+ from mad_cli.ui.console import console, error, info, ok, warn
34
+ from mad_cli.ui.prompts import confirm
35
+
36
+ profiles_app = typer.Typer(
37
+ no_args_is_help=True,
38
+ add_completion=False,
39
+ help="Manage reusable named environment profiles (credentials + tuning).",
40
+ )
41
+
42
+ # A profile variable name: a shell env-var name (leading underscore allowed so
43
+ # keys like _CLAUDE_OAUTH_TOKEN round-trip).
44
+ _ENV_KEY_RE = re.compile(r"[A-Z_][A-Z0-9_]*")
45
+
46
+ # Module-level singleton for the repeatable --set option (a mutable-typed default
47
+ # may not be an inline call — flake8-bugbear B008).
48
+ _SET_OPTION = typer.Option(
49
+ None,
50
+ "--set",
51
+ metavar="KEY=VALUE",
52
+ help="Set a KEY=VALUE pair in the profile (repeatable).",
53
+ )
54
+
55
+
56
+ def _interactive() -> bool:
57
+ """True only when stdin is a TTY, so a prompt will not block."""
58
+ try:
59
+ return sys.stdin.isatty()
60
+ except (ValueError, OSError):
61
+ return False
62
+
63
+
64
+ def _split_set(item: str) -> tuple[str, str]:
65
+ """Split a ``KEY=VALUE`` --set entry, validating the key, or exit."""
66
+ key, sep, value = item.partition("=")
67
+ key = key.strip()
68
+ if not sep or not key:
69
+ error(f"invalid --set {item!r}: expected KEY=VALUE.")
70
+ raise typer.Exit(1)
71
+ if not _ENV_KEY_RE.fullmatch(key):
72
+ error(f"invalid key {key!r}: must match [A-Z_][A-Z0-9_]* (an env-var name).")
73
+ raise typer.Exit(1)
74
+ return key, value
75
+
76
+
77
+ def _resolve_profile(name: str) -> EnvFile:
78
+ """Load profile ``name`` to an :class:`EnvFile`, or exit with a hint."""
79
+ try:
80
+ return load_profile(name)
81
+ except ProfileNotFoundError as exc:
82
+ error(f"Profile {name!r} not found. Run `mad profiles list` to see available profiles.")
83
+ raise typer.Exit(1) from exc
84
+
85
+
86
+ def _display(key: str, value: str, *, reveal: bool) -> str:
87
+ """Mask a secret-looking value unless ``reveal`` is set."""
88
+ if reveal or not value or not is_secret_key(key):
89
+ return value
90
+ return mask(value)
91
+
92
+
93
+ def _print_env(title: str, env: EnvFile, *, reveal: bool) -> None:
94
+ """Print a profile's variables as a table (secret-looking values masked)."""
95
+ table = Table(title=title)
96
+ table.add_column("Key", style="bold cyan", no_wrap=True)
97
+ table.add_column("Value")
98
+ for key in env.keys(): # noqa: SIM118 — EnvFile.keys() is its contract API, not a dict
99
+ table.add_row(key, _display(key, env.get(key) or "", reveal=reveal))
100
+ console.print(table)
101
+
102
+
103
+ def _prompt_pairs(env: EnvFile) -> None:
104
+ """Interactive mini-loop to add KEY=VALUE pairs to a new profile."""
105
+ if not confirm("Add environment variables now?", default=False):
106
+ return
107
+ while True:
108
+ key = console.input("[cyan]?[/cyan] Variable name (empty to finish): ").strip()
109
+ if not key:
110
+ break
111
+ if not _ENV_KEY_RE.fullmatch(key):
112
+ warn(f"invalid key {key!r}: must match [A-Z_][A-Z0-9_]*.")
113
+ continue
114
+ # Hide the value only when the key looks like a secret.
115
+ value = console.input(f"[cyan]?[/cyan] Value for {key}: ", password=is_secret_key(key))
116
+ env.set(key, value)
117
+ if not confirm("Add another?", default=False):
118
+ break
119
+
120
+
121
+ @profiles_app.command("create")
122
+ def create(
123
+ name: str = typer.Argument(..., help="Profile name ([a-z0-9][a-z0-9-]*)."),
124
+ from_instance: str | None = typer.Option(
125
+ None,
126
+ "--from-instance",
127
+ help="Seed the profile from an instance's .env (identity keys excluded).",
128
+ ),
129
+ set_: list[str] | None = _SET_OPTION,
130
+ ) -> None:
131
+ """Create a profile, empty or seeded from an instance, plus optional KEY=VALUEs."""
132
+ if name in list_profiles():
133
+ error(f"Profile {name!r} already exists. Delete it first or pick another name.")
134
+ raise typer.Exit(1)
135
+
136
+ env = EnvFile.empty()
137
+
138
+ if from_instance is not None:
139
+ try:
140
+ source = get_instance(from_instance)
141
+ except InstanceNotFoundError as exc:
142
+ error(f"Instance {from_instance!r} not found. Run `mad list` to see instances.")
143
+ raise typer.Exit(1) from exc
144
+ for key in source.env.keys(): # noqa: SIM118 — EnvFile.keys() is its contract API
145
+ if key in IDENTITY_KEYS:
146
+ continue
147
+ value = source.env.get(key)
148
+ if value is not None:
149
+ env.set(key, value)
150
+
151
+ for item in set_ or []:
152
+ key, value = _split_set(item)
153
+ env.set(key, value)
154
+
155
+ if _interactive():
156
+ _prompt_pairs(env)
157
+
158
+ try:
159
+ path = save_profile(name, env)
160
+ except ValueError as exc: # invalid name
161
+ error(str(exc))
162
+ raise typer.Exit(1) from exc
163
+
164
+ ok(f"Created profile {name!r} ({len(env.keys())} variable(s)) → {path}")
165
+ if env.keys():
166
+ _print_env(f"Profile — {name}", env, reveal=False)
167
+ info(f"Apply it to an instance with: mad profiles apply {name} <instance>")
168
+
169
+
170
+ @profiles_app.command("list")
171
+ def list_( # noqa: A001 — command name; the Typer name is "list"
172
+ ) -> None:
173
+ """List every stored profile with its variable count."""
174
+ names = list_profiles()
175
+ if not names:
176
+ info("No profiles yet. Create one with `mad profiles create NAME`.")
177
+ return
178
+ table = Table(title="Profiles")
179
+ table.add_column("Profile", style="bold cyan", no_wrap=True)
180
+ table.add_column("Variables", justify="right")
181
+ for name in names:
182
+ env = load_profile(name)
183
+ table.add_row(name, str(len(env.keys())))
184
+ console.print(table)
185
+
186
+
187
+ @profiles_app.command("show")
188
+ def show(
189
+ name: str = typer.Argument(..., help="Profile to display."),
190
+ reveal: bool = typer.Option(False, "--reveal", help="Show secret values in full."),
191
+ ) -> None:
192
+ """Print a profile's variables (secret-looking values masked)."""
193
+ env = _resolve_profile(name)
194
+ if not env.keys():
195
+ info(f"Profile {name!r} is empty.")
196
+ return
197
+ _print_env(f"Profile — {name}", env, reveal=reveal)
198
+
199
+
200
+ @profiles_app.command("delete")
201
+ def delete(
202
+ name: str = typer.Argument(..., help="Profile to delete."),
203
+ yes: bool = typer.Option(False, "--yes", "-y", help="Skip the confirmation prompt."),
204
+ ) -> None:
205
+ """Delete a profile (asks for confirmation unless --yes)."""
206
+ if name not in list_profiles():
207
+ error(f"Profile {name!r} not found. Run `mad profiles list` to see available profiles.")
208
+ raise typer.Exit(1)
209
+ if not yes and not confirm(f"Delete profile {name!r}?", default=False):
210
+ info("Aborted; nothing was deleted.")
211
+ return
212
+ delete_profile(name)
213
+ ok(f"Deleted profile {name!r}.")
214
+
215
+
216
+ @profiles_app.command("apply")
217
+ def apply(
218
+ name: str = typer.Argument(..., help="Profile to apply."),
219
+ instance: str = typer.Argument(..., help="Instance to overlay the profile onto."),
220
+ ) -> None:
221
+ """Overlay a profile's variables onto an instance's ``.env``."""
222
+ profile = _resolve_profile(name)
223
+ try:
224
+ inst = get_instance(instance)
225
+ except InstanceNotFoundError as exc:
226
+ error(f"Instance {instance!r} not found. Run `mad list` to see available instances.")
227
+ raise typer.Exit(1) from exc
228
+
229
+ applied = 0
230
+ for key in profile.keys(): # noqa: SIM118 — EnvFile.keys() is its contract API
231
+ value = profile.get(key)
232
+ if value is not None:
233
+ inst.env.set(key, value)
234
+ applied += 1
235
+ inst.env.save()
236
+
237
+ ok(f"Applied {applied} variable(s) from profile {name!r} to instance {inst.name!r}.")
238
+ info(f"Restart the instance to apply: mad restart {inst.name}")
@@ -0,0 +1,220 @@
1
+ """``mad serve`` and ``mad service install|uninstall|status|update`` — service mode.
2
+
3
+ ``serve`` runs the HTTP API in the foreground (uvicorn). ``service`` manages a
4
+ boot-persistent background service: a systemd **user** unit on Linux, a launchd
5
+ LaunchAgent on macOS. When the ``server`` extra is not importable, ``service
6
+ install`` auto-provisions a dedicated venv under ``config_root()/server-venv`` and
7
+ points the unit at it — the base CLI never needs FastAPI installed.
8
+
9
+ Thin adapter over :mod:`mad_cli.core.usecases.service`. Activating the unit
10
+ (``systemctl`` / ``launchctl``) happens only for a real install; ``--render-to``
11
+ writes the file and touches nothing on the live system (used by tests and the E2E).
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import os
17
+ import platform
18
+ import subprocess
19
+ from pathlib import Path
20
+
21
+ import typer
22
+
23
+ from mad_cli import __version__
24
+ from mad_cli.commands._adapt import fail
25
+ from mad_cli.core.paths import config_root
26
+ from mad_cli.core.usecases import service as uc
27
+ from mad_cli.core.usecases.errors import UseCaseError
28
+ from mad_cli.ui.console import error, header, info, ok, warn
29
+
30
+ service_app = typer.Typer(
31
+ no_args_is_help=True,
32
+ add_completion=False,
33
+ help="Install/manage the mad HTTP API as a boot-persistent background service.",
34
+ )
35
+
36
+ _HOST_OPTION = typer.Option(uc.DEFAULT_HOST, "--host", help="Bind address (default: 127.0.0.1).")
37
+ _PORT_OPTION = typer.Option(uc.DEFAULT_PORT, "--port", help="Bind port (default: 7373).")
38
+ _WHEEL_OPTION = typer.Option(
39
+ None,
40
+ "--wheel",
41
+ "--from",
42
+ help="Provision the server venv from this local wheel/sdist instead of PyPI.",
43
+ )
44
+ _RENDER_TO_OPTION = typer.Option(
45
+ None,
46
+ "--render-to",
47
+ help="Write the unit/plist to PATH and stop — do not touch systemctl/launchctl.",
48
+ )
49
+
50
+
51
+ def _warn_public_bind(host: str) -> None:
52
+ if uc.is_loopback(host):
53
+ return
54
+ bar = "!" * 68
55
+ warn(bar)
56
+ warn(f"Binding to {host} exposes the mad API BEYOND localhost.")
57
+ warn("Anyone who can reach this address and holds the bearer token can control")
58
+ warn("your instances (start/stop, config, keys). Put it behind a firewall/VPN.")
59
+ warn(bar)
60
+
61
+
62
+ def _platform() -> str:
63
+ return platform.system().lower()
64
+
65
+
66
+ # ── mad serve ─────────────────────────────────────────────────────────────────
67
+ def serve(host: str = _HOST_OPTION, port: int = _PORT_OPTION) -> None:
68
+ """Run the HTTP API in the foreground (Ctrl-C to stop)."""
69
+ uc.ensure_api_token() # create the token file on first run
70
+ _warn_public_bind(host)
71
+
72
+ if uc.server_deps_available():
73
+ import uvicorn # noqa: PLC0415 — optional dependency, imported on demand
74
+
75
+ from mad_cli.server import create_app # lazy: only when the extra is present
76
+
77
+ app = create_app()
78
+ header("mad API")
79
+ info(f"Listening on http://{host}:{port}")
80
+ info(f"Bearer token: {uc.api_token_path()} (send as `Authorization: Bearer <token>`)")
81
+ uvicorn.run(app, host=host, port=port, log_level="info")
82
+ return
83
+
84
+ if uc.server_venv_exists():
85
+ argv = uc.serve_argv([str(uc.server_venv_mad())], host, port)
86
+ info("The server extra is not in this environment — handing off to the dedicated venv.")
87
+ os.execv(argv[0], argv) # replace this process; never returns
88
+ return
89
+
90
+ error(
91
+ "The HTTP API needs the 'server' extra. Install it with "
92
+ "`pip install 'mad-cli[server]'`, or run `mad service install` "
93
+ "(it auto-provisions a dedicated environment for you)."
94
+ )
95
+ raise typer.Exit(1)
96
+
97
+
98
+ # ── mad service install / uninstall / status / update ─────────────────────────
99
+ def _activate(platform_name: str, unit_path: Path) -> None:
100
+ """Enable + start the freshly installed service (real installs only)."""
101
+ if platform_name == "linux":
102
+ try:
103
+ subprocess.run(["systemctl", "--user", "daemon-reload"], check=True)
104
+ subprocess.run(
105
+ ["systemctl", "--user", "enable", "--now", uc.SYSTEMD_UNIT_NAME], check=True
106
+ )
107
+ except (OSError, subprocess.SubprocessError) as exc:
108
+ warn(f"Could not activate the systemd unit automatically: {exc}")
109
+ return
110
+ ok("Enabled and started (systemctl --user enable --now).")
111
+ info(
112
+ "To survive logout/reboot without an active session, run: loginctl enable-linger $USER"
113
+ )
114
+ elif platform_name == "darwin":
115
+ try:
116
+ subprocess.run(["launchctl", "load", str(unit_path)], check=True)
117
+ except (OSError, subprocess.SubprocessError) as exc:
118
+ warn(f"Could not load the LaunchAgent automatically: {exc}")
119
+ return
120
+ ok("Loaded (launchctl load).")
121
+
122
+
123
+ @service_app.command("install")
124
+ def install(
125
+ host: str = _HOST_OPTION,
126
+ port: int = _PORT_OPTION,
127
+ wheel: Path | None = _WHEEL_OPTION,
128
+ render_to: Path | None = _RENDER_TO_OPTION,
129
+ ) -> None:
130
+ """Render the service file (and provision the server venv if needed)."""
131
+ uc.ensure_api_token()
132
+ system = _platform()
133
+
134
+ header("Provisioning the server runtime")
135
+ try:
136
+ launcher, bootstrapped = uc.ensure_server_runtime(wheel=wheel)
137
+ except UseCaseError as exc:
138
+ fail(exc)
139
+ if bootstrapped:
140
+ ok(f"Server venv ready → {uc.server_venv_dir()}")
141
+ else:
142
+ info("Using the current environment's `mad` (the server extra is already installed).")
143
+
144
+ exec_args = uc.serve_argv(launcher, host, port)
145
+ try:
146
+ rendered = uc.render_service(platform=system, exec_args=exec_args, config_dir=config_root())
147
+ except UseCaseError as exc:
148
+ fail(exc)
149
+
150
+ target = render_to if render_to is not None else rendered.default_path
151
+ target.parent.mkdir(parents=True, exist_ok=True)
152
+ target.write_text(rendered.content, encoding="utf-8")
153
+ ok(f"Service file → {target}")
154
+
155
+ if render_to is not None:
156
+ info("Rendered only (--render-to) — systemctl/launchctl were not touched.")
157
+ return
158
+ _activate(rendered.platform, rendered.default_path)
159
+
160
+
161
+ @service_app.command("uninstall")
162
+ def uninstall() -> None:
163
+ """Stop and remove the installed service file."""
164
+ system = _platform()
165
+ try:
166
+ rendered_path = uc.systemd_unit_path() if system == "linux" else uc.launchd_plist_path()
167
+ except Exception: # noqa: BLE001 - defensive; unsupported platform
168
+ info("No supported service manager on this platform.")
169
+ return
170
+ if not rendered_path.exists():
171
+ info("No service file installed; nothing to remove.")
172
+ return
173
+ if system == "linux":
174
+ try:
175
+ subprocess.run(
176
+ ["systemctl", "--user", "disable", "--now", uc.SYSTEMD_UNIT_NAME], check=False
177
+ )
178
+ except (OSError, subprocess.SubprocessError) as exc:
179
+ warn(f"Could not stop the unit: {exc}")
180
+ elif system == "darwin":
181
+ try:
182
+ subprocess.run(["launchctl", "unload", str(rendered_path)], check=False)
183
+ except (OSError, subprocess.SubprocessError) as exc:
184
+ warn(f"Could not unload the LaunchAgent: {exc}")
185
+ rendered_path.unlink()
186
+ ok(f"Removed {rendered_path}. The server venv (if any) was left in place.")
187
+
188
+
189
+ @service_app.command("status")
190
+ def status() -> None:
191
+ """Report the service file, the server venv and version alignment."""
192
+ system = _platform()
193
+ path = uc.systemd_unit_path() if system == "linux" else uc.launchd_plist_path()
194
+ header("mad service status")
195
+ info(f"Platform: {system}")
196
+ info(f"Service file: {path} ({'present' if path.exists() else 'absent'})")
197
+
198
+ if uc.server_venv_exists():
199
+ venv_version = uc.server_venv_version()
200
+ ok(f"Server venv: {uc.server_venv_dir()} (mad-cli {venv_version or 'unknown'})")
201
+ if venv_version is not None and venv_version != __version__:
202
+ warn(
203
+ f"Server venv runs mad-cli {venv_version} but this CLI is {__version__}. "
204
+ "Run `mad service update` to realign."
205
+ )
206
+ elif uc.server_deps_available():
207
+ info("Server venv: not provisioned (the current environment has the server extra).")
208
+ else:
209
+ info("Server venv: not provisioned. `mad service install` will create one.")
210
+
211
+
212
+ @service_app.command("update")
213
+ def update(wheel: Path | None = _WHEEL_OPTION) -> None:
214
+ """Reinstall ``mad-cli[server]`` into the server venv at the CLI's version."""
215
+ header("Updating the server venv")
216
+ try:
217
+ uc.bootstrap_server_venv(wheel=wheel)
218
+ except UseCaseError as exc:
219
+ fail(exc)
220
+ ok(f"Server venv realigned to mad-cli {__version__} → {uc.server_venv_dir()}")