Compare commits
15 Commits
pr-211
...
92518a43b5
| Author | SHA1 | Date | |
|---|---|---|---|
| 92518a43b5 | |||
| 6b5fbe84cb | |||
| e1e68b52d4 | |||
| c9a910f740 | |||
| b71b8cf296 | |||
| 1a5b6e25f8 | |||
| 54760964cf | |||
| e463670649 | |||
| 6e6890ebd9 | |||
| 609b3ed090 | |||
| 65faa40b9a | |||
| 9f97de115b | |||
| 8f21f4df19 | |||
| ff7a52c1d2 | |||
| 4ed6b84863 |
@@ -68,6 +68,27 @@ The Docker topology looks like this:
|
|||||||
|
|
||||||
When the agent exits, `cli.py` tears down every sidecar and both networks; nothing about a bottle persists between runs.
|
When the agent exits, `cli.py` tears down every sidecar and both networks; nothing about a bottle persists between runs.
|
||||||
|
|
||||||
|
## Install
|
||||||
|
|
||||||
|
Install the CLI with the bootstrap script:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
curl -fsSL https://gitea.dideric.is/didericis/bot-bottle/raw/branch/main/install.sh | sh
|
||||||
|
```
|
||||||
|
|
||||||
|
The script checks Python 3.11+, checks Docker daemon reachability, creates the `~/.bot-bottle/` config directories, installs the Python package with `pipx` when available or `pip --user` otherwise, then runs:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
bot-bottle doctor
|
||||||
|
```
|
||||||
|
|
||||||
|
Python-native installers can use the package metadata directly:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
pipx install git+https://gitea.dideric.is/didericis/bot-bottle.git
|
||||||
|
uv tool install git+https://gitea.dideric.is/didericis/bot-bottle.git
|
||||||
|
```
|
||||||
|
|
||||||
## Quickstart
|
## Quickstart
|
||||||
|
|
||||||
On compatible macOS hosts, the default backend requires Apple's `container` CLI and does not require Docker. The smolmachines backend requires Docker on the host for the sidecar bundle plus smolvm. The legacy Docker backend requires Docker. Claude bottles also need a long-lived Claude Code OAuth token (`claude setup-token`) exported as `BOT_BOTTLE_CLAUDE_OAUTH_TOKEN`.
|
On compatible macOS hosts, the default backend requires Apple's `container` CLI and does not require Docker. The smolmachines backend requires Docker on the host for the sidecar bundle plus smolvm. The legacy Docker backend requires Docker. Claude bottles also need a long-lived Claude Code OAuth token (`claude setup-token`) exported as `BOT_BOTTLE_CLAUDE_OAUTH_TOKEN`.
|
||||||
|
|||||||
@@ -0,0 +1,96 @@
|
|||||||
|
# Per-bottle sidecar bundle image (PRD 0024).
|
||||||
|
#
|
||||||
|
# Collapses the prior per-sidecar images (egress, git-gate,
|
||||||
|
# supervise) into one. A small stdlib-Python init supervisor at
|
||||||
|
# /app/sidecar_init.py spawns all daemons, forwards SIGTERM, and
|
||||||
|
# propagates per-daemon stdout/stderr to the container log with a
|
||||||
|
# `[name]` prefix. See PRD 0024 for the rationale.
|
||||||
|
#
|
||||||
|
# Layout:
|
||||||
|
#
|
||||||
|
# /usr/bin/gitleaks gitleaks binary
|
||||||
|
# /app/egress_addon.py + siblings mitmproxy addon (egress)
|
||||||
|
# /app/egress-entrypoint.sh mitmdump launcher
|
||||||
|
# /app/supervise_server.py + .py supervise MCP server
|
||||||
|
# /app/sidecar_init.py PID 1 supervisor
|
||||||
|
# /etc/egress/routes.yaml bind-mounted at run time
|
||||||
|
# /etc/git-gate/pre-receive docker-cp'd at start time
|
||||||
|
# /git-gate-entrypoint.sh docker-cp'd at start time
|
||||||
|
# /git-gate/creds/* docker-cp'd at start time
|
||||||
|
# /git/* bare repos, populated at runtime
|
||||||
|
# /run/supervise/queue/ bind-mounted at run time
|
||||||
|
# /home/mitmproxy/.mitmproxy/ mitmproxy CA dir
|
||||||
|
#
|
||||||
|
# Exposed ports inside the container:
|
||||||
|
# 9099 egress (mitmproxy, agent-facing HTTPS proxy)
|
||||||
|
# 9418 git-gate (git-daemon)
|
||||||
|
# 9420 git-gate smart HTTP (smolmachines agent-facing transport)
|
||||||
|
# 9100 supervise (MCP HTTP)
|
||||||
|
|
||||||
|
# Stage 1: gitleaks binary. The upstream gitleaks image is alpine
|
||||||
|
# with the binary at /usr/bin/gitleaks. Pinned by digest in lockstep
|
||||||
|
# with Dockerfile.git-gate's prior base (now deleted at chunk 3).
|
||||||
|
FROM zricethezav/gitleaks@sha256:c00b6bd0aeb3071cbcb79009cb16a60dd9e0a7c60e2be9ab65d25e6bc8abbb7f AS gitleaks-src
|
||||||
|
|
||||||
|
# Stage 2: assembly. mitmproxy/mitmproxy is debian-slim-based with
|
||||||
|
# Python + mitmdump pre-installed — heavier than the others, so
|
||||||
|
# this stage starts there and pulls the standalone binaries in.
|
||||||
|
FROM mitmproxy/mitmproxy:11.1.3
|
||||||
|
|
||||||
|
# Run as root inside the bundle. The bundle is the isolation
|
||||||
|
# boundary; per-daemon user separation inside it is not load-bearing
|
||||||
|
# and complicates the supervisor's spawn path.
|
||||||
|
USER root
|
||||||
|
|
||||||
|
# Runtime system deps:
|
||||||
|
# git supplies the `git daemon` subcommand (no separate package)
|
||||||
|
# plus the core `git` binary the pre-receive hook invokes.
|
||||||
|
# openssh-client supplies the upstream SSH transport the
|
||||||
|
# pre-receive hook uses to forward accepted refs.
|
||||||
|
# ca-certificates is needed for mitmdump upstream TLS (the
|
||||||
|
# base image already has it; listed for explicitness).
|
||||||
|
RUN apt-get update \
|
||||||
|
&& apt-get install -y --no-install-recommends \
|
||||||
|
git openssh-client ca-certificates \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
# Pull the standalone binaries into the final image.
|
||||||
|
COPY --from=gitleaks-src /usr/bin/gitleaks /usr/bin/gitleaks
|
||||||
|
|
||||||
|
# Project Python: addon + server modules + the init supervisor.
|
||||||
|
# Kept flat under /app/ so mitmdump's loader resolves them as
|
||||||
|
# top-level siblings (absolute imports), matching the prior
|
||||||
|
# Dockerfile.egress / Dockerfile.supervise layout.
|
||||||
|
COPY bot_bottle/egress_addon_core.py /app/egress_addon_core.py
|
||||||
|
COPY bot_bottle/egress_addon.py /app/egress_addon.py
|
||||||
|
COPY bot_bottle/dlp_detectors.py /app/dlp_detectors.py
|
||||||
|
COPY bot_bottle/yaml_subset.py /app/yaml_subset.py
|
||||||
|
COPY bot_bottle/supervise.py /app/supervise.py
|
||||||
|
COPY bot_bottle/supervise_server.py /app/supervise_server.py
|
||||||
|
COPY bot_bottle/sidecar_init.py /app/sidecar_init.py
|
||||||
|
COPY bot_bottle/git_http_backend.py /app/git_http_backend.py
|
||||||
|
COPY bot_bottle/egress_entrypoint.sh /app/egress-entrypoint.sh
|
||||||
|
RUN chmod +x /app/egress-entrypoint.sh
|
||||||
|
|
||||||
|
# Pre-create runtime directories the compose renderer + start
|
||||||
|
# step expect to exist. `docker cp` does not create intermediate
|
||||||
|
# dirs, and bind mounts won't either if the parent is missing.
|
||||||
|
RUN mkdir -p \
|
||||||
|
/etc/egress \
|
||||||
|
/etc/git-gate \
|
||||||
|
/git-gate/creds \
|
||||||
|
/git \
|
||||||
|
/run/supervise/queue \
|
||||||
|
/home/mitmproxy/.mitmproxy
|
||||||
|
|
||||||
|
# Documentation only — the compose renderer publishes whichever
|
||||||
|
# subset the bottle uses.
|
||||||
|
EXPOSE 8888 9099 9418 9420 9100
|
||||||
|
|
||||||
|
# WORKDIR matches Dockerfile.supervise's prior layout so the
|
||||||
|
# in-app same-dir import in supervise_server.py stays deterministic.
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# PID 1 is the supervisor. It owns signal handling and exit-code
|
||||||
|
# propagation; no `exec` chain in the entrypoint itself.
|
||||||
|
ENTRYPOINT ["python3", "/app/sidecar_init.py"]
|
||||||
@@ -45,7 +45,7 @@ from ..agent_provider import AgentProvisionPlan, get_provider, build_agent_provi
|
|||||||
from ..egress import EgressPlan
|
from ..egress import EgressPlan
|
||||||
from ..git_gate import GitGatePlan
|
from ..git_gate import GitGatePlan
|
||||||
from ..log import die, info
|
from ..log import die, info
|
||||||
from ..manifest import ManifestGitEntry, Manifest
|
from ..manifest import Manifest
|
||||||
from ..supervise import SupervisePlan
|
from ..supervise import SupervisePlan
|
||||||
from ..util import expand_tilde
|
from ..util import expand_tilde
|
||||||
from ..env import resolve_env, ResolvedEnv
|
from ..env import resolve_env, ResolvedEnv
|
||||||
@@ -356,16 +356,14 @@ class BottleBackend(ABC, Generic[PlanT, CleanupT]):
|
|||||||
pass
|
pass
|
||||||
|
|
||||||
def _validate(self, spec: BottleSpec) -> None:
|
def _validate(self, spec: BottleSpec) -> None:
|
||||||
"""Cross-backend pre-launch checks. Confirms the agent exists,
|
"""Cross-backend pre-launch checks. Confirms the agent exists
|
||||||
the named skills are present on the host, and every git
|
and the named skills are present on the host. Subclasses with
|
||||||
IdentityFile resolves. Subclasses with additional preconditions
|
additional preconditions should override and call
|
||||||
should override and call `super()._validate(spec)` first."""
|
`super()._validate(spec)` first."""
|
||||||
manifest = spec.manifest
|
manifest = spec.manifest
|
||||||
manifest.require_agent(spec.agent_name)
|
manifest.require_agent(spec.agent_name)
|
||||||
agent = manifest.agents[spec.agent_name]
|
agent = manifest.agents[spec.agent_name]
|
||||||
bottle = manifest.bottle_for(spec.agent_name)
|
|
||||||
self._validate_skills(agent.skills)
|
self._validate_skills(agent.skills)
|
||||||
self._validate_git_entries(bottle.git)
|
|
||||||
self._validate_agent_provider_dockerfile(spec)
|
self._validate_agent_provider_dockerfile(spec)
|
||||||
|
|
||||||
def _validate_skills(self, skills: Sequence[str]) -> None:
|
def _validate_skills(self, skills: Sequence[str]) -> None:
|
||||||
@@ -380,16 +378,6 @@ class BottleBackend(ABC, Generic[PlanT, CleanupT]):
|
|||||||
f"Create it under ~/.claude/skills/, then re-run."
|
f"Create it under ~/.claude/skills/, then re-run."
|
||||||
)
|
)
|
||||||
|
|
||||||
def _validate_git_entries(self, entries: Sequence[ManifestGitEntry]) -> None:
|
|
||||||
"""Each entry's IdentityFile must exist on the host (after
|
|
||||||
expanding leading ~) — the git-gate copies it in at start time
|
|
||||||
to authenticate the upstream push (PRD 0008). Shape is already
|
|
||||||
enforced by Manifest validation; this only checks presence."""
|
|
||||||
for entry in entries:
|
|
||||||
key = expand_tilde(entry.IdentityFile)
|
|
||||||
if not os.path.isfile(key):
|
|
||||||
die(f"git upstream key file not found for '{entry.Name}': {key}")
|
|
||||||
|
|
||||||
def _validate_agent_provider_dockerfile(self, spec: BottleSpec) -> None:
|
def _validate_agent_provider_dockerfile(self, spec: BottleSpec) -> None:
|
||||||
bottle = spec.manifest.bottle_for(spec.agent_name)
|
bottle = spec.manifest.bottle_for(spec.agent_name)
|
||||||
dockerfile = bottle.agent_provider.dockerfile
|
dockerfile = bottle.agent_provider.dockerfile
|
||||||
|
|||||||
@@ -58,10 +58,17 @@ from .sidecar_bundle import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
# Repo root, used as the build context for the bundle Dockerfile.
|
# Repo root or installed site-packages root, used as the build context for
|
||||||
|
# Dockerfiles that COPY bot_bottle source files.
|
||||||
_REPO_DIR = str(Path(__file__).resolve().parent.parent.parent.parent)
|
_REPO_DIR = str(Path(__file__).resolve().parent.parent.parent.parent)
|
||||||
|
|
||||||
|
|
||||||
|
def _sidecar_bundle_dockerfile() -> str:
|
||||||
|
if (Path(_REPO_DIR) / SIDECAR_BUNDLE_DOCKERFILE).is_file():
|
||||||
|
return SIDECAR_BUNDLE_DOCKERFILE
|
||||||
|
return f"bot_bottle/{SIDECAR_BUNDLE_DOCKERFILE}"
|
||||||
|
|
||||||
|
|
||||||
def bottle_plan_to_compose(plan: DockerBottlePlan) -> dict[str, Any]:
|
def bottle_plan_to_compose(plan: DockerBottlePlan) -> dict[str, Any]:
|
||||||
"""Render a Compose v2 spec dict from a fully-resolved
|
"""Render a Compose v2 spec dict from a fully-resolved
|
||||||
DockerBottlePlan.
|
DockerBottlePlan.
|
||||||
@@ -183,7 +190,7 @@ def _sidecar_bundle_service(plan: DockerBottlePlan) -> dict[str, Any]:
|
|||||||
"image": SIDECAR_BUNDLE_IMAGE,
|
"image": SIDECAR_BUNDLE_IMAGE,
|
||||||
"build": {
|
"build": {
|
||||||
"context": _REPO_DIR,
|
"context": _REPO_DIR,
|
||||||
"dockerfile": SIDECAR_BUNDLE_DOCKERFILE,
|
"dockerfile": _sidecar_bundle_dockerfile(),
|
||||||
},
|
},
|
||||||
"container_name": sidecar_bundle_container_name(plan.slug),
|
"container_name": sidecar_bundle_container_name(plan.slug),
|
||||||
"networks": {
|
"networks": {
|
||||||
|
|||||||
@@ -12,9 +12,10 @@ from __future__ import annotations
|
|||||||
import os
|
import os
|
||||||
|
|
||||||
|
|
||||||
# Bundle image. Defaults to a built-locally tag (built from the
|
# Bundle image. Defaults to a built-locally tag. Source checkouts
|
||||||
# repo's Dockerfile.sidecars via compose `build:`). Operators
|
# build from the repo-root Dockerfile.sidecars; installed packages
|
||||||
# pinning to a published digest can override via env.
|
# build from the packaged copy under bot_bottle/.
|
||||||
|
# Operators pinning to a published digest can override via env.
|
||||||
SIDECAR_BUNDLE_IMAGE = os.environ.get(
|
SIDECAR_BUNDLE_IMAGE = os.environ.get(
|
||||||
"BOT_BOTTLE_SIDECAR_IMAGE",
|
"BOT_BOTTLE_SIDECAR_IMAGE",
|
||||||
"bot-bottle-sidecars:latest",
|
"bot-bottle-sidecars:latest",
|
||||||
|
|||||||
@@ -33,8 +33,18 @@ from . import BottleSpec
|
|||||||
|
|
||||||
def mint_slug(spec: BottleSpec) -> str:
|
def mint_slug(spec: BottleSpec) -> str:
|
||||||
"""Return the bottle identity: the recorded identity for a resume,
|
"""Return the bottle identity: the recorded identity for a resume,
|
||||||
or a freshly minted one for a new start."""
|
or a freshly minted one for a new start.
|
||||||
return spec.identity or bottle_identity(spec.agent_name)
|
|
||||||
|
When a label is provided it becomes the full slug (no random suffix),
|
||||||
|
so two launches with the same label collide by design. When no label
|
||||||
|
is given the identity is minted with a random suffix to avoid
|
||||||
|
collisions between anonymous launches of the same agent."""
|
||||||
|
if spec.identity:
|
||||||
|
return spec.identity
|
||||||
|
if spec.label:
|
||||||
|
from .docker import util as docker_mod
|
||||||
|
return docker_mod.slugify(spec.label)
|
||||||
|
return bottle_identity(spec.agent_name)
|
||||||
|
|
||||||
|
|
||||||
def write_launch_metadata(
|
def write_launch_metadata(
|
||||||
|
|||||||
@@ -12,22 +12,11 @@ import shlex
|
|||||||
# uses true/24-bit colors for its own chrome, which would otherwise bypass
|
# uses true/24-bit colors for its own chrome, which would otherwise bypass
|
||||||
# the palette entirely.
|
# the palette entirely.
|
||||||
_COLORS: dict[str, tuple[int, str, int, str, str]] = {
|
_COLORS: dict[str, tuple[int, str, int, str, str]] = {
|
||||||
"black": (0, "#2d2d2d", 8, "#5c5c5c", "#0a0a0a"),
|
"red": (9, "#e74c3c", 1, "#c0392b", "#200808"),
|
||||||
"red": (1, "#c0392b", 9, "#e74c3c", "#1a0707"),
|
"green": (10, "#2ecc71", 2, "#27ae60", "#082008"),
|
||||||
"green": (2, "#27ae60", 10, "#2ecc71", "#071a09"),
|
"yellow": (11, "#f1c40f", 3, "#d4ac0d", "#201808"),
|
||||||
"yellow": (3, "#d4ac0d", 11, "#f1c40f", "#1a1507"),
|
"blue": (12, "#3498db", 4, "#2471a3", "#080820"),
|
||||||
"blue": (4, "#2471a3", 12, "#3498db", "#07071a"),
|
"magenta": (13, "#9b59b6", 5, "#7d3c98", "#160820"),
|
||||||
"magenta": (5, "#7d3c98", 13, "#9b59b6", "#12071a"),
|
|
||||||
"cyan": (6, "#148f77", 14, "#1abc9c", "#071a1a"),
|
|
||||||
"white": (7, "#bdc3c7", 15, "#ecf0f1", "#111111"),
|
|
||||||
"bright-black": (8, "#5c5c5c", 0, "#2d2d2d", "#111111"),
|
|
||||||
"bright-red": (9, "#e74c3c", 1, "#c0392b", "#200808"),
|
|
||||||
"bright-green": (10, "#2ecc71", 2, "#27ae60", "#082008"),
|
|
||||||
"bright-yellow": (11, "#f1c40f", 3, "#d4ac0d", "#201808"),
|
|
||||||
"bright-blue": (12, "#3498db", 4, "#2471a3", "#080820"),
|
|
||||||
"bright-magenta": (13, "#9b59b6", 5, "#7d3c98", "#160820"),
|
|
||||||
"bright-cyan": (14, "#1abc9c", 6, "#148f77", "#082020"),
|
|
||||||
"bright-white": (15, "#ecf0f1", 7, "#bdc3c7", "#151515"),
|
|
||||||
}
|
}
|
||||||
|
|
||||||
# OSC 104 resets all indexed palette entries; OSC 111 resets default background.
|
# OSC 104 resets all indexed palette entries; OSC 111 resets default background.
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
"""Main CLI dispatcher.
|
"""Main CLI dispatcher.
|
||||||
|
|
||||||
Commands: cleanup, edit, info, init, list, resume, start, supervise
|
Commands: cleanup, doctor, edit, info, init, list, resume, start, supervise
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
@@ -12,6 +12,7 @@ from ..manifest import ManifestError
|
|||||||
from ._common import PROG
|
from ._common import PROG
|
||||||
from . import list as _list_mod
|
from . import list as _list_mod
|
||||||
from .cleanup import cmd_cleanup
|
from .cleanup import cmd_cleanup
|
||||||
|
from .doctor import cmd_doctor
|
||||||
from .edit import cmd_edit
|
from .edit import cmd_edit
|
||||||
from .info import cmd_info
|
from .info import cmd_info
|
||||||
from .init import cmd_init
|
from .init import cmd_init
|
||||||
@@ -23,6 +24,7 @@ cmd_list = _list_mod.cmd_list
|
|||||||
|
|
||||||
COMMANDS = {
|
COMMANDS = {
|
||||||
"cleanup": cmd_cleanup,
|
"cleanup": cmd_cleanup,
|
||||||
|
"doctor": cmd_doctor,
|
||||||
"edit": cmd_edit,
|
"edit": cmd_edit,
|
||||||
"info": cmd_info,
|
"info": cmd_info,
|
||||||
"init": cmd_init,
|
"init": cmd_init,
|
||||||
@@ -37,6 +39,7 @@ def usage() -> None:
|
|||||||
sys.stderr.write(f"usage: {PROG} <command> [args...]\n\n")
|
sys.stderr.write(f"usage: {PROG} <command> [args...]\n\n")
|
||||||
sys.stderr.write("Commands:\n")
|
sys.stderr.write("Commands:\n")
|
||||||
sys.stderr.write(" cleanup stop and remove all active bot-bottle containers\n")
|
sys.stderr.write(" cleanup stop and remove all active bot-bottle containers\n")
|
||||||
|
sys.stderr.write(" doctor check Python, Docker, and bot-bottle config prerequisites\n")
|
||||||
sys.stderr.write(" edit open an agent in vim for editing\n")
|
sys.stderr.write(" edit open an agent in vim for editing\n")
|
||||||
sys.stderr.write(" info print env, skills, and prompt details for a named agent\n")
|
sys.stderr.write(" info print env, skills, and prompt details for a named agent\n")
|
||||||
sys.stderr.write(" init interactively create a new agent and add it to bot-bottle.json\n")
|
sys.stderr.write(" init interactively create a new agent and add it to bot-bottle.json\n")
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import os
|
|||||||
import sys
|
import sys
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
PROG = "cli.py"
|
PROG = Path(sys.argv[0]).name or "bot-bottle"
|
||||||
USER_CWD = os.getcwd()
|
USER_CWD = os.getcwd()
|
||||||
REPO_DIR = str(Path(__file__).resolve().parent.parent.parent)
|
REPO_DIR = str(Path(__file__).resolve().parent.parent.parent)
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,73 @@
|
|||||||
|
"""doctor: validate host prerequisites for running bot-bottle."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import shutil
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from ._common import PROG
|
||||||
|
|
||||||
|
|
||||||
|
def _ok(label: str, detail: str) -> None:
|
||||||
|
print(f"ok: {label}: {detail}")
|
||||||
|
|
||||||
|
|
||||||
|
def _fail(label: str, detail: str) -> None:
|
||||||
|
print(f"fail: {label}: {detail}")
|
||||||
|
|
||||||
|
|
||||||
|
def _check_python() -> bool:
|
||||||
|
version = sys.version_info
|
||||||
|
detail = f"{version.major}.{version.minor}.{version.micro}"
|
||||||
|
if version >= (3, 11):
|
||||||
|
_ok("python", detail)
|
||||||
|
return True
|
||||||
|
_fail("python", f"{detail}; need 3.11 or newer")
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def _check_docker() -> bool:
|
||||||
|
docker = shutil.which("docker")
|
||||||
|
if not docker:
|
||||||
|
_fail("docker", "docker command not found")
|
||||||
|
return False
|
||||||
|
try:
|
||||||
|
result = subprocess.run(
|
||||||
|
[docker, "info"],
|
||||||
|
stdout=subprocess.DEVNULL,
|
||||||
|
stderr=subprocess.DEVNULL,
|
||||||
|
check=False,
|
||||||
|
timeout=10,
|
||||||
|
)
|
||||||
|
except (OSError, subprocess.TimeoutExpired) as exc:
|
||||||
|
_fail("docker", f"daemon check failed: {exc}")
|
||||||
|
return False
|
||||||
|
if result.returncode == 0:
|
||||||
|
_ok("docker", "daemon reachable")
|
||||||
|
return True
|
||||||
|
_fail("docker", "daemon not reachable")
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def _check_config_dir() -> bool:
|
||||||
|
config = Path.home() / ".bot-bottle"
|
||||||
|
if config.is_dir():
|
||||||
|
_ok("config", str(config))
|
||||||
|
return True
|
||||||
|
_fail("config", f"{config} does not exist")
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def cmd_doctor(argv: list[str]) -> int:
|
||||||
|
parser = argparse.ArgumentParser(prog=f"{PROG} doctor", add_help=True)
|
||||||
|
parser.parse_args(argv)
|
||||||
|
|
||||||
|
checks = (
|
||||||
|
_check_python(),
|
||||||
|
_check_docker(),
|
||||||
|
_check_config_dir(),
|
||||||
|
)
|
||||||
|
return 0 if all(checks) else 1
|
||||||
+5
-16
@@ -11,22 +11,11 @@ from ..manifest import Manifest
|
|||||||
from ._common import PROG, USER_CWD
|
from ._common import PROG, USER_CWD
|
||||||
|
|
||||||
_ANSI_COLOR_CODES: dict[str, str] = {
|
_ANSI_COLOR_CODES: dict[str, str] = {
|
||||||
"black": "\033[30m",
|
"red": "\033[91m",
|
||||||
"red": "\033[31m",
|
"green": "\033[92m",
|
||||||
"green": "\033[32m",
|
"yellow": "\033[93m",
|
||||||
"yellow": "\033[33m",
|
"blue": "\033[94m",
|
||||||
"blue": "\033[34m",
|
"magenta": "\033[95m",
|
||||||
"magenta": "\033[35m",
|
|
||||||
"cyan": "\033[36m",
|
|
||||||
"white": "\033[37m",
|
|
||||||
"bright-black": "\033[90m",
|
|
||||||
"bright-red": "\033[91m",
|
|
||||||
"bright-green": "\033[92m",
|
|
||||||
"bright-yellow": "\033[93m",
|
|
||||||
"bright-blue": "\033[94m",
|
|
||||||
"bright-magenta": "\033[95m",
|
|
||||||
"bright-cyan": "\033[96m",
|
|
||||||
"bright-white": "\033[97m",
|
|
||||||
}
|
}
|
||||||
_ANSI_RESET = "\033[0m"
|
_ANSI_RESET = "\033[0m"
|
||||||
|
|
||||||
|
|||||||
@@ -20,9 +20,11 @@ from ..agent_provider import runtime_for
|
|||||||
from ..backend import (
|
from ..backend import (
|
||||||
Bottle,
|
Bottle,
|
||||||
BottleSpec,
|
BottleSpec,
|
||||||
|
enumerate_active_agents,
|
||||||
get_bottle_backend,
|
get_bottle_backend,
|
||||||
known_backend_names,
|
known_backend_names,
|
||||||
)
|
)
|
||||||
|
from ..backend.docker import util as docker_mod
|
||||||
from ..backend.docker.bottle_plan import DockerBottlePlan
|
from ..backend.docker.bottle_plan import DockerBottlePlan
|
||||||
from ..bottle_state import (
|
from ..bottle_state import (
|
||||||
cleanup_state,
|
cleanup_state,
|
||||||
@@ -74,6 +76,7 @@ def cmd_start(argv: list[str]) -> int:
|
|||||||
backend_name: str | None = args.backend
|
backend_name: str | None = args.backend
|
||||||
|
|
||||||
label, color = tui.name_color_modal(default_label=agent_name)
|
label, color = tui.name_color_modal(default_label=agent_name)
|
||||||
|
label, color = _resolve_unique_label(label, color)
|
||||||
|
|
||||||
spec = BottleSpec(
|
spec = BottleSpec(
|
||||||
manifest=manifest,
|
manifest=manifest,
|
||||||
@@ -191,6 +194,21 @@ def _identity_from_plan(plan: object) -> str:
|
|||||||
return getattr(plan, "slug", "")
|
return getattr(plan, "slug", "")
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_unique_label(label: str, color: str) -> tuple[str, str]:
|
||||||
|
"""Re-prompt with a disclaimer until the label's slug is not already
|
||||||
|
in use among running bottles. Passes through unchanged when no
|
||||||
|
collision is found on the first check."""
|
||||||
|
while True:
|
||||||
|
slug_candidate = docker_mod.slugify(label)
|
||||||
|
active_slugs = {a.slug for a in enumerate_active_agents()}
|
||||||
|
if slug_candidate not in active_slugs:
|
||||||
|
return label, color
|
||||||
|
label, color = tui.name_color_modal(
|
||||||
|
default_label=label,
|
||||||
|
disclaimer=f'"{label}" is already in use',
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def _text_prompt_yes() -> bool:
|
def _text_prompt_yes() -> bool:
|
||||||
"""Default `prompt_yes` for CLI use: reads y/N from the
|
"""Default `prompt_yes` for CLI use: reads y/N from the
|
||||||
controlling tty via stderr prompt + tty-line read."""
|
controlling tty via stderr prompt + tty-line read."""
|
||||||
|
|||||||
+19
-19
@@ -226,20 +226,15 @@ def _addstr_safe(screen: Any, row: int, col: int, text: str, attr: int = curses.
|
|||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
_ANSI_COLORS = [
|
_ANSI_COLORS = [
|
||||||
"red", "green", "blue", "yellow", "magenta", "cyan", "white", "black",
|
"red", "green", "yellow", "blue", "magenta",
|
||||||
"bright-red", "bright-green", "bright-blue", "bright-yellow",
|
|
||||||
"bright-magenta", "bright-cyan", "bright-white", "bright-black",
|
|
||||||
]
|
]
|
||||||
|
|
||||||
_CURSES_COLOR_MAP: dict[str, int] = {
|
_CURSES_COLOR_MAP: dict[str, int] = {
|
||||||
"black": curses.COLOR_BLACK,
|
|
||||||
"red": curses.COLOR_RED,
|
"red": curses.COLOR_RED,
|
||||||
"green": curses.COLOR_GREEN,
|
"green": curses.COLOR_GREEN,
|
||||||
"yellow": curses.COLOR_YELLOW,
|
"yellow": curses.COLOR_YELLOW,
|
||||||
"blue": curses.COLOR_BLUE,
|
"blue": curses.COLOR_BLUE,
|
||||||
"magenta": curses.COLOR_MAGENTA,
|
"magenta": curses.COLOR_MAGENTA,
|
||||||
"cyan": curses.COLOR_CYAN,
|
|
||||||
"white": curses.COLOR_WHITE,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
_COLOR_NONE = "(none)"
|
_COLOR_NONE = "(none)"
|
||||||
@@ -248,11 +243,15 @@ _COLOR_NONE = "(none)"
|
|||||||
def name_color_modal(
|
def name_color_modal(
|
||||||
default_label: str,
|
default_label: str,
|
||||||
*,
|
*,
|
||||||
|
disclaimer: str = "",
|
||||||
tty_path: str = "/dev/tty",
|
tty_path: str = "/dev/tty",
|
||||||
) -> tuple[str, str]:
|
) -> tuple[str, str]:
|
||||||
"""Present a two-step curses modal: first edit the agent label,
|
"""Present a two-step curses modal: first edit the agent label,
|
||||||
then optionally pick a color.
|
then optionally pick a color.
|
||||||
|
|
||||||
|
``disclaimer`` is shown below the input field — use it to surface
|
||||||
|
an error from a previous attempt (e.g. name already in use).
|
||||||
|
|
||||||
Returns ``(label, color)`` where ``color`` is one of the 16 ANSI
|
Returns ``(label, color)`` where ``color`` is one of the 16 ANSI
|
||||||
color name strings or ``""`` for no color. Falls back to
|
color name strings or ``""`` for no color. Falls back to
|
||||||
``(default_label, "")`` on any error (terminal too small, not a tty).
|
``(default_label, "")`` on any error (terminal too small, not a tty).
|
||||||
@@ -264,14 +263,14 @@ def name_color_modal(
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
fd_dup = os.dup(tty_fd.fileno())
|
fd_dup = os.dup(tty_fd.fileno())
|
||||||
return _run_name_color(default_label, tty_fd=fd_dup)
|
return _run_name_color(default_label, tty_fd=fd_dup, disclaimer=disclaimer)
|
||||||
except Exception: # noqa: BLE001 # pylint: disable=broad-exception-caught
|
except Exception: # noqa: BLE001 # pylint: disable=broad-exception-caught
|
||||||
return default_label, ""
|
return default_label, ""
|
||||||
finally:
|
finally:
|
||||||
tty_fd.close()
|
tty_fd.close()
|
||||||
|
|
||||||
|
|
||||||
def _run_name_color(default_label: str, *, tty_fd: int) -> tuple[str, str]:
|
def _run_name_color(default_label: str, *, tty_fd: int, disclaimer: str = "") -> tuple[str, str]:
|
||||||
import io
|
import io
|
||||||
orig_stdin = sys.__stdin__
|
orig_stdin = sys.__stdin__
|
||||||
orig_stdout = sys.__stdout__
|
orig_stdout = sys.__stdout__
|
||||||
@@ -286,7 +285,7 @@ def _run_name_color(default_label: str, *, tty_fd: int) -> tuple[str, str]:
|
|||||||
curses.cbreak()
|
curses.cbreak()
|
||||||
screen.keypad(True)
|
screen.keypad(True)
|
||||||
try:
|
try:
|
||||||
label = _label_step(screen, default_label)
|
label = _label_step(screen, default_label, disclaimer=disclaimer)
|
||||||
color = _color_step(screen, label)
|
color = _color_step(screen, label)
|
||||||
finally:
|
finally:
|
||||||
screen.keypad(False)
|
screen.keypad(False)
|
||||||
@@ -299,14 +298,14 @@ def _run_name_color(default_label: str, *, tty_fd: int) -> tuple[str, str]:
|
|||||||
return label, color
|
return label, color
|
||||||
|
|
||||||
|
|
||||||
def _label_step(screen: Any, default_label: str) -> str:
|
def _label_step(screen: Any, default_label: str, *, disclaimer: str = "") -> str:
|
||||||
"""Step 1: edit the label. First printable key replaces the
|
"""Step 1: edit the label. First printable key replaces the
|
||||||
pre-fill; subsequent keys append. Enter confirms."""
|
pre-fill; subsequent keys append. Enter confirms."""
|
||||||
text = default_label
|
text = default_label
|
||||||
replaced = False # True once the user has typed their first char
|
replaced = False # True once the user has typed their first char
|
||||||
|
|
||||||
while True:
|
while True:
|
||||||
_render_label(screen, text)
|
_render_label(screen, text, disclaimer=disclaimer)
|
||||||
try:
|
try:
|
||||||
key = screen.getch()
|
key = screen.getch()
|
||||||
except KeyboardInterrupt:
|
except KeyboardInterrupt:
|
||||||
@@ -330,7 +329,7 @@ def _label_step(screen: Any, default_label: str) -> str:
|
|||||||
text += chr(key)
|
text += chr(key)
|
||||||
|
|
||||||
|
|
||||||
def _render_label(screen: Any, text: str) -> None:
|
def _render_label(screen: Any, text: str, *, disclaimer: str = "") -> None:
|
||||||
screen.erase()
|
screen.erase()
|
||||||
rows, cols = screen.getmaxyx()
|
rows, cols = screen.getmaxyx()
|
||||||
sep = "─" * min(cols - 1, 40)
|
sep = "─" * min(cols - 1, 40)
|
||||||
@@ -338,8 +337,12 @@ def _render_label(screen: Any, text: str) -> None:
|
|||||||
_addstr_safe(screen, 1, 0, sep)
|
_addstr_safe(screen, 1, 0, sep)
|
||||||
_addstr_safe(screen, 2, 0, text[:cols - 1], curses.A_REVERSE)
|
_addstr_safe(screen, 2, 0, text[:cols - 1], curses.A_REVERSE)
|
||||||
_addstr_safe(screen, 3, 0, sep)
|
_addstr_safe(screen, 3, 0, sep)
|
||||||
if rows > 5:
|
row = 4
|
||||||
_addstr_safe(screen, 5, 0, "[any key] edit [Enter] confirm", curses.A_DIM)
|
if disclaimer and rows > row + 1:
|
||||||
|
_addstr_safe(screen, row, 0, disclaimer[:cols - 1], curses.A_BOLD)
|
||||||
|
row += 1
|
||||||
|
if rows > row + 1:
|
||||||
|
_addstr_safe(screen, row, 0, "[any key] edit [Enter] confirm", curses.A_DIM)
|
||||||
screen.refresh()
|
screen.refresh()
|
||||||
|
|
||||||
|
|
||||||
@@ -379,13 +382,10 @@ def _init_color_pairs() -> dict[str, int]:
|
|||||||
curses.use_default_colors()
|
curses.use_default_colors()
|
||||||
pair_idx = 2 # pair 1 reserved for other uses
|
pair_idx = 2 # pair 1 reserved for other uses
|
||||||
for name in _ANSI_COLORS:
|
for name in _ANSI_COLORS:
|
||||||
base = name.replace("bright-", "")
|
fg = _CURSES_COLOR_MAP.get(name, curses.COLOR_WHITE)
|
||||||
fg = _CURSES_COLOR_MAP.get(base, curses.COLOR_WHITE)
|
|
||||||
try:
|
try:
|
||||||
curses.init_pair(pair_idx, fg, -1)
|
curses.init_pair(pair_idx, fg, -1)
|
||||||
attr = curses.color_pair(pair_idx)
|
attr = curses.color_pair(pair_idx) | curses.A_BOLD
|
||||||
if name.startswith("bright-"):
|
|
||||||
attr |= curses.A_BOLD
|
|
||||||
attrs[name] = attr
|
attrs[name] = attr
|
||||||
pair_idx += 1
|
pair_idx += 1
|
||||||
except curses.error:
|
except curses.error:
|
||||||
|
|||||||
@@ -42,41 +42,19 @@ def _prompt_path(guest_home: str) -> str:
|
|||||||
|
|
||||||
|
|
||||||
_STATUS_LINE_COLORS = {
|
_STATUS_LINE_COLORS = {
|
||||||
"black": "\033[30m",
|
"red": "\033[91m",
|
||||||
"red": "\033[31m",
|
"green": "\033[92m",
|
||||||
"green": "\033[32m",
|
"yellow": "\033[93m",
|
||||||
"yellow": "\033[33m",
|
"blue": "\033[94m",
|
||||||
"blue": "\033[34m",
|
"magenta": "\033[95m",
|
||||||
"magenta": "\033[35m",
|
|
||||||
"cyan": "\033[36m",
|
|
||||||
"white": "\033[37m",
|
|
||||||
"bright-black": "\033[90m",
|
|
||||||
"bright-red": "\033[91m",
|
|
||||||
"bright-green": "\033[92m",
|
|
||||||
"bright-yellow": "\033[93m",
|
|
||||||
"bright-blue": "\033[94m",
|
|
||||||
"bright-magenta": "\033[95m",
|
|
||||||
"bright-cyan": "\033[96m",
|
|
||||||
"bright-white": "\033[97m",
|
|
||||||
}
|
}
|
||||||
|
|
||||||
_CLAUDE_THEME_COLORS = {
|
_CLAUDE_THEME_COLORS = {
|
||||||
"black": "black",
|
"red": "redBright",
|
||||||
"red": "red",
|
"green": "greenBright",
|
||||||
"green": "green",
|
"yellow": "yellowBright",
|
||||||
"yellow": "yellow",
|
"blue": "blueBright",
|
||||||
"blue": "blue",
|
"magenta": "magentaBright",
|
||||||
"magenta": "magenta",
|
|
||||||
"cyan": "cyan",
|
|
||||||
"white": "white",
|
|
||||||
"bright-black": "blackBright",
|
|
||||||
"bright-red": "redBright",
|
|
||||||
"bright-green": "greenBright",
|
|
||||||
"bright-yellow": "yellowBright",
|
|
||||||
"bright-blue": "blueBright",
|
|
||||||
"bright-magenta": "magentaBright",
|
|
||||||
"bright-cyan": "cyanBright",
|
|
||||||
"bright-white": "whiteBright",
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -5,16 +5,20 @@ from __future__ import annotations
|
|||||||
from typing import TYPE_CHECKING
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from .manifest import ManifestBottle, ManifestGitEntry
|
from .manifest import ManifestBottle
|
||||||
from .manifest_egress import ManifestEgressConfig
|
from .manifest_egress import ManifestEgressConfig
|
||||||
|
|
||||||
|
|
||||||
def resolve_bottles(raws: dict[str, dict[str, object]]) -> dict[str, ManifestBottle]:
|
def resolve_bottles(raws: dict[str, dict[str, object]]) -> dict[str, ManifestBottle]:
|
||||||
"""Apply `extends:` chains and return resolved ManifestBottle objects."""
|
"""Apply `extends:` chains and return resolved ManifestBottle objects."""
|
||||||
cache: dict[str, ManifestBottle] = {}
|
cache: dict[str, ManifestBottle] = {}
|
||||||
|
# Per-bottle effective git-gate.repos, as raw dicts keyed by repo name.
|
||||||
|
# Threaded alongside `cache` so a child can field-merge against its
|
||||||
|
# parent's repos without reconstructing them from parsed entries.
|
||||||
|
repos_cache: dict[str, dict[str, object]] = {}
|
||||||
for name in raws:
|
for name in raws:
|
||||||
if name not in cache:
|
if name not in cache:
|
||||||
_resolve_one_bottle(name, raws, cache, ())
|
_resolve_one_bottle(name, raws, cache, repos_cache, ())
|
||||||
return cache
|
return cache
|
||||||
|
|
||||||
|
|
||||||
@@ -22,6 +26,7 @@ def _resolve_one_bottle(
|
|||||||
name: str,
|
name: str,
|
||||||
raws: dict[str, dict[str, object]],
|
raws: dict[str, dict[str, object]],
|
||||||
cache: dict[str, ManifestBottle],
|
cache: dict[str, ManifestBottle],
|
||||||
|
repos_cache: dict[str, dict[str, object]],
|
||||||
seen: tuple[str, ...],
|
seen: tuple[str, ...],
|
||||||
) -> ManifestBottle:
|
) -> ManifestBottle:
|
||||||
from .manifest import ManifestBottle, ManifestError
|
from .manifest import ManifestBottle, ManifestError
|
||||||
@@ -41,6 +46,7 @@ def _resolve_one_bottle(
|
|||||||
if parent_name_raw is None:
|
if parent_name_raw is None:
|
||||||
bottle = ManifestBottle.from_dict(name, child_raw)
|
bottle = ManifestBottle.from_dict(name, child_raw)
|
||||||
cache[name] = bottle
|
cache[name] = bottle
|
||||||
|
repos_cache[name] = _resolve_repos_raw({}, child_raw)
|
||||||
return bottle
|
return bottle
|
||||||
|
|
||||||
if not isinstance(parent_name_raw, str):
|
if not isinstance(parent_name_raw, str):
|
||||||
@@ -60,20 +66,33 @@ def _resolve_one_bottle(
|
|||||||
f"bottle '{name}' extends '{parent_name}' which is not "
|
f"bottle '{name}' extends '{parent_name}' which is not "
|
||||||
f"defined. Available bottles: {avail}"
|
f"defined. Available bottles: {avail}"
|
||||||
)
|
)
|
||||||
parent = _resolve_one_bottle(parent_name, raws, cache, seen + (name,))
|
parent = _resolve_one_bottle(
|
||||||
bottle = _merge_bottles(parent, child_raw, name)
|
parent_name, raws, cache, repos_cache, seen + (name,)
|
||||||
|
)
|
||||||
|
merged_repos_raw = _resolve_repos_raw(repos_cache[parent_name], child_raw)
|
||||||
|
bottle = _merge_bottles(parent, child_raw, merged_repos_raw, name)
|
||||||
cache[name] = bottle
|
cache[name] = bottle
|
||||||
|
repos_cache[name] = merged_repos_raw
|
||||||
return bottle
|
return bottle
|
||||||
|
|
||||||
|
|
||||||
def _merge_bottles(
|
def _merge_bottles(
|
||||||
parent: ManifestBottle,
|
parent: ManifestBottle,
|
||||||
child_raw: dict[str, object],
|
child_raw: dict[str, object],
|
||||||
|
merged_repos_raw: dict[str, object],
|
||||||
name: str,
|
name: str,
|
||||||
) -> ManifestBottle:
|
) -> ManifestBottle:
|
||||||
"""Apply PRD 0025 merge rules."""
|
"""Apply PRD 0025 merge rules."""
|
||||||
from .manifest import ManifestBottle, ManifestGitUser
|
from .manifest import ManifestBottle, ManifestGitUser
|
||||||
from .manifest_egress import validate_egress_routes
|
from .manifest_egress import validate_egress_routes
|
||||||
|
from .manifest_util import as_json_object
|
||||||
|
|
||||||
|
# git-gate.repos: when the child declares repos, inject the already
|
||||||
|
# name-merged repo set (computed by _resolve_repos_raw) so the child
|
||||||
|
# parses with the full inherited+overridden list (issue #237).
|
||||||
|
if _child_declares_git_gate_repos(child_raw):
|
||||||
|
git_raw = as_json_object(child_raw.get("git-gate", {}), "child git-gate")
|
||||||
|
child_raw = {**child_raw, "git-gate": {**git_raw, "repos": merged_repos_raw}}
|
||||||
|
|
||||||
# Parse the child's declared fields into a ManifestBottle (with the
|
# Parse the child's declared fields into a ManifestBottle (with the
|
||||||
# usual defaults for anything missing). Validation runs the same
|
# usual defaults for anything missing). Validation runs the same
|
||||||
@@ -92,11 +111,11 @@ def _merge_bottles(
|
|||||||
email=child.git_user.email or parent.git_user.email,
|
email=child.git_user.email or parent.git_user.email,
|
||||||
)
|
)
|
||||||
|
|
||||||
# git-gate.repos: missing means inherit; an explicit empty object
|
# git-gate.repos: when declared, child.git already holds the merged
|
||||||
# clears; otherwise parent and child merge by UpstreamHost with
|
# set (an explicit empty dict clears parent, leaving child.git empty).
|
||||||
# child entries replacing duplicate hosts.
|
# When omitted, the parent's entries are inherited verbatim.
|
||||||
if _child_declares_git_gate_repos(child_raw):
|
if _child_declares_git_gate_repos(child_raw):
|
||||||
merged_git = _merge_git_remotes(parent.git, child.git) if child.git else ()
|
merged_git = child.git
|
||||||
else:
|
else:
|
||||||
merged_git = parent.git
|
merged_git = parent.git
|
||||||
|
|
||||||
@@ -130,6 +149,45 @@ def _merge_bottles(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_repos_raw(
|
||||||
|
parent_repos: dict[str, object],
|
||||||
|
child_raw: dict[str, object],
|
||||||
|
) -> dict[str, object]:
|
||||||
|
"""Compute a bottle's effective git-gate.repos as raw dicts.
|
||||||
|
|
||||||
|
Repos are keyed by name. When the child omits git-gate.repos it
|
||||||
|
inherits the parent's set verbatim; an explicit empty dict clears it.
|
||||||
|
Otherwise parent and child unite by name, with same-name entries
|
||||||
|
field-merged (parent fields are defaults, child fields win)."""
|
||||||
|
from .manifest_util import as_json_object
|
||||||
|
|
||||||
|
if not _child_declares_git_gate_repos(child_raw):
|
||||||
|
return parent_repos
|
||||||
|
child_repos = _declared_repos_raw(child_raw)
|
||||||
|
if not child_repos:
|
||||||
|
return {}
|
||||||
|
# Parent entries keep their order; child-only names are appended.
|
||||||
|
names = list(parent_repos) + [n for n in child_repos if n not in parent_repos]
|
||||||
|
return {
|
||||||
|
name: {
|
||||||
|
**as_json_object(parent_repos.get(name, {}), "parent git-gate repo"),
|
||||||
|
**as_json_object(child_repos.get(name, {}), "child git-gate repo"),
|
||||||
|
}
|
||||||
|
for name in names
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _declared_repos_raw(child_raw: dict[str, object]) -> dict[str, object]:
|
||||||
|
"""Return the child's explicitly declared git-gate.repos as raw dicts,
|
||||||
|
or an empty dict when none are declared."""
|
||||||
|
from .manifest_util import as_json_object
|
||||||
|
|
||||||
|
if not _child_declares_git_gate_repos(child_raw):
|
||||||
|
return {}
|
||||||
|
git_raw = as_json_object(child_raw.get("git-gate", {}), "child git-gate")
|
||||||
|
return as_json_object(git_raw.get("repos", {}), "child git-gate.repos")
|
||||||
|
|
||||||
|
|
||||||
def _child_declares_git_gate_repos(child_raw: dict[str, object]) -> bool:
|
def _child_declares_git_gate_repos(child_raw: dict[str, object]) -> bool:
|
||||||
from .manifest_util import as_json_object
|
from .manifest_util import as_json_object
|
||||||
|
|
||||||
@@ -140,16 +198,6 @@ def _child_declares_git_gate_repos(child_raw: dict[str, object]) -> bool:
|
|||||||
return "repos" in git_obj
|
return "repos" in git_obj
|
||||||
|
|
||||||
|
|
||||||
def _merge_git_remotes(
|
|
||||||
parent: tuple[ManifestGitEntry, ...],
|
|
||||||
child: tuple[ManifestGitEntry, ...],
|
|
||||||
) -> tuple[ManifestGitEntry, ...]:
|
|
||||||
by_host = {entry.UpstreamHost: entry for entry in parent}
|
|
||||||
for entry in child:
|
|
||||||
by_host[entry.UpstreamHost] = entry
|
|
||||||
return tuple(by_host.values())
|
|
||||||
|
|
||||||
|
|
||||||
def _merge_egress(
|
def _merge_egress(
|
||||||
parent: ManifestEgressConfig,
|
parent: ManifestEgressConfig,
|
||||||
child: ManifestEgressConfig,
|
child: ManifestEgressConfig,
|
||||||
|
|||||||
@@ -0,0 +1,75 @@
|
|||||||
|
# PRD prd-new: Install script
|
||||||
|
|
||||||
|
- **Status:** Active
|
||||||
|
- **Author:** didericis
|
||||||
|
- **Created:** 2026-06-06
|
||||||
|
- **Issue:** #197
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
Add a proper Python package distribution and a thin `install.sh` bootstrapper so users can install bot-bottle with a single command without cloning the repo.
|
||||||
|
|
||||||
|
## 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` directly. This blocks any HN-style public demo: readers want `curl | sh` or `pipx install`, not a manual clone-and-configure flow.
|
||||||
|
|
||||||
|
## Goals / Success Criteria
|
||||||
|
|
||||||
|
- `curl -fsSL <url>/install.sh | sh` (or equivalent) leaves a working `bot-bottle` command on PATH.
|
||||||
|
- Python-native users can install with `pipx install bot-bottle` or `uv tool install bot-bottle`.
|
||||||
|
- `install.sh` validates prerequisites (Python ≥ 3.11, Docker) and exits with a clear message if they are missing. It does not silently install Docker.
|
||||||
|
- `install.sh` runs `bot-bottle doctor` (or equivalent diagnostic) after install to confirm the environment is ready.
|
||||||
|
- The package has no runtime pip dependencies (stdlib-only, matching the existing constraint).
|
||||||
|
|
||||||
|
## Non-goals
|
||||||
|
|
||||||
|
- Bundling a Python runtime or producing a standalone binary.
|
||||||
|
- Automatic Docker installation.
|
||||||
|
- Plugin architecture changes (out of scope; see issue #197 for future direction).
|
||||||
|
- Publishing to PyPI in this PR — the package structure is the deliverable; publishing is a separate step.
|
||||||
|
|
||||||
|
## Design
|
||||||
|
|
||||||
|
### Package structure
|
||||||
|
|
||||||
|
Add a minimal `pyproject.toml` at the repo root:
|
||||||
|
|
||||||
|
```toml
|
||||||
|
[project]
|
||||||
|
name = "bot-bottle"
|
||||||
|
version = "0.1.0"
|
||||||
|
requires-python = ">=3.11"
|
||||||
|
dependencies = []
|
||||||
|
|
||||||
|
[project.scripts]
|
||||||
|
bot-bottle = "bot_bottle.cli:main"
|
||||||
|
```
|
||||||
|
|
||||||
|
The existing `bot_bottle/` package and `cli.py` entry point already contain the logic; this just wires up the standard entry point. `cli.py` may need a small refactor to expose a `main()` callable if it uses `if __name__ == "__main__"` only.
|
||||||
|
|
||||||
|
### `install.sh`
|
||||||
|
|
||||||
|
A thin bootstrapper that:
|
||||||
|
|
||||||
|
1. Checks `python3 --version` ≥ 3.11; exits with instructions if not met.
|
||||||
|
2. Checks `docker info` exits 0; exits with instructions if Docker is not running.
|
||||||
|
3. Installs via `pipx` if available, otherwise falls back to `pip install --user`.
|
||||||
|
4. Runs `bot-bottle doctor` to verify the install.
|
||||||
|
|
||||||
|
The script must be idempotent (safe to re-run) and must not require `sudo`.
|
||||||
|
|
||||||
|
### `bot-bottle doctor`
|
||||||
|
|
||||||
|
A new subcommand that checks and reports:
|
||||||
|
|
||||||
|
- Python version.
|
||||||
|
- Docker daemon reachability.
|
||||||
|
- Whether `~/.bot-bottle/` config directory exists.
|
||||||
|
|
||||||
|
Exits 0 if all checks pass, non-zero otherwise.
|
||||||
|
|
||||||
|
## Decisions
|
||||||
|
|
||||||
|
- `install.sh` is hosted from the repo's raw Gitea URL for now:
|
||||||
|
`https://gitea.dideric.is/didericis/bot-bottle/raw/branch/main/install.sh`.
|
||||||
|
- Should `version` in `pyproject.toml` be driven by a git tag at build time (e.g. via `hatch-vcs`) or kept as a static string? Static is simpler for now.
|
||||||
Executable
+50
@@ -0,0 +1,50 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
set -eu
|
||||||
|
|
||||||
|
PACKAGE_SPEC="${BOT_BOTTLE_INSTALL_SPEC:-git+https://gitea.dideric.is/didericis/bot-bottle.git}"
|
||||||
|
MIN_PYTHON="3.11"
|
||||||
|
|
||||||
|
say() {
|
||||||
|
printf 'bot-bottle install: %s\n' "$*" >&2
|
||||||
|
}
|
||||||
|
|
||||||
|
die() {
|
||||||
|
say "error: $*"
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
command -v python3 >/dev/null 2>&1 || die "python3 is required (version ${MIN_PYTHON} or newer)"
|
||||||
|
|
||||||
|
python3 - <<'PY' || die "python3 3.11 or newer is required"
|
||||||
|
import sys
|
||||||
|
|
||||||
|
raise SystemExit(0 if sys.version_info >= (3, 11) else 1)
|
||||||
|
PY
|
||||||
|
|
||||||
|
command -v docker >/dev/null 2>&1 || die "Docker is required; install Docker and start the daemon, then re-run this script"
|
||||||
|
docker info >/dev/null 2>&1 || die "Docker is installed but the daemon is not reachable; start Docker and re-run this script"
|
||||||
|
|
||||||
|
mkdir -p \
|
||||||
|
"${HOME}/.bot-bottle/agents" \
|
||||||
|
"${HOME}/.bot-bottle/bottles" \
|
||||||
|
"${HOME}/.bot-bottle/contrib"
|
||||||
|
|
||||||
|
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 --user"
|
||||||
|
python3 -m pip install --user --upgrade "${PACKAGE_SPEC}"
|
||||||
|
fi
|
||||||
|
|
||||||
|
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 "using ${BOT_BOTTLE_BIN}; add ${HOME}/.local/bin to PATH for future shells"
|
||||||
|
else
|
||||||
|
die "bot-bottle was installed but is not on PATH"
|
||||||
|
fi
|
||||||
|
|
||||||
|
say "running bot-bottle doctor"
|
||||||
|
"${BOT_BOTTLE_BIN}" doctor
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
[build-system]
|
||||||
|
requires = ["setuptools>=68"]
|
||||||
|
build-backend = "setuptools.build_meta"
|
||||||
|
|
||||||
|
[project]
|
||||||
|
name = "bot-bottle"
|
||||||
|
version = "0.1.0"
|
||||||
|
description = "Self-hosted sandbox for AI coding agents with egress controls"
|
||||||
|
readme = "README.md"
|
||||||
|
requires-python = ">=3.11"
|
||||||
|
license = { text = "Apache-2.0" }
|
||||||
|
dependencies = []
|
||||||
|
|
||||||
|
[project.scripts]
|
||||||
|
bot-bottle = "bot_bottle.cli:main"
|
||||||
|
|
||||||
|
[tool.setuptools.packages.find]
|
||||||
|
include = ["bot_bottle*"]
|
||||||
|
|
||||||
|
[tool.setuptools.package-data]
|
||||||
|
bot_bottle = [
|
||||||
|
"Dockerfile.sidecars",
|
||||||
|
"egress_entrypoint.sh",
|
||||||
|
"contrib/claude/Dockerfile",
|
||||||
|
"contrib/codex/Dockerfile",
|
||||||
|
"contrib/pi/Dockerfile",
|
||||||
|
]
|
||||||
@@ -92,10 +92,9 @@ class TestSandboxEscape(unittest.TestCase):
|
|||||||
"on PATH: curl -sSL https://smolmachines.com/install.sh | sh"
|
"on PATH: curl -sSL https://smolmachines.com/install.sh | sh"
|
||||||
)
|
)
|
||||||
|
|
||||||
# Throwaway "identity file" so the manifest's _validate_git_entries
|
# Throwaway "identity file" for the git-gate's `identity` field.
|
||||||
# passes (it only checks `os.path.isfile`, not that the content is
|
# It need not be a real SSH key: test 5 reaches gitleaks before
|
||||||
# a real SSH key). Test 5 reaches gitleaks before any SSH attempt
|
# any SSH attempt anyway.
|
||||||
# anyway.
|
|
||||||
fd, kp = tempfile.mkstemp(prefix="sandbox-test-key.")
|
fd, kp = tempfile.mkstemp(prefix="sandbox-test-key.")
|
||||||
os.close(fd)
|
os.close(fd)
|
||||||
cls._key_path = Path(kp)
|
cls._key_path = Path(kp)
|
||||||
|
|||||||
@@ -74,7 +74,7 @@ class TestAgentProviderRuntime(unittest.TestCase):
|
|||||||
instance_name="bot-bottle-test",
|
instance_name="bot-bottle-test",
|
||||||
prompt_file=prompt_file,
|
prompt_file=prompt_file,
|
||||||
label="review-api",
|
label="review-api",
|
||||||
color="bright-cyan",
|
color="cyan",
|
||||||
)
|
)
|
||||||
prompt = prompt_file.read_text()
|
prompt = prompt_file.read_text()
|
||||||
config = Path(tmp, "codex-config.toml").read_text()
|
config = Path(tmp, "codex-config.toml").read_text()
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ from bot_bottle import bottle_state
|
|||||||
from bot_bottle import supervise
|
from bot_bottle import supervise
|
||||||
from bot_bottle.backend import BottleSpec
|
from bot_bottle.backend import BottleSpec
|
||||||
from bot_bottle.backend.docker import DockerBottleBackend
|
from bot_bottle.backend.docker import DockerBottleBackend
|
||||||
|
from bot_bottle.backend.resolve_common import mint_slug
|
||||||
from bot_bottle.backend.smolmachines import SmolmachinesBottleBackend
|
from bot_bottle.backend.smolmachines import SmolmachinesBottleBackend
|
||||||
from bot_bottle.manifest import Manifest
|
from bot_bottle.manifest import Manifest
|
||||||
|
|
||||||
@@ -115,5 +116,36 @@ class TestSmolmachinesPrepare(_FakeStateMixin, unittest.TestCase):
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TestMintSlug(unittest.TestCase):
|
||||||
|
def _spec(self, *, label: str = "", identity: str = "") -> BottleSpec:
|
||||||
|
manifest = _manifest()
|
||||||
|
return BottleSpec(
|
||||||
|
manifest=manifest,
|
||||||
|
agent_name="demo",
|
||||||
|
copy_cwd=False,
|
||||||
|
user_cwd="/tmp",
|
||||||
|
label=label,
|
||||||
|
identity=identity,
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_no_label_uses_agent_name_with_random_suffix(self) -> None:
|
||||||
|
slug = mint_slug(self._spec(label=""))
|
||||||
|
self.assertTrue(slug.startswith("demo-"), slug)
|
||||||
|
# random suffix present — slug is longer than just "demo"
|
||||||
|
self.assertGreater(len(slug), len("demo-"))
|
||||||
|
|
||||||
|
def test_label_becomes_exact_slug(self) -> None:
|
||||||
|
slug = mint_slug(self._spec(label="my-run"))
|
||||||
|
self.assertEqual("my-run", slug)
|
||||||
|
|
||||||
|
def test_label_with_spaces_slugified_no_suffix(self) -> None:
|
||||||
|
slug = mint_slug(self._spec(label="My Feature Run"))
|
||||||
|
self.assertEqual("my-feature-run", slug)
|
||||||
|
|
||||||
|
def test_identity_takes_precedence_over_label(self) -> None:
|
||||||
|
slug = mint_slug(self._spec(label="my-run", identity="fixed-id"))
|
||||||
|
self.assertEqual("fixed-id", slug)
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
unittest.main()
|
unittest.main()
|
||||||
|
|||||||
@@ -11,14 +11,14 @@ class TestPalettePrintf(unittest.TestCase):
|
|||||||
def test_known_color_returns_printf(self):
|
def test_known_color_returns_printf(self):
|
||||||
cmd = palette_printf("red")
|
cmd = palette_printf("red")
|
||||||
self.assertTrue(cmd.startswith("printf '"))
|
self.assertTrue(cmd.startswith("printf '"))
|
||||||
self.assertIn("\\033]4;1;", cmd) # normal red
|
self.assertIn("\\033]4;9;", cmd) # bright-red slot
|
||||||
self.assertIn("\\033]4;9;", cmd) # bright red
|
self.assertIn("\\033]4;1;", cmd) # normal-red slot
|
||||||
self.assertIn("\\033]11;", cmd) # default background tint
|
self.assertIn("\\033]11;", cmd) # default background tint
|
||||||
|
|
||||||
def test_bright_variant_sets_both_slots(self):
|
def test_color_sets_both_palette_slots(self):
|
||||||
cmd = palette_printf("bright-blue")
|
cmd = palette_printf("blue")
|
||||||
self.assertIn("\\033]4;12;", cmd) # bright-blue
|
self.assertIn("\\033]4;12;", cmd) # bright-blue slot
|
||||||
self.assertIn("\\033]4;4;", cmd) # blue
|
self.assertIn("\\033]4;4;", cmd) # normal-blue slot
|
||||||
|
|
||||||
def test_unknown_color_returns_empty(self):
|
def test_unknown_color_returns_empty(self):
|
||||||
self.assertEqual("", palette_printf(""))
|
self.assertEqual("", palette_printf(""))
|
||||||
@@ -26,10 +26,7 @@ class TestPalettePrintf(unittest.TestCase):
|
|||||||
|
|
||||||
def test_all_named_colors_produce_output(self):
|
def test_all_named_colors_produce_output(self):
|
||||||
colors = [
|
colors = [
|
||||||
"black", "red", "green", "yellow",
|
"red", "green", "yellow", "blue", "magenta",
|
||||||
"blue", "magenta", "cyan", "white",
|
|
||||||
"bright-black", "bright-red", "bright-green", "bright-yellow",
|
|
||||||
"bright-blue", "bright-magenta", "bright-cyan", "bright-white",
|
|
||||||
]
|
]
|
||||||
for color in colors:
|
for color in colors:
|
||||||
with self.subTest(color=color):
|
with self.subTest(color=color):
|
||||||
@@ -65,7 +62,7 @@ class TestExecShellScript(unittest.TestCase):
|
|||||||
self.assertFalse(agent_part.startswith("exec "))
|
self.assertFalse(agent_part.startswith("exec "))
|
||||||
|
|
||||||
def test_title_and_color_both_appear(self):
|
def test_title_and_color_both_appear(self):
|
||||||
script = exec_shell_script(self._ARGV, terminal_title="bot", terminal_color="cyan")
|
script = exec_shell_script(self._ARGV, terminal_title="bot", terminal_color="magenta")
|
||||||
assert script is not None
|
assert script is not None
|
||||||
self.assertIn("bot", script)
|
self.assertIn("bot", script)
|
||||||
self.assertIn("\\033]4;", script)
|
self.assertIn("\\033]4;", script)
|
||||||
|
|||||||
@@ -0,0 +1,51 @@
|
|||||||
|
"""Unit: `bot-bottle doctor` host prerequisite checks."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import tempfile
|
||||||
|
import unittest
|
||||||
|
from pathlib import Path
|
||||||
|
from unittest.mock import MagicMock, patch
|
||||||
|
|
||||||
|
from bot_bottle.cli import doctor
|
||||||
|
|
||||||
|
|
||||||
|
class TestDoctor(unittest.TestCase):
|
||||||
|
def test_success_when_prerequisites_present(self):
|
||||||
|
with tempfile.TemporaryDirectory() as tmp, patch.object(
|
||||||
|
doctor.Path, "home", return_value=Path(tmp),
|
||||||
|
), patch.object(
|
||||||
|
doctor.shutil, "which", return_value="/usr/bin/docker",
|
||||||
|
), patch.object(
|
||||||
|
doctor.subprocess, "run",
|
||||||
|
return_value=MagicMock(returncode=0),
|
||||||
|
):
|
||||||
|
Path(tmp, ".bot-bottle").mkdir()
|
||||||
|
self.assertEqual(0, doctor.cmd_doctor([]))
|
||||||
|
|
||||||
|
def test_missing_config_fails(self):
|
||||||
|
with tempfile.TemporaryDirectory() as tmp, patch.object(
|
||||||
|
doctor.Path, "home", return_value=Path(tmp),
|
||||||
|
), patch.object(
|
||||||
|
doctor.shutil, "which", return_value="/usr/bin/docker",
|
||||||
|
), patch.object(
|
||||||
|
doctor.subprocess, "run",
|
||||||
|
return_value=MagicMock(returncode=0),
|
||||||
|
):
|
||||||
|
self.assertEqual(1, doctor.cmd_doctor([]))
|
||||||
|
|
||||||
|
def test_missing_docker_fails_before_daemon_check(self):
|
||||||
|
with tempfile.TemporaryDirectory() as tmp, patch.object(
|
||||||
|
doctor.Path, "home", return_value=Path(tmp),
|
||||||
|
), patch.object(
|
||||||
|
doctor.shutil, "which", return_value=None,
|
||||||
|
), patch.object(
|
||||||
|
doctor.subprocess, "run",
|
||||||
|
) as run:
|
||||||
|
Path(tmp, ".bot-bottle").mkdir()
|
||||||
|
self.assertEqual(1, doctor.cmd_doctor([]))
|
||||||
|
run.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -14,6 +14,7 @@ from unittest.mock import MagicMock, patch
|
|||||||
|
|
||||||
import bot_bottle.cli.start as start_mod
|
import bot_bottle.cli.start as start_mod
|
||||||
import bot_bottle.cli.tui as tui_mod
|
import bot_bottle.cli.tui as tui_mod
|
||||||
|
from bot_bottle.backend import ActiveAgent
|
||||||
|
|
||||||
|
|
||||||
def _make_manifest(agent_names: list[str]):
|
def _make_manifest(agent_names: list[str]):
|
||||||
@@ -133,5 +134,63 @@ class TestCmdStartSelector(unittest.TestCase):
|
|||||||
self._launch_mock.assert_not_called()
|
self._launch_mock.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
|
def _active_agent(slug: str) -> ActiveAgent:
|
||||||
|
return ActiveAgent(
|
||||||
|
backend_name="docker",
|
||||||
|
slug=slug,
|
||||||
|
agent_name="demo",
|
||||||
|
started_at="2026-01-01T00:00:00+00:00",
|
||||||
|
services=(),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TestCmdStartLabelCollision(unittest.TestCase):
|
||||||
|
"""cmd_start re-prompts when the label's slug is already running."""
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
self._manifest = _make_manifest(["researcher"])
|
||||||
|
patch("bot_bottle.cli.start.Manifest.resolve", return_value=self._manifest).start()
|
||||||
|
self._launch_mock = patch(
|
||||||
|
"bot_bottle.cli.start._launch_bottle", return_value=0,
|
||||||
|
).start()
|
||||||
|
self.addCleanup(patch.stopall)
|
||||||
|
|
||||||
|
def test_no_collision_proceeds_without_reprompt(self):
|
||||||
|
with (
|
||||||
|
patch.object(tui_mod, "name_color_modal", return_value=("researcher", "")) as modal,
|
||||||
|
patch("bot_bottle.cli.start.enumerate_active_agents", return_value=[]),
|
||||||
|
):
|
||||||
|
rc = start_mod.cmd_start(["researcher"])
|
||||||
|
self.assertEqual(0, rc)
|
||||||
|
modal.assert_called_once()
|
||||||
|
self._launch_mock.assert_called_once()
|
||||||
|
|
||||||
|
def test_collision_reprompts_with_disclaimer(self):
|
||||||
|
collision_agent = _active_agent("researcher")
|
||||||
|
call_count = 0
|
||||||
|
|
||||||
|
def _modal(default_label: str, *, disclaimer: str = "", **_kw: object) -> tuple[str, str]:
|
||||||
|
nonlocal call_count
|
||||||
|
call_count += 1
|
||||||
|
if call_count == 1:
|
||||||
|
return "researcher", ""
|
||||||
|
return "researcher-2", ""
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch.object(tui_mod, "name_color_modal", side_effect=_modal) as modal,
|
||||||
|
patch(
|
||||||
|
"bot_bottle.cli.start.enumerate_active_agents",
|
||||||
|
side_effect=[[collision_agent], []],
|
||||||
|
),
|
||||||
|
):
|
||||||
|
rc = start_mod.cmd_start(["researcher"])
|
||||||
|
|
||||||
|
self.assertEqual(0, rc)
|
||||||
|
self.assertEqual(2, modal.call_count)
|
||||||
|
second_call_kwargs = modal.call_args_list[1][1]
|
||||||
|
self.assertIn("researcher", second_call_kwargs.get("disclaimer", ""))
|
||||||
|
self.assertIn("already in use", second_call_kwargs.get("disclaimer", ""))
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
unittest.main()
|
unittest.main()
|
||||||
|
|||||||
@@ -304,6 +304,19 @@ class TestSidecarBundleShape(unittest.TestCase):
|
|||||||
self.assertEqual("bot-bottle-sidecars:latest", sc["image"])
|
self.assertEqual("bot-bottle-sidecars:latest", sc["image"])
|
||||||
self.assertEqual("Dockerfile.sidecars", sc["build"]["dockerfile"])
|
self.assertEqual("Dockerfile.sidecars", sc["build"]["dockerfile"])
|
||||||
|
|
||||||
|
def test_bundle_uses_packaged_dockerfile_when_root_missing(self):
|
||||||
|
from bot_bottle.backend.docker import compose as compose_mod
|
||||||
|
|
||||||
|
original = compose_mod._REPO_DIR
|
||||||
|
try:
|
||||||
|
compose_mod._REPO_DIR = "/tmp/does-not-exist"
|
||||||
|
self.assertEqual(
|
||||||
|
"bot_bottle/Dockerfile.sidecars",
|
||||||
|
compose_mod._sidecar_bundle_dockerfile(),
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
compose_mod._REPO_DIR = original
|
||||||
|
|
||||||
def test_bundle_container_name_uses_sidecars_prefix(self):
|
def test_bundle_container_name_uses_sidecars_prefix(self):
|
||||||
sc = self._render()["services"]["sidecars"]
|
sc = self._render()["services"]["sidecars"]
|
||||||
self.assertEqual(f"bot-bottle-sidecars-{SLUG}", sc["container_name"])
|
self.assertEqual(f"bot-bottle-sidecars-{SLUG}", sc["container_name"])
|
||||||
|
|||||||
@@ -276,7 +276,7 @@ class TestClaudeUiProvision(unittest.TestCase):
|
|||||||
instance_name="bot-bottle-demo-abc12",
|
instance_name="bot-bottle-demo-abc12",
|
||||||
prompt_file=prompt_file,
|
prompt_file=prompt_file,
|
||||||
label="research-ui",
|
label="research-ui",
|
||||||
color="bright-cyan",
|
color="blue",
|
||||||
)
|
)
|
||||||
settings = json.loads((state_dir / "claude-settings.json").read_text())
|
settings = json.loads((state_dir / "claude-settings.json").read_text())
|
||||||
statusline = (state_dir / "claude-statusline.sh").read_text()
|
statusline = (state_dir / "claude-statusline.sh").read_text()
|
||||||
@@ -288,9 +288,9 @@ class TestClaudeUiProvision(unittest.TestCase):
|
|||||||
self.assertEqual("~/.claude/statusline.sh", settings["statusLine"]["command"])
|
self.assertEqual("~/.claude/statusline.sh", settings["statusLine"]["command"])
|
||||||
self.assertEqual("custom:bot-bottle-research-ui", settings["theme"])
|
self.assertEqual("custom:bot-bottle-research-ui", settings["theme"])
|
||||||
self.assertIn("research-ui", statusline)
|
self.assertIn("research-ui", statusline)
|
||||||
self.assertIn("\x1b[96m", statusline)
|
self.assertIn("\x1b[94m", statusline)
|
||||||
self.assertEqual("dark", theme["base"])
|
self.assertEqual("dark", theme["base"])
|
||||||
self.assertEqual("ansi:cyanBright", theme["overrides"]["claude"])
|
self.assertEqual("ansi:blueBright", theme["overrides"]["claude"])
|
||||||
|
|
||||||
def test_runs_verify_commands(self):
|
def test_runs_verify_commands(self):
|
||||||
provision = AgentProvisionPlan(
|
provision = AgentProvisionPlan(
|
||||||
|
|||||||
@@ -158,7 +158,7 @@ class TestCodexProvisionPrompt(unittest.TestCase):
|
|||||||
instance_name="bot-bottle-demo-abc12",
|
instance_name="bot-bottle-demo-abc12",
|
||||||
prompt_file=prompt_file,
|
prompt_file=prompt_file,
|
||||||
label="research-ui",
|
label="research-ui",
|
||||||
color="bright-cyan",
|
color="cyan",
|
||||||
)
|
)
|
||||||
config = (state_dir / "codex-config.toml").read_text()
|
config = (state_dir / "codex-config.toml").read_text()
|
||||||
prompt_text = prompt_file.read_text()
|
prompt_text = prompt_file.read_text()
|
||||||
|
|||||||
@@ -0,0 +1,34 @@
|
|||||||
|
"""Unit: install.sh static contract checks."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import subprocess
|
||||||
|
import unittest
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
|
ROOT = Path(__file__).resolve().parents[2]
|
||||||
|
|
||||||
|
|
||||||
|
class TestInstallScript(unittest.TestCase):
|
||||||
|
def test_shell_syntax(self):
|
||||||
|
result = subprocess.run(
|
||||||
|
["sh", "-n", str(ROOT / "install.sh")],
|
||||||
|
check=False,
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
)
|
||||||
|
self.assertEqual("", result.stderr)
|
||||||
|
self.assertEqual(0, result.returncode)
|
||||||
|
|
||||||
|
def test_contract_phrases(self):
|
||||||
|
script = (ROOT / "install.sh").read_text(encoding="utf-8")
|
||||||
|
self.assertIn("python3", script)
|
||||||
|
self.assertIn("docker info", script)
|
||||||
|
self.assertIn("pipx install --force", script)
|
||||||
|
self.assertIn("pip install --user --upgrade", script)
|
||||||
|
self.assertIn('"${BOT_BOTTLE_BIN}" doctor', script)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -113,8 +113,8 @@ class TestExtendsEnvMerge(unittest.TestCase):
|
|||||||
|
|
||||||
|
|
||||||
class TestExtendsGitMerge(unittest.TestCase):
|
class TestExtendsGitMerge(unittest.TestCase):
|
||||||
"""git-gate.user overlays by field; git-gate.repos merges by upstream
|
"""git-gate.user overlays by field; git-gate.repos merges by name,
|
||||||
host, with child entries replacing duplicate hosts."""
|
with same-name child entries merging field-by-field (child wins)."""
|
||||||
|
|
||||||
_GIT_ENTRY_A = {"url": "ssh://git@host-a/a.git", "key": {"provider": "static", "path": "/dev/null"}}
|
_GIT_ENTRY_A = {"url": "ssh://git@host-a/a.git", "key": {"provider": "static", "path": "/dev/null"}}
|
||||||
_GIT_ENTRY_B = {"url": "ssh://git@host-b/b.git", "key": {"provider": "static", "path": "/dev/null"}}
|
_GIT_ENTRY_B = {"url": "ssh://git@host-b/b.git", "key": {"provider": "static", "path": "/dev/null"}}
|
||||||
@@ -130,19 +130,21 @@ class TestExtendsGitMerge(unittest.TestCase):
|
|||||||
names = [e.Name for e in m.bottles["child"].git]
|
names = [e.Name for e in m.bottles["child"].git]
|
||||||
self.assertEqual(["a", "b"], names)
|
self.assertEqual(["a", "b"], names)
|
||||||
|
|
||||||
def test_child_git_repo_replaces_same_host(self):
|
def test_child_git_repo_different_name_same_host_coexists(self):
|
||||||
replacement = {"url": "ssh://git@host-a/replacement.git", "key": {"provider": "static", "path": "/dev/null"}}
|
# Repos are keyed by Name, not UpstreamHost: two repos with
|
||||||
|
# different names on the same host both survive the merge.
|
||||||
|
same_host_b = {"url": "ssh://git@host-a/b.git", "key": {"provider": "static", "path": "/dev/null"}}
|
||||||
m = _build(
|
m = _build(
|
||||||
base={"git-gate": {"repos": {"a": self._GIT_ENTRY_A}}},
|
base={"git-gate": {"repos": {"a": self._GIT_ENTRY_A}}},
|
||||||
child={
|
child={
|
||||||
"extends": "base",
|
"extends": "base",
|
||||||
"git-gate": {"repos": {"a2": replacement}},
|
"git-gate": {"repos": {"a2": same_host_b}},
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
entries = m.bottles["child"].git
|
entries = m.bottles["child"].git
|
||||||
self.assertEqual(1, len(entries))
|
self.assertEqual(2, len(entries))
|
||||||
self.assertEqual("a2", entries[0].Name)
|
names = {e.Name for e in entries}
|
||||||
self.assertEqual("replacement.git", entries[0].UpstreamPath)
|
self.assertEqual({"a", "a2"}, names)
|
||||||
|
|
||||||
def test_child_omits_git_gate_inherits_full_list(self):
|
def test_child_omits_git_gate_inherits_full_list(self):
|
||||||
m = _build(
|
m = _build(
|
||||||
@@ -164,6 +166,77 @@ class TestExtendsGitMerge(unittest.TestCase):
|
|||||||
)
|
)
|
||||||
self.assertEqual((), m.bottles["child"].git)
|
self.assertEqual((), m.bottles["child"].git)
|
||||||
|
|
||||||
|
def test_child_same_name_repo_merges_key_field(self):
|
||||||
|
# Issue #237: child repo with same name as parent should merge
|
||||||
|
# field-by-field. Child overrides only `key`; parent's url and
|
||||||
|
# host_key are preserved.
|
||||||
|
parent_entry = {
|
||||||
|
"url": "ssh://git@host-a/repo.git",
|
||||||
|
"host_key": "ecdsa-sha2-nistp256 AAAA",
|
||||||
|
"key": {"provider": "static", "path": "/keys/id_rsa"},
|
||||||
|
}
|
||||||
|
m = _build(
|
||||||
|
base={"git-gate": {"repos": {"repo": parent_entry}}},
|
||||||
|
child={
|
||||||
|
"extends": "base",
|
||||||
|
"git-gate": {"repos": {"repo": {
|
||||||
|
"key": {"provider": "gitea", "forge_token_env": "GITEA_TOKEN"},
|
||||||
|
}}},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
entries = m.bottles["child"].git
|
||||||
|
self.assertEqual(1, len(entries))
|
||||||
|
e = entries[0]
|
||||||
|
self.assertEqual("repo", e.Name)
|
||||||
|
self.assertEqual("ssh://git@host-a/repo.git", e.Upstream)
|
||||||
|
self.assertEqual("ecdsa-sha2-nistp256 AAAA", e.KnownHostKey)
|
||||||
|
self.assertEqual("gitea", e.Key.provider)
|
||||||
|
self.assertEqual("GITEA_TOKEN", e.Key.forge_token_env)
|
||||||
|
|
||||||
|
def test_child_same_name_repo_overrides_url(self):
|
||||||
|
# Child can override url on a same-name repo; other parent fields
|
||||||
|
# fall through.
|
||||||
|
parent_entry = {
|
||||||
|
"url": "ssh://git@host-a/old.git",
|
||||||
|
"key": {"provider": "static", "path": "/keys/id_rsa"},
|
||||||
|
}
|
||||||
|
m = _build(
|
||||||
|
base={"git-gate": {"repos": {"repo": parent_entry}}},
|
||||||
|
child={
|
||||||
|
"extends": "base",
|
||||||
|
"git-gate": {"repos": {"repo": {
|
||||||
|
"url": "ssh://git@host-b/new.git",
|
||||||
|
"key": {"provider": "static", "path": "/keys/id_rsa"},
|
||||||
|
}}},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
entries = m.bottles["child"].git
|
||||||
|
self.assertEqual(1, len(entries))
|
||||||
|
self.assertEqual("ssh://git@host-b/new.git", entries[0].Upstream)
|
||||||
|
|
||||||
|
def test_child_same_name_plus_new_repo(self):
|
||||||
|
# Same-name repo is field-merged; a distinct new name in child
|
||||||
|
# is appended.
|
||||||
|
parent_entry = {
|
||||||
|
"url": "ssh://git@host-a/repo.git",
|
||||||
|
"key": {"provider": "static", "path": "/keys/id_rsa"},
|
||||||
|
}
|
||||||
|
m = _build(
|
||||||
|
base={"git-gate": {"repos": {"repo": parent_entry}}},
|
||||||
|
child={
|
||||||
|
"extends": "base",
|
||||||
|
"git-gate": {"repos": {
|
||||||
|
"repo": {"key": {"provider": "gitea", "forge_token_env": "TOK"}},
|
||||||
|
"other": self._GIT_ENTRY_B,
|
||||||
|
}},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
child = m.bottles["child"]
|
||||||
|
names = {e.Name for e in child.git}
|
||||||
|
self.assertEqual({"repo", "other"}, names)
|
||||||
|
repo_entry = next(e for e in child.git if e.Name == "repo")
|
||||||
|
self.assertEqual("gitea", repo_entry.Key.provider)
|
||||||
|
|
||||||
def test_child_git_user_inherits_parent_repos(self):
|
def test_child_git_user_inherits_parent_repos(self):
|
||||||
m = _build(
|
m = _build(
|
||||||
base={"git-gate": {"repos": {"a": self._GIT_ENTRY_A}}},
|
base={"git-gate": {"repos": {"a": self._GIT_ENTRY_A}}},
|
||||||
|
|||||||
@@ -0,0 +1,27 @@
|
|||||||
|
"""Unit: Python package metadata for install script PRD."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import tomllib
|
||||||
|
import unittest
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
|
ROOT = Path(__file__).resolve().parents[2]
|
||||||
|
|
||||||
|
|
||||||
|
class TestPyproject(unittest.TestCase):
|
||||||
|
def test_console_script_and_no_runtime_dependencies(self):
|
||||||
|
data = tomllib.loads((ROOT / "pyproject.toml").read_text(encoding="utf-8"))
|
||||||
|
project = data["project"]
|
||||||
|
self.assertEqual("bot-bottle", project["name"])
|
||||||
|
self.assertEqual(">=3.11", project["requires-python"])
|
||||||
|
self.assertEqual([], project["dependencies"])
|
||||||
|
self.assertEqual(
|
||||||
|
"bot_bottle.cli:main",
|
||||||
|
project["scripts"]["bot-bottle"],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
Reference in New Issue
Block a user