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