refactor(util): split private helpers off DockerBottleBackend
test / run tests/run_tests.py (pull_request) Successful in 14s

New file claude_bottle/backend/util.py for cross-backend host-side
helpers:
  host_skill_dir(name) — resolves $HOME/.claude/skills/<name>

docker/util.py gains:
  docker_exec_root(container, argv) — `docker exec -u 0` wrapper used
    by SSH provisioning

DockerBottleBackend drops the two methods that wrapped these
(`_host_skill_dir`, `_docker_exec_root`) — they had no instance state
and just lived on the class for organizational reasons. Call sites
now use the imported functions directly.
This commit is contained in:
2026-05-11 14:09:55 -04:00
parent 1269edf311
commit a786ca3391
3 changed files with 43 additions and 27 deletions
+15 -27
View File
@@ -24,6 +24,7 @@ from ...log import die, info
from ...manifest import SshEntry
from ...util import expand_tilde
from .. import BottleBackend, BottleCleanupPlan, BottlePlan, BottleSpec
from ..util import host_skill_dir
from . import network as network_mod
from . import util as docker_mod
from .bottle import DockerBottle
@@ -307,19 +308,13 @@ class DockerBottleBackend(BottleBackend):
user doesn't get a launch prompt for a plan that's already
known to break."""
for name in skills:
path = self._host_skill_dir(name)
path = host_skill_dir(name)
if not os.path.isdir(path):
die(
f"skill '{name}' not found on host at {path}. "
f"Create it under ~/.claude/skills/, then re-run."
)
def _host_skill_dir(self, name: str) -> str:
home = os.environ.get("HOME")
if not home:
die("HOME not set")
return f"{home}/.claude/skills/{name}"
def provision_skills(self, plan: BottlePlan, target: str) -> None:
"""Copy each of the agent's named skills from the host's
~/.claude/skills/<name>/ into the container's equivalent path.
@@ -345,7 +340,7 @@ class DockerBottleBackend(BottleBackend):
)
for n in agent.skills:
src = self._host_skill_dir(n)
src = host_skill_dir(n)
if not os.path.isdir(src):
die(f"skill '{n}' disappeared from host between validation and copy at {src}.")
dst = f"{skills_dir}/{n}"
@@ -423,14 +418,14 @@ class DockerBottleBackend(BottleBackend):
keys_dir = "/root/.claude-bottle-keys"
# ~/.ssh for node (700, owned by node).
self._docker_exec_root(container, ["mkdir", "-p", container_ssh])
self._docker_exec_root(container, ["chown", "node:node", container_ssh])
self._docker_exec_root(container, ["chmod", "700", container_ssh])
docker_mod.docker_exec_root(container, ["mkdir", "-p", container_ssh])
docker_mod.docker_exec_root(container, ["chown", "node:node", container_ssh])
docker_mod.docker_exec_root(container, ["chmod", "700", container_ssh])
# /root/.claude-bottle-keys for root (700, root-owned).
self._docker_exec_root(container, ["mkdir", "-p", keys_dir])
self._docker_exec_root(container, ["chown", "root:root", keys_dir])
self._docker_exec_root(container, ["chmod", "700", keys_dir])
docker_mod.docker_exec_root(container, ["mkdir", "-p", keys_dir])
docker_mod.docker_exec_root(container, ["chown", "root:root", keys_dir])
docker_mod.docker_exec_root(container, ["chmod", "700", keys_dir])
config_file = plan.stage_dir / "ssh_config"
known_hosts_file = plan.stage_dir / "ssh_known_hosts"
@@ -459,8 +454,8 @@ class DockerBottleBackend(BottleBackend):
stdout=subprocess.DEVNULL,
check=True,
)
self._docker_exec_root(container, ["chown", "root:root", container_key_path])
self._docker_exec_root(container, ["chmod", "600", container_key_path])
docker_mod.docker_exec_root(container, ["chown", "root:root", container_key_path])
docker_mod.docker_exec_root(container, ["chmod", "600", container_key_path])
container_key_paths.append(container_key_path)
@@ -533,8 +528,8 @@ class DockerBottleBackend(BottleBackend):
stdout=subprocess.DEVNULL,
check=True,
)
self._docker_exec_root(container, ["chown", "node:node", f"{container_ssh}/config"])
self._docker_exec_root(container, ["chmod", "600", f"{container_ssh}/config"])
docker_mod.docker_exec_root(container, ["chown", "node:node", f"{container_ssh}/config"])
docker_mod.docker_exec_root(container, ["chmod", "600", f"{container_ssh}/config"])
if known_hosts_file.stat().st_size > 0:
info(f"writing {container_ssh}/known_hosts")
@@ -543,15 +538,8 @@ class DockerBottleBackend(BottleBackend):
stdout=subprocess.DEVNULL,
check=True,
)
self._docker_exec_root(container, ["chown", "node:node", f"{container_ssh}/known_hosts"])
self._docker_exec_root(container, ["chmod", "600", f"{container_ssh}/known_hosts"])
def _docker_exec_root(self, container: str, argv: list[str]) -> None:
subprocess.run(
["docker", "exec", "-u", "0", container, *argv],
stdout=subprocess.DEVNULL,
check=True,
)
docker_mod.docker_exec_root(container, ["chown", "node:node", f"{container_ssh}/known_hosts"])
docker_mod.docker_exec_root(container, ["chmod", "600", f"{container_ssh}/known_hosts"])
def provision_git(self, plan: BottlePlan, target: str) -> None:
"""If --cwd was set and the host cwd has a .git directory, copy
+10
View File
@@ -48,6 +48,16 @@ def container_exists(name: str) -> bool:
return bool(result.stdout.strip())
def docker_exec_root(container: str, argv: list[str]) -> None:
"""Run `docker exec -u 0` in the named container, check=True. Used
by SSH provisioning to chown/chmod files that need root."""
subprocess.run(
["docker", "exec", "-u", "0", container, *argv],
stdout=subprocess.DEVNULL,
check=True,
)
_SLUG_RE = re.compile(r"[^a-z0-9]+")
+18
View File
@@ -0,0 +1,18 @@
"""Cross-backend utility helpers — host-side primitives shared by
every backend implementation. Backend-specific helpers live one level
deeper (e.g. claude_bottle/backend/docker/util.py)."""
from __future__ import annotations
import os
from ..log import die
def host_skill_dir(name: str) -> str:
"""Return the host-side path for a named skill:
`$HOME/.claude/skills/<name>`. Dies if HOME is unset."""
home = os.environ.get("HOME")
if not home:
die("HOME not set")
return f"{home}/.claude/skills/{name}"