@alanbem/dclaude 0.0.0-dev

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (4) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +319 -0
  3. package/dclaude +2031 -0
  4. package/package.json +52 -0
package/dclaude ADDED
@@ -0,0 +1,2031 @@
1
+ #!/bin/bash
2
+ # dclaude - Dockerized Claude Code Launcher
3
+ # https://github.com/alanbem/dclaude
4
+
5
+ set -euo pipefail
6
+
7
+ # Configuration
8
+ readonly IMAGE_NAME="${DCLAUDE_REGISTRY:-docker.io}/alanbem/dclaude"
9
+ readonly IMAGE_TAG="${DCLAUDE_TAG:-latest}"
10
+ readonly IMAGE="${IMAGE_NAME}:${IMAGE_TAG}"
11
+ readonly VOLUME_PREFIX="dclaude"
12
+ readonly DEBUG="${DCLAUDE_DEBUG:-false}"
13
+ readonly QUIET="${DCLAUDE_QUIET:-false}"
14
+ readonly REMOVE_CONTAINER="${DCLAUDE_RM:-false}"
15
+ # Docker socket will be detected dynamically unless overridden
16
+ DOCKER_SOCKET="${DCLAUDE_DOCKER_SOCKET:-}"
17
+ readonly MOUNT_CONFIGS="${DCLAUDE_MOUNT_CONFIGS:-false}"
18
+ readonly GIT_AUTH_MODE="${DCLAUDE_GIT_AUTH:-auto}" # auto, agent-forwarding, key-mount, none
19
+ readonly ENABLE_SYSTEM_CONTEXT="${DCLAUDE_SYSTEM_CONTEXT:-true}" # Inform Claude about dclaude environment
20
+
21
+ # Chrome configuration (not readonly to allow --port flag override)
22
+ CHROME_PROFILE="${DCLAUDE_CHROME_PROFILE:-claude}"
23
+ CHROME_PORT="${DCLAUDE_CHROME_PORT:-9222}"
24
+ CHROME_BIN="${DCLAUDE_CHROME_BIN:-}"
25
+ CHROME_FLAGS="${DCLAUDE_CHROME_FLAGS:-}"
26
+
27
+ # Colors for output (only if terminal supports it)
28
+ if [[ -t 1 ]]; then
29
+ readonly RED='\033[0;31m'
30
+ readonly GREEN='\033[0;32m'
31
+ readonly YELLOW='\033[1;33m'
32
+ readonly BLUE='\033[0;34m'
33
+ readonly CYAN='\033[0;36m'
34
+ readonly NC='\033[0m' # No Color
35
+ else
36
+ readonly RED=''
37
+ readonly GREEN=''
38
+ readonly YELLOW=''
39
+ readonly BLUE=''
40
+ readonly CYAN=''
41
+ readonly NC=''
42
+ fi
43
+
44
+ # Helper functions
45
+ error() {
46
+ echo -e "${RED}Error: $1${NC}" >&2
47
+ }
48
+
49
+ warning() {
50
+ echo -e "${YELLOW}Warning: $1${NC}" >&2
51
+ }
52
+
53
+ success() {
54
+ if [[ "$QUIET" != "true" ]]; then
55
+ echo -e "${GREEN}$1${NC}" >&2
56
+ fi
57
+ }
58
+
59
+ info() {
60
+ if [[ "$QUIET" != "true" ]]; then
61
+ echo -e "${BLUE}$1${NC}" >&2
62
+ fi
63
+ }
64
+
65
+ debug() {
66
+ # QUIET overrides DEBUG - if quiet, suppress debug too
67
+ if [[ "$QUIET" != "true" ]] && [[ "$DEBUG" == "true" ]]; then
68
+ echo -e "${CYAN}Debug: $1${NC}" >&2
69
+ fi
70
+ }
71
+
72
+ # Reset terminal mouse mode after tmux exits
73
+ # Prevents iTerm2 warnings about mouse reporting being left on
74
+ reset_terminal_mouse() {
75
+ # Disable all mouse reporting modes:
76
+ # ?1000 - X10 mouse reporting
77
+ # ?1002 - Cell motion mouse tracking
78
+ # ?1003 - All motion mouse tracking
79
+ # ?1006 - SGR extended mouse reporting
80
+ printf '\033[?1000l\033[?1002l\033[?1003l\033[?1006l'
81
+ }
82
+
83
+ # Detect platform
84
+ detect_platform() {
85
+ case "$(uname -s)" in
86
+ Darwin*)
87
+ echo "darwin"
88
+ ;;
89
+ Linux*)
90
+ echo "linux"
91
+ ;;
92
+ MINGW*|CYGWIN*|MSYS*)
93
+ echo "windows"
94
+ ;;
95
+ *)
96
+ echo "unknown"
97
+ ;;
98
+ esac
99
+ }
100
+
101
+ # Detect Docker socket path based on platform
102
+ detect_docker_socket() {
103
+ info "Detecting Docker socket..."
104
+
105
+ # Prefer Docker context (tells us the active socket)
106
+ if command -v docker &> /dev/null && command -v jq &> /dev/null; then
107
+ debug "Checking Docker context for socket path"
108
+ local context_output context_socket
109
+ context_output=$(docker context inspect 2>/dev/null || true)
110
+ if [[ -n "$context_output" ]]; then
111
+ context_socket=$(echo "$context_output" | jq -r '.[0].Endpoints.docker.Host' 2>/dev/null | sed 's|unix://||' 2>/dev/null) || true
112
+ if [[ -n "$context_socket" ]] && [[ -S "$context_socket" ]]; then
113
+ debug "Docker socket found via context: $context_socket"
114
+ echo "$context_socket"
115
+ return 0
116
+ else
117
+ debug "Context socket not valid: ${context_socket:-<empty>}"
118
+ fi
119
+ else
120
+ debug "No Docker context found"
121
+ fi
122
+ fi
123
+
124
+ # Fallback to common socket paths
125
+ debug "Falling back to filesystem search for Docker socket"
126
+ local socket_paths=(
127
+ "$HOME/.docker/run/docker.sock" # macOS Docker Desktop
128
+ "/var/run/docker.sock" # Linux, Docker CE
129
+ "$HOME/.orbstack/run/docker.sock" # OrbStack
130
+ "$HOME/.colima/default/docker.sock" # Colima
131
+ "$HOME/.colima/docker.sock" # Colima alternative
132
+ "$HOME/.rd/docker.sock" # Rancher Desktop
133
+ "/run/user/$(id -u)/docker.sock" # Rootless Docker on Linux
134
+ )
135
+
136
+ for socket in "${socket_paths[@]}"; do
137
+ if [[ -S "$socket" ]]; then
138
+ debug "Docker socket found at: $socket"
139
+ echo "$socket"
140
+ return 0
141
+ fi
142
+ done
143
+
144
+ debug "No Docker socket found"
145
+ return 1
146
+ }
147
+
148
+ # Check if Docker is installed
149
+ check_docker() {
150
+ if ! command -v docker &> /dev/null; then
151
+ error "Docker is not installed. Please install Docker first."
152
+ echo "Visit: https://docs.docker.com/get-docker/" >&2
153
+ exit 1
154
+ fi
155
+ debug "Docker found at: $(command -v docker)"
156
+ }
157
+
158
+ # Check if Docker daemon is running
159
+ check_docker_running() {
160
+ if ! docker info &> /dev/null; then
161
+ error "Docker daemon is not running. Please start Docker."
162
+ exit 1
163
+ fi
164
+ debug "Docker daemon is running"
165
+ }
166
+
167
+ # Find an available port in the specified range
168
+ find_available_port() {
169
+ local start_port=${1:-20000}
170
+ local end_port=${2:-65000}
171
+ local port
172
+
173
+ for ((port=start_port; port<=end_port; port++)); do
174
+ local port_in_use=false
175
+
176
+ # Fast check 1: netstat (catches most listening processes)
177
+ if command -v netstat &>/dev/null; then
178
+ if netstat -an 2>/dev/null | grep -qE "[.:]$port\s+(LISTEN|ESTABLISHED)" 2>/dev/null; then
179
+ port_in_use=true
180
+ fi
181
+ fi
182
+
183
+ # Fast check 2: lsof (more reliable on macOS, catches additional cases)
184
+ if ! $port_in_use && command -v lsof &>/dev/null; then
185
+ if lsof -i ":$port" &>/dev/null 2>&1; then
186
+ port_in_use=true
187
+ fi
188
+ fi
189
+
190
+ # Comprehensive check: Socket test (catches Docker containers, VMs, kernel-level bindings)
191
+ # Only run if fast checks didn't detect anything (avoids timeout overhead)
192
+ if ! $port_in_use; then
193
+ if timeout 0.1 bash -c "exec 3<>/dev/tcp/localhost/$port" 2>/dev/null; then
194
+ # Successfully connected - something is listening
195
+ exec 3<&- 2>/dev/null # Close connection
196
+ port_in_use=true
197
+ fi
198
+ fi
199
+
200
+ if ! $port_in_use; then
201
+ echo "$port"
202
+ return 0
203
+ fi
204
+ done
205
+
206
+ # Fallback: use a random port if no available port found
207
+ echo $((20000 + RANDOM % 45000))
208
+ }
209
+
210
+ # Portable timeout function that works on both Linux and macOS
211
+ # Usage: run_with_timeout <seconds> <command...>
212
+ run_with_timeout() {
213
+ local timeout_seconds=$1
214
+ shift
215
+
216
+ # Try to use timeout command if available (Linux, brew-installed coreutils)
217
+ if command -v timeout &>/dev/null; then
218
+ timeout "$timeout_seconds" "$@"
219
+ return $?
220
+ elif command -v gtimeout &>/dev/null; then
221
+ # macOS with coreutils installed via Homebrew
222
+ gtimeout "$timeout_seconds" "$@"
223
+ return $?
224
+ else
225
+ # Fallback: run command in background and kill after timeout
226
+ local pid
227
+ "$@" &
228
+ pid=$!
229
+
230
+ # Sleep in background and kill the process after timeout
231
+ (
232
+ sleep "$timeout_seconds"
233
+ kill -TERM "$pid" 2>/dev/null || true
234
+ ) &
235
+ local timer_pid=$!
236
+
237
+ # Wait for the command to finish
238
+ local result
239
+ if wait "$pid" 2>/dev/null; then
240
+ result=$?
241
+ kill -TERM "$timer_pid" 2>/dev/null || true
242
+ wait "$timer_pid" 2>/dev/null || true
243
+ return $result
244
+ else
245
+ # Command failed or was killed
246
+ return 124 # Standard timeout exit code
247
+ fi
248
+ fi
249
+ }
250
+
251
+ # Detect network capability (host vs bridge)
252
+ detect_network_capability() {
253
+ info "Detecting network mode..."
254
+
255
+ local cache_dir="${HOME}/.dclaude"
256
+ local cache_file="${cache_dir}/network-mode"
257
+ local test_timeout=10
258
+ local server_container=""
259
+
260
+ # Cleanup function for this function's containers
261
+ cleanup_nettest() {
262
+ if [[ -n "$server_container" ]]; then
263
+ debug "Cleaning up test container: $server_container"
264
+ docker stop "$server_container" &>/dev/null || true
265
+ docker rm -f "$server_container" &>/dev/null || true
266
+ fi
267
+ }
268
+
269
+ # Set up trap for cleanup
270
+ trap cleanup_nettest EXIT
271
+
272
+ # Create cache directory if it doesn't exist
273
+ if [[ ! -d "$cache_dir" ]]; then
274
+ mkdir -p "$cache_dir" || {
275
+ debug "Failed to create cache directory, proceeding without cache"
276
+ cache_file=""
277
+ }
278
+ fi
279
+
280
+ # Check cache first (valid for 24 hours)
281
+ if [[ -n "$cache_file" && -f "$cache_file" ]]; then
282
+ local cache_age
283
+ if cache_age=$(stat -c %Y "$cache_file" 2>/dev/null) || cache_age=$(stat -f %m "$cache_file" 2>/dev/null); then
284
+ local current_time
285
+ current_time=$(date +%s)
286
+ local age_hours=$(( (current_time - cache_age) / 3600 ))
287
+
288
+ if [[ $age_hours -lt 24 ]]; then
289
+ local cached_mode
290
+ if cached_mode=$(cat "$cache_file" 2>/dev/null) && [[ "$cached_mode" =~ ^(host|bridge)$ ]]; then
291
+ debug "Using cached network mode: $cached_mode (age: ${age_hours}h)"
292
+ trap - EXIT
293
+ echo "$cached_mode"
294
+ return 0
295
+ fi
296
+ fi
297
+ fi
298
+ fi
299
+
300
+ debug "Testing network capabilities..."
301
+
302
+ # Ensure alpine:3.19 is available
303
+ if ! docker image inspect alpine:3.19 &>/dev/null; then
304
+ debug "Alpine 3.19 image not found, pulling..."
305
+ if ! docker pull alpine:3.19 &>/dev/null; then
306
+ debug "Failed to pull alpine:3.19, falling back to bridge mode"
307
+ trap - EXIT
308
+ echo "bridge" | tee "$cache_file" 2>/dev/null || echo "bridge"
309
+ return 0
310
+ fi
311
+ debug "Alpine 3.19 image pulled successfully"
312
+ else
313
+ debug "Alpine 3.19 image is available"
314
+ fi
315
+
316
+ # Test 1: Basic host networking support
317
+ debug "Test 1: Basic host networking support"
318
+ if ! run_with_timeout "$test_timeout" docker run --rm --network host alpine:3.19 \
319
+ sh -c 'ip addr show lo | grep -q "127\.0\.0\.1"' &>/dev/null; then
320
+ debug "Host networking not supported (basic test failed)"
321
+ trap - EXIT
322
+ echo "bridge" | tee "$cache_file" 2>/dev/null || echo "bridge"
323
+ return 0
324
+ fi
325
+
326
+ # Test 2: Container-to-container localhost access
327
+ debug "Test 2: Container-to-container localhost access"
328
+ local test_port
329
+ test_port=$(find_available_port)
330
+ debug "Using dynamic port: $test_port"
331
+ server_container="dclaude-nettest-$$"
332
+
333
+ # Start test server
334
+ if ! docker run --rm -d --name "$server_container" --network host alpine:3.19 \
335
+ sh -c "echo 'test-response' | nc -l -p $test_port" &>/dev/null; then
336
+ debug "Failed to start test server"
337
+ trap - EXIT
338
+ echo "bridge" | tee "$cache_file" 2>/dev/null || echo "bridge"
339
+ return 0
340
+ fi
341
+
342
+ # Wait for server to be ready (max 5 seconds)
343
+ debug "Waiting for test server to be ready..."
344
+ local ready=false
345
+ local wait_count=0
346
+ local max_wait=50 # 5 seconds (50 * 0.1 seconds)
347
+
348
+ while [[ $wait_count -lt $max_wait ]]; do
349
+ # Use more portable netstat options for Alpine Linux
350
+ if docker exec "$server_container" sh -c "netstat -tuln 2>/dev/null | grep -q \":$test_port \"" &>/dev/null; then
351
+ ready=true
352
+ debug "Test server is ready after $((wait_count * 100))ms"
353
+ break
354
+ fi
355
+ sleep 0.1
356
+ ((wait_count++))
357
+ done
358
+
359
+ if [[ "$ready" != "true" ]]; then
360
+ debug "Test server failed to become ready within 5 seconds"
361
+ cleanup_nettest
362
+ trap - EXIT
363
+ echo "bridge" | tee "$cache_file" 2>/dev/null || echo "bridge"
364
+ return 0
365
+ fi
366
+
367
+ # Test client connection
368
+ local test_result="bridge"
369
+ if run_with_timeout 5 docker run --rm --network host alpine:3.19 \
370
+ sh -c "echo '' | nc localhost $test_port" &>/dev/null; then
371
+ test_result="host"
372
+ debug "Host networking fully supported"
373
+ else
374
+ debug "Host networking partially supported but localhost access failed"
375
+ fi
376
+
377
+ # Cleanup test server (handled by trap)
378
+ cleanup_nettest
379
+
380
+ # Cache the result
381
+ if [[ -n "$cache_file" ]]; then
382
+ echo "$test_result" > "$cache_file" 2>/dev/null || true
383
+ fi
384
+
385
+ # Platform-specific validation of detected mode
386
+ local platform
387
+ platform=$(detect_platform)
388
+ case "$platform" in
389
+ darwin)
390
+ if [[ "$test_result" == "host" ]]; then
391
+ debug "Host networking detected on macOS - validating compatibility"
392
+ # On macOS, host networking should only work with Docker Desktop beta or OrbStack
393
+ if ! docker system info 2>/dev/null | grep -qi "orbstack\|desktop.*beta" &>/dev/null; then
394
+ debug "Host networking may not work properly on this Docker setup for macOS"
395
+ warning "Host networking detected but may not work properly on macOS Docker Desktop (non-beta)"
396
+ fi
397
+ fi
398
+ ;;
399
+ windows)
400
+ if [[ "$test_result" == "host" ]]; then
401
+ debug "Host networking detected on Windows - this is unusual"
402
+ warning "Host networking detected on Windows - this may indicate WSL2 or special configuration"
403
+ fi
404
+ ;;
405
+ linux)
406
+ debug "Network mode '$test_result' is expected on Linux platform"
407
+ ;;
408
+ *)
409
+ debug "Unknown platform '$platform' - network mode validation skipped"
410
+ ;;
411
+ esac
412
+
413
+ # Clear the trap before returning
414
+ trap - EXIT
415
+
416
+ debug "Network capability detection result: $test_result"
417
+ echo "$test_result"
418
+ }
419
+
420
+ # Create Docker volumes if they don't exist
421
+ create_volumes() {
422
+ # Create essential volumes for persistence
423
+ local volumes=("${VOLUME_PREFIX}-claude" "${VOLUME_PREFIX}-config")
424
+
425
+ for volume in "${volumes[@]}"; do
426
+ if ! docker volume inspect "$volume" &> /dev/null; then
427
+ info "Creating volume: $volume"
428
+ if ! docker volume create "$volume" > /dev/null; then
429
+ error "Failed to create volume: $volume"
430
+ exit 1
431
+ fi
432
+ debug "Volume created successfully: $volume"
433
+ else
434
+ debug "Volume exists: $volume"
435
+ fi
436
+ done
437
+ }
438
+
439
+ # Pull or update the Docker image
440
+ update_image() {
441
+ # Skip updates if DCLAUDE_NO_UPDATE is set
442
+ if [[ "${DCLAUDE_NO_UPDATE:-false}" == "true" ]]; then
443
+ debug "Skipping image update (DCLAUDE_NO_UPDATE=true)"
444
+ # Ensure image exists
445
+ if ! docker image inspect "$IMAGE" &> /dev/null; then
446
+ error "Docker image $IMAGE not found locally and updates disabled."
447
+ error "Either unset DCLAUDE_NO_UPDATE or pull the image manually."
448
+ exit 1
449
+ fi
450
+ return
451
+ fi
452
+
453
+ local current_id=""
454
+ local new_id=""
455
+
456
+ # Get current image ID if exists
457
+ if docker image inspect "$IMAGE" &> /dev/null; then
458
+ current_id=$(docker image inspect "$IMAGE" --format='{{.Id}}')
459
+ debug "Current image ID: ${current_id:0:12}"
460
+ fi
461
+
462
+ # Try to pull latest
463
+ info "Checking for updates to $IMAGE..."
464
+ if docker pull "$IMAGE" 2> /dev/null; then
465
+ new_id=$(docker image inspect "$IMAGE" --format='{{.Id}}' 2>/dev/null || echo "")
466
+
467
+ if [[ -z "$new_id" ]]; then
468
+ warning "Failed to inspect image after pull"
469
+ elif [[ "$current_id" != "$new_id" ]]; then
470
+ success "Image updated successfully."
471
+ debug "New image ID: ${new_id:0:12}"
472
+ else
473
+ debug "Image is up to date"
474
+ fi
475
+ else
476
+ if [[ -z "$current_id" ]]; then
477
+ error "Failed to pull image and no local image exists."
478
+ exit 1
479
+ else
480
+ warning "Could not check for updates, using local image."
481
+ fi
482
+ fi
483
+ }
484
+
485
+ # Get the host path for mounting
486
+ get_host_path() {
487
+ local host_path
488
+ host_path=$(pwd) || {
489
+ error "Failed to get current directory"
490
+ exit 1
491
+ }
492
+
493
+ # Basic path validation
494
+ if [[ ! -d "$host_path" ]]; then
495
+ error "Current directory does not exist: $host_path"
496
+ exit 1
497
+ fi
498
+
499
+ echo "$host_path"
500
+ }
501
+
502
+ # Generate deterministic container name from path
503
+ get_container_name() {
504
+ local path="${1:-$HOST_PATH}"
505
+ local path_hash
506
+ path_hash=$(echo -n "$path" | md5sum 2>/dev/null | cut -d' ' -f1 || echo -n "$path" | md5 | cut -d' ' -f1)
507
+ echo "dclaude-${path_hash:0:12}"
508
+ }
509
+
510
+ # Generate system context prompt for Claude
511
+ generate_system_context() {
512
+ local network_mode="${1:-auto}"
513
+ local git_auth_mode="${2:-auto}"
514
+ local has_docker="${3:-false}"
515
+ local platform="${4:-unknown}"
516
+ local docker_socket="${5:-}"
517
+
518
+ cat <<'EOF'
519
+
520
+ # dclaude Environment Context
521
+
522
+ You are running inside **dclaude** - a Docker container that emulates the host environment. This is important context for understanding your capabilities and limitations:
523
+
524
+ ## Container Architecture
525
+ - **Host Emulation**: Your current working directory is mounted at the exact same path as on the host
526
+ - **Path Mirroring**: All file paths work identically to native execution (e.g., `/Users/alice/project` in container = same path on host)
527
+ - **Isolated Environment**: While you operate in a container, file operations affect the real host filesystem through volume mounts
528
+
529
+ ## Host Environment
530
+ EOF
531
+
532
+ # Platform info
533
+ case "$platform" in
534
+ darwin)
535
+ echo "- **Host OS**: macOS"
536
+ ;;
537
+ linux)
538
+ echo "- **Host OS**: Linux"
539
+ ;;
540
+ windows)
541
+ echo "- **Host OS**: Windows"
542
+ ;;
543
+ *)
544
+ echo "- **Host OS**: Unknown"
545
+ ;;
546
+ esac
547
+
548
+ # Architecture
549
+ local arch=$(uname -m 2>/dev/null || echo "unknown")
550
+ case "$arch" in
551
+ arm64|aarch64)
552
+ echo "- **Architecture**: ARM64 (Apple Silicon / ARM)"
553
+ ;;
554
+ x86_64|amd64)
555
+ echo "- **Architecture**: x86_64 (Intel/AMD)"
556
+ ;;
557
+ *)
558
+ echo "- **Architecture**: $arch"
559
+ ;;
560
+ esac
561
+
562
+ # Docker provider detection from socket path
563
+ if [[ -n "$docker_socket" ]]; then
564
+ case "$docker_socket" in
565
+ */.orbstack/*)
566
+ echo "- **Docker Provider**: OrbStack (high performance, native macOS integration)"
567
+ ;;
568
+ */.docker/run/*)
569
+ echo "- **Docker Provider**: Docker Desktop"
570
+ ;;
571
+ */.colima/*)
572
+ echo "- **Docker Provider**: Colima"
573
+ ;;
574
+ */.rd/*)
575
+ echo "- **Docker Provider**: Rancher Desktop"
576
+ ;;
577
+ /var/run/docker.sock)
578
+ echo "- **Docker Provider**: Docker Engine (native Linux)"
579
+ ;;
580
+ *)
581
+ echo "- **Docker Provider**: Custom Docker setup"
582
+ ;;
583
+ esac
584
+ fi
585
+
586
+ cat <<'EOF'
587
+
588
+ ## Available Capabilities
589
+ EOF
590
+
591
+ # Docker access
592
+ if [[ "$has_docker" == "true" ]]; then
593
+ cat <<'EOF'
594
+ - **Docker Access**: You have access to the host's Docker daemon via mounted socket
595
+ - Can build, run, and manage Docker containers
596
+ - Can execute docker and docker-compose commands
597
+ - All Docker operations affect the host Docker daemon
598
+ EOF
599
+ fi
600
+
601
+ # Network mode
602
+ case "$network_mode" in
603
+ host)
604
+ cat <<'EOF'
605
+ - **Networking**: Host networking mode enabled
606
+ - Direct access to `localhost:PORT` services on the host
607
+ - Can communicate with other containers via localhost
608
+ - Full network stack sharing with host
609
+ EOF
610
+ ;;
611
+ bridge)
612
+ cat <<'EOF'
613
+ - **Networking**: Bridge networking mode (isolated network)
614
+ - Cannot directly access `localhost` services
615
+ - Use `host.docker.internal:PORT` to access host services
616
+ - Container has isolated network namespace
617
+ EOF
618
+ ;;
619
+ esac
620
+
621
+ # Git SSH authentication
622
+ case "$git_auth_mode" in
623
+ agent-forwarding)
624
+ cat <<'EOF'
625
+ - **SSH Authentication**: Agent forwarding enabled
626
+ - SSH keys available via forwarded agent (secure, keys never in container)
627
+ - Can authenticate to GitHub, GitLab, and other SSH services
628
+ - Private keys remain on host machine only
629
+ - **Important**: Keys must be loaded in host's SSH agent (`ssh-add -l` to verify)
630
+ - On macOS: Proxy container (`dclaude-ssh-proxy-*`) bridges permissions automatically
631
+ - If git fails with auth errors, user needs to run `ssh-add ~/.ssh/id_ed25519` on host
632
+ - Socket location in container: $SSH_AUTH_SOCK (typically /tmp/ssh-proxy/agent)
633
+ - Known hosts pre-configured for: GitHub, GitLab, Bitbucket
634
+ EOF
635
+ ;;
636
+ key-mount)
637
+ cat <<'EOF'
638
+ - **SSH Authentication**: SSH keys mounted (read-only)
639
+ - Host's ~/.ssh directory mounted into container at /home/claude/.ssh
640
+ - Can authenticate to GitHub, GitLab, and other SSH services
641
+ - Keys are read-only, cannot be modified
642
+ - SSH agent not required - keys read directly from filesystem
643
+ - Use ssh-add inside container if agent needed
644
+ - Note: Uses host's known_hosts file (not container's pre-configured one)
645
+ EOF
646
+ ;;
647
+ none)
648
+ cat <<'EOF'
649
+ - **SSH Authentication**: Not configured
650
+ - SSH keys not available in container
651
+ - Cannot authenticate to remote Git repositories via SSH
652
+ - Recommend using HTTPS authentication for Git operations
653
+ - Or restart with DCLAUDE_GIT_AUTH=agent-forwarding or DCLAUDE_GIT_AUTH=key-mount
654
+ EOF
655
+ ;;
656
+ esac
657
+
658
+ cat <<'EOF'
659
+
660
+ ## Development Tools Available
661
+ - **Languages**: Node.js 20+, Python 3
662
+ - **Package Managers**: npm, pip, Homebrew/Linuxbrew
663
+ - **Tools**: git, gh (GitHub CLI), docker, docker-compose, curl, tmux, nano
664
+ - **Shell**: bash (your commands execute in bash shell)
665
+
666
+ ## Git Configuration Requirements
667
+ **IMPORTANT**: Before performing any git operations (commit, push, etc.), you MUST:
668
+ 1. Check if git is configured: `git config user.name` and `git config user.email`
669
+ 2. If either is missing/empty, ASK the user for their git name and email
670
+ 3. Configure git with: `git config --global user.name "User Name"` and `git config --global user.email "user@example.com"`
671
+ 4. Never assume or make up git credentials - always ask the user first
672
+
673
+ ## Important Notes
674
+ - File operations are performed on the host filesystem (not isolated)
675
+ - Changes persist after container exits (files are on host)
676
+ - System packages can be installed with apt-get, Homebrew, or npm
677
+ - Use relative paths when possible - absolute paths work due to path mirroring
678
+ - Git operations work normally - repository sees correct paths
679
+
680
+ When suggesting commands or file operations, you can treat this environment as if running natively on the host, with the benefits of containerization for tool isolation.
681
+ EOF
682
+ }
683
+
684
+ # Handle SSH authentication based on DCLAUDE_GIT_AUTH mode
685
+ # Setup SSH proxy container for macOS
686
+ setup_ssh_proxy_container() {
687
+ local proxy_container="dclaude-ssh-proxy-$(id -u)"
688
+
689
+ # Check if proxy container already exists and is running
690
+ if docker ps -q -f name="^${proxy_container}$" 2>/dev/null | grep -q .; then
691
+ debug "SSH proxy container already running"
692
+ return 0
693
+ fi
694
+
695
+ # Remove any stopped proxy container
696
+ if docker ps -aq -f name="^${proxy_container}$" 2>/dev/null | grep -q .; then
697
+ debug "Removing stopped SSH proxy container"
698
+ docker rm -f "$proxy_container" >/dev/null 2>&1
699
+ fi
700
+
701
+ info "Starting SSH agent proxy container..."
702
+ debug "Proxy container name: $proxy_container"
703
+ debug "Bridging macOS SSH agent socket permissions"
704
+
705
+ # Create the proxy container that runs socat as root
706
+ # This container just bridges the permission gap and exits
707
+ docker run -d \
708
+ --name "$proxy_container" \
709
+ -v "/run/host-services/ssh-auth.sock:/run/host-services/ssh-auth.sock:ro" \
710
+ -v "dclaude-ssh-proxy:/tmp/ssh-proxy" \
711
+ --rm \
712
+ alpine:3.19 sh -c '
713
+ # Install socat
714
+ apk add --no-cache socat >/dev/null 2>&1
715
+
716
+ # Create proxy socket accessible to all users
717
+ rm -f /tmp/ssh-proxy/agent
718
+ socat UNIX-LISTEN:/tmp/ssh-proxy/agent,fork,mode=660 \
719
+ UNIX-CONNECT:/run/host-services/ssh-auth.sock
720
+ ' >/dev/null 2>&1
721
+
722
+ # Give it a moment to start
723
+ sleep 0.5
724
+
725
+ # Verify the proxy is working
726
+ if ! docker ps -q -f name="^${proxy_container}$" 2>/dev/null | grep -q .; then
727
+ error "Failed to start SSH proxy container"
728
+ return 1
729
+ fi
730
+
731
+ debug "SSH proxy container started successfully"
732
+ return 0
733
+ }
734
+
735
+ handle_git_auth() {
736
+ local docker_args=()
737
+ local git_auth_mode="${GIT_AUTH_MODE}"
738
+
739
+ # If config mounting is enabled and git auth mode is auto, prefer key-mount for consistency
740
+ if [[ "$MOUNT_CONFIGS" == "true" ]] && [[ "$git_auth_mode" == "auto" ]]; then
741
+ if [[ -d "${HOME}/.ssh" ]] && [[ -r "${HOME}/.ssh" ]]; then
742
+ git_auth_mode="key-mount"
743
+ debug "Git auth: key-mount (DCLAUDE_MOUNT_CONFIGS=true prefers key mounting)"
744
+ fi
745
+ fi
746
+
747
+ # Auto-detect best method if set to auto
748
+ if [[ "$git_auth_mode" == "auto" ]]; then
749
+ if [[ -n "${SSH_AUTH_SOCK:-}" ]] && [[ -S "${SSH_AUTH_SOCK}" ]]; then
750
+ git_auth_mode="agent-forwarding"
751
+ debug "Git auth: agent-forwarding (auto-detected active agent)"
752
+ elif [[ -d "${HOME}/.ssh" ]] && [[ -r "${HOME}/.ssh" ]]; then
753
+ git_auth_mode="key-mount"
754
+ debug "Git auth: key-mount (auto-detected SSH directory)"
755
+ else
756
+ git_auth_mode="none"
757
+ debug "Git auth: none (no SSH agent or keys found)"
758
+ fi
759
+ fi
760
+
761
+ case "$git_auth_mode" in
762
+ agent-forwarding)
763
+ if [[ -z "${SSH_AUTH_SOCK:-}" ]]; then
764
+ warning "SSH agent forwarding requested but SSH_AUTH_SOCK not set"
765
+ warning "Start SSH agent with: eval \$(ssh-agent) && ssh-add"
766
+ return 1
767
+ fi
768
+
769
+ if [[ ! -S "${SSH_AUTH_SOCK}" ]]; then
770
+ warning "SSH agent forwarding requested but socket not found: ${SSH_AUTH_SOCK}"
771
+ return 1
772
+ fi
773
+
774
+ # Platform-specific socket mounting
775
+ local platform=$(detect_platform)
776
+ debug "Setting up SSH agent forwarding for platform: $platform"
777
+ case "$platform" in
778
+ linux)
779
+ debug "Using direct socket mount: ${SSH_AUTH_SOCK}"
780
+ docker_args+=(-v "${SSH_AUTH_SOCK}:/tmp/ssh-agent" -e "SSH_AUTH_SOCK=/tmp/ssh-agent")
781
+ info "SSH agent forwarding enabled (Linux)"
782
+ ;;
783
+ darwin)
784
+ # On macOS, we need to set up a proxy container first
785
+ debug "Setting up SSH proxy container for macOS"
786
+ setup_ssh_proxy_container
787
+
788
+ # Now mount the proxied socket from the shared volume
789
+ docker_args+=(-v "dclaude-ssh-proxy:/tmp/ssh-proxy:ro"
790
+ -e "SSH_AUTH_SOCK=/tmp/ssh-proxy/agent")
791
+ info "SSH agent forwarding enabled via proxy container"
792
+ debug "Mounted SSH proxy volume: dclaude-ssh-proxy:/tmp/ssh-proxy"
793
+ ;;
794
+ windows)
795
+ warning "SSH agent forwarding not fully supported on Windows"
796
+ warning "Consider using key-mount mode instead: DCLAUDE_GIT_AUTH=key-mount"
797
+ return 1
798
+ ;;
799
+ esac
800
+ ;;
801
+
802
+ key-mount)
803
+ if [[ -d "${HOME}/.ssh" ]] && [[ -r "${HOME}/.ssh" ]]; then
804
+ docker_args+=(-v "${HOME}/.ssh:/home/claude/.ssh:ro")
805
+ info "SSH key mounting enabled (read-only)"
806
+ debug "Mounting SSH directory: ${HOME}/.ssh"
807
+ warning "SSH private keys are accessible in container (read-only)"
808
+ else
809
+ warning "SSH key mount requested but ~/.ssh not found or not readable"
810
+ return 1
811
+ fi
812
+ ;;
813
+
814
+ none)
815
+ debug "SSH authentication disabled"
816
+ ;;
817
+
818
+ *)
819
+ error "Invalid git auth mode: $git_auth_mode (valid: auto, agent-forwarding, key-mount, none)"
820
+ return 1
821
+ ;;
822
+ esac
823
+
824
+ # Print arguments separated by null characters for safe parsing (only if we have args)
825
+ if [[ ${#docker_args[@]} -gt 0 ]]; then
826
+ printf '%s\0' "${docker_args[@]}"
827
+ fi
828
+ }
829
+
830
+ # Mount configuration directories from host
831
+ mount_host_configs() {
832
+ local docker_args=()
833
+ local mounted_count=0
834
+
835
+ if [[ "$MOUNT_CONFIGS" != "true" ]]; then
836
+ debug "Config mounting disabled (DCLAUDE_MOUNT_CONFIGS=$MOUNT_CONFIGS)"
837
+ return 0
838
+ fi
839
+
840
+ info "Mounting host configurations (read-only)"
841
+ debug "SSH authentication handled separately via DCLAUDE_GIT_AUTH"
842
+
843
+ # Docker configuration (docker-cli installed)
844
+ # Default to true when master switch is enabled
845
+ if [[ "${DCLAUDE_MOUNT_DOCKER:-true}" == "true" ]] && [[ -d "${HOME}/.docker" ]]; then
846
+ if [[ -r "${HOME}/.docker" ]]; then
847
+ docker_args+=(-v "${HOME}/.docker:/home/claude/.docker:ro")
848
+ debug "Mounting Docker config: ${HOME}/.docker"
849
+ ((mounted_count++))
850
+ else
851
+ warning "Docker config exists but is not readable: ${HOME}/.docker"
852
+ fi
853
+ elif [[ "${DCLAUDE_MOUNT_DOCKER:-true}" == "true" ]]; then
854
+ debug "Docker config not found: ${HOME}/.docker"
855
+ fi
856
+
857
+ # GitHub CLI configuration (github-cli installed)
858
+ # Default to true when master switch is enabled
859
+ if [[ "${DCLAUDE_MOUNT_GH:-true}" == "true" ]] && [[ -d "${HOME}/.config/gh" ]]; then
860
+ if [[ -r "${HOME}/.config/gh" ]]; then
861
+ docker_args+=(-v "${HOME}/.config/gh:/home/claude/.config/gh:ro")
862
+ debug "Mounting GitHub CLI config: ${HOME}/.config/gh"
863
+ ((mounted_count++))
864
+ else
865
+ warning "GitHub CLI config exists but is not readable: ${HOME}/.config/gh"
866
+ fi
867
+ elif [[ "${DCLAUDE_MOUNT_GH:-true}" == "true" ]]; then
868
+ debug "GitHub CLI config not found: ${HOME}/.config/gh"
869
+ fi
870
+
871
+ # Git configuration (git installed)
872
+ # Default to true when master switch is enabled
873
+ if [[ "${DCLAUDE_MOUNT_GIT:-true}" == "true" ]] && [[ -f "${HOME}/.gitconfig" ]]; then
874
+ if [[ -r "${HOME}/.gitconfig" ]]; then
875
+ docker_args+=(-v "${HOME}/.gitconfig:/home/claude/.gitconfig:ro")
876
+ debug "Mounting Git config: ${HOME}/.gitconfig"
877
+ ((mounted_count++))
878
+ else
879
+ warning "Git config exists but is not readable: ${HOME}/.gitconfig"
880
+ fi
881
+ elif [[ "${DCLAUDE_MOUNT_GIT:-true}" == "true" ]]; then
882
+ debug "Git config not found: ${HOME}/.gitconfig"
883
+ fi
884
+
885
+ # NPM configuration (npm installed)
886
+ # Default to true when master switch is enabled
887
+ if [[ "${DCLAUDE_MOUNT_NPM:-true}" == "true" ]] && [[ -f "${HOME}/.npmrc" ]]; then
888
+ if [[ -r "${HOME}/.npmrc" ]]; then
889
+ docker_args+=(-v "${HOME}/.npmrc:/home/claude/.npmrc:ro")
890
+ debug "Mounting NPM config: ${HOME}/.npmrc"
891
+ ((mounted_count++))
892
+ else
893
+ warning "NPM config exists but is not readable: ${HOME}/.npmrc"
894
+ fi
895
+ elif [[ "${DCLAUDE_MOUNT_NPM:-true}" == "true" ]]; then
896
+ debug "NPM config not found: ${HOME}/.npmrc"
897
+ fi
898
+
899
+ # Note: AWS CLI, gcloud, kubectl are NOT installed in the container
900
+ # These configs will not be mounted unless those tools are added to Dockerfile
901
+
902
+ # Security warning with details
903
+ if [[ $mounted_count -gt 0 ]]; then
904
+ warning "Mounted $mounted_count configuration directories (read-only)"
905
+ warning "Security: The following sensitive data is now accessible in the container:"
906
+ # SSH is now handled separately, don't warn here
907
+ if [[ "${DCLAUDE_MOUNT_DOCKER:-true}" == "true" ]] && [[ -d "${HOME}/.docker" ]] && [[ -r "${HOME}/.docker" ]]; then
908
+ warning " - Docker registry authentication tokens"
909
+ fi
910
+ if [[ "${DCLAUDE_MOUNT_GH:-true}" == "true" ]] && [[ -d "${HOME}/.config/gh" ]] && [[ -r "${HOME}/.config/gh" ]]; then
911
+ warning " - GitHub CLI authentication tokens"
912
+ fi
913
+ if [[ "${DCLAUDE_MOUNT_NPM:-true}" == "true" ]] && [[ -f "${HOME}/.npmrc" ]] && [[ -r "${HOME}/.npmrc" ]]; then
914
+ warning " - NPM registry authentication tokens"
915
+ fi
916
+ warning "Only use in trusted environments!"
917
+ fi
918
+
919
+ # Print arguments separated by null characters for safe parsing (only if we have args)
920
+ if [[ ${#docker_args[@]} -gt 0 ]]; then
921
+ printf '%s\0' "${docker_args[@]}"
922
+ fi
923
+ }
924
+
925
+ # Detect TTY availability and return appropriate Docker flags
926
+ detect_tty_flags() {
927
+ local tty_flags=""
928
+
929
+ # Check if stdin is a TTY
930
+ if [[ -t 0 ]]; then
931
+ tty_flags="-i"
932
+ fi
933
+
934
+ # Check if stdout is a TTY
935
+ if [[ -t 1 ]]; then
936
+ tty_flags="${tty_flags} -t"
937
+ fi
938
+
939
+ # Trim whitespace
940
+ tty_flags=$(echo $tty_flags | xargs)
941
+
942
+ if [[ "$DEBUG" == "true" ]]; then
943
+ debug "TTY detection: stdin=$(test -t 0 && echo yes || echo no), stdout=$(test -t 1 && echo yes || echo no)"
944
+ debug "TTY flags: ${tty_flags:-none}"
945
+ fi
946
+
947
+ echo "$tty_flags"
948
+ }
949
+
950
+ # Check if Claude is being run in print mode (-p/--print)
951
+ # This checks arguments as separate array elements, so -p inside a string won't match
952
+ is_print_mode() {
953
+ for arg in "$@"; do
954
+ if [[ "$arg" == "-p" ]] || [[ "$arg" == "--print" ]]; then
955
+ return 0
956
+ fi
957
+ done
958
+ return 1
959
+ }
960
+
961
+ # Main execution
962
+ main() {
963
+ # No argument parsing - pass everything through to Claude
964
+ # All arguments are preserved as-is for Claude
965
+
966
+ info "Verifying environment..."
967
+ debug "Host path: $HOST_PATH"
968
+
969
+ # Check prerequisites
970
+ check_docker
971
+ check_docker_running
972
+
973
+ # Setup environment
974
+ create_volumes
975
+ update_image
976
+
977
+ # Platform-specific settings
978
+ local platform
979
+ platform=$(detect_platform)
980
+ debug "Platform detected: $platform"
981
+
982
+ # Set network mode based on auto-detection or user preference
983
+ local network_mode="${DCLAUDE_NETWORK:-auto}"
984
+ local detection_source="default"
985
+
986
+ if [[ "$network_mode" == "auto" || -z "$network_mode" ]]; then
987
+ # Auto-detect network capability
988
+ network_mode=$(detect_network_capability)
989
+ detection_source="auto-detected"
990
+ debug "Auto-detected network mode: $network_mode"
991
+
992
+ # Show warning if using bridge mode
993
+ if [[ "$network_mode" == "bridge" ]]; then
994
+ warning "Using bridge networking mode on $platform"
995
+ info "Bridge mode limitations:"
996
+ info " - Cannot access services on localhost (use host.docker.internal instead)"
997
+ info " - Cannot access other containers via localhost"
998
+ info " - Port mapping required for container services"
999
+ info ""
1000
+ info "For better localhost access, consider:"
1001
+ info " - macOS: Enable host networking in Docker Desktop (beta) or use OrbStack"
1002
+ info " - Windows: Enable host networking in Docker Desktop (beta feature)"
1003
+ else
1004
+ debug "Host networking available - full localhost access enabled"
1005
+ fi
1006
+ elif [[ "$network_mode" =~ ^(host|bridge)$ ]]; then
1007
+ detection_source="user-specified"
1008
+ debug "Using user-specified network mode: $network_mode"
1009
+ else
1010
+ warning "Invalid network mode '$network_mode'. Valid options: host, bridge, auto. Falling back to auto-detection"
1011
+ network_mode=$(detect_network_capability)
1012
+ detection_source="fallback auto-detected"
1013
+ debug "Fallback auto-detected network mode: $network_mode"
1014
+ fi
1015
+ debug "Network mode: $network_mode ($detection_source)"
1016
+
1017
+ # Enhanced debug output for network configuration
1018
+ if [[ "$DEBUG" == "true" ]]; then
1019
+ debug "Network configuration summary:"
1020
+ debug " - Platform: $platform"
1021
+ debug " - Network mode: $network_mode"
1022
+ debug " - Detection source: $detection_source"
1023
+ debug " - DCLAUDE_NETWORK environment: ${DCLAUDE_NETWORK:-<not set>}"
1024
+ if [[ "$detection_source" =~ auto-detected ]]; then
1025
+ debug " - Auto-detection performed: yes"
1026
+ if [[ -f "${HOME}/.dclaude/network-mode" ]]; then
1027
+ local cache_age
1028
+ if cache_age=$(stat -c %Y "${HOME}/.dclaude/network-mode" 2>/dev/null) || cache_age=$(stat -f %m "${HOME}/.dclaude/network-mode" 2>/dev/null); then
1029
+ local current_time
1030
+ current_time=$(date +%s)
1031
+ local age_hours=$(( (current_time - cache_age) / 3600 ))
1032
+ debug " - Cache used: yes (age: ${age_hours}h)"
1033
+ else
1034
+ debug " - Cache used: yes (age: unknown)"
1035
+ fi
1036
+ else
1037
+ debug " - Cache used: no"
1038
+ fi
1039
+ else
1040
+ debug " - Auto-detection performed: no"
1041
+ fi
1042
+ fi
1043
+
1044
+ # Detect TTY availability
1045
+ local tty_flags=$(detect_tty_flags)
1046
+
1047
+ # Detect Docker socket early (needed for system context generation)
1048
+ if [[ -z "$DOCKER_SOCKET" ]]; then
1049
+ DOCKER_SOCKET=$(detect_docker_socket) || true
1050
+ fi
1051
+ debug "Docker socket detection: ${DOCKER_SOCKET:-<not found>}"
1052
+
1053
+ # Resolve git auth mode from 'auto' to actual mode (needed for system context)
1054
+ local resolved_git_auth="$GIT_AUTH_MODE"
1055
+ if [[ "$GIT_AUTH_MODE" == "auto" ]]; then
1056
+ # Same auto-detection logic as handle_git_auth()
1057
+ if [[ "$MOUNT_CONFIGS" == "true" ]] && [[ -d "${HOME}/.ssh" ]] && [[ -r "${HOME}/.ssh" ]]; then
1058
+ resolved_git_auth="key-mount"
1059
+ debug "Git auth resolved: key-mount (DCLAUDE_MOUNT_CONFIGS=true)"
1060
+ elif [[ -n "${SSH_AUTH_SOCK:-}" ]] && [[ -S "${SSH_AUTH_SOCK}" ]]; then
1061
+ resolved_git_auth="agent-forwarding"
1062
+ debug "Git auth resolved: agent-forwarding (active agent detected)"
1063
+ elif [[ -d "${HOME}/.ssh" ]] && [[ -r "${HOME}/.ssh" ]]; then
1064
+ resolved_git_auth="key-mount"
1065
+ debug "Git auth resolved: key-mount (SSH directory detected)"
1066
+ else
1067
+ resolved_git_auth="none"
1068
+ debug "Git auth resolved: none (no SSH agent or keys found)"
1069
+ fi
1070
+ else
1071
+ debug "Git auth: $resolved_git_auth (user-specified)"
1072
+ fi
1073
+
1074
+ # Generate system context for Claude (if enabled) - must be done early for all code paths
1075
+ local claude_args=()
1076
+ if [[ "$ENABLE_SYSTEM_CONTEXT" == "true" ]]; then
1077
+ local has_docker="false"
1078
+ [[ -n "$DOCKER_SOCKET" ]] && [[ -S "$DOCKER_SOCKET" ]] && has_docker="true"
1079
+
1080
+ local system_context
1081
+ system_context=$(generate_system_context "$network_mode" "$resolved_git_auth" "$has_docker" "$platform" "$DOCKER_SOCKET")
1082
+
1083
+ claude_args+=("--append-system-prompt" "$system_context")
1084
+ debug "System context enabled (${#system_context} chars, has_docker=$has_docker, git_auth=$resolved_git_auth, platform=$platform)"
1085
+ if [[ "$DEBUG" == "true" ]]; then
1086
+ debug "System context preview: ${system_context:0:100}..."
1087
+ fi
1088
+ else
1089
+ debug "System context disabled (DCLAUDE_SYSTEM_CONTEXT=$ENABLE_SYSTEM_CONTEXT)"
1090
+ fi
1091
+
1092
+ # Generate container name based on path (for reuse when DCLAUDE_RM=false)
1093
+ local container_name=""
1094
+ if [[ "$REMOVE_CONTAINER" == "false" ]]; then
1095
+ # Create deterministic name from path hash
1096
+ container_name=$(get_container_name "$HOST_PATH")
1097
+ debug "Container name: $container_name (path: $HOST_PATH)"
1098
+
1099
+ # Check if container already exists
1100
+ if docker ps -a --format '{{.Names}}' | grep -q "^${container_name}$"; then
1101
+ local container_status=$(docker inspect --format='{{.State.Status}}' "$container_name" 2>/dev/null)
1102
+ debug "Found existing container: $container_name (status: $container_status)"
1103
+
1104
+ if [[ "$container_status" == "running" ]]; then
1105
+ info "Attaching to running container: $container_name"
1106
+ elif [[ "$container_status" == "exited" ]]; then
1107
+ info "Restarting existing container: $container_name"
1108
+ docker start "$container_name" >/dev/null
1109
+
1110
+ # Wait for container to be running
1111
+ local wait_count=0
1112
+ while [[ $wait_count -lt 30 ]]; do
1113
+ if docker ps -q -f name="^${container_name}$" 2>/dev/null | grep -q .; then
1114
+ debug "Container restarted successfully"
1115
+ break
1116
+ fi
1117
+ sleep 0.1
1118
+ ((wait_count++))
1119
+ done
1120
+ fi
1121
+
1122
+ # Check if interactive (TTY available) and not in print mode
1123
+ if [[ -n "$tty_flags" ]] && ! is_print_mode "${claude_args[@]}" "$@"; then
1124
+ # Interactive and not print mode - use tmux for session management
1125
+ local tmux_session
1126
+ if [[ -n "${DCLAUDE_TMUX_SESSION:-}" ]]; then
1127
+ tmux_session="$DCLAUDE_TMUX_SESSION"
1128
+ debug "Using custom tmux session name: $tmux_session"
1129
+ else
1130
+ tmux_session="claude-$(date +%Y%m%d-%H%M%S)"
1131
+ debug "Generated unique tmux session name: $tmux_session"
1132
+ fi
1133
+
1134
+ # Build env args for docker exec to pass terminal info to tmux session
1135
+ local exec_env_args=()
1136
+ [[ -n "${TERM_PROGRAM:-}" ]] && exec_env_args+=(-e "TERM_PROGRAM=${TERM_PROGRAM}")
1137
+ [[ -n "${TERM_PROGRAM_VERSION:-}" ]] && exec_env_args+=(-e "TERM_PROGRAM_VERSION=${TERM_PROGRAM_VERSION}")
1138
+ [[ -n "${TERM_SESSION_ID:-}" ]] && exec_env_args+=(-e "TERM_SESSION_ID=${TERM_SESSION_ID}")
1139
+ [[ -n "${COLORTERM:-}" ]] && exec_env_args+=(-e "COLORTERM=${COLORTERM}")
1140
+ # Internal env vars for tmux status bar display
1141
+ exec_env_args+=(-e "_DCLAUDE_NET=${network_mode}")
1142
+ exec_env_args+=(-e "_DCLAUDE_TAG=${DCLAUDE_TAG:-latest}")
1143
+ exec_env_args+=(-e "_DCLAUDE_SESSION=${DCLAUDE_TMUX_SESSION:-auto}")
1144
+
1145
+ debug "Creating new tmux session running Claude"
1146
+ debug "Claude args count: ${#claude_args[@]}, user args: $*"
1147
+ info "Starting new Claude session..."
1148
+ docker exec -it -u claude "${exec_env_args[@]}" "$container_name" tmux -f /home/claude/.tmux.conf new-session -s "$tmux_session" claude "${claude_args[@]}" "$@"
1149
+ local tmux_exit=$?
1150
+ reset_terminal_mouse
1151
+ exit $tmux_exit
1152
+ else
1153
+ # Non-interactive or print mode - run claude directly without tmux
1154
+ debug "Non-interactive or print mode, running Claude directly (no tmux)"
1155
+ debug "Claude args count: ${#claude_args[@]}, user args: $*"
1156
+ exec docker exec -u claude -w "$HOST_PATH" "$container_name" claude "${claude_args[@]}" "$@"
1157
+ fi
1158
+ fi
1159
+ fi
1160
+
1161
+ # Prepare Docker run arguments
1162
+ DOCKER_ARGS=(
1163
+ "run"
1164
+ )
1165
+
1166
+ # Add --rm flag if enabled (default: true)
1167
+ if [[ "$REMOVE_CONTAINER" == "true" ]]; then
1168
+ DOCKER_ARGS+=("--rm")
1169
+ else
1170
+ # Use named container for reuse
1171
+ DOCKER_ARGS+=("--name" "$container_name")
1172
+ fi
1173
+
1174
+ # TTY flags will be added only when needed (ephemeral containers)
1175
+ # Persistent containers run in background and shouldn't have TTY/stdin attached
1176
+
1177
+
1178
+ DOCKER_ARGS+=(
1179
+ # Mount current directory
1180
+ -v "${HOST_PATH}:${HOST_PATH}"
1181
+ # Mount persistent Claude configuration volume
1182
+ -v "${VOLUME_PREFIX}-claude:/home/claude/.claude"
1183
+ # Mount persistent config volume (gh, etc.)
1184
+ -v "${VOLUME_PREFIX}-config:/home/claude/.config"
1185
+ # Set working directory
1186
+ -w "${HOST_PATH}"
1187
+ # Network mode
1188
+ --network="$network_mode"
1189
+ # Environment - pass through terminal identification for proper feature detection
1190
+ -e "TERM=${TERM:-xterm-256color}"
1191
+ )
1192
+
1193
+ # Reserve SSH port for remote access (JetBrains Gateway, VS Code Remote, etc.)
1194
+ local ssh_port
1195
+ ssh_port=$(find_available_port 2222 65000)
1196
+ debug "SSH port reserved: $ssh_port"
1197
+ DOCKER_ARGS+=(--label "dclaude.ssh.port=${ssh_port}")
1198
+
1199
+ # Port mapping only needed for bridge mode (host mode shares network stack)
1200
+ if [[ "$network_mode" != "host" ]]; then
1201
+ DOCKER_ARGS+=(-p "${ssh_port}:${ssh_port}")
1202
+ debug "SSH port mapping: ${ssh_port}:${ssh_port} (bridge mode)"
1203
+ else
1204
+ debug "SSH port: ${ssh_port} (host mode, no mapping needed)"
1205
+ fi
1206
+
1207
+ # Pass through terminal program information for proper logo rendering
1208
+ if [[ -n "${TERM_PROGRAM:-}" ]]; then
1209
+ DOCKER_ARGS+=(-e "TERM_PROGRAM=${TERM_PROGRAM}")
1210
+ fi
1211
+ if [[ -n "${TERM_PROGRAM_VERSION:-}" ]]; then
1212
+ DOCKER_ARGS+=(-e "TERM_PROGRAM_VERSION=${TERM_PROGRAM_VERSION}")
1213
+ fi
1214
+ if [[ -n "${TERM_SESSION_ID:-}" ]]; then
1215
+ DOCKER_ARGS+=(-e "TERM_SESSION_ID=${TERM_SESSION_ID}")
1216
+ fi
1217
+ if [[ -n "${COLORTERM:-}" ]]; then
1218
+ DOCKER_ARGS+=(-e "COLORTERM=${COLORTERM}")
1219
+ fi
1220
+
1221
+ # Mount Docker socket if detected (detection done earlier for system context)
1222
+ if [[ -n "$DOCKER_SOCKET" ]] && [[ -S "$DOCKER_SOCKET" ]]; then
1223
+ DOCKER_ARGS+=(-v "${DOCKER_SOCKET}:/var/run/docker.sock")
1224
+ debug "Docker socket mounted: $DOCKER_SOCKET"
1225
+ else
1226
+ warning "Docker socket not found"
1227
+ warning "Container will not have Docker access"
1228
+ debug "Searched standard locations and Docker context"
1229
+ fi
1230
+
1231
+ # Handle SSH authentication (agent forwarding or key mounting)
1232
+ # Use process substitution to preserve null bytes
1233
+ while IFS= read -r -d '' ssh_arg; do
1234
+ [[ -n "$ssh_arg" ]] && DOCKER_ARGS+=("$ssh_arg")
1235
+ done < <(handle_git_auth)
1236
+
1237
+ # Mount host configurations if enabled
1238
+ # Use process substitution to preserve null bytes
1239
+ while IFS= read -r -d '' config_arg; do
1240
+ [[ -n "$config_arg" ]] && DOCKER_ARGS+=("$config_arg")
1241
+ done < <(mount_host_configs)
1242
+
1243
+ # Add any additional environment variables
1244
+ if [[ -n "${CLAUDE_MODEL:-}" ]]; then
1245
+ DOCKER_ARGS+=(-e "CLAUDE_MODEL=${CLAUDE_MODEL}")
1246
+ fi
1247
+
1248
+ if [[ "$DEBUG" == "true" ]]; then
1249
+ debug "Container removal: $REMOVE_CONTAINER (DCLAUDE_RM=${DCLAUDE_RM:-<not set>})"
1250
+ debug "Docker command: docker ${DOCKER_ARGS[*]} $IMAGE $*"
1251
+ fi
1252
+
1253
+ # Run Claude in Docker
1254
+ if [[ "$REMOVE_CONTAINER" == "false" ]]; then
1255
+ # For persistent containers, run tini with tail daemon for zombie reaping
1256
+ # tini will reap zombie processes created by exec commands
1257
+ debug "Starting persistent container with tini + tail daemon"
1258
+
1259
+ # TODO: Add retry logic for SSH port allocation race condition
1260
+ # When multiple dclaude instances start simultaneously, they can both detect
1261
+ # the same SSH port as available before either binds to it. This causes the
1262
+ # second instance to fail with "port is already allocated" error.
1263
+ # Solution: If docker run fails with port conflict, re-detect port and retry (max 3 attempts)
1264
+ # See: SSH_TEST_RESULTS.md - Issue 1: Race Condition in Parallel Container Startup
1265
+
1266
+ # Run in detached mode (-d) and capture output (Container ID) or errors
1267
+ local run_output
1268
+ if ! run_output=$(docker "${DOCKER_ARGS[@]}" -d --entrypoint /usr/bin/tini "$IMAGE" -- tail -f /dev/null 2>&1); then
1269
+ error "Failed to start container: $run_output"
1270
+ exit 1
1271
+ fi
1272
+
1273
+ debug "Container started successfully: $run_output"
1274
+
1275
+ # Run entrypoint setup as root (for Docker socket permissions, etc.)
1276
+ debug "Running entrypoint initialization"
1277
+ docker exec -u root "$container_name" /usr/local/bin/docker-entrypoint.sh true >/dev/null 2>&1 || true
1278
+
1279
+ # Check if interactive (TTY available) and not in print mode
1280
+ if [[ -n "$tty_flags" ]] && ! is_print_mode "${claude_args[@]}" "$@"; then
1281
+ # Interactive and not print mode - use tmux for session management
1282
+ local tmux_session
1283
+ if [[ -n "${DCLAUDE_TMUX_SESSION:-}" ]]; then
1284
+ tmux_session="$DCLAUDE_TMUX_SESSION"
1285
+ debug "Using custom tmux session name: $tmux_session"
1286
+ else
1287
+ tmux_session="claude-$(date +%Y%m%d-%H%M%S)"
1288
+ debug "Generated unique tmux session name: $tmux_session"
1289
+ fi
1290
+
1291
+ # Build env args for docker exec to pass terminal info to tmux session
1292
+ local exec_env_args=()
1293
+ [[ -n "${TERM_PROGRAM:-}" ]] && exec_env_args+=(-e "TERM_PROGRAM=${TERM_PROGRAM}")
1294
+ [[ -n "${TERM_PROGRAM_VERSION:-}" ]] && exec_env_args+=(-e "TERM_PROGRAM_VERSION=${TERM_PROGRAM_VERSION}")
1295
+ [[ -n "${TERM_SESSION_ID:-}" ]] && exec_env_args+=(-e "TERM_SESSION_ID=${TERM_SESSION_ID}")
1296
+ [[ -n "${COLORTERM:-}" ]] && exec_env_args+=(-e "COLORTERM=${COLORTERM}")
1297
+ # Internal env vars for tmux status bar display
1298
+ exec_env_args+=(-e "_DCLAUDE_NET=${network_mode}")
1299
+ exec_env_args+=(-e "_DCLAUDE_TAG=${DCLAUDE_TAG:-latest}")
1300
+ exec_env_args+=(-e "_DCLAUDE_SESSION=${DCLAUDE_TMUX_SESSION:-auto}")
1301
+
1302
+ debug "Creating new tmux session running Claude"
1303
+ debug "Claude args count: ${#claude_args[@]}, user args: $*"
1304
+ info "Starting new Claude session..."
1305
+ docker exec -it -u claude "${exec_env_args[@]}" "$container_name" tmux -f /home/claude/.tmux.conf new-session -s "$tmux_session" claude "${claude_args[@]}" "$@"
1306
+ local tmux_exit=$?
1307
+ reset_terminal_mouse
1308
+ exit $tmux_exit
1309
+ else
1310
+ # Non-interactive or print mode - run claude directly without tmux
1311
+ debug "Non-interactive or print mode, running Claude directly (no tmux)"
1312
+ debug "Claude args count: ${#claude_args[@]}, user args: $*"
1313
+ exec docker exec -u claude -w "$HOST_PATH" "$container_name" claude "${claude_args[@]}" "$@"
1314
+ fi
1315
+ else
1316
+ # Ephemeral container - run directly
1317
+ # Add TTY flags here as we are running interactively
1318
+ if [[ -n "$tty_flags" ]]; then
1319
+ DOCKER_ARGS+=($tty_flags)
1320
+ fi
1321
+ debug "Claude args count: ${#claude_args[@]}, user args: $*"
1322
+ exec docker "${DOCKER_ARGS[@]}" "$IMAGE" "${claude_args[@]}" "$@"
1323
+ fi
1324
+ }
1325
+
1326
+
1327
+
1328
+ # Subcommand: exec into container
1329
+ cmd_exec() {
1330
+ # Generate container name from path hash (same logic as main)
1331
+ local container_name=$(get_container_name "$HOST_PATH")
1332
+
1333
+
1334
+ debug "Looking for container: $container_name (path: $HOST_PATH)"
1335
+
1336
+ # Check if container exists
1337
+ if ! docker ps -a --format '{{.Names}}' | grep -q "^${container_name}$"; then
1338
+ error "No container found for this directory"
1339
+ info "Run 'DCLAUDE_RM=false dclaude' first to create a persistent container"
1340
+ exit 1
1341
+ fi
1342
+
1343
+ local container_status=$(docker inspect --format='{{.State.Status}}' "$container_name" 2>/dev/null)
1344
+ debug "Container status: $container_status"
1345
+
1346
+ if [[ "$container_status" != "running" ]]; then
1347
+ if [[ "$container_status" == "exited" ]]; then
1348
+ info "Restarting existing container: $container_name"
1349
+ docker start "$container_name" >/dev/null
1350
+ debug "Container restarted successfully"
1351
+ else
1352
+ error "Container $container_name is in unexpected state: $container_status"
1353
+ info "Remove it with: docker rm $container_name"
1354
+ exit 1
1355
+ fi
1356
+ fi
1357
+
1358
+ # Detect TTY availability
1359
+ local tty_flags=$(detect_tty_flags)
1360
+
1361
+ # Exec into container as claude user
1362
+ if [[ $# -eq 0 ]]; then
1363
+ # No command specified, open bash shell
1364
+ info "Opening shell in container: $container_name"
1365
+ debug "Exec command: docker exec $tty_flags -u claude -w $HOST_PATH $container_name bash"
1366
+ exec docker exec $tty_flags -u claude -w "$HOST_PATH" "$container_name" bash
1367
+ else
1368
+ # Execute specific command
1369
+ info "Executing command in container: $container_name"
1370
+ debug "Exec command: docker exec $tty_flags -u claude -w $HOST_PATH $container_name $*"
1371
+ exec docker exec $tty_flags -u claude -w "$HOST_PATH" "$container_name" "$@"
1372
+ fi
1373
+ }
1374
+
1375
+ # Subcommand: authenticate GitHub CLI
1376
+ cmd_gh() {
1377
+ # Generate container name based on current directory
1378
+ local container_name=$(get_container_name "$HOST_PATH")
1379
+
1380
+ # Check if container exists
1381
+ if ! docker ps -a --format '{{.Names}}' | grep -q "^${container_name}$"; then
1382
+ error "No container found for this directory"
1383
+ info "Run 'dclaude' first to create a container"
1384
+ exit 1
1385
+ fi
1386
+
1387
+ # Check if container is running
1388
+ local container_status=$(docker inspect --format='{{.State.Status}}' "$container_name" 2>/dev/null)
1389
+ if [[ "$container_status" != "running" ]]; then
1390
+ if [[ "$container_status" == "exited" ]]; then
1391
+ info "Restarting container: $container_name"
1392
+ docker start "$container_name" >/dev/null
1393
+ else
1394
+ error "Container $container_name is in unexpected state: $container_status"
1395
+ exit 1
1396
+ fi
1397
+ fi
1398
+
1399
+ info "Starting GitHub CLI authentication..."
1400
+ exec docker exec -it -u claude "$container_name" gh auth login
1401
+ }
1402
+
1403
+ # Subcommand: attach to existing tmux session
1404
+ cmd_attach() {
1405
+ local session_name="${1:-${DCLAUDE_TMUX_SESSION:-}}"
1406
+
1407
+ if [[ -z "$session_name" ]]; then
1408
+ error "No session name provided"
1409
+ info "Usage: dclaude attach <session-name>"
1410
+ info " or: DCLAUDE_TMUX_SESSION=<name> dclaude attach"
1411
+ exit 1
1412
+ fi
1413
+
1414
+ # Generate container name based on current directory
1415
+ local container_name=$(get_container_name "$HOST_PATH")
1416
+
1417
+
1418
+ # Check if container exists
1419
+ if ! docker ps -a --format '{{.Names}}' | grep -q "^${container_name}$"; then
1420
+ error "No container found for this directory"
1421
+ info "Run 'dclaude' first to create a container"
1422
+ exit 1
1423
+ fi
1424
+
1425
+ # Check if container is running
1426
+ local container_status=$(docker inspect --format='{{.State.Status}}' "$container_name" 2>/dev/null)
1427
+ if [[ "$container_status" != "running" ]]; then
1428
+ if [[ "$container_status" == "exited" ]]; then
1429
+ info "Restarting container: $container_name"
1430
+ docker start "$container_name" >/dev/null
1431
+ else
1432
+ error "Container $container_name is in unexpected state: $container_status"
1433
+ exit 1
1434
+ fi
1435
+ fi
1436
+
1437
+ # Check if session exists
1438
+ if ! docker exec -u claude "$container_name" tmux has-session -t "$session_name" 2>/dev/null; then
1439
+ error "Session '$session_name' not found in container"
1440
+ info "Available sessions:"
1441
+ docker exec -u claude "$container_name" tmux list-sessions 2>/dev/null || echo " (no sessions running)"
1442
+ exit 1
1443
+ fi
1444
+
1445
+ # Build env args for docker exec
1446
+ local exec_env_args=()
1447
+ [[ -n "${TERM_PROGRAM:-}" ]] && exec_env_args+=(-e "TERM_PROGRAM=${TERM_PROGRAM}")
1448
+ [[ -n "${TERM_PROGRAM_VERSION:-}" ]] && exec_env_args+=(-e "TERM_PROGRAM_VERSION=${TERM_PROGRAM_VERSION}")
1449
+ [[ -n "${TERM_SESSION_ID:-}" ]] && exec_env_args+=(-e "TERM_SESSION_ID=${TERM_SESSION_ID}")
1450
+ [[ -n "${COLORTERM:-}" ]] && exec_env_args+=(-e "COLORTERM=${COLORTERM}")
1451
+
1452
+ # Attach to existing session
1453
+ info "Attaching to session: $session_name"
1454
+ docker exec -it -u claude "${exec_env_args[@]}" "$container_name" tmux -f /home/claude/.tmux.conf attach-session -t "$session_name"
1455
+ local tmux_exit=$?
1456
+ reset_terminal_mouse
1457
+ exit $tmux_exit
1458
+ }
1459
+
1460
+ # Subcommand: launch Chrome with DevTools and ensure MCP configured
1461
+ cmd_chrome() {
1462
+ local setup_only=false
1463
+
1464
+ # Parse flags
1465
+ while [[ $# -gt 0 ]]; do
1466
+ case "$1" in
1467
+ --setup-only)
1468
+ setup_only=true
1469
+ shift
1470
+ ;;
1471
+ --port=*)
1472
+ CHROME_PORT="${1#*=}"
1473
+ shift
1474
+ ;;
1475
+ *)
1476
+ error "Unknown option: $1"
1477
+ info "Usage: dclaude chrome [--setup-only] [--port=PORT]"
1478
+ exit 1
1479
+ ;;
1480
+ esac
1481
+ done
1482
+
1483
+ info "Setting up Chrome DevTools integration"
1484
+
1485
+ # 1. Detect Chrome binary
1486
+ local chrome_bin="$CHROME_BIN"
1487
+ if [[ -z "$chrome_bin" ]]; then
1488
+ debug "Auto-detecting Chrome binary"
1489
+ if [[ "$(uname)" == "Darwin" ]]; then
1490
+ # macOS
1491
+ if [[ -f "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome" ]]; then
1492
+ chrome_bin="/Applications/Google Chrome.app/Contents/MacOS/Google Chrome"
1493
+ elif [[ -f "/Applications/Chromium.app/Contents/MacOS/Chromium" ]]; then
1494
+ chrome_bin="/Applications/Chromium.app/Contents/MacOS/Chromium"
1495
+ fi
1496
+ elif [[ "$(uname)" == "Linux" ]]; then
1497
+ # Linux
1498
+ chrome_bin=$(command -v google-chrome || command -v chromium-browser || command -v chromium || echo "")
1499
+ fi
1500
+
1501
+ if [[ -z "$chrome_bin" ]]; then
1502
+ error "Chrome not found. Set DCLAUDE_CHROME_BIN to specify location"
1503
+ exit 1
1504
+ fi
1505
+ debug "Found Chrome: $chrome_bin"
1506
+ fi
1507
+
1508
+ # 2. Setup profile directory
1509
+ local profile_dir="$HOST_PATH/.dclaude/chrome/profiles/$CHROME_PROFILE"
1510
+ mkdir -p "$profile_dir"
1511
+ debug "Profile directory: $profile_dir"
1512
+
1513
+ # 3. Check/create .mcp.json
1514
+ local mcp_json="$HOST_PATH/.mcp.json"
1515
+ local mcp_port=""
1516
+ local port_mismatch=false
1517
+
1518
+ if [[ -f "$mcp_json" ]]; then
1519
+ debug "Found existing .mcp.json"
1520
+ # Extract port from --browserUrl in .mcp.json
1521
+ mcp_port=$(jq -r '.mcpServers.chrome.args[]? | select(startswith("--browserUrl=")) | split(":")[-1]' "$mcp_json" 2>/dev/null || echo "")
1522
+
1523
+ if [[ -n "$mcp_port" && "$mcp_port" != "$CHROME_PORT" ]]; then
1524
+ port_mismatch=true
1525
+ fi
1526
+ else
1527
+ debug "Creating .mcp.json"
1528
+ cat > "$mcp_json" << EOF
1529
+ {
1530
+ "mcpServers": {
1531
+ "chrome": {
1532
+ "command": "npx",
1533
+ "args": [
1534
+ "-y",
1535
+ "chrome-devtools-mcp@latest",
1536
+ "--browserUrl=http://localhost:${CHROME_PORT}"
1537
+ ]
1538
+ }
1539
+ }
1540
+ }
1541
+ EOF
1542
+ success "Created .mcp.json with Chrome MCP server (port ${CHROME_PORT})"
1543
+ fi
1544
+
1545
+ # 4. Warn if port mismatch
1546
+ if [[ "$port_mismatch" == "true" ]]; then
1547
+ warning "Port mismatch detected!"
1548
+ echo ""
1549
+ echo " Chrome will launch on port: ${CHROME_PORT}"
1550
+ echo " MCP expects (.mcp.json): ${mcp_port}"
1551
+ echo ""
1552
+ warning "MCP will not be able to connect until .mcp.json is updated"
1553
+ echo ""
1554
+ fi
1555
+
1556
+ # 5. Exit if setup-only
1557
+ if [[ "$setup_only" == "true" ]]; then
1558
+ success "Setup complete (--setup-only mode)"
1559
+ exit 0
1560
+ fi
1561
+
1562
+ # 6. Check if Chrome already running on this port
1563
+ if curl -s "http://localhost:${CHROME_PORT}/json/version" >/dev/null 2>&1; then
1564
+ success "Chrome already running on port ${CHROME_PORT}"
1565
+ curl -s "http://localhost:${CHROME_PORT}/json/version" | jq -r '" Browser: " + .Browser'
1566
+ exit 0
1567
+ fi
1568
+
1569
+ # 7. Launch Chrome
1570
+ info "Launching Chrome with remote debugging on port ${CHROME_PORT}"
1571
+
1572
+ local chrome_args=(
1573
+ "--user-data-dir=$profile_dir"
1574
+ "--remote-debugging-port=$CHROME_PORT"
1575
+ "--no-first-run"
1576
+ "--no-default-browser-check"
1577
+ "--disable-default-apps"
1578
+ "--disable-sync"
1579
+ "--allow-insecure-localhost"
1580
+ )
1581
+
1582
+ # Add user-specified flags
1583
+ if [[ -n "$CHROME_FLAGS" ]]; then
1584
+ debug "Adding custom flags: $CHROME_FLAGS"
1585
+ read -ra custom_flags <<< "$CHROME_FLAGS"
1586
+ chrome_args+=("${custom_flags[@]}")
1587
+ fi
1588
+
1589
+ debug "Chrome command: $chrome_bin ${chrome_args[*]}"
1590
+
1591
+ # Launch Chrome in background
1592
+ "$chrome_bin" "${chrome_args[@]}" >/dev/null 2>&1 &
1593
+ local chrome_pid=$!
1594
+
1595
+ # 8. Wait for Chrome to be ready
1596
+ info "Waiting for Chrome to start..."
1597
+ local wait_count=0
1598
+ while [[ $wait_count -lt 30 ]]; do
1599
+ if curl -s "http://localhost:${CHROME_PORT}/json/version" >/dev/null 2>&1; then
1600
+ success "Chrome is ready on port ${CHROME_PORT}"
1601
+ curl -s "http://localhost:${CHROME_PORT}/json/version" | jq -r '" Browser: " + .Browser + "\n Protocol: " + ."Protocol-Version" + "\n WebSocket: " + .webSocketDebuggerUrl'
1602
+ echo ""
1603
+ success "Chrome DevTools ready for MCP integration!"
1604
+ info "Next step: Run 'dclaude' to start Claude with Chrome MCP"
1605
+ exit 0
1606
+ fi
1607
+ sleep 0.5
1608
+ ((wait_count++))
1609
+ done
1610
+
1611
+ error "Chrome failed to start or remote debugging port not accessible"
1612
+ exit 1
1613
+ }
1614
+
1615
+ # Subcommand: pull latest image
1616
+ cmd_pull() {
1617
+ info "Pulling latest image: $IMAGE"
1618
+ if docker pull "$IMAGE"; then
1619
+ success "Image pulled successfully"
1620
+ else
1621
+ error "Failed to pull image: $IMAGE"
1622
+ exit 1
1623
+ fi
1624
+ }
1625
+
1626
+ # Subcommand: update Claude CLI inside container
1627
+ cmd_update() {
1628
+ local container_name=$(get_container_name "$HOST_PATH")
1629
+
1630
+ # Check if container exists
1631
+ if ! docker ps -a --format '{{.Names}}' | grep -q "^${container_name}$"; then
1632
+ error "No container found for this directory"
1633
+ info "Run 'dclaude' first to create a persistent container"
1634
+ exit 1
1635
+ fi
1636
+
1637
+ local container_status=$(docker inspect --format='{{.State.Status}}' "$container_name" 2>/dev/null)
1638
+
1639
+ if [[ "$container_status" != "running" ]]; then
1640
+ error "Container is not running"
1641
+ info "Start it with 'dclaude' first"
1642
+ exit 1
1643
+ fi
1644
+
1645
+ info "Updating Claude CLI..."
1646
+ local npm_output
1647
+ if npm_output=$(docker exec -u claude "$container_name" npm update -g @anthropic-ai/claude-code 2>&1); then
1648
+ # Show command and output together, indented to align with "Debug: " prefix
1649
+ debug "npm update -g @anthropic-ai/claude-code
1650
+ $(echo "$npm_output" | sed 's/^/ /')"
1651
+ local new_version=$(docker exec -u claude "$container_name" claude --version 2>/dev/null | head -1)
1652
+ success "Claude CLI updated: $new_version"
1653
+ else
1654
+ debug "npm update -g @anthropic-ai/claude-code
1655
+ $(echo "$npm_output" | sed 's/^/ /')"
1656
+ error "Failed to update Claude CLI"
1657
+ exit 1
1658
+ fi
1659
+ exit 0
1660
+ }
1661
+
1662
+ # Subcommand: stop container for current directory
1663
+ cmd_stop() {
1664
+ local container_name=$(get_container_name "$HOST_PATH")
1665
+
1666
+ # Check if container exists
1667
+ if ! docker ps -a --format '{{.Names}}' | grep -q "^${container_name}$"; then
1668
+ info "No container found for this directory"
1669
+ debug "Container name would be: $container_name"
1670
+ exit 0
1671
+ fi
1672
+
1673
+ local container_status=$(docker inspect --format='{{.State.Status}}' "$container_name" 2>/dev/null)
1674
+
1675
+ if [[ "$container_status" == "running" ]]; then
1676
+ # Count active tmux sessions
1677
+ local session_count
1678
+ session_count=$(docker exec -u claude "$container_name" tmux list-sessions 2>/dev/null | wc -l | tr -d '[:space:]') || session_count=0
1679
+
1680
+ if [[ "$session_count" =~ ^[0-9]+$ ]] && [[ "$session_count" -gt 0 ]]; then
1681
+ info "Stopping container $container_name ($session_count active session(s))..."
1682
+ else
1683
+ info "Stopping container $container_name..."
1684
+ fi
1685
+
1686
+ if docker stop "$container_name" >/dev/null; then
1687
+ success "Container stopped"
1688
+ else
1689
+ error "Failed to stop container"
1690
+ exit 1
1691
+ fi
1692
+ else
1693
+ info "Container $container_name is not running (status: $container_status)"
1694
+ fi
1695
+ exit 0
1696
+ }
1697
+
1698
+ # Subcommand: SSH server for remote access (JetBrains Gateway, debugging, etc.)
1699
+ cmd_ssh() {
1700
+ local action=""
1701
+
1702
+ # Parse flags
1703
+ while [[ $# -gt 0 ]]; do
1704
+ case "$1" in
1705
+ --stop)
1706
+ action="stop"
1707
+ shift
1708
+ ;;
1709
+ --help|-h)
1710
+ cat << 'SSH_HELP'
1711
+ dclaude ssh - SSH Server for Remote Access
1712
+
1713
+ Usage:
1714
+ dclaude ssh Start SSH server and show connection info
1715
+ dclaude ssh --stop Stop SSH server
1716
+
1717
+ Connection:
1718
+ ssh claude@localhost -p <port>
1719
+ Username: claude
1720
+ Password: claude
1721
+
1722
+ Use Cases:
1723
+ - JetBrains Gateway (PhpStorm, IntelliJ, WebStorm, etc.)
1724
+ - Remote debugging
1725
+ - SFTP file transfer
1726
+ - VS Code Remote SSH
1727
+
1728
+ JetBrains Gateway Setup:
1729
+ 1. Start container: dclaude
1730
+ 2. Start SSH: dclaude ssh
1731
+ 3. Open JetBrains Gateway
1732
+ 4. New Connection → SSH → localhost:<port shown above>
1733
+ 5. Username: claude, Password: claude
1734
+ 6. Gateway will download and install IDE backend automatically
1735
+ 7. Select your project directory
1736
+
1737
+ SSH_HELP
1738
+ exit 0
1739
+ ;;
1740
+ *)
1741
+ error "Unknown option: $1"
1742
+ info "Usage: dclaude ssh [--stop]"
1743
+ exit 1
1744
+ ;;
1745
+ esac
1746
+ done
1747
+
1748
+ local container_name=$(get_container_name "$HOST_PATH")
1749
+
1750
+ # Check if container exists
1751
+ if ! docker ps -a --format '{{.Names}}' | grep -q "^${container_name}$"; then
1752
+ error "No container found for this directory"
1753
+ info "Run 'dclaude' first to create a persistent container"
1754
+ exit 1
1755
+ fi
1756
+
1757
+ local container_status=$(docker inspect --format='{{.State.Status}}' "$container_name" 2>/dev/null)
1758
+
1759
+ if [[ "$container_status" != "running" ]]; then
1760
+ if [[ "$container_status" == "exited" ]]; then
1761
+ info "Starting container: $container_name"
1762
+ docker start "$container_name" >/dev/null
1763
+ sleep 1
1764
+ else
1765
+ error "Container $container_name is in unexpected state: $container_status"
1766
+ exit 1
1767
+ fi
1768
+ fi
1769
+
1770
+ # Get SSH port from container label
1771
+ local ssh_port
1772
+ ssh_port=$(docker inspect --format='{{index .Config.Labels "dclaude.ssh.port"}}' "$container_name" 2>/dev/null)
1773
+
1774
+ if [[ -z "$ssh_port" ]]; then
1775
+ error "SSH port not configured for this container"
1776
+ echo ""
1777
+ info "This container was created with an older version of dclaude."
1778
+ info "To enable SSH, recreate the container:"
1779
+ echo ""
1780
+ echo " dclaude rm -f"
1781
+ echo " dclaude"
1782
+ echo " dclaude ssh"
1783
+ echo ""
1784
+ exit 1
1785
+ fi
1786
+
1787
+ case "$action" in
1788
+ stop)
1789
+ if docker exec "$container_name" pgrep -x sshd >/dev/null 2>&1; then
1790
+ info "Stopping SSH server..."
1791
+ docker exec -u root "$container_name" pkill -x sshd 2>/dev/null || true
1792
+ success "SSH server stopped"
1793
+ else
1794
+ info "SSH server is not running"
1795
+ fi
1796
+ ;;
1797
+
1798
+ "")
1799
+ # Default: start SSH and show connection info
1800
+ # Start SSH if not running
1801
+ if docker exec "$container_name" pgrep -x sshd >/dev/null 2>&1; then
1802
+ info "SSH server already running"
1803
+ else
1804
+ info "Starting SSH server on port $ssh_port..."
1805
+ docker exec -u root "$container_name" sh -c "
1806
+ if [ ! -f /etc/ssh/ssh_host_rsa_key ]; then
1807
+ ssh-keygen -A >/dev/null 2>&1
1808
+ fi
1809
+ /usr/sbin/sshd -p $ssh_port
1810
+ "
1811
+ success "SSH server started"
1812
+ fi
1813
+
1814
+ echo ""
1815
+ echo "SSH Connection"
1816
+ echo "=============="
1817
+ echo " Host: localhost"
1818
+ echo " Port: $ssh_port"
1819
+ echo " Username: claude"
1820
+ echo " Password: claude"
1821
+ echo ""
1822
+ echo " ssh claude@localhost -p $ssh_port"
1823
+ echo ""
1824
+ ;;
1825
+ esac
1826
+
1827
+ exit 0
1828
+ }
1829
+
1830
+ # Subcommand: remove container for current directory
1831
+ cmd_rm() {
1832
+ local force=false
1833
+
1834
+ # Parse flags
1835
+ while [[ $# -gt 0 ]]; do
1836
+ case "$1" in
1837
+ -f|--force)
1838
+ force=true
1839
+ shift
1840
+ ;;
1841
+ *)
1842
+ error "Unknown option: $1"
1843
+ info "Usage: dclaude rm [-f|--force]"
1844
+ exit 1
1845
+ ;;
1846
+ esac
1847
+ done
1848
+
1849
+ local container_name=$(get_container_name "$HOST_PATH")
1850
+
1851
+ # Check if container exists
1852
+ if ! docker ps -a --format '{{.Names}}' | grep -q "^${container_name}$"; then
1853
+ info "No container found for this directory"
1854
+ debug "Container name would be: $container_name"
1855
+ exit 0
1856
+ fi
1857
+
1858
+ local container_status=$(docker inspect --format='{{.State.Status}}' "$container_name" 2>/dev/null)
1859
+
1860
+ if [[ "$container_status" == "running" ]]; then
1861
+ if [[ "$force" == "true" ]]; then
1862
+ # Count active tmux sessions
1863
+ local session_count
1864
+ session_count=$(docker exec -u claude "$container_name" tmux list-sessions 2>/dev/null | wc -l | tr -d '[:space:]') || session_count=0
1865
+
1866
+ if [[ "$session_count" =~ ^[0-9]+$ ]] && [[ "$session_count" -gt 0 ]]; then
1867
+ info "Removing running container $container_name ($session_count active session(s))..."
1868
+ else
1869
+ info "Removing running container $container_name..."
1870
+ fi
1871
+
1872
+ if docker rm -f "$container_name" >/dev/null; then
1873
+ success "Container removed"
1874
+ else
1875
+ error "Failed to remove container"
1876
+ exit 1
1877
+ fi
1878
+ else
1879
+ error "Container $container_name is running"
1880
+ info "Stop it first with: dclaude stop"
1881
+ info "Or force remove with: dclaude rm -f"
1882
+ exit 1
1883
+ fi
1884
+ else
1885
+ info "Removing container $container_name..."
1886
+ if docker rm "$container_name" >/dev/null; then
1887
+ success "Container removed"
1888
+ else
1889
+ error "Failed to remove container"
1890
+ exit 1
1891
+ fi
1892
+ fi
1893
+ exit 0
1894
+ }
1895
+
1896
+ # Initialize HOST_PATH once for all commands
1897
+ HOST_PATH=$(get_host_path)
1898
+
1899
+ # Handle subcommands
1900
+ if [[ $# -gt 0 ]]; then
1901
+ case "$1" in
1902
+ new)
1903
+ # Explicit "new" command - shift and pass remaining args to main
1904
+ shift
1905
+ # Fall through to main function
1906
+ ;;
1907
+ attach)
1908
+ shift
1909
+ cmd_attach "$@"
1910
+ ;;
1911
+ exec|shell|bash)
1912
+ shift
1913
+ cmd_exec "$@"
1914
+ ;;
1915
+ chrome)
1916
+ shift
1917
+ cmd_chrome "$@"
1918
+ ;;
1919
+ gh)
1920
+ shift
1921
+ cmd_gh "$@"
1922
+ ;;
1923
+ pull)
1924
+ shift
1925
+ cmd_pull "$@"
1926
+ ;;
1927
+ update)
1928
+ shift
1929
+ cmd_update "$@"
1930
+ ;;
1931
+ stop)
1932
+ shift
1933
+ cmd_stop "$@"
1934
+ ;;
1935
+ rm)
1936
+ shift
1937
+ cmd_rm "$@"
1938
+ ;;
1939
+ ssh)
1940
+ shift
1941
+ cmd_ssh "$@"
1942
+ ;;
1943
+ --help|-h|help)
1944
+ cat << 'EOF'
1945
+ dclaude - Dockerized Claude Code Launcher
1946
+
1947
+ Usage:
1948
+ dclaude [options] Start new Claude session (default)
1949
+ dclaude new [options] Start new Claude session (explicit)
1950
+ dclaude attach <session> Attach to existing tmux session
1951
+ dclaude pull Pull latest Docker image
1952
+ dclaude update Update Claude CLI inside container
1953
+ dclaude stop Stop container for current directory
1954
+ dclaude rm [-f] Remove container for current directory
1955
+ dclaude ssh Start SSH server for remote access
1956
+ dclaude chrome [options] Launch Chrome with DevTools and MCP integration
1957
+ dclaude gh Authenticate GitHub CLI (runs gh auth login)
1958
+ dclaude exec [command] Execute command in container (default: bash)
1959
+ dclaude shell Open bash shell in container
1960
+ dclaude --help Show this help
1961
+
1962
+ Environment Variables:
1963
+ DCLAUDE_TAG Docker image tag (default: latest)
1964
+ DCLAUDE_RM Remove container on exit (default: false)
1965
+ DCLAUDE_DEBUG Enable debug output (default: false)
1966
+ DCLAUDE_MOUNT_CONFIGS Mount host configs (default: false)
1967
+ DCLAUDE_GIT_AUTH SSH auth for Git: auto, agent-forwarding, key-mount, none
1968
+ DCLAUDE_NETWORK Network mode: auto, host, bridge
1969
+ DCLAUDE_DOCKER_SOCKET Override Docker socket path
1970
+ DCLAUDE_TMUX_SESSION Custom tmux session name (default: claude-TIMESTAMP)
1971
+ DCLAUDE_CHROME_BIN Chrome executable path (auto-detected if not set)
1972
+ DCLAUDE_CHROME_PROFILE Chrome profile name (default: claude)
1973
+ DCLAUDE_CHROME_PORT Chrome debugging port (default: 9222)
1974
+ DCLAUDE_CHROME_FLAGS Additional Chrome flags (default: empty)
1975
+
1976
+ Examples:
1977
+ # Start new Claude session (auto-generated session name)
1978
+ dclaude
1979
+ dclaude new
1980
+
1981
+ # Start with custom session name (role-based workflows)
1982
+ DCLAUDE_TMUX_SESSION=claude-architect dclaude
1983
+ dclaude new --dangerously-skip-permissions
1984
+
1985
+ # Attach to existing named session
1986
+ dclaude attach claude-architect
1987
+ DCLAUDE_TMUX_SESSION=claude-architect dclaude attach
1988
+
1989
+ # Start with ephemeral container (removed on exit)
1990
+ DCLAUDE_RM=true dclaude
1991
+
1992
+ # Update image and restart container
1993
+ dclaude pull # Pull latest image
1994
+ dclaude update # Update Claude CLI in container
1995
+ dclaude stop # Stop container (preserves it)
1996
+ dclaude rm # Remove stopped container
1997
+ dclaude rm -f # Force remove running container
1998
+
1999
+ # SSH server for remote access (JetBrains Gateway, VS Code Remote, etc.)
2000
+ dclaude ssh # Start SSH server, show connection info
2001
+ dclaude ssh --stop # Stop SSH server
2002
+
2003
+ # Launch Chrome with DevTools for MCP integration
2004
+ dclaude chrome
2005
+ dclaude chrome --port=9223 # Custom debugging port
2006
+ dclaude chrome --setup-only # Just create .mcp.json, don't launch
2007
+ DCLAUDE_CHROME_PROFILE=testing dclaude chrome # Use different profile
2008
+
2009
+ # Authenticate GitHub CLI (persists in dclaude-config volume)
2010
+ dclaude gh
2011
+
2012
+ # Open bash shell in running container
2013
+ dclaude exec
2014
+
2015
+ # Run command in container
2016
+ dclaude exec npm install
2017
+
2018
+ For more information: https://github.com/alanbem/dclaude
2019
+ EOF
2020
+ exit 0
2021
+ ;;
2022
+ esac
2023
+ fi
2024
+
2025
+ # Handle signals
2026
+
2027
+ trap 'exit 130' INT
2028
+ trap 'exit 143' TERM
2029
+
2030
+ # Run main function
2031
+ main "$@"