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,61 @@
1
+ """``mad versions [INSTANCE]`` and ``mad update INSTANCE`` — versions and upgrades.
2
+
3
+ Thin adapter over :mod:`mad_cli.core.usecases.versions`. ``versions`` renders, per
4
+ instance, the pinned / installed / latest-on-PyPI versions and whether an update
5
+ is available; ``update`` re-pins ``MAD_VERSION`` and rebuilds from scratch.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import typer
11
+ from rich.table import Table
12
+
13
+ from mad_cli.commands._adapt import resolve_or_die
14
+ from mad_cli.core.usecases import versions as uc
15
+ from mad_cli.core.usecases.errors import NotFoundError
16
+ from mad_cli.ui.console import console, error, header, info, ok, run_step, warn
17
+
18
+
19
+ def versions(
20
+ instance: str | None = typer.Argument(
21
+ None, help="Instance name (reports on every instance when omitted)."
22
+ ),
23
+ ) -> None:
24
+ """Show pinned / installed / latest versions and whether an update is available."""
25
+ try:
26
+ rows = uc.versions(instance)
27
+ except NotFoundError:
28
+ error(f"Instance {instance!r} not found. Run `mad list` to see available instances.")
29
+ raise typer.Exit(1) from None
30
+
31
+ if not rows:
32
+ info("No instances yet. Run `mad install` to create one.")
33
+ return
34
+
35
+ table = Table(title="mad versions")
36
+ table.add_column("Name", style="bold cyan", no_wrap=True)
37
+ table.add_column("Pinned")
38
+ table.add_column("Installed")
39
+ table.add_column("Latest on PyPI")
40
+ table.add_column("Update")
41
+ for row in rows:
42
+ label = f"{row.name} (legacy)" if row.legacy else row.name
43
+ table.add_row(label, row.pinned, row.installed, row.latest, row.update)
44
+ console.print(table)
45
+
46
+
47
+ def update(
48
+ instance: str = typer.Argument(..., help="Instance name."),
49
+ version: str | None = typer.Option(
50
+ None, "--version", help="Version to pin (omit or blank to track latest)."
51
+ ),
52
+ ) -> None:
53
+ """Re-pin ``MAD_VERSION`` and rebuild the instance from scratch."""
54
+ inst = resolve_or_die(instance)
55
+ header(f"Updating {inst.name} → {version or 'latest'}")
56
+ res = run_step("Rebuilding image (no cache) and starting…", lambda: uc.update(inst, version))
57
+ if res.healthy:
58
+ ok(f"{inst.name} updated to {res.target}.")
59
+ else:
60
+ warn(f"{inst.name} rebuilt but is not healthy yet. Check `mad status` and `mad logs`.")
61
+ raise typer.Exit(1)
@@ -0,0 +1,4 @@
1
+ """Framework-free engine for mad-cli.
2
+
3
+ Layering rule (CONTRACTS.md): nothing under ``mad_cli.core`` imports typer or rich.
4
+ """
@@ -0,0 +1,31 @@
1
+ """Claude OAuth credentials file (``claudeAiOauth`` format, ``chmod 600``).
2
+
3
+ Ports ``write_claude_credentials`` from the reference ``configure.sh``: the file
4
+ that the ``claude-code`` CLI reads from ``~/.claude/.credentials.json`` inside the
5
+ container, so the agent authenticates without an interactive login.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import json
11
+ from pathlib import Path
12
+
13
+
14
+ def write_claude_credentials(claude_dir: Path, token: str) -> Path:
15
+ """Write ``claude_dir/.credentials.json`` and return its path.
16
+
17
+ The directory is created if needed and the file is ``chmod 600`` because it
18
+ holds a bearer token.
19
+ """
20
+ claude_dir.mkdir(parents=True, exist_ok=True)
21
+ path = claude_dir / ".credentials.json"
22
+ payload = {
23
+ "claudeAiOauth": {
24
+ "accessToken": token,
25
+ "expiresAt": 9999999999999,
26
+ "scopes": ["user:inference", "user:profile"],
27
+ }
28
+ }
29
+ path.write_text(json.dumps(payload, indent=2) + "\n", encoding="utf-8")
30
+ path.chmod(0o600)
31
+ return path
@@ -0,0 +1,145 @@
1
+ """docker compose runner — the one module that shells out to Docker.
2
+
3
+ Ports the lifecycle semantics of the reference ``start.sh``. Every invocation is
4
+ scoped to the instance with ``-p mad-<name> -f <compose.yml> --env-file <.env>``
5
+ so instances never collide. ``dry_run`` records the argv on
6
+ :attr:`ComposeRunner.last_command` without executing anything, which is what the
7
+ tests assert against.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import subprocess
13
+ import time
14
+
15
+ from mad_cli.core.instance import Instance
16
+
17
+
18
+ class ComposeError(Exception):
19
+ """A docker compose invocation failed."""
20
+
21
+
22
+ class ComposeRunner:
23
+ def __init__(self, instance: Instance, *, dry_run: bool = False) -> None:
24
+ self.instance = instance
25
+ self.dry_run = dry_run
26
+ self.last_command: list[str] | None = None
27
+
28
+ # ── argv construction ───────────────────────────────────────────────────
29
+ def _base(self) -> list[str]:
30
+ return [
31
+ "docker",
32
+ "compose",
33
+ "-p",
34
+ f"mad-{self.instance.name}",
35
+ "-f",
36
+ str(self.instance.compose_file),
37
+ "--env-file",
38
+ str(self.instance.env_file),
39
+ ]
40
+
41
+ def _run(
42
+ self,
43
+ args: list[str],
44
+ *,
45
+ capture: bool = False,
46
+ check: bool = True,
47
+ ) -> subprocess.CompletedProcess[str] | None:
48
+ cmd = self._base() + args
49
+ self.last_command = cmd
50
+ if self.dry_run:
51
+ return None
52
+ try:
53
+ completed = subprocess.run(cmd, capture_output=capture, text=True, check=False)
54
+ except OSError as exc:
55
+ raise ComposeError(f"failed to run {' '.join(cmd)}: {exc}") from exc
56
+ if check and completed.returncode != 0:
57
+ stderr = (completed.stderr or "").strip()
58
+ raise ComposeError(
59
+ f"`{' '.join(cmd)}` failed (exit {completed.returncode})"
60
+ + (f": {stderr}" if stderr else "")
61
+ )
62
+ return completed
63
+
64
+ # ── lifecycle ───────────────────────────────────────────────────────────
65
+ def up(self, build: bool = True, detach: bool = True) -> None:
66
+ args = ["up"]
67
+ if detach:
68
+ args.append("-d")
69
+ if build:
70
+ args.append("--build")
71
+ self._run(args)
72
+
73
+ def down(self) -> None:
74
+ self._run(["down"])
75
+
76
+ def restart(self) -> None:
77
+ """Stop then start (with a rebuild), matching ``start.sh restart``."""
78
+ self.down()
79
+ self.up()
80
+
81
+ def ps(self) -> str:
82
+ completed = self._run(["ps"], capture=True)
83
+ return "" if completed is None else (completed.stdout or "")
84
+
85
+ def logs(self, follow: bool = True) -> None:
86
+ args = ["logs"]
87
+ if follow:
88
+ args.append("-f")
89
+ args.append("mad")
90
+ # Interactive stream: no capture, and a Ctrl-C exit is not an error.
91
+ self._run(args, check=False)
92
+
93
+ def shell(self) -> None:
94
+ # Interactive: attaches the caller's TTY to `bash` in the service.
95
+ self._run(["exec", "mad", "bash"], check=False)
96
+
97
+ def config_check(self) -> None:
98
+ self._run(["config", "-q"])
99
+
100
+ def build(self, no_cache: bool = False) -> None:
101
+ args = ["build"]
102
+ if no_cache:
103
+ args.append("--no-cache")
104
+ self._run(args)
105
+
106
+ def exec(self, cmd: list[str], capture: bool = True) -> str:
107
+ completed = self._run(["exec", "mad", *cmd], capture=capture)
108
+ if completed is None:
109
+ return ""
110
+ return completed.stdout or "" if capture else ""
111
+
112
+ # ── health ──────────────────────────────────────────────────────────────
113
+ def wait_healthy(self, timeout_s: int = 180) -> bool:
114
+ """Poll ``docker inspect`` until the container reports ``healthy``.
115
+
116
+ Returns ``True`` as soon as the health status is ``healthy``; returns
117
+ ``False`` if the timeout elapses first. A ``dry_run`` runner reports
118
+ healthy immediately.
119
+ """
120
+ if self.dry_run:
121
+ return True
122
+ container = f"mad-{self.instance.name}"
123
+ interval = 2.0
124
+ deadline = time.monotonic() + timeout_s
125
+ while True:
126
+ if self._inspect_health(container) == "healthy":
127
+ return True
128
+ if time.monotonic() >= deadline:
129
+ return False
130
+ time.sleep(interval)
131
+
132
+ @staticmethod
133
+ def _inspect_health(container: str) -> str | None:
134
+ try:
135
+ result = subprocess.run(
136
+ ["docker", "inspect", "--format", "{{.State.Health.Status}}", container],
137
+ capture_output=True,
138
+ text=True,
139
+ check=False,
140
+ )
141
+ except OSError:
142
+ return None
143
+ if result.returncode != 0:
144
+ return None
145
+ return (result.stdout or "").strip()
@@ -0,0 +1,89 @@
1
+ """Docker / daemon / compose-v2 detection and opt-in Linux install.
2
+
3
+ Everything is probed through ``subprocess`` (not ``shutil.which``) so the whole
4
+ module can be exercised in tests with a single ``subprocess.run`` double.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import os
10
+ import platform
11
+ import subprocess
12
+ import tempfile
13
+ import urllib.request
14
+ from dataclasses import dataclass
15
+
16
+
17
+ @dataclass
18
+ class DockerStatus:
19
+ docker_present: bool
20
+ daemon_running: bool
21
+ compose_v2: bool
22
+ version: str | None
23
+
24
+
25
+ def _capture(cmd: list[str]) -> str | None:
26
+ """Run ``cmd``; return trimmed stdout on success, ``None`` otherwise."""
27
+ try:
28
+ result = subprocess.run(cmd, capture_output=True, text=True, check=False)
29
+ except (OSError, ValueError):
30
+ return None
31
+ if result.returncode != 0:
32
+ return None
33
+ return (result.stdout or "").strip()
34
+
35
+
36
+ def _ok(cmd: list[str]) -> bool:
37
+ """Return ``True`` when ``cmd`` exits 0."""
38
+ try:
39
+ result = subprocess.run(cmd, capture_output=True, text=True, check=False)
40
+ except (OSError, ValueError):
41
+ return False
42
+ return result.returncode == 0
43
+
44
+
45
+ def check_docker() -> DockerStatus:
46
+ """Probe for the docker binary, a running daemon and Compose v2."""
47
+ version = _capture(["docker", "--version"])
48
+ if version is None:
49
+ return DockerStatus(
50
+ docker_present=False,
51
+ daemon_running=False,
52
+ compose_v2=False,
53
+ version=None,
54
+ )
55
+ return DockerStatus(
56
+ docker_present=True,
57
+ daemon_running=_ok(["docker", "info"]),
58
+ compose_v2=_ok(["docker", "compose", "version"]),
59
+ version=version,
60
+ )
61
+
62
+
63
+ def install_docker_linux(assume_yes: bool = False) -> bool:
64
+ """Install Docker on Linux via the official ``get.docker.com`` script.
65
+
66
+ Returns ``True`` on success. The interactive confirmation lives in the
67
+ command layer; ``assume_yes`` is accepted for symmetry and does not change
68
+ the (already opted-in) behaviour here. Never touches non-Linux hosts.
69
+ """
70
+ if platform.system() != "Linux":
71
+ return False
72
+ script_path = ""
73
+ try:
74
+ with urllib.request.urlopen("https://get.docker.com", timeout=30) as response:
75
+ script = response.read()
76
+ with tempfile.NamedTemporaryFile("wb", suffix=".sh", delete=False) as handle:
77
+ handle.write(script)
78
+ script_path = handle.name
79
+ subprocess.run(["sh", script_path], check=True)
80
+ subprocess.run(["sudo", "systemctl", "enable", "--now", "docker"], check=True)
81
+ user = os.environ.get("USER", "")
82
+ if user:
83
+ subprocess.run(["sudo", "usermod", "-aG", "docker", user], check=False)
84
+ return True
85
+ except (OSError, ValueError, subprocess.SubprocessError):
86
+ return False
87
+ finally:
88
+ if script_path and os.path.exists(script_path):
89
+ os.unlink(script_path)
@@ -0,0 +1,140 @@
1
+ """.env parser/writer that preserves comments, ordering and blank lines.
2
+
3
+ A pure ``load`` then ``save`` round-trips the file byte-for-byte. Mutations
4
+ (:meth:`EnvFile.set`, :meth:`EnvFile.unset`) touch only the affected line:
5
+ ``set`` rewrites an existing assignment in place or appends a new one at the
6
+ end, and ``unset`` drops the assignment line, leaving every comment and blank
7
+ line untouched.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ from dataclasses import dataclass
13
+ from pathlib import Path
14
+
15
+
16
+ @dataclass
17
+ class _Line:
18
+ """One physical line of the file.
19
+
20
+ ``kind`` is ``"kv"`` for an assignment, ``"comment"`` for a ``#`` line and
21
+ ``"blank"`` otherwise. ``text`` is the exact rendered line; ``key``/``value``
22
+ are populated only for ``kv`` lines.
23
+ """
24
+
25
+ kind: str
26
+ text: str
27
+ key: str | None = None
28
+ value: str | None = None
29
+
30
+
31
+ def _parse_kv(line: str) -> tuple[str, str] | None:
32
+ """Return ``(key, value)`` if ``line`` is an assignment, else ``None``."""
33
+ stripped = line.lstrip()
34
+ if not stripped or stripped.startswith("#"):
35
+ return None
36
+ if "=" not in stripped:
37
+ return None
38
+ key, _, value = stripped.partition("=")
39
+ if key.startswith("export "):
40
+ key = key[len("export ") :]
41
+ key = key.strip()
42
+ if not key:
43
+ return None
44
+ value = value.strip()
45
+ if len(value) >= 2 and value[0] == value[-1] and value[0] in ("'", '"'):
46
+ value = value[1:-1]
47
+ return key, value
48
+
49
+
50
+ class EnvFile:
51
+ """Comment- and order-preserving representation of a ``.env`` file."""
52
+
53
+ path: Path | None
54
+
55
+ def __init__(self) -> None:
56
+ self.path = None
57
+ self._lines: list[_Line] = []
58
+ self._final_newline: bool = False
59
+
60
+ # ── constructors ────────────────────────────────────────────────────────
61
+ @classmethod
62
+ def load(cls, path: Path) -> EnvFile:
63
+ """Parse ``path`` tolerantly, preserving its exact layout."""
64
+ obj = cls()
65
+ obj.path = path
66
+ obj._parse(path.read_text(encoding="utf-8"))
67
+ return obj
68
+
69
+ @classmethod
70
+ def empty(cls) -> EnvFile:
71
+ """Return a fresh, path-less, empty env file."""
72
+ return cls()
73
+
74
+ def _parse(self, text: str) -> None:
75
+ self._lines = []
76
+ if text == "":
77
+ self._final_newline = False
78
+ return
79
+ self._final_newline = text.endswith("\n")
80
+ body = text[:-1] if self._final_newline else text
81
+ for raw in body.split("\n"):
82
+ parsed = _parse_kv(raw)
83
+ if parsed is not None:
84
+ key, value = parsed
85
+ self._lines.append(_Line("kv", raw, key=key, value=value))
86
+ elif raw.lstrip().startswith("#"):
87
+ self._lines.append(_Line("comment", raw))
88
+ else:
89
+ self._lines.append(_Line("blank", raw))
90
+
91
+ # ── accessors ───────────────────────────────────────────────────────────
92
+ def get(self, key: str) -> str | None:
93
+ """Return the value of ``key`` (first assignment wins), else ``None``."""
94
+ for line in self._lines:
95
+ if line.kind == "kv" and line.key == key:
96
+ return line.value
97
+ return None
98
+
99
+ def set(self, key: str, value: str) -> None:
100
+ """Set ``key`` to ``value``, rewriting in place or appending at the end."""
101
+ for line in self._lines:
102
+ if line.kind == "kv" and line.key == key:
103
+ line.value = value
104
+ line.text = f"{key}={value}"
105
+ return
106
+ self._lines.append(_Line("kv", f"{key}={value}", key=key, value=value))
107
+
108
+ def unset(self, key: str) -> None:
109
+ """Remove every assignment of ``key`` (no-op if absent)."""
110
+ self._lines = [line for line in self._lines if not (line.kind == "kv" and line.key == key)]
111
+
112
+ def add_comment(self, text: str) -> None:
113
+ """Append a comment line (used to leave documented, inactive references).
114
+
115
+ ``text`` is written verbatim; a leading ``#`` is added if missing. The
116
+ line is inert — it never shadows an assignment and :meth:`get`/:meth:`keys`
117
+ ignore it — so it round-trips as a plain reference in the generated file.
118
+ """
119
+ rendered = text if text.lstrip().startswith("#") else f"# {text}"
120
+ self._lines.append(_Line("comment", rendered))
121
+
122
+ def keys(self) -> list[str]:
123
+ """Return the assignment keys in file order."""
124
+ return [line.key for line in self._lines if line.kind == "kv" and line.key is not None]
125
+
126
+ # ── serialisation ───────────────────────────────────────────────────────
127
+ def render(self) -> str:
128
+ """Return the file contents as a single string."""
129
+ body = "\n".join(line.text for line in self._lines)
130
+ if self._final_newline:
131
+ return body + "\n"
132
+ return body
133
+
134
+ def save(self, path: Path | None = None) -> None:
135
+ """Write the file to ``path`` (or the loaded path) byte-stably."""
136
+ target = path or self.path
137
+ if target is None:
138
+ raise ValueError("no path to save to; pass an explicit path")
139
+ target.write_text(self.render(), encoding="utf-8")
140
+ self.path = target
@@ -0,0 +1,110 @@
1
+ """Instance model and filesystem discovery.
2
+
3
+ An *instance* is one mad-edge container plus its config directory
4
+ (``compose.yml`` / ``.env`` / ``Dockerfile`` / ``entrypoint.sh``). The modern
5
+ layout stores one instance per directory under ``config_root()/instances/``;
6
+ the legacy single-instance layout kept those files directly at ``config_root()``.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from dataclasses import dataclass
12
+ from pathlib import Path
13
+
14
+ from mad_cli.core import paths
15
+ from mad_cli.core.envfile import EnvFile
16
+
17
+
18
+ class InstanceNotFoundError(Exception):
19
+ """Raised when a named instance does not exist under the config root."""
20
+
21
+
22
+ @dataclass
23
+ class Instance:
24
+ name: str
25
+ config_dir: Path
26
+ env: EnvFile
27
+ legacy: bool = False
28
+
29
+ @property
30
+ def host_port(self) -> int | None:
31
+ """Host port from ``MAD_HOST_PORT`` (``None`` if unset/non-numeric)."""
32
+ raw = self.env.get("MAD_HOST_PORT")
33
+ if not raw:
34
+ return None
35
+ try:
36
+ return int(raw)
37
+ except ValueError:
38
+ return None
39
+
40
+ @property
41
+ def data_path(self) -> Path | None:
42
+ """Host data root from ``MAD_DATA_PATH`` (``None`` if unset)."""
43
+ raw = self.env.get("MAD_DATA_PATH")
44
+ if not raw:
45
+ return None
46
+ return Path(raw)
47
+
48
+ @property
49
+ def version_pin(self) -> str | None:
50
+ """Pinned mad-edge version from ``MAD_VERSION`` ('' -> ``None``)."""
51
+ raw = self.env.get("MAD_VERSION")
52
+ if not raw:
53
+ return None
54
+ return raw
55
+
56
+ @property
57
+ def compose_file(self) -> Path:
58
+ return self.config_dir / "compose.yml"
59
+
60
+ @property
61
+ def env_file(self) -> Path:
62
+ return self.config_dir / ".env"
63
+
64
+
65
+ def _load(config_dir: Path, name: str, *, legacy: bool) -> Instance:
66
+ env = EnvFile.load(config_dir / ".env")
67
+ return Instance(name=name, config_dir=config_dir, env=env, legacy=legacy)
68
+
69
+
70
+ def discover_instances() -> list[Instance]:
71
+ """Return every configured instance.
72
+
73
+ Modern instances (``instances/<name>/.env``) come first, sorted by name; a
74
+ directory without a ``.env`` is ignored. A legacy top-level layout
75
+ (``config_root()/compose.yml`` + ``.env``) is appended with ``legacy=True``,
76
+ its name taken from ``MAD_INSTANCE`` (default ``"default"``).
77
+ """
78
+ found: list[Instance] = []
79
+
80
+ root = paths.instances_root()
81
+ if root.is_dir():
82
+ for child in sorted(root.iterdir(), key=lambda p: p.name):
83
+ if child.is_dir() and (child / ".env").is_file():
84
+ found.append(_load(child, child.name, legacy=False))
85
+
86
+ config_root = paths.config_root()
87
+ if (config_root / ".env").is_file() and (config_root / "compose.yml").is_file():
88
+ legacy = _load(config_root, "default", legacy=True)
89
+ name = legacy.env.get("MAD_INSTANCE")
90
+ if name:
91
+ legacy.name = name
92
+ found.append(legacy)
93
+
94
+ return found
95
+
96
+
97
+ def get_instance(name: str) -> Instance:
98
+ """Return the instance called ``name`` or raise :class:`InstanceNotFoundError`."""
99
+ for instance in discover_instances():
100
+ if instance.name == name:
101
+ return instance
102
+ raise InstanceNotFoundError(name)
103
+
104
+
105
+ def default_instance() -> Instance | None:
106
+ """Return the sole instance when exactly one exists, else ``None``."""
107
+ instances = discover_instances()
108
+ if len(instances) == 1:
109
+ return instances[0]
110
+ return None
@@ -0,0 +1,98 @@
1
+ """Registry of the credentials / API keys an operator can store in ``.env``.
2
+
3
+ Each :class:`KeySpec` maps one logical secret to the environment variable(s) it
4
+ is written to. Some secrets fan out to several variables that must carry the
5
+ same value (``github`` -> ``GITHUB_TOKEN`` + ``GH_TOKEN``); ``claude-oauth`` is
6
+ special-cased because, besides landing in ``.env``, it is materialised into the
7
+ container's Claude credentials file (see :mod:`mad_cli.core.claude_creds`).
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ from dataclasses import dataclass
13
+
14
+
15
+ @dataclass(frozen=True)
16
+ class KeySpec:
17
+ id: str
18
+ env_vars: tuple[str, ...]
19
+ prompt: str
20
+ secret: bool = True
21
+ help_url: str | None = None
22
+ writes_claude_credentials: bool = False
23
+
24
+
25
+ BUILTIN_KEYS: dict[str, KeySpec] = {
26
+ "claude-oauth": KeySpec(
27
+ id="claude-oauth",
28
+ env_vars=("_CLAUDE_OAUTH_TOKEN",),
29
+ prompt="Claude OAuth token (run `claude setup-token` on a logged-in machine)",
30
+ help_url="https://docs.anthropic.com/en/docs/claude-code",
31
+ writes_claude_credentials=True,
32
+ ),
33
+ "anthropic": KeySpec(
34
+ id="anthropic",
35
+ env_vars=("ANTHROPIC_API_KEY",),
36
+ prompt="Anthropic API key (sk-ant-…)",
37
+ help_url="https://console.anthropic.com/settings/keys",
38
+ ),
39
+ "github": KeySpec(
40
+ id="github",
41
+ env_vars=("GITHUB_TOKEN", "GH_TOKEN"),
42
+ prompt="GitHub personal access token (ghp_…)",
43
+ help_url="https://github.com/settings/tokens",
44
+ ),
45
+ "deepseek": KeySpec(
46
+ id="deepseek",
47
+ env_vars=("DEEPSEEK_API_KEY",),
48
+ prompt="DeepSeek API key",
49
+ help_url="https://platform.deepseek.com/api_keys",
50
+ ),
51
+ "linear": KeySpec(
52
+ id="linear",
53
+ env_vars=("LINEAR_API_KEY",),
54
+ prompt="Linear API key",
55
+ help_url="https://linear.app/settings/api",
56
+ ),
57
+ "opencode": KeySpec(
58
+ id="opencode",
59
+ env_vars=("OPENCODE_API_KEY",),
60
+ prompt="OpenCode API key",
61
+ help_url="https://opencode.ai/docs",
62
+ ),
63
+ }
64
+
65
+
66
+ def mask(value: str) -> str:
67
+ """Return a display-safe masking of ``value``.
68
+
69
+ Long secrets keep a short prefix and suffix (``"sk-a…f3"``); anything short
70
+ enough that a prefix/suffix would leak most of it is fully hidden.
71
+ """
72
+ if not value:
73
+ return ""
74
+ if len(value) <= 8:
75
+ return "…"
76
+ return f"{value[:4]}…{value[-2:]}"
77
+
78
+
79
+ # Substrings that mark an env key as holding a secret whose value must be masked
80
+ # before it is shown to a human or returned over the API.
81
+ _SECRET_HINTS = ("TOKEN", "KEY", "SECRET", "PASSWORD")
82
+
83
+
84
+ def is_secret_key(key: str) -> bool:
85
+ """True if ``key`` looks like it holds a credential (masked on display)."""
86
+ upper = key.upper()
87
+ return any(hint in upper for hint in _SECRET_HINTS)
88
+
89
+
90
+ def display_value(key: str, value: str, *, reveal: bool = False) -> str:
91
+ """Return ``value`` for display: masked when it looks secret, else verbatim.
92
+
93
+ ``reveal=True`` returns the raw value (the CLI ``--reveal`` path); the HTTP
94
+ API never passes it, so secret-looking values are always masked there.
95
+ """
96
+ if reveal or not value or not is_secret_key(key):
97
+ return value
98
+ return mask(value)