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
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
+ )
@@ -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
@@ -0,0 +1,5 @@
1
+ """User-facing presentation layer (rich console + prompts).
2
+
3
+ This package never talks to ``subprocess`` or the filesystem directly — all such
4
+ work goes through ``mad_cli.core`` (see CONTRACTS.md, layering rule).
5
+ """
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
+ [![PyPI](https://img.shields.io/pypi/v/mad-cli.svg)](https://pypi.org/project/mad-cli/)
63
+ [![CI](https://github.com/mad-core/mad-cli/actions/workflows/ci.yml/badge.svg)](https://github.com/mad-core/mad-cli/actions/workflows/ci.yml)
64
+ [![Python](https://img.shields.io/pypi/pyversions/mad-cli.svg)](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).