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
mad_cli/server/auth.py
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
"""Bearer-token authentication for the v1 API.
|
|
2
|
+
|
|
3
|
+
Every route except ``/health`` requires ``Authorization: Bearer <token>`` where
|
|
4
|
+
``<token>`` matches the auto-generated ``config_root()/api-token`` (created on the
|
|
5
|
+
first ``mad serve``). The comparison is constant-time. A missing or wrong token is
|
|
6
|
+
a 401; a server with no token file configured is a 503.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import secrets
|
|
12
|
+
from typing import Annotated
|
|
13
|
+
|
|
14
|
+
from fastapi import Depends, HTTPException, status
|
|
15
|
+
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
|
|
16
|
+
|
|
17
|
+
from mad_cli.core.usecases import service
|
|
18
|
+
|
|
19
|
+
_bearer = HTTPBearer(auto_error=False, description="The token from config_root()/api-token.")
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def require_token(
|
|
23
|
+
credentials: Annotated[HTTPAuthorizationCredentials | None, Depends(_bearer)],
|
|
24
|
+
) -> None:
|
|
25
|
+
"""FastAPI dependency: enforce a valid bearer token or raise 401/503."""
|
|
26
|
+
expected = service.read_api_token()
|
|
27
|
+
if expected is None:
|
|
28
|
+
raise HTTPException(
|
|
29
|
+
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
|
30
|
+
detail="API token is not configured on the server.",
|
|
31
|
+
)
|
|
32
|
+
if (
|
|
33
|
+
credentials is None
|
|
34
|
+
or credentials.scheme.lower() != "bearer"
|
|
35
|
+
or not secrets.compare_digest(credentials.credentials, expected)
|
|
36
|
+
):
|
|
37
|
+
raise HTTPException(
|
|
38
|
+
status_code=status.HTTP_401_UNAUTHORIZED,
|
|
39
|
+
detail="Missing or invalid bearer token.",
|
|
40
|
+
headers={"WWW-Authenticate": "Bearer"},
|
|
41
|
+
)
|
mad_cli/server/models.py
ADDED
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
"""Typed request / response models for the v1 HTTP API (OpenAPI for free).
|
|
2
|
+
|
|
3
|
+
Secret-looking values are only ever carried out masked — the response models are
|
|
4
|
+
populated from the use-case layer's already-masked views, and there is no field
|
|
5
|
+
(and no query flag) that reveals a full secret.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from pydantic import BaseModel, Field
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class HealthResponse(BaseModel):
|
|
14
|
+
status: str = "ok"
|
|
15
|
+
version: str
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class InstanceSummaryModel(BaseModel):
|
|
19
|
+
name: str
|
|
20
|
+
legacy: bool
|
|
21
|
+
port: int | None
|
|
22
|
+
state: str
|
|
23
|
+
health: str
|
|
24
|
+
version: str
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class EnvItemModel(BaseModel):
|
|
28
|
+
key: str
|
|
29
|
+
value: str = Field(description="Masked when the key looks like a secret.")
|
|
30
|
+
secret: bool
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class InstanceInfoModel(BaseModel):
|
|
34
|
+
name: str
|
|
35
|
+
legacy: bool
|
|
36
|
+
config_dir: str
|
|
37
|
+
compose_file: str
|
|
38
|
+
data_path: str | None
|
|
39
|
+
port: int | None
|
|
40
|
+
version: str | None
|
|
41
|
+
env: list[EnvItemModel]
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class InstallRequest(BaseModel):
|
|
45
|
+
name: str = "default"
|
|
46
|
+
port: int = 8080
|
|
47
|
+
data_path: str
|
|
48
|
+
timeout_s: int = 600
|
|
49
|
+
github_token: str
|
|
50
|
+
git_name: str = ""
|
|
51
|
+
git_email: str = ""
|
|
52
|
+
claude_token: str = ""
|
|
53
|
+
anthropic_api_key: str = ""
|
|
54
|
+
extra_keys: dict[str, str] = Field(
|
|
55
|
+
default_factory=dict,
|
|
56
|
+
description="Extra API keys as {builtin-id-or-VAR: value}; builtins fan out.",
|
|
57
|
+
)
|
|
58
|
+
retention_days: str = ""
|
|
59
|
+
mcp_allowed_hosts: str = ""
|
|
60
|
+
edge_package: str | None = None
|
|
61
|
+
edge_version: str = ""
|
|
62
|
+
start: bool = False
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
class InstallResponse(BaseModel):
|
|
66
|
+
name: str
|
|
67
|
+
config_dir: str
|
|
68
|
+
data_dir: str
|
|
69
|
+
port: int
|
|
70
|
+
started: bool
|
|
71
|
+
healthy: bool | None
|
|
72
|
+
url: str | None
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
class StartResponse(BaseModel):
|
|
76
|
+
name: str
|
|
77
|
+
healthy: bool
|
|
78
|
+
url: str | None
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
class ActionResponse(BaseModel):
|
|
82
|
+
name: str
|
|
83
|
+
action: str
|
|
84
|
+
ok: bool = True
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
class StatusResponse(BaseModel):
|
|
88
|
+
name: str
|
|
89
|
+
health: str
|
|
90
|
+
url: str | None
|
|
91
|
+
ps: str
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
class SetConfigRequest(BaseModel):
|
|
95
|
+
value: str
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
class SetConfigResponse(BaseModel):
|
|
99
|
+
key: str
|
|
100
|
+
value: str = Field(description="Masked when the key looks like a secret.")
|
|
101
|
+
secret: bool
|
|
102
|
+
compose_baked: bool
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
class BuiltinKeyModel(BaseModel):
|
|
106
|
+
id: str
|
|
107
|
+
env_vars: list[str]
|
|
108
|
+
is_set: bool
|
|
109
|
+
value: str = Field(description="Masked value, or '-' when unset.")
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
class CustomSecretModel(BaseModel):
|
|
113
|
+
key: str
|
|
114
|
+
value: str = Field(description="Masked value.")
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
class KeysResponse(BaseModel):
|
|
118
|
+
builtins: list[BuiltinKeyModel]
|
|
119
|
+
custom: list[CustomSecretModel]
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
class SetKeyRequest(BaseModel):
|
|
123
|
+
value: str
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
class SetKeyResponse(BaseModel):
|
|
127
|
+
id: str
|
|
128
|
+
env_vars: list[str]
|
|
129
|
+
builtin: bool
|
|
130
|
+
credentials_written: bool
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
class VersionRowModel(BaseModel):
|
|
134
|
+
name: str
|
|
135
|
+
legacy: bool
|
|
136
|
+
pinned: str
|
|
137
|
+
installed: str
|
|
138
|
+
latest: str
|
|
139
|
+
update: str
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
class UpdateRequest(BaseModel):
|
|
143
|
+
version: str | None = None
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
class UpdateResponse(BaseModel):
|
|
147
|
+
name: str
|
|
148
|
+
target: str
|
|
149
|
+
healthy: bool
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
class AdoptResponse(BaseModel):
|
|
153
|
+
adopted: bool
|
|
154
|
+
name: str | None = None
|
|
155
|
+
target: str | None = None
|
|
156
|
+
moved: list[str] = Field(default_factory=list)
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
# Mad runtime image — generated by mad-cli (`mad install`). Do not edit by hand.
|
|
2
|
+
# Installs ${edge_package} from PyPI + Claude Code CLI + OpenCode + gh CLI + git.
|
|
3
|
+
# Multi-arch: arm64 (Raspberry Pi) and amd64.
|
|
4
|
+
FROM node:20-slim
|
|
5
|
+
|
|
6
|
+
# --- system dependencies + gh CLI --------------------------------------------
|
|
7
|
+
RUN set -eux; \
|
|
8
|
+
apt-get update; \
|
|
9
|
+
apt-get install -y --no-install-recommends \
|
|
10
|
+
ca-certificates \
|
|
11
|
+
curl \
|
|
12
|
+
git \
|
|
13
|
+
gnupg \
|
|
14
|
+
python3 \
|
|
15
|
+
python3-venv \
|
|
16
|
+
python3-pip; \
|
|
17
|
+
mkdir -p -m 755 /etc/apt/keyrings; \
|
|
18
|
+
curl -fsSL https://cli.github.com/packages/githubcli-archive-keyring.gpg \
|
|
19
|
+
-o /etc/apt/keyrings/githubcli-archive-keyring.gpg; \
|
|
20
|
+
chmod go+r /etc/apt/keyrings/githubcli-archive-keyring.gpg; \
|
|
21
|
+
echo "deb [arch=$$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/githubcli-archive-keyring.gpg] https://cli.github.com/packages stable main" \
|
|
22
|
+
> /etc/apt/sources.list.d/github-cli.list; \
|
|
23
|
+
apt-get update; \
|
|
24
|
+
apt-get install -y --no-install-recommends gh; \
|
|
25
|
+
apt-get clean; \
|
|
26
|
+
rm -rf /var/lib/apt/lists/*
|
|
27
|
+
|
|
28
|
+
# --- agent CLIs --------------------------------------------------------------
|
|
29
|
+
RUN npm install -g @anthropic-ai/claude-code opencode-ai \
|
|
30
|
+
&& npm cache clean --force
|
|
31
|
+
|
|
32
|
+
# --- ${edge_package} runtime -------------------------------------------------
|
|
33
|
+
# The version is baked at render time; an empty spec installs the latest release.
|
|
34
|
+
RUN python3 -m venv /opt/venv \
|
|
35
|
+
&& /opt/venv/bin/pip install --no-cache-dir --upgrade pip \
|
|
36
|
+
&& /opt/venv/bin/pip install --no-cache-dir "${edge_package}${edge_version_spec}"
|
|
37
|
+
ENV PATH="/opt/venv/bin:$${PATH}"
|
|
38
|
+
|
|
39
|
+
# --- non-root user -----------------------------------------------------------
|
|
40
|
+
ARG PUID=${puid}
|
|
41
|
+
ARG PGID=${pgid}
|
|
42
|
+
RUN set -eux; \
|
|
43
|
+
userdel -r node 2>/dev/null || true; \
|
|
44
|
+
groupdel node 2>/dev/null || true; \
|
|
45
|
+
# The host GID may already exist in the base image (e.g. macOS gid 20 is
|
|
46
|
+
# Debian's dialout); reuse it instead of failing, and allow a duplicate UID.
|
|
47
|
+
getent group "$${PGID}" >/dev/null || groupadd -g "$${PGID}" mad; \
|
|
48
|
+
useradd -o -u "$${PUID}" -g "$${PGID}" -m -d /home/mad -s /bin/bash mad; \
|
|
49
|
+
mkdir -p /workspaces /sessions /home/mad/.claude /home/mad/.aws; \
|
|
50
|
+
chown -R "$${PUID}:$${PGID}" /workspaces /sessions /home/mad
|
|
51
|
+
|
|
52
|
+
ENV HOME=/home/mad \
|
|
53
|
+
MAD_WORKSPACE_DIR=/workspaces
|
|
54
|
+
|
|
55
|
+
# Entrypoint: authenticate the gh CLI with the token before launching the server.
|
|
56
|
+
COPY --chown=mad:mad entrypoint.sh /home/mad/entrypoint.sh
|
|
57
|
+
RUN chmod +x /home/mad/entrypoint.sh
|
|
58
|
+
|
|
59
|
+
USER mad
|
|
60
|
+
WORKDIR /home/mad
|
|
61
|
+
EXPOSE 8000
|
|
62
|
+
|
|
63
|
+
HEALTHCHECK --interval=30s --timeout=5s --start-period=30s --retries=3 \
|
|
64
|
+
CMD curl -fsS http://localhost:8000/openapi.json >/dev/null || exit 1
|
|
65
|
+
|
|
66
|
+
ENTRYPOINT ["/home/mad/entrypoint.sh"]
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
"""Packaged ``string.Template`` sources for an instance's generated files.
|
|
2
|
+
|
|
3
|
+
These ``*.tmpl`` files are package data rendered by :mod:`mad_cli.core.templates`.
|
|
4
|
+
Literal ``$`` in the templates is escaped as ``$$``; ``${name}`` placeholders are
|
|
5
|
+
the only render-time substitutions.
|
|
6
|
+
"""
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
<?xml version="1.0" encoding="UTF-8"?>
|
|
2
|
+
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
3
|
+
<plist version="1.0">
|
|
4
|
+
<dict>
|
|
5
|
+
<key>Label</key>
|
|
6
|
+
<string>${label}</string>
|
|
7
|
+
<key>ProgramArguments</key>
|
|
8
|
+
<array>
|
|
9
|
+
${program_arguments}
|
|
10
|
+
</array>
|
|
11
|
+
<key>EnvironmentVariables</key>
|
|
12
|
+
<dict>
|
|
13
|
+
<key>MAD_CLI_CONFIG_DIR</key>
|
|
14
|
+
<string>${config_dir}</string>
|
|
15
|
+
</dict>
|
|
16
|
+
<key>RunAtLoad</key>
|
|
17
|
+
<true/>
|
|
18
|
+
<key>KeepAlive</key>
|
|
19
|
+
<dict>
|
|
20
|
+
<key>SuccessfulExit</key>
|
|
21
|
+
<false/>
|
|
22
|
+
</dict>
|
|
23
|
+
<key>StandardOutPath</key>
|
|
24
|
+
<string>${log_path}</string>
|
|
25
|
+
<key>StandardErrorPath</key>
|
|
26
|
+
<string>${log_path}</string>
|
|
27
|
+
</dict>
|
|
28
|
+
</plist>
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
# Mad — compose generated by mad-cli. Edit .env to change values, not this file.
|
|
2
|
+
services:
|
|
3
|
+
mad:
|
|
4
|
+
image: ${edge_package}:${image_tag}
|
|
5
|
+
build:
|
|
6
|
+
context: .
|
|
7
|
+
args:
|
|
8
|
+
PUID: "$${PUID:-${puid}}"
|
|
9
|
+
PGID: "$${PGID:-${pgid}}"
|
|
10
|
+
container_name: mad-${instance}
|
|
11
|
+
restart: unless-stopped
|
|
12
|
+
env_file:
|
|
13
|
+
- .env
|
|
14
|
+
environment:
|
|
15
|
+
MAD_WORKSPACE_DIR: /workspaces
|
|
16
|
+
MAD_SESSIONS_DIR: /sessions
|
|
17
|
+
ports:
|
|
18
|
+
- "${host_port}:8000"
|
|
19
|
+
volumes:
|
|
20
|
+
- ${data_path}/${instance}/workspaces:/workspaces
|
|
21
|
+
- ${data_path}/${instance}/sessions:/sessions
|
|
22
|
+
- ${data_path}/${instance}/claude:/home/mad/.claude
|
|
23
|
+
- ${data_path}/${instance}/aws:/home/mad/.aws:ro
|
|
24
|
+
healthcheck:
|
|
25
|
+
test: ["CMD", "curl", "-fsS", "http://localhost:8000/openapi.json"]
|
|
26
|
+
interval: 30s
|
|
27
|
+
timeout: 5s
|
|
28
|
+
start_period: 30s
|
|
29
|
+
retries: 3
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
#!/bin/bash
|
|
2
|
+
set -e
|
|
3
|
+
|
|
4
|
+
# Authenticate the gh CLI with the token from the environment so that
|
|
5
|
+
# `gh auth status` shows the account and every git operation (push, PR, …) works.
|
|
6
|
+
if [[ -n "$${GITHUB_TOKEN:-}" ]]; then
|
|
7
|
+
echo "$$GITHUB_TOKEN" | gh auth login --with-token --hostname github.com 2>/dev/null || true
|
|
8
|
+
gh auth setup-git 2>/dev/null || true
|
|
9
|
+
fi
|
|
10
|
+
|
|
11
|
+
exec ${edge_entrypoint} serve --host 0.0.0.0 --port 8000
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
[Unit]
|
|
2
|
+
Description=Mad operator CLI HTTP API (mad serve)
|
|
3
|
+
Documentation=https://github.com/mad-core/mad-cli
|
|
4
|
+
After=network-online.target
|
|
5
|
+
Wants=network-online.target
|
|
6
|
+
|
|
7
|
+
[Service]
|
|
8
|
+
Type=simple
|
|
9
|
+
ExecStart=${exec_start}
|
|
10
|
+
Environment=MAD_CLI_CONFIG_DIR=${config_dir}
|
|
11
|
+
Restart=on-failure
|
|
12
|
+
RestartSec=3
|
|
13
|
+
|
|
14
|
+
[Install]
|
|
15
|
+
WantedBy=default.target
|
mad_cli/ui/__init__.py
ADDED
mad_cli/ui/console.py
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
"""Shared rich console and consistently-styled status helpers.
|
|
2
|
+
|
|
3
|
+
The single module-level :data:`console` is the one place output is written, so
|
|
4
|
+
every command shares the same width, theme and capture behaviour. The helpers
|
|
5
|
+
below add a coloured status glyph so ``info``/``ok``/``warn``/``error`` read the
|
|
6
|
+
same everywhere.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from collections.abc import Callable
|
|
12
|
+
from typing import TypeVar
|
|
13
|
+
|
|
14
|
+
from rich.console import Console
|
|
15
|
+
from rich.markup import escape
|
|
16
|
+
|
|
17
|
+
_T = TypeVar("_T")
|
|
18
|
+
|
|
19
|
+
# A single shared console. No explicit ``file`` is passed so rich resolves
|
|
20
|
+
# ``sys.stdout`` lazily at print time — this is what lets test runners that
|
|
21
|
+
# redirect stdout (e.g. Typer's CliRunner) capture our output.
|
|
22
|
+
console = Console()
|
|
23
|
+
|
|
24
|
+
# The message argument to these helpers is plain text (paths, key names,
|
|
25
|
+
# ``pip install 'mad-cli[server]'`` hints, ``[A-Z][A-Z0-9_]*`` patterns …), so it
|
|
26
|
+
# is escaped before printing — only the status glyph carries markup. Otherwise
|
|
27
|
+
# rich would silently swallow any ``[...]`` in the message as a style tag.
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def info(msg: str) -> None:
|
|
31
|
+
"""Neutral progress message."""
|
|
32
|
+
console.print(f"[cyan]▸[/cyan] {escape(msg)}", soft_wrap=True)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def ok(msg: str) -> None:
|
|
36
|
+
"""Success message."""
|
|
37
|
+
console.print(f"[green]✓[/green] {escape(msg)}", soft_wrap=True)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def warn(msg: str) -> None:
|
|
41
|
+
"""Non-fatal warning."""
|
|
42
|
+
console.print(f"[yellow]![/yellow] {escape(msg)}", soft_wrap=True)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def error(msg: str) -> None:
|
|
46
|
+
"""Error message. Rendering only — callers decide whether to exit."""
|
|
47
|
+
console.print(f"[red]✗[/red] {escape(msg)}", soft_wrap=True)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def header(msg: str) -> None:
|
|
51
|
+
"""Bold section header, preceded by a blank line."""
|
|
52
|
+
console.print(f"\n[bold]{escape(msg)}[/bold]", soft_wrap=True)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def run_step(message: str, func: Callable[[], _T]) -> _T:
|
|
56
|
+
"""Run ``func`` under a spinner on a real terminal, or plainly otherwise.
|
|
57
|
+
|
|
58
|
+
Falling back to a plain ``info`` line keeps output deterministic and
|
|
59
|
+
non-blocking under test runners and redirected pipes (no live display).
|
|
60
|
+
"""
|
|
61
|
+
if console.is_terminal:
|
|
62
|
+
with console.status(message, spinner="dots"):
|
|
63
|
+
return func()
|
|
64
|
+
info(message)
|
|
65
|
+
return func()
|
mad_cli/ui/prompts.py
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
"""Interactive prompt helpers built on ``rich.prompt``.
|
|
2
|
+
|
|
3
|
+
Contract (CONTRACTS.md):
|
|
4
|
+
|
|
5
|
+
* ``ask`` re-prompts while ``validator`` raises ``ValueError``; the validator's
|
|
6
|
+
return value is the normalised answer.
|
|
7
|
+
* In a non-interactive context (stdin is not a TTY) prompts MUST NOT block:
|
|
8
|
+
``ask`` returns ``default`` when one exists, otherwise raises
|
|
9
|
+
``PromptRequiredError``; ``confirm`` returns its ``default``.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import sys
|
|
15
|
+
from collections.abc import Callable
|
|
16
|
+
|
|
17
|
+
from rich.prompt import Confirm, Prompt
|
|
18
|
+
|
|
19
|
+
from mad_cli.ui.console import console, error
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class PromptRequiredError(Exception):
|
|
23
|
+
"""A value was required but no default was available and we cannot prompt."""
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _stdin_is_tty() -> bool:
|
|
27
|
+
try:
|
|
28
|
+
return sys.stdin.isatty()
|
|
29
|
+
except (ValueError, OSError): # detached / closed stdin
|
|
30
|
+
return False
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def ask(
|
|
34
|
+
text: str,
|
|
35
|
+
default: str | None = None,
|
|
36
|
+
secret: bool = False,
|
|
37
|
+
validator: Callable[[str], str] | None = None,
|
|
38
|
+
) -> str:
|
|
39
|
+
"""Ask for a single string value.
|
|
40
|
+
|
|
41
|
+
``validator`` receives the raw input and returns the normalised value; it
|
|
42
|
+
raises ``ValueError`` (whose message is shown) to trigger a re-prompt.
|
|
43
|
+
"""
|
|
44
|
+
if not _stdin_is_tty():
|
|
45
|
+
if default is None:
|
|
46
|
+
raise PromptRequiredError(text)
|
|
47
|
+
if validator is not None:
|
|
48
|
+
try:
|
|
49
|
+
return validator(default)
|
|
50
|
+
except ValueError as exc: # a bad default is not recoverable here
|
|
51
|
+
raise PromptRequiredError(str(exc)) from exc
|
|
52
|
+
return default
|
|
53
|
+
|
|
54
|
+
prompt_text = f"[cyan]?[/cyan] {text}"
|
|
55
|
+
while True:
|
|
56
|
+
if default is not None:
|
|
57
|
+
raw = Prompt.ask(
|
|
58
|
+
prompt_text,
|
|
59
|
+
console=console,
|
|
60
|
+
password=secret,
|
|
61
|
+
show_default=not secret,
|
|
62
|
+
default=default,
|
|
63
|
+
)
|
|
64
|
+
else:
|
|
65
|
+
raw = Prompt.ask(
|
|
66
|
+
prompt_text,
|
|
67
|
+
console=console,
|
|
68
|
+
password=secret,
|
|
69
|
+
show_default=not secret,
|
|
70
|
+
)
|
|
71
|
+
if validator is None:
|
|
72
|
+
return raw
|
|
73
|
+
try:
|
|
74
|
+
return validator(raw)
|
|
75
|
+
except ValueError as exc:
|
|
76
|
+
error(str(exc))
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def confirm(text: str, default: bool = True) -> bool:
|
|
80
|
+
"""Yes/no confirmation. Returns ``default`` when stdin is not a TTY."""
|
|
81
|
+
if not _stdin_is_tty():
|
|
82
|
+
return default
|
|
83
|
+
return Confirm.ask(f"[cyan]?[/cyan] {text}", console=console, default=default)
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: mad-cli
|
|
3
|
+
Version: 0.4.0
|
|
4
|
+
Summary: Operator CLI for Mad — guided install, lifecycle, configuration, keys and version management for mad-edge containers.
|
|
5
|
+
Project-URL: Homepage, https://github.com/mad-core/mad-cli
|
|
6
|
+
Project-URL: Repository, https://github.com/mad-core/mad-cli
|
|
7
|
+
Project-URL: Issues, https://github.com/mad-core/mad-cli/issues
|
|
8
|
+
Author-email: Jose Salamanca <jose.salamancacoy@gmail.com>
|
|
9
|
+
License: MIT License
|
|
10
|
+
|
|
11
|
+
Copyright (c) 2026 Jose Salamanca
|
|
12
|
+
|
|
13
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
14
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
15
|
+
in the Software without restriction, including without limitation the rights
|
|
16
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
17
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
18
|
+
furnished to do so, subject to the following conditions:
|
|
19
|
+
|
|
20
|
+
The above copyright notice and this permission notice shall be included in all
|
|
21
|
+
copies or substantial portions of the Software.
|
|
22
|
+
|
|
23
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
24
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
25
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
26
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
27
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
28
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
29
|
+
SOFTWARE.
|
|
30
|
+
License-File: LICENSE
|
|
31
|
+
Keywords: agents,claude,cli,docker,mad,mcp,self-hosted
|
|
32
|
+
Classifier: Development Status :: 3 - Alpha
|
|
33
|
+
Classifier: Environment :: Console
|
|
34
|
+
Classifier: Intended Audience :: Developers
|
|
35
|
+
Classifier: Operating System :: MacOS
|
|
36
|
+
Classifier: Operating System :: POSIX :: Linux
|
|
37
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
38
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
39
|
+
Requires-Python: >=3.11
|
|
40
|
+
Requires-Dist: rich>=13.7
|
|
41
|
+
Requires-Dist: typer>=0.12
|
|
42
|
+
Provides-Extra: dev
|
|
43
|
+
Requires-Dist: build>=1.2; extra == 'dev'
|
|
44
|
+
Requires-Dist: fastapi>=0.110; extra == 'dev'
|
|
45
|
+
Requires-Dist: httpx>=0.27; extra == 'dev'
|
|
46
|
+
Requires-Dist: mypy>=1.10; extra == 'dev'
|
|
47
|
+
Requires-Dist: pip-audit>=2.7; extra == 'dev'
|
|
48
|
+
Requires-Dist: pytest-cov>=5.0; extra == 'dev'
|
|
49
|
+
Requires-Dist: pytest-timeout>=2.3; extra == 'dev'
|
|
50
|
+
Requires-Dist: pytest>=8.0; extra == 'dev'
|
|
51
|
+
Requires-Dist: python-semantic-release>=10.5.3; extra == 'dev'
|
|
52
|
+
Requires-Dist: ruff>=0.6; extra == 'dev'
|
|
53
|
+
Requires-Dist: twine>=5.1; extra == 'dev'
|
|
54
|
+
Requires-Dist: uvicorn>=0.29; extra == 'dev'
|
|
55
|
+
Provides-Extra: server
|
|
56
|
+
Requires-Dist: fastapi>=0.110; extra == 'server'
|
|
57
|
+
Requires-Dist: uvicorn>=0.29; extra == 'server'
|
|
58
|
+
Description-Content-Type: text/markdown
|
|
59
|
+
|
|
60
|
+
# mad-cli
|
|
61
|
+
|
|
62
|
+
[](https://pypi.org/project/mad-cli/)
|
|
63
|
+
[](https://github.com/mad-core/mad-cli/actions/workflows/ci.yml)
|
|
64
|
+
[](https://pypi.org/project/mad-cli/)
|
|
65
|
+
|
|
66
|
+
**Operator CLI for [Mad](https://github.com/mad-core).** `mad-cli` installs, runs and
|
|
67
|
+
manages **mad-edge** containers — the Mad runtime that provisions isolated workspaces and
|
|
68
|
+
launches autonomous coding agents (Claude Code, OpenCode, …) against your repositories.
|
|
69
|
+
|
|
70
|
+
`mad-cli` is the thing an operator installs on a server or a Raspberry Pi. It does **not**
|
|
71
|
+
run agents itself: it generates a `Dockerfile`, a `compose.yml` and a `.env`, builds the
|
|
72
|
+
image and drives the container lifecycle through Docker Compose. The agent runtime lives in
|
|
73
|
+
the [`mad-edge`](https://pypi.org/project/mad-edge/) package, which is installed *inside* the
|
|
74
|
+
container.
|
|
75
|
+
|
|
76
|
+
> **Alpha (0.x).** Interfaces may change between minor releases. Pin a version in production.
|
|
77
|
+
|
|
78
|
+
## Quickstart
|
|
79
|
+
|
|
80
|
+
```bash
|
|
81
|
+
pip install mad-cli
|
|
82
|
+
|
|
83
|
+
# Guided install: generates the config, builds the image, starts the container.
|
|
84
|
+
mad install
|
|
85
|
+
|
|
86
|
+
# Day-to-day lifecycle.
|
|
87
|
+
mad start # build + up (detached)
|
|
88
|
+
mad status # container state + API health
|
|
89
|
+
mad logs # follow logs
|
|
90
|
+
mad stop # stop, keeping workspace data on the host
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
`mad install` walks you through the instance name, host port, data path, GitHub token,
|
|
94
|
+
git identity and Claude OAuth token, then writes everything under `~/.config/mad` (override
|
|
95
|
+
with `MAD_CLI_CONFIG_DIR`) and starts the container. Once it is healthy the Mad HTTP/MCP API
|
|
96
|
+
is available on the port you chose.
|
|
97
|
+
|
|
98
|
+
## Commands
|
|
99
|
+
|
|
100
|
+
The surface grows across the early minor releases; everything below the current line is
|
|
101
|
+
planned.
|
|
102
|
+
|
|
103
|
+
| Command | Since | What it does |
|
|
104
|
+
|---|---|---|
|
|
105
|
+
| `mad install` | v0.1 | Guided setup: render config, build image, start the container. |
|
|
106
|
+
| `mad start` / `stop` / `restart` | v0.1 | Container lifecycle (`up --build` / `down` / `down`+`up`). |
|
|
107
|
+
| `mad status` | v0.1 | Container state and API health for an instance. |
|
|
108
|
+
| `mad logs` | v0.1 | Follow container logs. |
|
|
109
|
+
| `mad shell` | v0.1 | Open an interactive `bash` shell inside the container. |
|
|
110
|
+
| `mad list` | v0.2 | List every configured instance. |
|
|
111
|
+
| `mad info NAME` | v0.2 | Show one instance's resolved config. |
|
|
112
|
+
| `mad keys` | v0.2 | Set, rotate and mask API keys / tokens in `.env`. |
|
|
113
|
+
| `mad config` | v0.2 | Read and edit `.env` values safely. |
|
|
114
|
+
| `mad versions` / `update` | v0.3 | Show the latest published mad-edge and rebuild onto it. |
|
|
115
|
+
| `mad serve` / `service` | v0.4 | Run the local HTTP API, or install it as a background service. |
|
|
116
|
+
|
|
117
|
+
Instance-scoped commands take an optional `INSTANCE` argument; when exactly one instance is
|
|
118
|
+
configured it is used by default.
|
|
119
|
+
|
|
120
|
+
## HTTP API (optional)
|
|
121
|
+
|
|
122
|
+
Every CLI capability is also exposed over a local HTTP API so a UI/dashboard can build on the
|
|
123
|
+
same logic. It ships as an **optional extra** so the base CLI stays a two-dependency package
|
|
124
|
+
(typer + rich):
|
|
125
|
+
|
|
126
|
+
```bash
|
|
127
|
+
pip install 'mad-cli[server]'
|
|
128
|
+
|
|
129
|
+
mad serve # foreground API on http://127.0.0.1:7373
|
|
130
|
+
```
|
|
131
|
+
|
|
132
|
+
The API binds `127.0.0.1` by default and requires a bearer token (auto-generated at
|
|
133
|
+
`~/.config/mad/api-token`, mode 0600) on every request except `/health`. Secret values are
|
|
134
|
+
always masked on reads. OpenAPI is served at `/openapi.json`, docs at `/docs`. See
|
|
135
|
+
[`docs/03-contracts/http-api.md`](docs/03-contracts/http-api.md).
|
|
136
|
+
|
|
137
|
+
To keep it running across reboots:
|
|
138
|
+
|
|
139
|
+
```bash
|
|
140
|
+
mad service install # systemd user unit (Linux) / launchd LaunchAgent (macOS)
|
|
141
|
+
```
|
|
142
|
+
|
|
143
|
+
If the `server` extra is not installed, `mad service install` **auto-provisions a dedicated
|
|
144
|
+
virtualenv** under `~/.config/mad/server-venv` and installs the API there (use `--wheel PATH`
|
|
145
|
+
to install from a local artifact instead of PyPI), then points the service at it — the base
|
|
146
|
+
CLI never needs FastAPI in its own environment.
|
|
147
|
+
|
|
148
|
+
> **MVP limitation.** Long operations (install with start, `start`, `update`) run
|
|
149
|
+
> synchronously — the request blocks until the Docker build and health wait finish. Background
|
|
150
|
+
> jobs are a future enhancement.
|
|
151
|
+
|
|
152
|
+
## How it relates to mad-edge
|
|
153
|
+
|
|
154
|
+
| | `mad-cli` (this package) | `mad-edge` |
|
|
155
|
+
|---|---|---|
|
|
156
|
+
| Runs on | the operator's host | inside the container |
|
|
157
|
+
| Console script | `mad` | `mad-edge` (server) |
|
|
158
|
+
| Role | install & lifecycle management | the agent runtime / HTTP + MCP API |
|
|
159
|
+
| Talks to | Docker, the filesystem, PyPI | Claude Code, OpenCode, GitHub, workspaces |
|
|
160
|
+
|
|
161
|
+
`mad-cli` pins or tracks the mad-edge version through the generated `Dockerfile`; the
|
|
162
|
+
container installs `mad-edge` from PyPI at build time. Version bumps follow the mad-edge
|
|
163
|
+
versioning policy.
|
|
164
|
+
|
|
165
|
+
## License
|
|
166
|
+
|
|
167
|
+
[MIT](LICENSE).
|