Add Claude Code container management scripts
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.
This commit is contained in:
commit
a9a645abca
6 changed files with 284 additions and 0 deletions
173
claude-container.py
Executable file
173
claude-container.py
Executable file
|
|
@ -0,0 +1,173 @@
|
|||
#!/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()
|
||||
Loading…
Add table
Add a link
Reference in a new issue