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.
- mad_cli/__init__.py +3 -0
- mad_cli/__main__.py +6 -0
- mad_cli/app.py +77 -0
- mad_cli/commands/__init__.py +5 -0
- mad_cli/commands/_adapt.py +41 -0
- mad_cli/commands/_common.py +12 -0
- mad_cli/commands/config.py +94 -0
- mad_cli/commands/install.py +504 -0
- mad_cli/commands/instances.py +102 -0
- mad_cli/commands/keys.py +126 -0
- mad_cli/commands/lifecycle.py +69 -0
- mad_cli/commands/profiles.py +238 -0
- mad_cli/commands/service.py +220 -0
- mad_cli/commands/versions.py +61 -0
- mad_cli/core/__init__.py +4 -0
- mad_cli/core/claude_creds.py +31 -0
- mad_cli/core/compose.py +145 -0
- mad_cli/core/docker_check.py +89 -0
- mad_cli/core/envfile.py +140 -0
- mad_cli/core/instance.py +110 -0
- mad_cli/core/keyspec.py +98 -0
- mad_cli/core/paths.py +40 -0
- mad_cli/core/profiles.py +93 -0
- mad_cli/core/pypi.py +29 -0
- mad_cli/core/templates.py +91 -0
- mad_cli/core/usecases/__init__.py +11 -0
- mad_cli/core/usecases/adopt.py +55 -0
- mad_cli/core/usecases/configvals.py +94 -0
- mad_cli/core/usecases/errors.py +57 -0
- mad_cli/core/usecases/install.py +263 -0
- mad_cli/core/usecases/instances.py +156 -0
- mad_cli/core/usecases/keys.py +169 -0
- mad_cli/core/usecases/lifecycle.py +76 -0
- mad_cli/core/usecases/service.py +269 -0
- mad_cli/core/usecases/versions.py +126 -0
- mad_cli/py.typed +0 -0
- mad_cli/server/__init__.py +13 -0
- mad_cli/server/app.py +260 -0
- mad_cli/server/auth.py +41 -0
- mad_cli/server/models.py +156 -0
- mad_cli/templates/Dockerfile.tmpl +66 -0
- mad_cli/templates/__init__.py +6 -0
- mad_cli/templates/com.mad-core.mad-cli.plist.tmpl +28 -0
- mad_cli/templates/compose.yml.tmpl +29 -0
- mad_cli/templates/entrypoint.sh.tmpl +11 -0
- mad_cli/templates/mad-cli.service.tmpl +15 -0
- mad_cli/ui/__init__.py +5 -0
- mad_cli/ui/console.py +65 -0
- mad_cli/ui/prompts.py +83 -0
- mad_cli-0.4.0.dist-info/METADATA +167 -0
- mad_cli-0.4.0.dist-info/RECORD +54 -0
- mad_cli-0.4.0.dist-info/WHEEL +4 -0
- mad_cli-0.4.0.dist-info/entry_points.txt +2 -0
- 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)
|
mad_cli/core/__init__.py
ADDED
|
@@ -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
|
mad_cli/core/compose.py
ADDED
|
@@ -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)
|
mad_cli/core/envfile.py
ADDED
|
@@ -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
|
mad_cli/core/instance.py
ADDED
|
@@ -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
|
mad_cli/core/keyspec.py
ADDED
|
@@ -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)
|