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,504 @@
1
+ """``mad install`` — guided install / reconfiguration of a mad-edge instance.
2
+
3
+ Thin adapter over :func:`mad_cli.core.usecases.install.install`: this module owns
4
+ the interactive collection (each parameter has a flag that skips its prompt),
5
+ the Docker preflight, and the masked summary; the use case owns assembling the
6
+ ``.env``, rendering the files, creating the data dirs and writing the Claude
7
+ credentials. Re-running against an existing instance pre-fills from its ``.env``.
8
+ """
9
+
10
+ import os
11
+ import platform
12
+ import sys
13
+ from collections.abc import Callable
14
+ from pathlib import Path
15
+
16
+ import typer
17
+ from rich.panel import Panel
18
+ from rich.table import Table
19
+
20
+ from mad_cli.core.docker_check import check_docker, install_docker_linux
21
+ from mad_cli.core.envfile import EnvFile
22
+ from mad_cli.core.instance import Instance, InstanceNotFoundError, get_instance
23
+ from mad_cli.core.keyspec import BUILTIN_KEYS, mask
24
+ from mad_cli.core.profiles import ProfileNotFoundError, load_profile
25
+ from mad_cli.core.templates import EDGE_PACKAGE
26
+ from mad_cli.core.usecases import install as uc
27
+ from mad_cli.core.usecases import lifecycle as uc_lifecycle
28
+ from mad_cli.core.usecases.errors import UseCaseError
29
+ from mad_cli.core.usecases.install import (
30
+ InstallParams,
31
+ validate_name,
32
+ validate_port,
33
+ validate_retention,
34
+ validate_timeout,
35
+ )
36
+ from mad_cli.ui.console import console, error, header, info, ok, run_step, warn
37
+ from mad_cli.ui.prompts import PromptRequiredError, ask, confirm
38
+
39
+
40
+ class _MissingValue(Exception):
41
+ """A required value was not supplied and we cannot interactively prompt."""
42
+
43
+ def __init__(self, flag: str) -> None:
44
+ super().__init__(flag)
45
+ self.flag = flag
46
+
47
+
48
+ class _KeyError(Exception):
49
+ """An ``--set-key`` / extra-key entry could not be applied (bad id or value)."""
50
+
51
+
52
+ # Module-level singleton for the repeatable --set-key option (a mutable-typed
53
+ # default may not be an inline call — see flake8-bugbear B008).
54
+ _SET_KEY_OPTION = typer.Option(
55
+ None,
56
+ "--set-key",
57
+ metavar="ID=VALUE",
58
+ help=(
59
+ "Extra API key to store, ID=VALUE (repeatable). ID is a builtin "
60
+ "(deepseek, linear, opencode, github, anthropic) or a custom VAR name."
61
+ ),
62
+ )
63
+
64
+
65
+ def _split_set_key(item: str) -> tuple[str, str]:
66
+ """Split an ``ID=VALUE`` --set-key entry, or raise :class:`_KeyError`."""
67
+ ident, sep, value = item.partition("=")
68
+ if not sep:
69
+ raise _KeyError(f"invalid --set-key {item!r}: expected ID=VALUE.")
70
+ return ident.strip(), value
71
+
72
+
73
+ def _apply_key(env: EnvFile, ident: str, value: str, applied: list[str]) -> None:
74
+ """Write a builtin (fanned out) or custom key into ``env`` (a scratch overlay).
75
+
76
+ Appends every env var it touched to ``applied``. Rejects ``claude-oauth`` — it
77
+ has its own ``--claude-token`` flag because it also materialises the container
78
+ credentials file — and raises :class:`_KeyError` on a bad id so the caller
79
+ decides whether to abort (flags) or re-prompt (loop).
80
+ """
81
+ spec = BUILTIN_KEYS.get(ident)
82
+ if spec is not None and spec.writes_claude_credentials:
83
+ raise _KeyError(
84
+ f"{ident!r} cannot be set with --set-key; use --claude-token "
85
+ "(it also writes the container credentials file)."
86
+ )
87
+ try:
88
+ applied.extend(uc.apply_extra_key(env, ident, value))
89
+ except UseCaseError as exc:
90
+ raise _KeyError(str(exc)) from exc
91
+
92
+
93
+ def _prompt_extra_keys(env: EnvFile, applied: list[str]) -> None:
94
+ """Interactive mini-loop to add extra API keys after the main credentials."""
95
+ if not confirm(
96
+ "Configure additional API keys now? (deepseek, linear, opencode, or custom)",
97
+ default=False,
98
+ ):
99
+ return
100
+ while True:
101
+ ident = ask("Key id (deepseek, linear, opencode) or a custom VAR name").strip()
102
+ if not ident:
103
+ break
104
+ value = ask(f"Value for {ident}", secret=True)
105
+ try:
106
+ _apply_key(env, ident, value, applied)
107
+ except _KeyError as exc:
108
+ warn(str(exc))
109
+ continue
110
+ if not confirm("Add another?", default=False):
111
+ break
112
+
113
+
114
+ def _interactive(assume_yes: bool) -> bool:
115
+ """True only when we may block on a prompt: not --yes and stdin is a TTY."""
116
+ if assume_yes:
117
+ return False
118
+ try:
119
+ return sys.stdin.isatty()
120
+ except (ValueError, OSError):
121
+ return False
122
+
123
+
124
+ def _host_id(getter_name: str) -> int:
125
+ """os.getuid/os.getgid, or 1000 on platforms that lack them (Windows)."""
126
+ getter = getattr(os, getter_name, None)
127
+ return getter() if getter is not None else 1000
128
+
129
+
130
+ def _collect(
131
+ *,
132
+ interactive: bool,
133
+ flag: str | None,
134
+ flag_name: str,
135
+ prompt: str,
136
+ default: str | None = None,
137
+ secret: bool = False,
138
+ validator: Callable[[str], str] | None = None,
139
+ required: bool = False,
140
+ ) -> str:
141
+ """Resolve a single value from its flag, an interactive prompt, or a default."""
142
+ if flag is not None:
143
+ return validator(flag) if validator is not None else flag
144
+ if interactive:
145
+ try:
146
+ return ask(prompt, default=default, secret=secret, validator=validator)
147
+ except PromptRequiredError as exc: # pragma: no cover - guarded by isatty()
148
+ raise _MissingValue(flag_name) from exc
149
+ if default is None:
150
+ if required:
151
+ raise _MissingValue(flag_name)
152
+ return ""
153
+ return validator(default) if validator is not None else default
154
+
155
+
156
+ def _ensure_docker(*, assume_yes: bool) -> None:
157
+ header("Checking Docker")
158
+ status = check_docker()
159
+ if not status.docker_present:
160
+ if platform.system() == "Linux":
161
+ if not confirm("Docker was not found. Install it now?", default=True):
162
+ error("Docker is required. Install it from https://docs.docker.com/engine/install/")
163
+ raise typer.Exit(1)
164
+ if not install_docker_linux(assume_yes=assume_yes):
165
+ error("Docker installation did not complete. Re-run once Docker is available.")
166
+ raise typer.Exit(1)
167
+ status = check_docker()
168
+ else:
169
+ error(
170
+ "Docker was not found. Install Docker Desktop "
171
+ "(https://docs.docker.com/desktop/) and re-run `mad install`."
172
+ )
173
+ raise typer.Exit(1)
174
+ if not status.docker_present:
175
+ error("Docker is still not available after installation.")
176
+ raise typer.Exit(1)
177
+ if not status.daemon_running:
178
+ error("The Docker daemon is not running. Start Docker and re-run `mad install`.")
179
+ raise typer.Exit(1)
180
+ if not status.compose_v2:
181
+ error(
182
+ "Docker Compose v2 was not found. Install it: https://docs.docker.com/compose/install/"
183
+ )
184
+ raise typer.Exit(1)
185
+ ok(f"Docker ready — {status.version}" if status.version else "Docker ready")
186
+
187
+
188
+ def _print_summary(result: uc.InstallResult, *, extra_key_vars: list[str]) -> None:
189
+ env = result.env
190
+ table = Table(show_header=False, box=None, pad_edge=False)
191
+ table.add_column("key", style="bold cyan", no_wrap=True)
192
+ table.add_column("value")
193
+ table.add_row("Instance", result.name)
194
+ table.add_row("Port", str(result.port))
195
+ table.add_row("Data path", str(result.data_dir))
196
+ table.add_row("Sessions", str(result.data_dir / result.name / "sessions"))
197
+ table.add_row("Timeout", f"{result.timeout_s}s")
198
+ retention = env.get("MAD_SESSIONS_RETENTION_DAYS")
199
+ table.add_row("Session retention", f"{retention} days" if retention else "keep forever")
200
+ table.add_row("Config dir", str(result.config_dir))
201
+
202
+ shown: set[str] = set()
203
+ for key in ("GITHUB_TOKEN", "_CLAUDE_OAUTH_TOKEN", "ANTHROPIC_API_KEY"):
204
+ value = env.get(key)
205
+ if value:
206
+ table.add_row(key, mask(value))
207
+ shown.add(key)
208
+ shown.add("GH_TOKEN") # fanned out from GITHUB_TOKEN; shown once above
209
+ for var in extra_key_vars:
210
+ if var in shown:
211
+ continue
212
+ shown.add(var)
213
+ value = env.get(var)
214
+ if value:
215
+ table.add_row(var, mask(value))
216
+
217
+ mcp = env.get("MAD_MCP_ALLOWED_HOSTS")
218
+ table.add_row("MCP allowed hosts", mcp if mcp else "disabled")
219
+ console.print(Panel(table, title="Configuration complete", border_style="green", expand=False))
220
+
221
+
222
+ def install(
223
+ name: str | None = typer.Option(None, "--name", help="Instance name (default: default)."),
224
+ port: str | None = typer.Option(None, "--port", help="Host port to expose (default: 8080)."),
225
+ data_path: str | None = typer.Option(
226
+ None, "--data-path", help="Host data directory (default: ~/mad-data)."
227
+ ),
228
+ timeout: str | None = typer.Option(
229
+ None, "--timeout", help="Agent wall-clock timeout in seconds (default: 600)."
230
+ ),
231
+ github_token: str | None = typer.Option(
232
+ None, "--github-token", help="GitHub token for agent clones, pushes and PRs."
233
+ ),
234
+ git_name: str | None = typer.Option(
235
+ None, "--git-name", help="Git author/committer name for the agent's commits."
236
+ ),
237
+ git_email: str | None = typer.Option(
238
+ None, "--git-email", help="Git author/committer email for the agent's commits."
239
+ ),
240
+ claude_token: str | None = typer.Option(
241
+ None,
242
+ "--claude-token",
243
+ help="Claude OAuth token — run `claude setup-token` on any machine with Claude Code.",
244
+ ),
245
+ anthropic_api_key: str | None = typer.Option(
246
+ None,
247
+ "--anthropic-api-key",
248
+ help="Anthropic API key (optional — alternative billing to the Claude OAuth token).",
249
+ ),
250
+ set_key: list[str] | None = _SET_KEY_OPTION,
251
+ profile: str | None = typer.Option(
252
+ None,
253
+ "--profile",
254
+ help="Named profile whose values seed the wizard defaults (flags still win).",
255
+ ),
256
+ retention_days: str | None = typer.Option(
257
+ None,
258
+ "--retention-days",
259
+ help="Session log retention in days, >= 1 (omit to keep session logs forever).",
260
+ ),
261
+ mcp_allowed_hosts: str | None = typer.Option(
262
+ None,
263
+ "--mcp-allowed-hosts",
264
+ help="MCP allowed hosts for DNS-rebinding protection (comma-separated).",
265
+ ),
266
+ edge_package: str | None = typer.Option(
267
+ None, "--edge-package", hidden=True, help="Override the mad-edge package name."
268
+ ),
269
+ edge_version: str | None = typer.Option(
270
+ None, "--edge-version", help="Pin the mad-edge version (blank = latest)."
271
+ ),
272
+ yes: bool = typer.Option(
273
+ False, "--yes", "-y", help="Non-interactive: use flags and defaults, never prompt."
274
+ ),
275
+ no_start: bool = typer.Option(
276
+ False, "--no-start", help="Write configuration but do not start the container."
277
+ ),
278
+ ) -> None:
279
+ """Install or reconfigure a mad-edge instance."""
280
+ header("Mad installer")
281
+ info("Writes an instance configuration and, unless --no-start, launches its container.")
282
+
283
+ _ensure_docker(assume_yes=yes)
284
+
285
+ interactive = _interactive(yes)
286
+
287
+ try:
288
+ name_value = _collect(
289
+ interactive=interactive,
290
+ flag=name,
291
+ flag_name="--name",
292
+ prompt="Instance name",
293
+ default="default",
294
+ validator=validate_name,
295
+ )
296
+
297
+ existing: Instance | None
298
+ try:
299
+ existing = get_instance(name_value)
300
+ except InstanceNotFoundError:
301
+ existing = None
302
+ if existing is not None:
303
+ warn(f"Instance {name_value!r} already exists — values pre-filled from its .env.")
304
+
305
+ # Default layer feeding every prompt: an existing instance's .env
306
+ # pre-fills a reconfiguration, then a --profile overlays its reusable
307
+ # credentials/tuning on top (a profile never carries instance identity).
308
+ # Explicit flags still win — they short-circuit `prior` in `_collect`.
309
+ defaults = EnvFile.empty()
310
+ if existing is not None:
311
+ for key in existing.env.keys(): # noqa: SIM118 — EnvFile.keys() is its API
312
+ value = existing.env.get(key)
313
+ if value is not None:
314
+ defaults.set(key, value)
315
+ if profile is not None:
316
+ try:
317
+ profile_env = load_profile(profile)
318
+ except ProfileNotFoundError as exc:
319
+ error(f"Profile {profile!r} not found. Run `mad profiles list` to see profiles.")
320
+ raise typer.Exit(1) from exc
321
+ for key in profile_env.keys(): # noqa: SIM118 — EnvFile.keys() is its API
322
+ value = profile_env.get(key)
323
+ if value is not None:
324
+ defaults.set(key, value)
325
+
326
+ def prior(key: str, fallback: str | None) -> str | None:
327
+ current = defaults.get(key)
328
+ if current:
329
+ return current
330
+ return fallback
331
+
332
+ port_value = _collect(
333
+ interactive=interactive,
334
+ flag=port,
335
+ flag_name="--port",
336
+ prompt="Host port",
337
+ default=prior("MAD_HOST_PORT", "8080"),
338
+ validator=validate_port,
339
+ )
340
+ data_value = _collect(
341
+ interactive=interactive,
342
+ flag=data_path,
343
+ flag_name="--data-path",
344
+ prompt="Host data path",
345
+ default=prior("MAD_DATA_PATH", str(Path.home() / "mad-data")),
346
+ )
347
+ timeout_value = _collect(
348
+ interactive=interactive,
349
+ flag=timeout,
350
+ flag_name="--timeout",
351
+ prompt="Agent timeout (seconds)",
352
+ default=prior("MAD_AGENT_TIMEOUT_S", "600"),
353
+ validator=validate_timeout,
354
+ )
355
+ retention_value = _collect(
356
+ interactive=interactive,
357
+ flag=retention_days,
358
+ flag_name="--retention-days",
359
+ prompt="Session log retention in days (empty = keep forever)",
360
+ default=prior("MAD_SESSIONS_RETENTION_DAYS", ""),
361
+ validator=validate_retention,
362
+ )
363
+ github_value = _collect(
364
+ interactive=interactive,
365
+ flag=github_token,
366
+ flag_name="--github-token",
367
+ prompt="GitHub token (used for agent clones, pushes and PRs)",
368
+ default=prior("GITHUB_TOKEN", None),
369
+ secret=True,
370
+ required=True,
371
+ )
372
+ git_name_value = _collect(
373
+ interactive=interactive,
374
+ flag=git_name,
375
+ flag_name="--git-name",
376
+ prompt="Git author name",
377
+ default=prior("GIT_AUTHOR_NAME", ""),
378
+ )
379
+ git_email_value = _collect(
380
+ interactive=interactive,
381
+ flag=git_email,
382
+ flag_name="--git-email",
383
+ prompt="Git author email",
384
+ default=prior("GIT_AUTHOR_EMAIL", ""),
385
+ )
386
+ claude_value = _collect(
387
+ interactive=interactive,
388
+ flag=claude_token,
389
+ flag_name="--claude-token",
390
+ prompt="Claude OAuth token (run `claude setup-token` and paste it here)",
391
+ default=prior("_CLAUDE_OAUTH_TOKEN", ""),
392
+ secret=True,
393
+ )
394
+ anthropic_value = _collect(
395
+ interactive=interactive,
396
+ flag=anthropic_api_key,
397
+ flag_name="--anthropic-api-key",
398
+ prompt=(
399
+ "Anthropic API key (optional — alternative billing to the Claude "
400
+ "OAuth token, Enter to skip)"
401
+ ),
402
+ default=prior("ANTHROPIC_API_KEY", ""),
403
+ secret=True,
404
+ )
405
+ edge_package_value = _collect(
406
+ interactive=interactive,
407
+ flag=edge_package,
408
+ flag_name="--edge-package",
409
+ prompt="mad-edge package",
410
+ default=EDGE_PACKAGE,
411
+ )
412
+ edge_version_value = _collect(
413
+ interactive=interactive,
414
+ flag=edge_version,
415
+ flag_name="--edge-version",
416
+ prompt="mad-edge version pin (blank = latest)",
417
+ default=prior("MAD_VERSION", ""),
418
+ )
419
+ mcp_hosts_value = _collect(
420
+ interactive=interactive,
421
+ flag=mcp_allowed_hosts,
422
+ flag_name="--mcp-allowed-hosts",
423
+ prompt=(
424
+ "MCP allowed hosts for DNS-rebinding protection "
425
+ "(comma-separated, Enter to leave disabled)"
426
+ ),
427
+ default=prior("MAD_MCP_ALLOWED_HOSTS", None),
428
+ )
429
+ except _MissingValue as exc:
430
+ error(
431
+ f"Missing required value: provide {exc.flag} "
432
+ "(required in --yes / non-interactive mode)."
433
+ )
434
+ raise typer.Exit(1) from exc
435
+
436
+ if not git_name_value or not git_email_value:
437
+ warn(
438
+ "No git identity set — the agent's commits may be rejected. Use --git-name/--git-email."
439
+ )
440
+ if not claude_value:
441
+ warn("No Claude token set — the container starts but agents cannot authenticate.")
442
+
443
+ # Extra API keys: --set-key flags first (abort on a bad entry), then the
444
+ # interactive mini-loop (re-prompts on a bad entry). Collected into a scratch
445
+ # env overlay (builtins fanned out), then flattened to {VAR: value}.
446
+ scratch = EnvFile.empty()
447
+ extra_key_vars: list[str] = []
448
+ try:
449
+ for item in set_key or []:
450
+ ident, value = _split_set_key(item)
451
+ _apply_key(scratch, ident, value, extra_key_vars)
452
+ except _KeyError as exc:
453
+ error(str(exc))
454
+ raise typer.Exit(1) from exc
455
+ if interactive:
456
+ _prompt_extra_keys(scratch, extra_key_vars)
457
+ extra_env = {var: scratch.get(var) or "" for var in scratch.keys()} # noqa: SIM118
458
+
459
+ params = InstallParams(
460
+ name=name_value,
461
+ port=int(port_value),
462
+ data_path=Path(data_value).expanduser(),
463
+ timeout_s=int(timeout_value),
464
+ github_token=github_value,
465
+ puid=_host_id("getuid"),
466
+ pgid=_host_id("getgid"),
467
+ git_name=git_name_value,
468
+ git_email=git_email_value,
469
+ claude_token=claude_value,
470
+ anthropic_api_key=anthropic_value,
471
+ extra_env=extra_env,
472
+ retention_days=retention_value,
473
+ mcp_allowed_hosts=mcp_hosts_value or "",
474
+ edge_package=edge_package_value,
475
+ edge_version=edge_version_value,
476
+ start=False, # the CLI starts separately below (so the summary prints first)
477
+ )
478
+
479
+ header("Writing configuration")
480
+ try:
481
+ result = uc.install(params)
482
+ except UseCaseError as exc:
483
+ error(str(exc))
484
+ raise typer.Exit(1) from exc
485
+ ok(f"Instance files → {result.config_dir}")
486
+ if result.claude_credentials_path is not None:
487
+ ok(f"Claude credentials → {result.claude_credentials_path}")
488
+ else:
489
+ warn(f"Claude credentials directory left empty: {result.claude_dir}")
490
+
491
+ _print_summary(result, extra_key_vars=extra_key_vars)
492
+
493
+ if no_start:
494
+ hint = "mad start" if name_value == "default" else f"mad start {name_value}"
495
+ info(f"Configuration written. Start the container later with `{hint}` (--no-start).")
496
+ return
497
+
498
+ instance = Instance(name=result.name, config_dir=result.config_dir, env=result.env)
499
+ header("Starting mad-edge")
500
+ res = run_step("Building and starting the container…", lambda: uc_lifecycle.start(instance))
501
+ if res.healthy:
502
+ ok(f"Mad is up — API/MCP on {res.url}" if res.url else "Mad is up.")
503
+ else:
504
+ warn("Container started but is not healthy yet. Check `mad status` and `mad logs`.")
@@ -0,0 +1,102 @@
1
+ """``mad list``, ``mad info NAME`` and ``mad adopt`` — instance inventory and migration.
2
+
3
+ Thin adapter over :mod:`mad_cli.core.usecases.instances` and
4
+ :mod:`mad_cli.core.usecases.adopt`.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import typer
10
+ from rich.panel import Panel
11
+ from rich.table import Table
12
+
13
+ from mad_cli.commands._adapt import fail
14
+ from mad_cli.core.keyspec import mask
15
+ from mad_cli.core.usecases import adopt as uc_adopt
16
+ from mad_cli.core.usecases import instances as uc
17
+ from mad_cli.core.usecases.errors import NotFoundError, UseCaseError
18
+ from mad_cli.ui.console import console, error, header, info, ok, warn
19
+ from mad_cli.ui.prompts import confirm
20
+
21
+
22
+ def list_() -> None:
23
+ """List configured instances with their port, state, health and pinned version."""
24
+ rows = uc.list_instances()
25
+ if not rows:
26
+ info("No instances yet. Run `mad install` to create one.")
27
+ return
28
+
29
+ table = Table(title="mad instances")
30
+ table.add_column("Name", style="bold cyan", no_wrap=True)
31
+ table.add_column("Port")
32
+ table.add_column("State")
33
+ table.add_column("Health")
34
+ table.add_column("Version")
35
+ for row in rows:
36
+ label = f"{row.name} (legacy)" if row.legacy else row.name
37
+ table.add_row(
38
+ label,
39
+ str(row.port) if row.port is not None else "-",
40
+ row.state,
41
+ row.health,
42
+ row.version,
43
+ )
44
+ console.print(table)
45
+
46
+
47
+ def info_cmd(name: str = typer.Argument(..., help="Instance name.")) -> None:
48
+ """Show an instance's paths and its .env values (secrets masked)."""
49
+ try:
50
+ details = uc.instance_info(name)
51
+ except NotFoundError:
52
+ error(f"Instance {name!r} not found. Run `mad list` to see available instances.")
53
+ raise typer.Exit(1) from None
54
+
55
+ paths = Table(show_header=False, box=None, pad_edge=False)
56
+ paths.add_column("key", style="bold cyan", no_wrap=True)
57
+ paths.add_column("value")
58
+ paths.add_row("Config dir", str(details.config_dir))
59
+ paths.add_row("Compose file", str(details.compose_file))
60
+ paths.add_row("Data path", str(details.data_path) if details.data_path else "-")
61
+ console.print(Panel(paths, title=f"Instance {details.name}", border_style="cyan", expand=False))
62
+
63
+ env_table = Table(title=".env")
64
+ env_table.add_column("Key", style="bold", no_wrap=True)
65
+ env_table.add_column("Value")
66
+ for item in details.env:
67
+ shown = mask(item.value) if item.value and item.secret else item.value
68
+ env_table.add_row(item.key, shown)
69
+ console.print(env_table)
70
+
71
+
72
+ def adopt() -> None:
73
+ """Migrate the legacy single-instance layout into ``instances/<name>/``."""
74
+ try:
75
+ plan = uc_adopt.plan_adopt()
76
+ except UseCaseError as exc:
77
+ fail(exc)
78
+ if plan is None:
79
+ info("Nothing to adopt — no legacy single-instance layout found.")
80
+ return
81
+
82
+ header(f"Adopt legacy instance {plan.name!r}")
83
+ table = Table(show_header=False, box=None, pad_edge=False)
84
+ table.add_column("from", style="bold cyan")
85
+ table.add_column("arrow")
86
+ table.add_column("to")
87
+ for name in plan.movable:
88
+ table.add_row(str(plan.source / name), "→", str(plan.target / name))
89
+ console.print(table)
90
+ info("Data (MAD_DATA_PATH) is not moved — only the config files above are relocated.")
91
+ warn(
92
+ f"The Compose project name changes from the legacy layout to mad-{plan.name}. "
93
+ f"If the legacy container is still running, stop it first with "
94
+ f"`docker compose -f {plan.source / 'compose.yml'} down` (this command does not)."
95
+ )
96
+
97
+ if not confirm(f"Move {len(plan.movable)} file(s) into {plan.target}?", default=True):
98
+ info("Adoption cancelled.")
99
+ return
100
+
101
+ uc_adopt.apply_adopt(plan)
102
+ ok(f"Adopted {plan.name!r} → {plan.target}")