Scripts to run Claude Code inside an Arch Linux Docker container that mirrors the local development environment while limiting access to sensitive host files. Includes per-project container isolation, a shared persistent home directory, and a shell alias for launching Claude interactively from any project directory.
173 lines
5.7 KiB
Python
Executable file
173 lines
5.7 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:
|
|
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 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 = []
|
|
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()
|