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,263 @@
|
|
|
1
|
+
"""Install / reconfigure use case: params -> .env -> files -> dirs -> (start).
|
|
2
|
+
|
|
3
|
+
The deterministic heart of ``mad install`` and ``POST /v1/instances``. Given a
|
|
4
|
+
fully-resolved :class:`InstallParams` (the CLI does the interactive collection and
|
|
5
|
+
Docker preflight; the API takes them from the request body), it assembles the
|
|
6
|
+
``.env``, renders the instance files, creates the data directories (including
|
|
7
|
+
``sessions/``), writes the Claude credentials file and, unless ``start`` is
|
|
8
|
+
false, builds and starts the container. No prompting, no Docker preflight, no
|
|
9
|
+
presentation — those belong to the adapters.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import re
|
|
15
|
+
from dataclasses import dataclass, field
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
|
|
18
|
+
from mad_cli.core.claude_creds import write_claude_credentials
|
|
19
|
+
from mad_cli.core.compose import ComposeRunner
|
|
20
|
+
from mad_cli.core.envfile import EnvFile
|
|
21
|
+
from mad_cli.core.instance import Instance
|
|
22
|
+
from mad_cli.core.keyspec import BUILTIN_KEYS
|
|
23
|
+
from mad_cli.core.paths import instance_dir
|
|
24
|
+
from mad_cli.core.templates import EDGE_PACKAGE, RenderContext, write_instance_files
|
|
25
|
+
from mad_cli.core.usecases.errors import ValidationError
|
|
26
|
+
|
|
27
|
+
_NAME_RE = re.compile(r"[a-z0-9][a-z0-9-]*")
|
|
28
|
+
# A custom extra key is written verbatim; it must look like a shell env-var name.
|
|
29
|
+
_CUSTOM_KEY_RE = re.compile(r"[A-Z][A-Z0-9_]*")
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
# ── validators (shared with the CLI's interactive prompts) ───────────────────
|
|
33
|
+
def validate_name(value: str) -> str:
|
|
34
|
+
value = value.strip()
|
|
35
|
+
if not _NAME_RE.fullmatch(value):
|
|
36
|
+
raise ValidationError(
|
|
37
|
+
f"invalid instance name {value!r}: use lowercase letters, digits and "
|
|
38
|
+
"hyphens, starting with a letter or digit"
|
|
39
|
+
)
|
|
40
|
+
return value
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def validate_port(value: str) -> str:
|
|
44
|
+
try:
|
|
45
|
+
port = int(value)
|
|
46
|
+
except ValueError:
|
|
47
|
+
raise ValidationError(f"invalid port {value!r}: must be an integer") from None
|
|
48
|
+
if not 1 <= port <= 65535:
|
|
49
|
+
raise ValidationError(f"invalid port {value!r}: must be between 1 and 65535")
|
|
50
|
+
return str(port)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def validate_timeout(value: str) -> str:
|
|
54
|
+
try:
|
|
55
|
+
seconds = int(value)
|
|
56
|
+
except ValueError:
|
|
57
|
+
raise ValidationError(f"invalid timeout {value!r}: must be an integer") from None
|
|
58
|
+
if seconds <= 0:
|
|
59
|
+
raise ValidationError(f"invalid timeout {value!r}: must be a positive integer")
|
|
60
|
+
return str(seconds)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def validate_retention(value: str) -> str:
|
|
64
|
+
"""A retention: a positive integer number of days, or empty (keep forever)."""
|
|
65
|
+
value = value.strip()
|
|
66
|
+
if not value:
|
|
67
|
+
return ""
|
|
68
|
+
try:
|
|
69
|
+
days = int(value)
|
|
70
|
+
except ValueError:
|
|
71
|
+
raise ValidationError(
|
|
72
|
+
f"invalid retention {value!r}: must be a positive integer of days, or empty"
|
|
73
|
+
) from None
|
|
74
|
+
if days < 1:
|
|
75
|
+
raise ValidationError(
|
|
76
|
+
f"invalid retention {value!r}: must be >= 1 (or empty to keep forever)"
|
|
77
|
+
)
|
|
78
|
+
return str(days)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def apply_extra_key(env: EnvFile, ident: str, value: str) -> list[str]:
|
|
82
|
+
"""Write a builtin (fanned out) or custom extra key into ``env``.
|
|
83
|
+
|
|
84
|
+
Returns the env vars it touched. Rejects ``claude-oauth`` (it also
|
|
85
|
+
materialises the credentials file — set it as the dedicated Claude token) and
|
|
86
|
+
raises :class:`ValidationError` for an unknown id. The message is neutral; the
|
|
87
|
+
CLI adds its ``--claude-token`` hint before delegating here.
|
|
88
|
+
"""
|
|
89
|
+
spec = BUILTIN_KEYS.get(ident)
|
|
90
|
+
if spec is not None:
|
|
91
|
+
if spec.writes_claude_credentials:
|
|
92
|
+
raise ValidationError(
|
|
93
|
+
f"{ident!r} cannot be set as an extra key; provide the Claude token directly."
|
|
94
|
+
)
|
|
95
|
+
for var in spec.env_vars:
|
|
96
|
+
env.set(var, value)
|
|
97
|
+
return list(spec.env_vars)
|
|
98
|
+
if _CUSTOM_KEY_RE.fullmatch(ident):
|
|
99
|
+
env.set(ident, value)
|
|
100
|
+
return [ident]
|
|
101
|
+
raise ValidationError(
|
|
102
|
+
f"unknown key {ident!r}: use a builtin id ({', '.join(BUILTIN_KEYS)}) "
|
|
103
|
+
"or an env-var name matching [A-Z][A-Z0-9_]*."
|
|
104
|
+
)
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
@dataclass
|
|
108
|
+
class InstallParams:
|
|
109
|
+
"""Fully-resolved install inputs (post prompting / request parsing)."""
|
|
110
|
+
|
|
111
|
+
name: str
|
|
112
|
+
port: int
|
|
113
|
+
data_path: Path
|
|
114
|
+
timeout_s: int
|
|
115
|
+
github_token: str
|
|
116
|
+
puid: int
|
|
117
|
+
pgid: int
|
|
118
|
+
git_name: str = ""
|
|
119
|
+
git_email: str = ""
|
|
120
|
+
claude_token: str = ""
|
|
121
|
+
anthropic_api_key: str = ""
|
|
122
|
+
# Extra API keys as a flat {ENV_VAR: value} overlay (already fanned out by the
|
|
123
|
+
# adapter via :func:`apply_extra_key`); written verbatim after the base keys.
|
|
124
|
+
extra_env: dict[str, str] = field(default_factory=dict)
|
|
125
|
+
retention_days: str = "" # "" = keep forever
|
|
126
|
+
mcp_allowed_hosts: str = "" # "" = disabled
|
|
127
|
+
edge_package: str = EDGE_PACKAGE
|
|
128
|
+
edge_version: str = ""
|
|
129
|
+
start: bool = True
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
@dataclass(frozen=True)
|
|
133
|
+
class InstallResult:
|
|
134
|
+
"""Outcome of an install for the adapter to present."""
|
|
135
|
+
|
|
136
|
+
name: str
|
|
137
|
+
config_dir: Path
|
|
138
|
+
data_dir: Path
|
|
139
|
+
port: int
|
|
140
|
+
timeout_s: int
|
|
141
|
+
env: EnvFile
|
|
142
|
+
extra_key_vars: list[str]
|
|
143
|
+
claude_credentials_path: Path | None
|
|
144
|
+
claude_dir: Path
|
|
145
|
+
started: bool
|
|
146
|
+
healthy: bool | None
|
|
147
|
+
url: str | None
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def build_env(params: InstallParams) -> tuple[EnvFile, list[str]]:
|
|
151
|
+
"""Assemble the ``.env`` for ``params``; return it and the extra-key vars.
|
|
152
|
+
|
|
153
|
+
Split out so it can be unit-tested and reused; validates the extra keys,
|
|
154
|
+
raising :class:`ValidationError` on a bad entry (before any file is written).
|
|
155
|
+
"""
|
|
156
|
+
env = EnvFile.empty()
|
|
157
|
+
env.set("MAD_INSTANCE", params.name)
|
|
158
|
+
env.set("MAD_HOST_PORT", str(params.port))
|
|
159
|
+
env.set("MAD_VERSION", params.edge_version)
|
|
160
|
+
env.set("PUID", str(params.puid))
|
|
161
|
+
env.set("PGID", str(params.pgid))
|
|
162
|
+
env.set("MAD_DATA_PATH", str(params.data_path))
|
|
163
|
+
env.set("GITHUB_TOKEN", params.github_token)
|
|
164
|
+
env.set("GH_TOKEN", params.github_token)
|
|
165
|
+
env.set("GIT_AUTHOR_NAME", params.git_name)
|
|
166
|
+
env.set("GIT_AUTHOR_EMAIL", params.git_email)
|
|
167
|
+
env.set("GIT_COMMITTER_NAME", params.git_name)
|
|
168
|
+
env.set("GIT_COMMITTER_EMAIL", params.git_email)
|
|
169
|
+
env.set("MAD_AGENT_TIMEOUT_S", str(params.timeout_s))
|
|
170
|
+
env.set("_CLAUDE_OAUTH_TOKEN", params.claude_token)
|
|
171
|
+
if params.anthropic_api_key:
|
|
172
|
+
env.set("ANTHROPIC_API_KEY", params.anthropic_api_key)
|
|
173
|
+
|
|
174
|
+
extra_vars: list[str] = []
|
|
175
|
+
for var, value in params.extra_env.items():
|
|
176
|
+
env.set(var, value)
|
|
177
|
+
extra_vars.append(var)
|
|
178
|
+
|
|
179
|
+
# Session-log retention: a value activates it; otherwise a documented,
|
|
180
|
+
# inactive reference so the operator knows the knob exists (keep forever).
|
|
181
|
+
if params.retention_days:
|
|
182
|
+
env.set("MAD_SESSIONS_RETENTION_DAYS", params.retention_days)
|
|
183
|
+
else:
|
|
184
|
+
env.add_comment(
|
|
185
|
+
"MAD_SESSIONS_RETENTION_DAYS= # session log retention in days; unset = keep forever"
|
|
186
|
+
)
|
|
187
|
+
# MCP DNS-rebinding protection: commented reference when left disabled.
|
|
188
|
+
if params.mcp_allowed_hosts:
|
|
189
|
+
env.set("MAD_MCP_ALLOWED_HOSTS", params.mcp_allowed_hosts)
|
|
190
|
+
else:
|
|
191
|
+
env.add_comment(
|
|
192
|
+
"MAD_MCP_ALLOWED_HOSTS= # comma-separated allowed hosts; unset = protection disabled"
|
|
193
|
+
)
|
|
194
|
+
# SSE keep-alive heartbeat is never prompted — leave it as a reference knob.
|
|
195
|
+
if env.get("MAD_SSE_HEARTBEAT_S") is None:
|
|
196
|
+
env.add_comment("MAD_SSE_HEARTBEAT_S= # SSE keep-alive heartbeat in seconds")
|
|
197
|
+
return env, extra_vars
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def install(params: InstallParams) -> InstallResult:
|
|
201
|
+
"""Write an instance's files and data dirs, and optionally start it.
|
|
202
|
+
|
|
203
|
+
Validates ``params`` (name / port / timeout / retention) and the extra keys,
|
|
204
|
+
then renders the files, creates ``workspaces/`` ``sessions/`` ``aws/``
|
|
205
|
+
``claude/`` under the data path, writes the Claude credentials when a token is
|
|
206
|
+
given and — unless ``params.start`` is false — builds and awaits health.
|
|
207
|
+
"""
|
|
208
|
+
name = validate_name(params.name)
|
|
209
|
+
validate_port(str(params.port))
|
|
210
|
+
validate_timeout(str(params.timeout_s))
|
|
211
|
+
validate_retention(params.retention_days)
|
|
212
|
+
|
|
213
|
+
env, extra_vars = build_env(params)
|
|
214
|
+
|
|
215
|
+
ctx = RenderContext(
|
|
216
|
+
instance=name,
|
|
217
|
+
host_port=params.port,
|
|
218
|
+
data_path=params.data_path,
|
|
219
|
+
timeout_s=params.timeout_s,
|
|
220
|
+
puid=params.puid,
|
|
221
|
+
pgid=params.pgid,
|
|
222
|
+
edge_package=params.edge_package,
|
|
223
|
+
edge_version=params.edge_version,
|
|
224
|
+
)
|
|
225
|
+
config_dir = instance_dir(name)
|
|
226
|
+
write_instance_files(config_dir, ctx, env)
|
|
227
|
+
|
|
228
|
+
instance_data = params.data_path / name
|
|
229
|
+
(instance_data / "workspaces").mkdir(parents=True, exist_ok=True)
|
|
230
|
+
(instance_data / "sessions").mkdir(parents=True, exist_ok=True)
|
|
231
|
+
(instance_data / "aws").mkdir(parents=True, exist_ok=True)
|
|
232
|
+
claude_dir = instance_data / "claude"
|
|
233
|
+
creds: Path | None = None
|
|
234
|
+
if params.claude_token:
|
|
235
|
+
creds = write_claude_credentials(claude_dir, params.claude_token)
|
|
236
|
+
else:
|
|
237
|
+
claude_dir.mkdir(parents=True, exist_ok=True)
|
|
238
|
+
|
|
239
|
+
started = False
|
|
240
|
+
healthy: bool | None = None
|
|
241
|
+
url: str | None = None
|
|
242
|
+
if params.start:
|
|
243
|
+
instance = Instance(name=name, config_dir=config_dir, env=env)
|
|
244
|
+
runner = ComposeRunner(instance)
|
|
245
|
+
runner.up(build=True)
|
|
246
|
+
healthy = runner.wait_healthy()
|
|
247
|
+
started = True
|
|
248
|
+
url = f"http://localhost:{params.port}"
|
|
249
|
+
|
|
250
|
+
return InstallResult(
|
|
251
|
+
name=name,
|
|
252
|
+
config_dir=config_dir,
|
|
253
|
+
data_dir=params.data_path,
|
|
254
|
+
port=params.port,
|
|
255
|
+
timeout_s=params.timeout_s,
|
|
256
|
+
env=env,
|
|
257
|
+
extra_key_vars=extra_vars,
|
|
258
|
+
claude_credentials_path=creds,
|
|
259
|
+
claude_dir=claude_dir,
|
|
260
|
+
started=started,
|
|
261
|
+
healthy=healthy,
|
|
262
|
+
url=url,
|
|
263
|
+
)
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
"""Instance inventory use cases: resolve, list (with state/health) and info.
|
|
2
|
+
|
|
3
|
+
Shared by ``mad list`` / ``mad info`` and the ``/v1/instances`` routes. The
|
|
4
|
+
state/health probe is best-effort — Docker being unreachable degrades the row to
|
|
5
|
+
``unknown``/``-`` rather than raising, so a listing always renders.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from dataclasses import dataclass
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
|
|
13
|
+
from mad_cli.core.compose import ComposeRunner
|
|
14
|
+
from mad_cli.core.instance import (
|
|
15
|
+
Instance,
|
|
16
|
+
InstanceNotFoundError,
|
|
17
|
+
default_instance,
|
|
18
|
+
discover_instances,
|
|
19
|
+
get_instance,
|
|
20
|
+
)
|
|
21
|
+
from mad_cli.core.keyspec import display_value, is_secret_key
|
|
22
|
+
from mad_cli.core.usecases.errors import AmbiguousInstanceError, NotFoundError
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def resolve_instance(name: str | None) -> Instance:
|
|
26
|
+
"""Resolve ``name`` (or the sole instance) to an :class:`Instance`.
|
|
27
|
+
|
|
28
|
+
Raises :class:`NotFoundError` when a named instance is missing or none are
|
|
29
|
+
configured, and :class:`AmbiguousInstanceError` when several exist and none
|
|
30
|
+
was named. The messages are adapter-agnostic; the CLI appends its own hints.
|
|
31
|
+
"""
|
|
32
|
+
if name is not None:
|
|
33
|
+
try:
|
|
34
|
+
return get_instance(name)
|
|
35
|
+
except InstanceNotFoundError as exc:
|
|
36
|
+
raise NotFoundError(f"instance {name!r} not found") from exc
|
|
37
|
+
|
|
38
|
+
single = default_instance()
|
|
39
|
+
if single is not None:
|
|
40
|
+
return single
|
|
41
|
+
|
|
42
|
+
instances = discover_instances()
|
|
43
|
+
if not instances:
|
|
44
|
+
raise NotFoundError("no instances configured")
|
|
45
|
+
names = ", ".join(sorted(inst.name for inst in instances))
|
|
46
|
+
raise AmbiguousInstanceError(f"multiple instances exist ({names}); name one")
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def state_health(instance: Instance) -> tuple[str, str]:
|
|
50
|
+
"""Best-effort ``(state, health)`` parsed from ``docker compose ps``.
|
|
51
|
+
|
|
52
|
+
Health is read from the ``ps`` text rather than ``wait_healthy`` (which
|
|
53
|
+
blocks). A missing token degrades to ``"-"``; any failure to reach Docker
|
|
54
|
+
leaves state ``"unknown"`` so callers still render.
|
|
55
|
+
"""
|
|
56
|
+
try:
|
|
57
|
+
out = ComposeRunner(instance).ps()
|
|
58
|
+
except Exception: # docker missing/erroring — inventory must still render
|
|
59
|
+
return "unknown", "-"
|
|
60
|
+
if not isinstance(out, str):
|
|
61
|
+
return "unknown", "-"
|
|
62
|
+
lowered = out.lower()
|
|
63
|
+
state = "running" if "running" in lowered or " up " in f" {lowered} " else "stopped"
|
|
64
|
+
if "unhealthy" in lowered:
|
|
65
|
+
health = "unhealthy"
|
|
66
|
+
elif "healthy" in lowered:
|
|
67
|
+
health = "healthy"
|
|
68
|
+
else:
|
|
69
|
+
health = "-"
|
|
70
|
+
return state, health
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
@dataclass(frozen=True)
|
|
74
|
+
class InstanceSummary:
|
|
75
|
+
"""One row of the instance inventory."""
|
|
76
|
+
|
|
77
|
+
name: str
|
|
78
|
+
legacy: bool
|
|
79
|
+
port: int | None
|
|
80
|
+
state: str
|
|
81
|
+
health: str
|
|
82
|
+
version: str # the pinned MAD_VERSION, or "latest" when it tracks latest
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def list_instances() -> list[InstanceSummary]:
|
|
86
|
+
"""Return a summary row per configured instance (state/health best-effort)."""
|
|
87
|
+
rows: list[InstanceSummary] = []
|
|
88
|
+
for inst in discover_instances():
|
|
89
|
+
state, health = state_health(inst)
|
|
90
|
+
rows.append(
|
|
91
|
+
InstanceSummary(
|
|
92
|
+
name=inst.name,
|
|
93
|
+
legacy=bool(getattr(inst, "legacy", False)),
|
|
94
|
+
port=inst.host_port,
|
|
95
|
+
state=state,
|
|
96
|
+
health=health,
|
|
97
|
+
version=inst.version_pin or "latest",
|
|
98
|
+
)
|
|
99
|
+
)
|
|
100
|
+
return rows
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
@dataclass(frozen=True)
|
|
104
|
+
class EnvItem:
|
|
105
|
+
"""One ``.env`` assignment, tagged as secret-looking or not."""
|
|
106
|
+
|
|
107
|
+
key: str
|
|
108
|
+
value: str
|
|
109
|
+
secret: bool
|
|
110
|
+
|
|
111
|
+
def display(self, *, reveal: bool = False) -> str:
|
|
112
|
+
"""The value as it should be shown (masked unless ``reveal``)."""
|
|
113
|
+
return display_value(self.key, self.value, reveal=reveal)
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
@dataclass(frozen=True)
|
|
117
|
+
class InstanceInfo:
|
|
118
|
+
"""An instance's resolved paths plus its ``.env`` items."""
|
|
119
|
+
|
|
120
|
+
name: str
|
|
121
|
+
legacy: bool
|
|
122
|
+
config_dir: Path
|
|
123
|
+
compose_file: Path
|
|
124
|
+
data_path: Path | None
|
|
125
|
+
port: int | None
|
|
126
|
+
version: str | None
|
|
127
|
+
env: list[EnvItem]
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def _env_items(instance: Instance) -> list[EnvItem]:
|
|
131
|
+
env = instance.env
|
|
132
|
+
return [
|
|
133
|
+
EnvItem(key=key, value=env.get(key) or "", secret=is_secret_key(key))
|
|
134
|
+
for key in env.keys() # noqa: SIM118 — EnvFile.keys() is its contract API
|
|
135
|
+
]
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def instance_info(name: str) -> InstanceInfo:
|
|
139
|
+
"""Return the resolved paths and ``.env`` items for the named instance.
|
|
140
|
+
|
|
141
|
+
Raises :class:`NotFoundError` when the instance does not exist.
|
|
142
|
+
"""
|
|
143
|
+
try:
|
|
144
|
+
instance = get_instance(name)
|
|
145
|
+
except InstanceNotFoundError as exc:
|
|
146
|
+
raise NotFoundError(f"instance {name!r} not found") from exc
|
|
147
|
+
return InstanceInfo(
|
|
148
|
+
name=instance.name,
|
|
149
|
+
legacy=bool(getattr(instance, "legacy", False)),
|
|
150
|
+
config_dir=instance.config_dir,
|
|
151
|
+
compose_file=instance.compose_file,
|
|
152
|
+
data_path=instance.data_path,
|
|
153
|
+
port=instance.host_port,
|
|
154
|
+
version=instance.version_pin,
|
|
155
|
+
env=_env_items(instance),
|
|
156
|
+
)
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
"""Credential / API-key use cases: set / list / remove.
|
|
2
|
+
|
|
3
|
+
A key is either a *builtin* from :data:`mad_cli.core.keyspec.BUILTIN_KEYS`
|
|
4
|
+
(fanned out to one or more env vars, ``claude-oauth`` also materialising the
|
|
5
|
+
container credentials file) or a raw *custom* ``[A-Z][A-Z0-9_]*`` variable.
|
|
6
|
+
Values are always masked on read — a full secret is never returned.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import re
|
|
12
|
+
from dataclasses import dataclass
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
|
|
15
|
+
from mad_cli.core.claude_creds import write_claude_credentials
|
|
16
|
+
from mad_cli.core.instance import Instance
|
|
17
|
+
from mad_cli.core.keyspec import BUILTIN_KEYS, KeySpec, is_secret_key, mask
|
|
18
|
+
from mad_cli.core.usecases.errors import PreconditionError, ValidationError
|
|
19
|
+
|
|
20
|
+
# A custom key is written verbatim; it must look like a shell env-var name.
|
|
21
|
+
CUSTOM_KEY_RE = re.compile(r"[A-Z][A-Z0-9_]*")
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _unknown_key_error(key: str) -> ValidationError:
|
|
25
|
+
return ValidationError(
|
|
26
|
+
f"Unknown key {key!r}: use a builtin id ({', '.join(BUILTIN_KEYS)}) "
|
|
27
|
+
"or an env-var name matching [A-Z][A-Z0-9_]*."
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def key_prompt(key: str) -> tuple[str, bool]:
|
|
32
|
+
"""Return ``(prompt_text, secret)`` for interactively collecting ``key``.
|
|
33
|
+
|
|
34
|
+
Raises :class:`ValidationError` for an id that is neither a builtin nor a
|
|
35
|
+
valid custom variable, so an adapter never prompts for a bad key.
|
|
36
|
+
"""
|
|
37
|
+
spec = BUILTIN_KEYS.get(key)
|
|
38
|
+
if spec is not None:
|
|
39
|
+
return spec.prompt, spec.secret
|
|
40
|
+
if CUSTOM_KEY_RE.fullmatch(key):
|
|
41
|
+
return f"Value for {key}", True
|
|
42
|
+
raise _unknown_key_error(key)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
@dataclass(frozen=True)
|
|
46
|
+
class SetKeyResult:
|
|
47
|
+
id: str
|
|
48
|
+
env_vars: tuple[str, ...]
|
|
49
|
+
builtin: bool
|
|
50
|
+
credentials_path: Path | None # set when the Claude credentials file was written
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _claude_creds_path(instance: Instance) -> Path | None:
|
|
54
|
+
if instance.data_path is None:
|
|
55
|
+
return None
|
|
56
|
+
return instance.data_path / instance.name / "claude" / ".credentials.json"
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _set_builtin(instance: Instance, spec: KeySpec, value: str) -> SetKeyResult:
|
|
60
|
+
for var in spec.env_vars:
|
|
61
|
+
instance.env.set(var, value)
|
|
62
|
+
instance.env.save()
|
|
63
|
+
creds: Path | None = None
|
|
64
|
+
if spec.writes_claude_credentials:
|
|
65
|
+
if instance.data_path is None:
|
|
66
|
+
raise PreconditionError(
|
|
67
|
+
"cannot write Claude credentials: MAD_DATA_PATH is not set for this instance"
|
|
68
|
+
)
|
|
69
|
+
creds = write_claude_credentials(instance.data_path / instance.name / "claude", value)
|
|
70
|
+
return SetKeyResult(id=spec.id, env_vars=spec.env_vars, builtin=True, credentials_path=creds)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def set_key(instance: Instance, key: str, value: str) -> SetKeyResult:
|
|
74
|
+
"""Store a builtin key (fanned out) or a custom variable.
|
|
75
|
+
|
|
76
|
+
Raises :class:`ValidationError` for an unknown id and
|
|
77
|
+
:class:`PreconditionError` when ``claude-oauth`` cannot write its credentials.
|
|
78
|
+
"""
|
|
79
|
+
spec = BUILTIN_KEYS.get(key)
|
|
80
|
+
if spec is not None:
|
|
81
|
+
return _set_builtin(instance, spec, value)
|
|
82
|
+
if CUSTOM_KEY_RE.fullmatch(key):
|
|
83
|
+
instance.env.set(key, value)
|
|
84
|
+
instance.env.save()
|
|
85
|
+
return SetKeyResult(id=key, env_vars=(key,), builtin=False, credentials_path=None)
|
|
86
|
+
raise _unknown_key_error(key)
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
@dataclass(frozen=True)
|
|
90
|
+
class BuiltinKeyStatus:
|
|
91
|
+
id: str
|
|
92
|
+
env_vars: tuple[str, ...]
|
|
93
|
+
is_set: bool
|
|
94
|
+
masked: str # masked value or "-"
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
@dataclass(frozen=True)
|
|
98
|
+
class CustomSecret:
|
|
99
|
+
key: str
|
|
100
|
+
masked: str
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
@dataclass(frozen=True)
|
|
104
|
+
class KeysView:
|
|
105
|
+
builtins: list[BuiltinKeyStatus]
|
|
106
|
+
custom: list[CustomSecret]
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def list_keys(instance: Instance) -> KeysView:
|
|
110
|
+
"""Return the builtin key statuses (masked) plus any custom secret vars."""
|
|
111
|
+
env = instance.env
|
|
112
|
+
spec_vars = {var for spec in BUILTIN_KEYS.values() for var in spec.env_vars}
|
|
113
|
+
|
|
114
|
+
builtins: list[BuiltinKeyStatus] = []
|
|
115
|
+
for spec in BUILTIN_KEYS.values():
|
|
116
|
+
first = next((env.get(var) for var in spec.env_vars if env.get(var)), None)
|
|
117
|
+
builtins.append(
|
|
118
|
+
BuiltinKeyStatus(
|
|
119
|
+
id=spec.id,
|
|
120
|
+
env_vars=spec.env_vars,
|
|
121
|
+
is_set=first is not None,
|
|
122
|
+
masked=mask(first) if first else "-",
|
|
123
|
+
)
|
|
124
|
+
)
|
|
125
|
+
|
|
126
|
+
custom = [
|
|
127
|
+
CustomSecret(key=key, masked=mask(env.get(key) or "") if env.get(key) else "-")
|
|
128
|
+
for key in env.keys() # noqa: SIM118 — EnvFile.keys() is its contract API
|
|
129
|
+
if key not in spec_vars and is_secret_key(key)
|
|
130
|
+
]
|
|
131
|
+
return KeysView(builtins=builtins, custom=custom)
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
@dataclass(frozen=True)
|
|
135
|
+
class RemoveKeyResult:
|
|
136
|
+
id: str
|
|
137
|
+
env_vars: tuple[str, ...]
|
|
138
|
+
builtin: bool
|
|
139
|
+
existed: bool
|
|
140
|
+
credentials_left: Path | None # claude-oauth: the on-disk file we left in place
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def remove_key(instance: Instance, key: str) -> RemoveKeyResult:
|
|
144
|
+
"""Remove a builtin key's env vars (or a custom variable).
|
|
145
|
+
|
|
146
|
+
``existed`` is False when nothing was set (a no-op the adapter reports). The
|
|
147
|
+
``claude-oauth`` credentials file is intentionally left on disk.
|
|
148
|
+
"""
|
|
149
|
+
spec = BUILTIN_KEYS.get(key)
|
|
150
|
+
if spec is not None:
|
|
151
|
+
present = [var for var in spec.env_vars if instance.env.get(var) is not None]
|
|
152
|
+
if not present:
|
|
153
|
+
return RemoveKeyResult(spec.id, spec.env_vars, True, False, None)
|
|
154
|
+
for var in spec.env_vars:
|
|
155
|
+
instance.env.unset(var)
|
|
156
|
+
instance.env.save()
|
|
157
|
+
left: Path | None = None
|
|
158
|
+
if spec.writes_claude_credentials:
|
|
159
|
+
creds = _claude_creds_path(instance)
|
|
160
|
+
if creds is not None and creds.exists():
|
|
161
|
+
left = creds
|
|
162
|
+
return RemoveKeyResult(spec.id, spec.env_vars, True, True, left)
|
|
163
|
+
if CUSTOM_KEY_RE.fullmatch(key):
|
|
164
|
+
if instance.env.get(key) is None:
|
|
165
|
+
return RemoveKeyResult(key, (key,), False, False, None)
|
|
166
|
+
instance.env.unset(key)
|
|
167
|
+
instance.env.save()
|
|
168
|
+
return RemoveKeyResult(key, (key,), False, True, None)
|
|
169
|
+
raise _unknown_key_error(key)
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
"""Container lifecycle use cases: start / stop / restart / status.
|
|
2
|
+
|
|
3
|
+
Each operates on an already-resolved :class:`Instance` (the adapter resolves via
|
|
4
|
+
:func:`mad_cli.core.usecases.instances.resolve_instance`, mapping resolution
|
|
5
|
+
failures to its own idiom). The interactive streams (``logs`` / ``shell``) stay in
|
|
6
|
+
the CLI adapter — they attach the caller's TTY and have no HTTP equivalent.
|
|
7
|
+
Everything here is synchronous; the build + health wait blocks in the MVP.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
from dataclasses import dataclass
|
|
13
|
+
|
|
14
|
+
from mad_cli.core.compose import ComposeRunner
|
|
15
|
+
from mad_cli.core.instance import Instance
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def instance_url(instance: Instance) -> str | None:
|
|
19
|
+
port = instance.host_port
|
|
20
|
+
return f"http://localhost:{port}" if port is not None else None
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@dataclass(frozen=True)
|
|
24
|
+
class StartResult:
|
|
25
|
+
instance: Instance
|
|
26
|
+
healthy: bool
|
|
27
|
+
url: str | None
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def start(instance: Instance) -> StartResult:
|
|
31
|
+
"""Build if needed, start the instance, and await health."""
|
|
32
|
+
runner = ComposeRunner(instance)
|
|
33
|
+
runner.up(build=True)
|
|
34
|
+
healthy = runner.wait_healthy()
|
|
35
|
+
return StartResult(instance=instance, healthy=healthy, url=instance_url(instance))
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def stop(instance: Instance) -> None:
|
|
39
|
+
"""Stop the instance and remove its containers."""
|
|
40
|
+
ComposeRunner(instance).down()
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def restart(instance: Instance) -> None:
|
|
44
|
+
"""Restart the instance's containers (down, then up with a rebuild)."""
|
|
45
|
+
ComposeRunner(instance).restart()
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
@dataclass(frozen=True)
|
|
49
|
+
class StatusResult:
|
|
50
|
+
instance: Instance
|
|
51
|
+
ps_text: str
|
|
52
|
+
health: str # healthy / unhealthy / running / not running
|
|
53
|
+
url: str | None
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _status_health(ps_text: str) -> str:
|
|
57
|
+
lowered = ps_text.lower()
|
|
58
|
+
if "unhealthy" in lowered:
|
|
59
|
+
return "unhealthy"
|
|
60
|
+
if "healthy" in lowered:
|
|
61
|
+
return "healthy"
|
|
62
|
+
if "running" in lowered or " up " in f" {lowered} ":
|
|
63
|
+
return "running"
|
|
64
|
+
return "not running"
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def status(instance: Instance) -> StatusResult:
|
|
68
|
+
"""Return the container state, a health summary and the instance URL."""
|
|
69
|
+
out = ComposeRunner(instance).ps()
|
|
70
|
+
ps_text = out if isinstance(out, str) else ""
|
|
71
|
+
return StatusResult(
|
|
72
|
+
instance=instance,
|
|
73
|
+
ps_text=ps_text,
|
|
74
|
+
health=_status_health(ps_text),
|
|
75
|
+
url=instance_url(instance),
|
|
76
|
+
)
|