|
#!/usr/bin/env bash |
|
set -euo pipefail |
|
|
|
IMAGE="local/claude-code" |
|
VOLUME="claude-auth" |
|
SSH_DIR="${HOME}/.ssh" |
|
CLAUDE_DIR="${HOME}/.claude" |
|
SETUP_MARKER=".claude-docker-setup-done" |
|
|
|
# ── Build image if missing ────────────────────────────────── |
|
if ! docker image inspect "$IMAGE" >/dev/null 2>&1; then |
|
echo "Building Docker image ${IMAGE} ..." |
|
docker build -t "$IMAGE" -<<'DOCKERFILE' |
|
FROM node:20-bookworm |
|
|
|
RUN apt-get update && apt-get install -y \ |
|
git curl ripgrep less vim openssh-client \ |
|
&& rm -rf /var/lib/apt/lists/* |
|
|
|
RUN npm install -g @anthropic-ai/claude-code |
|
|
|
RUN useradd -m -s /bin/bash claude |
|
|
|
USER claude |
|
WORKDIR /workspace |
|
|
|
CMD ["bash"] |
|
DOCKERFILE |
|
fi |
|
|
|
docker volume create "$VOLUME" > /dev/null |
|
|
|
# ── Helper: exec a command inside a disposable container with the volume ── |
|
vol_run() { |
|
docker run --rm -v "${VOLUME}:/home/claude" "$@" |
|
} |
|
|
|
# ── SSH key selection ─────────────────────────────────────── |
|
setup_ssh() { |
|
if [[ ! -d "$SSH_DIR" ]]; then |
|
echo "No ~/.ssh directory found — skipping SSH setup." |
|
return |
|
fi |
|
|
|
echo "" |
|
echo "═══ SSH Keys ═══" |
|
echo "Found in ${SSH_DIR}:" |
|
echo "" |
|
|
|
local files=() |
|
for f in "$SSH_DIR"/*; do |
|
[[ -f "$f" ]] || continue |
|
local name |
|
name=$(basename "$f") |
|
# skip noise |
|
case "$name" in |
|
.DS_Store|*.old) continue ;; |
|
esac |
|
# skip sockets (e.g. agent) |
|
[[ -S "$f" ]] && continue |
|
files+=("$name") |
|
done |
|
|
|
if [[ ${#files[@]} -eq 0 ]]; then |
|
echo " (no key files found)" |
|
return |
|
fi |
|
|
|
local i=1 |
|
for name in "${files[@]}"; do |
|
printf " %2d) %s\n" "$i" "$name" |
|
((i++)) |
|
done |
|
|
|
echo "" |
|
echo " a) All" |
|
echo " n) None" |
|
echo "" |
|
read -rp "Select files to copy (comma-separated numbers, 'a' for all, 'n' to skip): " selection |
|
|
|
[[ "$selection" == "n" ]] && { echo "Skipping SSH keys."; return; } |
|
|
|
local selected=() |
|
if [[ "$selection" == "a" ]]; then |
|
selected=("${files[@]}") |
|
else |
|
IFS=',' read -ra indices <<< "$selection" |
|
for idx in "${indices[@]}"; do |
|
idx="${idx// /}" |
|
if [[ "$idx" =~ ^[0-9]+$ ]] && (( idx >= 1 && idx <= ${#files[@]} )); then |
|
selected+=("${files[$((idx-1))]}") |
|
fi |
|
done |
|
fi |
|
|
|
if [[ ${#selected[@]} -eq 0 ]]; then |
|
echo "No valid selection — skipping SSH keys." |
|
return |
|
fi |
|
|
|
echo "" |
|
echo "Copying ${#selected[@]} SSH file(s) into container volume..." |
|
|
|
local tmpdir |
|
tmpdir=$(mktemp -d) |
|
|
|
for name in "${selected[@]}"; do |
|
cp -L "${SSH_DIR}/${name}" "${tmpdir}/${name}" |
|
done |
|
|
|
vol_run \ |
|
-v "${tmpdir}:/tmp/ssh-import:ro" \ |
|
"$IMAGE" bash -c ' |
|
mkdir -p /home/claude/.ssh |
|
chmod 700 /home/claude/.ssh |
|
cp /tmp/ssh-import/* /home/claude/.ssh/ |
|
for f in /home/claude/.ssh/*; do |
|
case "$(basename "$f")" in |
|
*.pub|config|known_hosts) chmod 644 "$f" ;; |
|
*) chmod 600 "$f" ;; |
|
esac |
|
done |
|
' |
|
|
|
rm -rf "$tmpdir" |
|
echo "Done — ${#selected[@]} SSH file(s) copied." |
|
} |
|
|
|
# ── Claude skills selection ───────────────────────────────── |
|
setup_skills() { |
|
local skills_dir="${CLAUDE_DIR}/skills" |
|
|
|
if [[ ! -d "$skills_dir" ]]; then |
|
echo "No ~/.claude/skills directory found — skipping skills setup." |
|
return |
|
fi |
|
|
|
echo "" |
|
echo "═══ Claude Skills ═══" |
|
echo "Found in ${skills_dir}:" |
|
echo "" |
|
|
|
local skills=() |
|
for d in "$skills_dir"/*/; do |
|
[[ -d "$d" ]] || continue |
|
skills+=("$(basename "$d")") |
|
done |
|
|
|
if [[ ${#skills[@]} -eq 0 ]]; then |
|
echo " (no skills found)" |
|
return |
|
fi |
|
|
|
local i=1 |
|
for s in "${skills[@]}"; do |
|
printf " %2d) %s\n" "$i" "$s" |
|
((i++)) |
|
done |
|
|
|
echo "" |
|
echo " a) All" |
|
echo " n) None" |
|
echo "" |
|
read -rp "Select skills to copy (comma-separated numbers, 'a' for all, 'n' to skip): " selection |
|
|
|
[[ "$selection" == "n" ]] && { echo "Skipping skills."; return; } |
|
|
|
local selected=() |
|
if [[ "$selection" == "a" ]]; then |
|
selected=("${skills[@]}") |
|
else |
|
IFS=',' read -ra indices <<< "$selection" |
|
for idx in "${indices[@]}"; do |
|
idx="${idx// /}" |
|
if [[ "$idx" =~ ^[0-9]+$ ]] && (( idx >= 1 && idx <= ${#skills[@]} )); then |
|
selected+=("${skills[$((idx-1))]}") |
|
fi |
|
done |
|
fi |
|
|
|
if [[ ${#selected[@]} -eq 0 ]]; then |
|
echo "No valid selection — skipping skills." |
|
return |
|
fi |
|
|
|
echo "" |
|
echo "Copying ${#selected[@]} skill(s) into container volume..." |
|
|
|
local tmpdir |
|
tmpdir=$(mktemp -d) |
|
|
|
for name in "${selected[@]}"; do |
|
# -R recursive, -L resolve symlinks (skills are often symlinked) |
|
cp -RL "${skills_dir}/${name}" "${tmpdir}/${name}" |
|
done |
|
|
|
vol_run \ |
|
-v "${tmpdir}:/tmp/skills-import:ro" \ |
|
"$IMAGE" bash -c ' |
|
mkdir -p /home/claude/.claude/skills |
|
cp -r /tmp/skills-import/* /home/claude/.claude/skills/ |
|
' |
|
|
|
rm -rf "$tmpdir" |
|
echo "Done — ${#selected[@]} skill(s) copied." |
|
} |
|
|
|
# ── Claude settings ───────────────────────────────────────── |
|
setup_settings() { |
|
local settings="${CLAUDE_DIR}/settings.json" |
|
[[ -f "$settings" ]] || return |
|
|
|
echo "" |
|
echo "═══ Claude Settings ═══" |
|
read -rp "Copy ~/.claude/settings.json into container? [Y/n] " answer |
|
answer="${answer:-Y}" |
|
|
|
if [[ "$answer" =~ ^[Yy]$ ]]; then |
|
local tmpdir |
|
tmpdir=$(mktemp -d) |
|
|
|
cp "$settings" "${tmpdir}/settings.json" |
|
|
|
vol_run \ |
|
-v "${tmpdir}:/tmp/settings-import:ro" \ |
|
"$IMAGE" bash -c ' |
|
mkdir -p /home/claude/.claude |
|
cp /tmp/settings-import/settings.json /home/claude/.claude/settings.json |
|
' |
|
|
|
rm -rf "$tmpdir" |
|
echo "Done — settings.json copied." |
|
fi |
|
} |
|
|
|
# ── Detect whether setup has already run ──────────────────── |
|
volume_needs_setup() { |
|
local output |
|
output=$(docker run --rm -v "${VOLUME}:/home/claude" "$IMAGE" \ |
|
bash -c "test -f /home/claude/${SETUP_MARKER} && echo exists || echo missing" 2>/dev/null) |
|
[[ "$output" == "missing" ]] |
|
} |
|
|
|
mark_setup_done() { |
|
vol_run "$IMAGE" bash -c "touch /home/claude/${SETUP_MARKER}" |
|
} |
|
|
|
# ── Run interactive setup ─────────────────────────────────── |
|
run_setup() { |
|
echo "" |
|
echo "╔══════════════════════════════════════╗" |
|
echo "║ Claude Docker — Initial Setup ║" |
|
echo "╚══════════════════════════════════════╝" |
|
|
|
setup_ssh |
|
setup_skills |
|
setup_settings |
|
mark_setup_done |
|
|
|
echo "" |
|
echo "Setup complete. Run with --setup to reconfigure." |
|
echo "" |
|
} |
|
|
|
# ── Parse args ────────────────────────────────────────────── |
|
FORCE_SETUP=false |
|
for arg in "$@"; do |
|
case "$arg" in |
|
--setup|-s) FORCE_SETUP=true ;; |
|
esac |
|
done |
|
|
|
if $FORCE_SETUP || volume_needs_setup; then |
|
run_setup |
|
fi |
|
|
|
# ── Launch container ──────────────────────────────────────── |
|
docker run --rm -it \ |
|
--security-opt=no-new-privileges:true \ |
|
--cap-drop=ALL \ |
|
-v "${VOLUME}:/home/claude" \ |
|
-v "$(pwd):/workspace:rw" \ |
|
-w /workspace \ |
|
"$IMAGE" claude |