From 5933fdc7897c7fda2adb53bb8183cb18b0aa5f71 Mon Sep 17 00:00:00 2001 From: claude Date: Sat, 25 Jul 2026 23:16:50 +0000 Subject: [PATCH] feat: add quick install script and packaging (#197) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- bot_bottle/cli/commands/__init__.py | 3 +- bot_bottle/cli/commands/doctor.py | 81 +++++++++++++++++++ bot_bottle/cli/commands/help.py | 1 + docs/prds/prd-new-install-script.md | 119 ++++++++++++++++++++++++++++ install.sh | 79 ++++++++++++++++++ pyproject.toml | 39 ++++++++- tests/unit/test_cli_doctor.py | 77 ++++++++++++++++++ tests/unit/test_install_script.py | 58 ++++++++++++++ tests/unit/test_pyproject.py | 45 +++++++++++ 9 files changed, 500 insertions(+), 2 deletions(-) create mode 100644 bot_bottle/cli/commands/doctor.py create mode 100644 docs/prds/prd-new-install-script.md create mode 100755 install.sh create mode 100644 tests/unit/test_cli_doctor.py create mode 100644 tests/unit/test_install_script.py create mode 100644 tests/unit/test_pyproject.py diff --git a/bot_bottle/cli/commands/__init__.py b/bot_bottle/cli/commands/__init__.py index de42a33a..3adb0be3 100644 --- a/bot_bottle/cli/commands/__init__.py +++ b/bot_bottle/cli/commands/__init__.py @@ -21,6 +21,7 @@ _HANDLERS: dict[str, str] = { "backend": "backend:cmd_backend", "cleanup": "cleanup:cmd_cleanup", "commit": "commit:cmd_commit", + "doctor": "doctor:cmd_doctor", "edit": "edit:cmd_edit", "help": "help:cmd_help", "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 # isn't a TTY and the migration prompt can't be answered. `help` and `login` # 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"] diff --git a/bot_bottle/cli/commands/doctor.py b/bot_bottle/cli/commands/doctor.py new file mode 100644 index 00000000..1cc218da --- /dev/null +++ b/bot_bottle/cli/commands/doctor.py @@ -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 diff --git a/bot_bottle/cli/commands/help.py b/bot_bottle/cli/commands/help.py index 9e163723..8c154da3 100644 --- a/bot_bottle/cli/commands/help.py +++ b/bot_bottle/cli/commands/help.py @@ -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(" 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(" doctor check host prerequisites (Python, backend, config dir)\n") w(" edit open an agent in vim for editing\n") w(" help show this command list\n") w(" init interactively create a new agent and add it to bot-bottle.json\n") diff --git a/docs/prds/prd-new-install-script.md b/docs/prds/prd-new-install-script.md new file mode 100644 index 00000000..54e302b9 --- /dev/null +++ b/docs/prds/prd-new-install-script.md @@ -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 /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. diff --git a/install.sh b/install.sh new file mode 100755 index 00000000..667b4b8c --- /dev/null +++ b/install.sh @@ -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 diff --git a/pyproject.toml b/pyproject.toml index 64359a16..6d600f2c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,5 +4,42 @@ build-backend = "setuptools.build_meta" [project] 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" +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", +] diff --git a/tests/unit/test_cli_doctor.py b/tests/unit/test_cli_doctor.py new file mode 100644 index 00000000..871398ed --- /dev/null +++ b/tests/unit/test_cli_doctor.py @@ -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() diff --git a/tests/unit/test_install_script.py b/tests/unit/test_install_script.py new file mode 100644 index 00000000..48475ed8 --- /dev/null +++ b/tests/unit/test_install_script.py @@ -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() diff --git a/tests/unit/test_pyproject.py b/tests/unit/test_pyproject.py new file mode 100644 index 00000000..fed76d02 --- /dev/null +++ b/tests/unit/test_pyproject.py @@ -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()