This repository has been archived on 2026-03-24. You can view files and clone it, but you cannot make any changes to it's state, such as pushing and creating new issues, pull requests or comments.
claude-container/claude-container.py
Jeena 4605a62d90 Switch to native Claude Code installer and add update command
Replace the deprecated npm installation with the native installer. The
binary is installed to /usr/local/bin so it survives the home directory
bind mount at runtime.

Add a 'claude update' subcommand that rebuilds the image with the latest
Claude Code binary and removes all existing containers.

Disable the in-container auto-updater since the binary lives in the
read-only image layer.
2026-03-18 03:53:55 +00:00

210 lines
7.1 KiB
Python
Executable file

#!/usr/bin/env python3
import hashlib
import logging
import os
import subprocess
import sys
import time
from pathlib import Path
logging.basicConfig(
level=logging.INFO,
format='%(message)s',
stream=sys.stderr
)
logger = logging.getLogger(__name__)
class ClaudeContainer:
IMAGE = "claude-container:latest"
PROJECT_ID_LEN = 12
START_TIMEOUT = 3.0
def __init__(self):
self.project_path = Path.cwd().resolve()
self.project_id = self._project_id(self.project_path)
self.host_username = os.environ.get('USER', 'dev')
self.host_uid = os.getuid()
self.host_gid = os.getgid()
self.container_name = f"cc-{self.project_path.name}-{self.project_id}"
self.docker_context_dir = Path(__file__).resolve().parent
def _get_xdg_data_home(self) -> Path:
xdg_data = os.environ.get('XDG_DATA_HOME')
if xdg_data:
return Path(xdg_data)
return Path.home() / '.local' / 'share'
@property
def container_home_path(self) -> Path:
return self._get_xdg_data_home() / 'claude-container' / 'container-home'
def run(self, args: list[str]) -> None:
if args and args[0] == "update":
self.update()
return
self.container_home_path.mkdir(parents=True, exist_ok=True)
if self.container_exists() and self.container_running():
logger.error(f"Project '{self.project_path.name}' already has a running Claude container.")
logger.error(f"Container name: {self.container_name}")
logger.error("Wait for the current instance to finish or manually stop it with:")
logger.error(f" docker stop {self.container_name}")
sys.exit(1)
try:
relative_path = self.project_path.relative_to(Path.home())
(self.container_home_path / relative_path).mkdir(parents=True, exist_ok=True)
except ValueError:
pass
if not self.image_exists():
self.build_image()
if not self.container_exists():
self.create_container()
if not self.start_container():
logger.warning("Recreating container due to failed start")
self.remove_container()
self.create_container()
if not self.start_container():
raise RuntimeError("Container failed to start after recreation")
try:
self.exec_claude(args)
finally:
self.stop_container()
def image_exists(self) -> bool:
return subprocess.run(
["docker", "image", "inspect", self.IMAGE],
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
).returncode == 0
def build_image(self) -> None:
logger.info(f"Building image '{self.IMAGE}' with user {self.host_username} ({self.host_uid}:{self.host_gid})")
self._run([
"docker", "build",
"--build-arg", f"USERNAME={self.host_username}",
"--build-arg", f"UID={self.host_uid}",
"--build-arg", f"GID={self.host_gid}",
"-t", self.IMAGE,
str(self.docker_context_dir),
])
def update(self) -> None:
logger.info("Updating Claude Code...")
logger.info("Removing existing containers...")
result = subprocess.run(
["docker", "ps", "-a", "--filter", "name=^cc-", "--format", "{{.Names}}"],
capture_output=True,
text=True,
)
for name in result.stdout.strip().splitlines():
if name:
logger.info(f" Removing container '{name}'")
subprocess.run(
["docker", "rm", "-f", name],
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
)
if self.image_exists():
logger.info(f"Removing image '{self.IMAGE}'...")
subprocess.run(
["docker", "rmi", self.IMAGE],
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
)
logger.info("Rebuilding image with latest Claude Code...")
self._run([
"docker", "build", "--no-cache",
"--build-arg", f"USERNAME={self.host_username}",
"--build-arg", f"UID={self.host_uid}",
"--build-arg", f"GID={self.host_gid}",
"-t", self.IMAGE,
str(self.docker_context_dir),
])
logger.info("Update complete. Containers will be recreated on next run.")
def create_container(self) -> None:
logger.info(f"Creating container '{self.container_name}'")
self._run([
"docker", "create",
"--name", self.container_name,
"--user", f"{self.host_uid}:{self.host_gid}",
"--volume", f"{self.project_path}:{self.project_path}",
"--volume", f"{self.container_home_path}:/home/{self.host_username}",
self.IMAGE,
"sh", "-c", "sleep infinity",
])
def start_container(self) -> bool:
subprocess.run(
["docker", "start", self.container_name],
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
)
return self.wait_until_running()
def stop_container(self) -> None:
if self.container_running():
self._run(["docker", "stop", "-t", "0", self.container_name])
def remove_container(self) -> None:
subprocess.run(
["docker", "rm", "-f", self.container_name],
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
)
def exec_claude(self, args: list[str]) -> None:
env_args = ["-e", "DISABLE_AUTOUPDATER=1"]
for var in ("ANTHROPIC_API_KEY", "ANTHROPIC_BASE_URL"):
val = os.environ.get(var)
if val:
env_args += ["-e", f"{var}={val}"]
subprocess.run([
"docker", "exec",
"-it",
*env_args,
"-w", str(self.project_path),
self.container_name,
"claude",
*args,
], check=True)
def container_exists(self) -> bool:
return subprocess.run(
["docker", "inspect", self.container_name],
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
).returncode == 0
def container_running(self) -> bool:
result = subprocess.run(
["docker", "inspect", "-f", "{{.State.Running}}", self.container_name],
capture_output=True,
text=True,
)
return result.returncode == 0 and result.stdout.strip() == "true"
def wait_until_running(self) -> bool:
deadline = time.time() + self.START_TIMEOUT
while time.time() < deadline:
if self.container_running():
return True
time.sleep(0.1)
return False
@staticmethod
def _project_id(path: Path) -> str:
return hashlib.sha256(str(path).encode()).hexdigest()[:12]
@staticmethod
def _run(cmd: list[str]) -> None:
subprocess.run(cmd, check=True)
def main() -> None:
ClaudeContainer().run(sys.argv[1:])
if __name__ == "__main__":
main()