feat: add quick install script and packaging (#197)

Give bot-bottle a real distribution path so new users can install
without cloning the repo:

- pyproject.toml: full project metadata, a `bot-bottle` console-script
  entry point (bot_bottle.cli:main), and package-data for the runtime
  assets (Dockerfiles, egress entrypoint, netpool defaults, macos init).
  Still zero runtime pip dependencies.
- install.sh: POSIX, sudo-free, idempotent bootstrapper — checks Python
  >= 3.11, creates ~/.bot-bottle/{agents,bottles,contrib}, installs via
  pipx (pip --user fallback), then runs `bot-bottle doctor`.
- `bot-bottle doctor`: new store-free subcommand reporting Python
  version, backend availability (reuses is_backend_available rather than
  hardcoding Docker), and config-dir presence. Exits non-zero when a hard
  prerequisite is unmet.
- PRD prd-new-install-script and unit tests for doctor, the packaging
  contract, and the install script.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-25 23:16:50 +00:00
parent 0e70d26af4
commit 5933fdc789
9 changed files with 500 additions and 2 deletions
+2 -1
View File
@@ -21,6 +21,7 @@ _HANDLERS: dict[str, str] = {
"backend": "backend:cmd_backend", "backend": "backend:cmd_backend",
"cleanup": "cleanup:cmd_cleanup", "cleanup": "cleanup:cmd_cleanup",
"commit": "commit:cmd_commit", "commit": "commit:cmd_commit",
"doctor": "doctor:cmd_doctor",
"edit": "edit:cmd_edit", "edit": "edit:cmd_edit",
"help": "help:cmd_help", "help": "help:cmd_help",
"init": "init:cmd_init", "init": "init:cmd_init",
@@ -53,6 +54,6 @@ COMMANDS = {name: _lazy(spec) for name, spec in _HANDLERS.items()}
# gating it on the schema breaks preflight on a fresh CI runner where stdin # gating it on the schema breaks preflight on a fresh CI runner where stdin
# isn't a TTY and the migration prompt can't be answered. `help` and `login` # isn't a TTY and the migration prompt can't be answered. `help` and `login`
# likewise never touch the store. # likewise never touch the store.
NO_MIGRATION_COMMANDS = frozenset({"backend", "help", "login"}) NO_MIGRATION_COMMANDS = frozenset({"backend", "doctor", "help", "login"})
__all__ = ["COMMANDS", "NO_MIGRATION_COMMANDS"] __all__ = ["COMMANDS", "NO_MIGRATION_COMMANDS"]
+81
View File
@@ -0,0 +1,81 @@
"""`doctor` CLI command — validate host prerequisites for running
bot-bottle and report what's ready.
Fails (non-zero exit) only on the two hard requirements: a new-enough
Python and at least one usable backend. The config directory is a soft
check — `install.sh` creates it, but a missing one only warrants a note,
not a failure, since `start` provisions what it needs on first run.
"""
from __future__ import annotations
import argparse
import sys
from pathlib import Path
from ...backend import is_backend_available, known_backend_names
from ..constants import PROG
MIN_PYTHON = (3, 11)
CONFIG_DIR = ".bot-bottle"
def _ok(label: str, detail: str) -> None:
print(f"ok: {label}: {detail}")
def _warn(label: str, detail: str) -> None:
print(f"warn: {label}: {detail}")
def _fail(label: str, detail: str) -> None:
print(f"fail: {label}: {detail}")
def _check_python() -> bool:
v = sys.version_info
detail = f"{v.major}.{v.minor}.{v.micro}"
if (v.major, v.minor) >= MIN_PYTHON:
_ok("python", detail)
return True
_fail("python", f"{detail}; need {MIN_PYTHON[0]}.{MIN_PYTHON[1]} or newer")
return False
def _check_backends() -> bool:
"""At least one backend must be available on this host. Report each
known backend so the operator sees why a missing one is missing."""
available = []
for name in known_backend_names():
if is_backend_available(name):
available.append(name)
if available:
_ok("backend", f"available: {', '.join(available)}")
return True
_fail(
"backend",
"no backend available; install Apple Container (macOS), "
"Firecracker (Linux), or Docker",
)
return False
def _check_config_dir() -> None:
config = Path.home() / CONFIG_DIR
if config.is_dir():
_ok("config", str(config))
else:
_warn("config", f"{config} does not exist yet (created on first use)")
def cmd_doctor(argv: list[str]) -> int:
parser = argparse.ArgumentParser(
prog=f"{PROG} doctor",
description="Check host prerequisites for running bot-bottle.",
)
parser.parse_args(argv)
# Hard requirements gate the exit code; the config note is advisory.
required = [_check_python(), _check_backends()]
_check_config_dir()
return 0 if all(required) else 1
+1
View File
@@ -25,6 +25,7 @@ def cmd_help(argv: list[str] | None = None) -> int:
w(" backend set up / check / undo a backend's host prerequisites (setup|status|teardown)\n") w(" backend set up / check / undo a backend's host prerequisites (setup|status|teardown)\n")
w(" cleanup stop and remove all active bot-bottle containers\n") w(" cleanup stop and remove all active bot-bottle containers\n")
w(" commit snapshot a running bottle's container state to a Docker image\n") w(" commit snapshot a running bottle's container state to a Docker image\n")
w(" doctor check host prerequisites (Python, backend, config dir)\n")
w(" edit open an agent in vim for editing\n") w(" edit open an agent in vim for editing\n")
w(" help show this command list\n") w(" help show this command list\n")
w(" init interactively create a new agent and add it to bot-bottle.json\n") w(" init interactively create a new agent and add it to bot-bottle.json\n")
+119
View File
@@ -0,0 +1,119 @@
# PRD prd-new: Quick install script
- **Status:** Draft
- **Author:** claude
- **Created:** 2026-07-25
- **Issue:** #197
## Summary
Add a proper Python package distribution (`pyproject.toml` with a
`bot-bottle` entry point) plus a thin `install.sh` bootstrapper, so users
can install bot-bottle with a single command instead of cloning the repo
and invoking `cli.py` directly. A new `bot-bottle doctor` subcommand
verifies host prerequisites after install.
## Problem
There is currently no install path for new users. The only way to run
bot-bottle is to clone the repo and invoke `./cli.py`. This blocks any
public demo: readers want `curl | sh` or `pipx install`, not a manual
clone-and-configure flow. There is also no single command that tells a
user whether their host is actually ready to run a bottle.
## Goals / Success Criteria
- `curl -fsSL <raw-url>/install.sh | sh` leaves a working `bot-bottle`
command on PATH.
- Python-native users can install with `pipx install bot-bottle` or
`uv tool install bot-bottle` (once published) — or from a local
checkout today.
- `install.sh` validates prerequisites (Python ≥ 3.11), creates the
`~/.bot-bottle/` config tree, installs the package, and runs
`bot-bottle doctor`. It never installs Docker or a VM backend silently
and never uses `sudo`.
- `install.sh` is idempotent — safe to re-run.
- `bot-bottle doctor` reports Python version, backend availability, and
config-dir presence, exiting non-zero when a hard prerequisite is unmet.
- The package keeps **zero runtime pip dependencies** (stdlib-only,
matching the existing constraint in `AGENTS.md`).
## Non-goals
- Bundling a Python runtime or producing a standalone binary.
- Automatic Docker / VM-backend installation.
- Plugin-architecture changes (issue #197 floats a containerized-plugin
direction; that's a separate feature).
- Publishing to a package index in this PR — the package *structure* is
the deliverable; publishing is a follow-up step.
## Design
### Package structure (`pyproject.toml`)
Fill out the previously-stub `pyproject.toml` with project metadata, a
console-script entry point, and package-data for the non-Python assets the
runtime reads from inside the package:
```toml
[project]
name = "bot-bottle"
version = "0.1.0"
requires-python = ">=3.11"
dependencies = []
[project.scripts]
bot-bottle = "bot_bottle.cli:main"
```
`bot_bottle.cli:main` already exists (the `cli.py` shim calls it), so no
refactor of the entry point is needed. `package-data` ships the container
Dockerfiles, `egress_entrypoint.sh`, the firecracker netpool defaults, and
the macos-container init script so an installed wheel can still build its
sidecar/agent images.
### `install.sh`
A POSIX `sh` bootstrapper that:
1. Checks `python3` is present and ≥ 3.11; exits with a clear message
otherwise.
2. Creates `~/.bot-bottle/{agents,bottles,contrib}`.
3. Installs via `pipx` if available, else `python3 -m pip install --user`.
The spec defaults to the git URL and is overridable via
`BOT_BOTTLE_INSTALL_SPEC` (used by tests / local installs).
4. Locates the `bot-bottle` entry point (PATH or `~/.local/bin`).
5. Runs `bot-bottle doctor` and reports the result.
It is idempotent and never calls `sudo`.
### `bot-bottle doctor`
A new store-free subcommand (no DB migration required) that checks and
reports:
- **python** — interpreter version (hard requirement: ≥ 3.11).
- **backend** — at least one backend available on this host
(macos-container / firecracker / docker), reusing
`is_backend_available()` rather than hardcoding Docker, since the
default backend is now a VM backend. Hard requirement.
- **config** — whether `~/.bot-bottle/` exists (advisory only; `start`
provisions on first run).
Exits 0 when both hard requirements pass, non-zero otherwise.
## Testing strategy
- Unit test `bot-bottle doctor` success/failure paths with backend
availability and Python version mocked.
- Unit test that `pyproject.toml` parses, declares the entry point and an
empty `dependencies` list, and that every `package-data` glob resolves
to a file that exists on disk (guards against drift).
- Unit test that `install.sh` is executable, POSIX-ish (`set -eu`), never
calls `sudo`, and runs `doctor` after install.
## Open questions
- Should `version` be derived from a git tag at build time (e.g.
`hatch-vcs`) or kept static? Static (`0.1.0`) is simpler for now.
- Publishing target (PyPI vs. a self-hosted index) is deferred.
Executable
+79
View File
@@ -0,0 +1,79 @@
#!/bin/sh
# bot-bottle quick installer.
#
# Usage:
# curl -fsSL https://gitea.dideric.is/didericis/bot-bottle/raw/branch/main/install.sh | sh
#
# Python-native users can skip this entirely:
# pipx install bot-bottle # from a checkout or a published index
# uv tool install bot-bottle
#
# This script is a thin bootstrapper: it checks prerequisites, installs the
# package with pipx (falling back to pip --user), creates the config dir, and
# runs `bot-bottle doctor`. It is idempotent (safe to re-run) and never uses
# sudo. It does NOT install Docker or a VM backend for you — `doctor` reports
# what's missing after install.
set -eu
PACKAGE_SPEC="${BOT_BOTTLE_INSTALL_SPEC:-git+https://gitea.dideric.is/didericis/bot-bottle.git}"
MIN_PYTHON_MAJOR=3
MIN_PYTHON_MINOR=11
say() {
printf 'bot-bottle install: %s\n' "$*" >&2
}
die() {
say "error: $*"
exit 1
}
# --- prerequisites -----------------------------------------------------------
command -v python3 >/dev/null 2>&1 \
|| die "python3 ${MIN_PYTHON_MAJOR}.${MIN_PYTHON_MINOR}+ is required but was not found"
python3 - "$MIN_PYTHON_MAJOR" "$MIN_PYTHON_MINOR" <<'PY' || die "python3 ${MIN_PYTHON_MAJOR}.${MIN_PYTHON_MINOR} or newer is required"
import sys
want = (int(sys.argv[1]), int(sys.argv[2]))
raise SystemExit(0 if sys.version_info[:2] >= want else 1)
PY
# --- config directories ------------------------------------------------------
mkdir -p \
"${HOME}/.bot-bottle/agents" \
"${HOME}/.bot-bottle/bottles" \
"${HOME}/.bot-bottle/contrib"
# --- install -----------------------------------------------------------------
if command -v pipx >/dev/null 2>&1; then
say "installing with pipx"
pipx install --force "${PACKAGE_SPEC}"
else
say "pipx not found; installing with 'python3 -m pip install --user'"
python3 -m pip install --user --upgrade "${PACKAGE_SPEC}"
fi
# --- locate the entry point --------------------------------------------------
if command -v bot-bottle >/dev/null 2>&1; then
BOT_BOTTLE_BIN="bot-bottle"
elif [ -x "${HOME}/.local/bin/bot-bottle" ]; then
BOT_BOTTLE_BIN="${HOME}/.local/bin/bot-bottle"
say "note: add ${HOME}/.local/bin to your PATH to run 'bot-bottle' directly"
else
die "bot-bottle was installed but is not on PATH; add ~/.local/bin to PATH and re-run"
fi
# --- verify ------------------------------------------------------------------
say "running '${BOT_BOTTLE_BIN} doctor'"
if "${BOT_BOTTLE_BIN}" doctor; then
say "done. Run '${BOT_BOTTLE_BIN} --help' to get started."
else
say "install completed, but 'doctor' reported unmet prerequisites (see above)."
say "resolve them, then re-run '${BOT_BOTTLE_BIN} doctor'."
fi
+38 -1
View File
@@ -4,5 +4,42 @@ build-backend = "setuptools.build_meta"
[project] [project]
name = "bot-bottle" name = "bot-bottle"
version = "0.0.0" version = "0.1.0"
description = "Self-hosted sandbox for running AI coding agents with egress controls"
readme = "README.md"
requires-python = ">=3.11" requires-python = ">=3.11"
license = { text = "Apache-2.0" }
authors = [{ name = "didericis" }]
keywords = ["ai", "agents", "sandbox", "security", "egress"]
classifiers = [
"Programming Language :: Python :: 3",
"Programming Language :: Python :: 3 :: Only",
"Operating System :: POSIX :: Linux",
"Operating System :: MacOS",
]
# The package itself has no runtime pip dependencies (stdlib-only); the
# only language runtime is the Python interpreter. Keep this empty.
dependencies = []
[project.urls]
Homepage = "https://gitea.dideric.is/didericis/bot-bottle"
Source = "https://gitea.dideric.is/didericis/bot-bottle"
[project.scripts]
bot-bottle = "bot_bottle.cli:main"
[tool.setuptools.packages.find]
include = ["bot_bottle*"]
# Non-Python assets the runtime reads from inside the package (container
# build contexts, entrypoints, netpool defaults). Keep in sync with the
# files shipped under bot_bottle/; test_pyproject.py asserts they exist.
[tool.setuptools.package-data]
bot_bottle = [
"egress_entrypoint.sh",
"contrib/claude/Dockerfile",
"contrib/codex/Dockerfile",
"contrib/pi/Dockerfile",
"backend/firecracker/netpool.defaults.env",
"backend/macos_container/nested-containers-init.sh",
]
+77
View File
@@ -0,0 +1,77 @@
"""Unit: `bot-bottle doctor` host prerequisite checks (ADR 0004).
`doctor` is a store-free diagnostic — it must run on a fresh install
before any DB migration, and its exit code gates only the two hard
prerequisites (Python and an available backend). The config-dir check is
advisory and never affects the exit code.
"""
from __future__ import annotations
import io
import tempfile
import unittest
from contextlib import redirect_stdout
from pathlib import Path
from unittest.mock import patch
from bot_bottle.cli.commands import doctor
def _run(argv: list[str] | None = None) -> tuple[int, str]:
buf = io.StringIO()
with redirect_stdout(buf):
code = doctor.cmd_doctor(argv or [])
return code, buf.getvalue()
class TestDoctor(unittest.TestCase):
def test_passes_when_python_and_backend_ok(self):
with patch.object(doctor, "known_backend_names", return_value=("docker",)), \
patch.object(doctor, "is_backend_available", return_value=True):
code, out = _run()
self.assertEqual(0, code)
self.assertIn("ok: python", out)
self.assertIn("ok: backend: available: docker", out)
def test_fails_when_no_backend_available(self):
with patch.object(doctor, "known_backend_names", return_value=("docker", "firecracker")), \
patch.object(doctor, "is_backend_available", return_value=False):
code, out = _run()
self.assertEqual(1, code)
self.assertIn("fail: backend", out)
def test_fails_when_python_too_old(self):
# Force the version gate to fail without touching the interpreter.
with patch.object(doctor, "MIN_PYTHON", (99, 0)), \
patch.object(doctor, "known_backend_names", return_value=("docker",)), \
patch.object(doctor, "is_backend_available", return_value=True):
code, out = _run()
self.assertEqual(1, code)
self.assertIn("fail: python", out)
def test_missing_config_dir_is_advisory_not_fatal(self):
# A missing ~/.bot-bottle warns but must not fail. Point home at a
# fresh empty dir so the shared suite HOME (which other tests may
# populate) can't turn this into an "ok: config".
with tempfile.TemporaryDirectory() as tmp, \
patch.object(doctor.Path, "home", return_value=Path(tmp)), \
patch.object(doctor, "known_backend_names", return_value=("docker",)), \
patch.object(doctor, "is_backend_available", return_value=True):
code, out = _run()
self.assertEqual(0, code)
self.assertIn("warn: config", out)
def test_present_config_dir_reports_ok(self):
with tempfile.TemporaryDirectory() as tmp, \
patch.object(doctor.Path, "home", return_value=Path(tmp)), \
patch.object(doctor, "known_backend_names", return_value=("docker",)), \
patch.object(doctor, "is_backend_available", return_value=True):
(Path(tmp) / ".bot-bottle").mkdir()
code, out = _run()
self.assertEqual(0, code)
self.assertIn("ok: config", out)
if __name__ == "__main__":
unittest.main()
+58
View File
@@ -0,0 +1,58 @@
"""Unit: install.sh bootstrapper contract.
The installer is a thin, sudo-free, idempotent bootstrapper. These are
static checks on the script text (no network / no real install) so CI can
run them anywhere: it must be executable, fail-fast, never call sudo,
create the config tree, install the package, and verify with `doctor`.
"""
from __future__ import annotations
import os
import unittest
from pathlib import Path
REPO_ROOT = Path(__file__).resolve().parents[2]
INSTALL_SH = REPO_ROOT / "install.sh"
class TestInstallScript(unittest.TestCase):
@classmethod
def setUpClass(cls):
cls.text = INSTALL_SH.read_text()
def test_exists_and_executable(self):
self.assertTrue(INSTALL_SH.is_file())
self.assertTrue(os.access(INSTALL_SH, os.X_OK), "install.sh must be executable")
def test_posix_shebang_and_failfast(self):
first = self.text.splitlines()[0]
self.assertEqual("#!/bin/sh", first)
self.assertIn("set -eu", self.text)
def test_never_uses_sudo(self):
# Only executable lines matter; the header comment may mention sudo.
code = [
ln for ln in self.text.splitlines()
if ln.strip() and not ln.lstrip().startswith("#")
]
self.assertNotIn("sudo", "\n".join(code))
def test_creates_config_tree(self):
self.assertIn(".bot-bottle/agents", self.text)
self.assertIn(".bot-bottle/bottles", self.text)
def test_installs_via_pipx_with_pip_fallback(self):
self.assertIn("pipx install", self.text)
self.assertIn("pip install --user", self.text)
def test_runs_doctor_after_install(self):
self.assertIn("doctor", self.text)
def test_install_spec_is_overridable(self):
# Tests / local installs point BOT_BOTTLE_INSTALL_SPEC at a checkout.
self.assertIn("BOT_BOTTLE_INSTALL_SPEC", self.text)
if __name__ == "__main__":
unittest.main()
+45
View File
@@ -0,0 +1,45 @@
"""Unit: pyproject.toml packaging contract.
Guards the install/distribution surface: the console-script entry point,
the stdlib-only (empty) dependency list, and that every package-data glob
still points at a file that exists (so an installed wheel isn't missing a
Dockerfile or entrypoint the runtime reads).
"""
from __future__ import annotations
import tomllib
import unittest
from pathlib import Path
REPO_ROOT = Path(__file__).resolve().parents[2]
PYPROJECT = REPO_ROOT / "pyproject.toml"
class TestPyproject(unittest.TestCase):
@classmethod
def setUpClass(cls):
with PYPROJECT.open("rb") as fh:
cls.data = tomllib.load(fh)
def test_entry_point_targets_cli_main(self):
scripts = self.data["project"]["scripts"]
self.assertEqual("bot_bottle.cli:main", scripts["bot-bottle"])
def test_no_runtime_dependencies(self):
# AGENTS.md: the package has no runtime pip dependencies.
self.assertEqual([], self.data["project"]["dependencies"])
def test_requires_python_311(self):
self.assertEqual(">=3.11", self.data["project"]["requires-python"])
def test_package_data_files_exist(self):
pkg_data = self.data["tool"]["setuptools"]["package-data"]["bot_bottle"]
self.assertTrue(pkg_data, "expected package-data entries")
for rel in pkg_data:
path = REPO_ROOT / "bot_bottle" / rel
self.assertTrue(path.is_file(), f"package-data missing: {path}")
if __name__ == "__main__":
unittest.main()