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,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
+ )