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
3
.gitignore
vendored
Normal file
3
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
__pycache__/
|
||||
*.pyc
|
||||
.env
|
||||
25
Dockerfile
Normal file
25
Dockerfile
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
FROM archlinux:latest
|
||||
|
||||
ARG USERNAME=dev
|
||||
ARG UID=1000
|
||||
ARG GID=1000
|
||||
|
||||
RUN pacman -Syu --noconfirm \
|
||||
base-devel \
|
||||
git \
|
||||
ca-certificates \
|
||||
bash \
|
||||
less \
|
||||
ripgrep \
|
||||
nodejs \
|
||||
npm \
|
||||
sudo && \
|
||||
groupadd -g ${GID} ${USERNAME} && \
|
||||
useradd -m -u ${UID} -g ${GID} -s /bin/bash ${USERNAME} && \
|
||||
echo "${USERNAME} ALL=(ALL) NOPASSWD: ALL" >> /etc/sudoers && \
|
||||
npm install -g @anthropic-ai/claude-code && \
|
||||
pacman -Scc --noconfirm
|
||||
|
||||
USER ${USERNAME}
|
||||
|
||||
WORKDIR /home/${USERNAME}
|
||||
64
README.md
Normal file
64
README.md
Normal file
|
|
@ -0,0 +1,64 @@
|
|||
# claude-container
|
||||
|
||||
Run Claude Code inside an Arch Linux Docker container that closely mirrors a
|
||||
local development environment, while limiting access to sensitive files on
|
||||
the host.
|
||||
|
||||
## Features
|
||||
|
||||
- Arch Linux–based image
|
||||
- Runs as the host user (same username, UID, GID)
|
||||
- **Per-project isolation**: Each project gets its own container (identified by project path hash)
|
||||
- **Shared persistent home**: All containers mount the same home directory from XDG_DATA_HOME, allowing tools to persist across projects
|
||||
- **Sudo access**: Claude agent can install project-specific dependencies that persist in the stopped container
|
||||
- **Hard linking support**: Can hard link files like `~/.gitconfig` to share configurations with containers
|
||||
- Mounts only the current project directory (same absolute path inside container)
|
||||
- **Security boundary**: No access to SSH keys, passwords, or full `$HOME` (intentionally prevents remote code pushes)
|
||||
- Simple shell function (`claude`) to launch interactively
|
||||
|
||||
## Install
|
||||
|
||||
Change to your projects directory and clone the repository:
|
||||
|
||||
```
|
||||
cd ~/Projects/
|
||||
git clone https://git.jeena.net/jeena/claude-container.git
|
||||
```
|
||||
|
||||
Source the helper file `claude.aliases` in your shell configuration
|
||||
(`.bashrc` or `.zshrc`) so the `claude` function is available in new sessions.
|
||||
|
||||
```sh
|
||||
source ~/Projects/claude-container/claude.aliases
|
||||
```
|
||||
|
||||
We set up the `XDG_DATA_HOME/claude-container/container-home` directory as a central `$HOME` inside
|
||||
the container, independent of the session or project directory we start in. This
|
||||
persists the whole `$HOME` from inside the container so everything Claude Code
|
||||
writes into config files etc. persists there.
|
||||
|
||||
## Environment Variables
|
||||
|
||||
- `XDG_DATA_HOME`: Override default data directory (default: `~/.local/share`)
|
||||
- `ANTHROPIC_API_KEY`: Your Anthropic API key (required, read from host environment)
|
||||
- `ANTHROPIC_BASE_URL`: Override the API base URL (optional)
|
||||
|
||||
## Usage
|
||||
|
||||
From any project directory:
|
||||
|
||||
```
|
||||
claude
|
||||
```
|
||||
|
||||
The image is built automatically on first use if it does not already exist.
|
||||
Claude Code starts inside the container with the current directory mounted and
|
||||
set as the working directory.
|
||||
|
||||
## Cleanup
|
||||
|
||||
To remove all containers, the image, and the persistent home directory:
|
||||
|
||||
```
|
||||
~/Projects/claude-container/force-cleanup.sh
|
||||
```
|
||||
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()
|
||||
5
claude.aliases
Normal file
5
claude.aliases
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
CLAUDE_CONTAINER_DIR="${0:A:h}"
|
||||
|
||||
claude() {
|
||||
python3 "$CLAUDE_CONTAINER_DIR/claude-container.py" "$@"
|
||||
}
|
||||
14
force-cleanup.sh
Executable file
14
force-cleanup.sh
Executable file
|
|
@ -0,0 +1,14 @@
|
|||
#!/bin/bash
|
||||
set -e
|
||||
|
||||
# Stop and remove all claude-container containers
|
||||
docker ps -a --filter "name=cc-" --format "{{.Names}}" | xargs -r docker rm -f
|
||||
|
||||
# Remove the image
|
||||
docker rmi claude-container:latest 2>/dev/null || true
|
||||
|
||||
# Remove the persistent home directory
|
||||
XDG_DATA_HOME="${XDG_DATA_HOME:-$HOME/.local/share}"
|
||||
rm -rf "$XDG_DATA_HOME/claude-container"
|
||||
|
||||
echo "Cleanup complete."
|
||||
Loading…
Add table
Add a link
Reference in a new issue