mirror of
https://github.com/trailofbits/claude-code-devcontainer
synced 2026-06-21 14:11:48 +00:00
5a2b92a790
* feat: add non-interactive auth via CLAUDE_CODE_OAUTH_TOKEN Bypass the interactive onboarding wizard when CLAUDE_CODE_OAUTH_TOKEN is set. On container create, post_install.py runs `claude -p` to populate auth state and sets hasCompletedOnboarding so the TUI starts without the login wizard. Workaround for https://github.com/anthropics/claude-code/issues/8938. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix: use remoteEnv instead of containerEnv for secrets containerEnv bakes values into the image as ENV instructions, visible in docker inspect/history. remoteEnv is set at runtime only. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix: improve error handling in onboarding bypass - Handle timeout as expected (claude -p writes config before API call) - Catch FileNotFoundError/OSError if claude is not installed - Check returncode explicitly instead of dead CalledProcessError catch - Guard on ~/.claude.json existence before writing onboarding flag - Replace contextlib.suppress with explicit try/except that logs - Update module docstring and README wording Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
306 lines
8.1 KiB
Python
306 lines
8.1 KiB
Python
#!/usr/bin/env python3
|
|
"""Post-install configuration for Claude Code devcontainer.
|
|
|
|
Runs on container creation to set up:
|
|
- Onboarding bypass (when CLAUDE_CODE_OAUTH_TOKEN is set)
|
|
- Claude settings (bypassPermissions mode)
|
|
- Tmux configuration (200k history, mouse support)
|
|
- Directory ownership fixes for mounted volumes
|
|
"""
|
|
|
|
import contextlib
|
|
import json
|
|
import os
|
|
import subprocess
|
|
import sys
|
|
from pathlib import Path
|
|
|
|
|
|
def setup_onboarding_bypass():
|
|
"""Bypass the interactive onboarding wizard when CLAUDE_CODE_OAUTH_TOKEN is set.
|
|
|
|
Runs `claude -p` to seed ~/.claude.json with auth state. The subprocess
|
|
writes the config file during startup before the API call completes, so
|
|
a timeout is expected and acceptable. After the subprocess finishes (or
|
|
times out), we check whether ~/.claude.json was populated and only then
|
|
set hasCompletedOnboarding.
|
|
|
|
Workaround for https://github.com/anthropics/claude-code/issues/8938.
|
|
"""
|
|
token = os.environ.get("CLAUDE_CODE_OAUTH_TOKEN", "").strip()
|
|
if not token:
|
|
print(
|
|
"[post_install] No CLAUDE_CODE_OAUTH_TOKEN set, skipping onboarding bypass",
|
|
file=sys.stderr,
|
|
)
|
|
return
|
|
|
|
claude_json = Path.home() / ".claude.json"
|
|
|
|
print("[post_install] Running claude -p to populate auth state...", file=sys.stderr)
|
|
try:
|
|
result = subprocess.run(
|
|
["claude", "-p", "ok"],
|
|
capture_output=True,
|
|
text=True,
|
|
timeout=30,
|
|
)
|
|
if result.returncode != 0:
|
|
print(
|
|
f"[post_install] claude -p exited {result.returncode}: "
|
|
f"{result.stderr.strip()}",
|
|
file=sys.stderr,
|
|
)
|
|
except subprocess.TimeoutExpired:
|
|
print(
|
|
"[post_install] claude -p timed out (expected on cold start)",
|
|
file=sys.stderr,
|
|
)
|
|
except (FileNotFoundError, OSError) as e:
|
|
print(
|
|
f"[post_install] Warning: could not run claude ({e}) — "
|
|
"onboarding bypass skipped",
|
|
file=sys.stderr,
|
|
)
|
|
return
|
|
|
|
if not claude_json.exists():
|
|
print(
|
|
f"[post_install] Warning: {claude_json} not created by claude -p — "
|
|
"onboarding bypass skipped",
|
|
file=sys.stderr,
|
|
)
|
|
return
|
|
|
|
config: dict = {}
|
|
try:
|
|
config = json.loads(claude_json.read_text())
|
|
except json.JSONDecodeError as e:
|
|
print(
|
|
f"[post_install] Warning: {claude_json} has invalid JSON ({e}), "
|
|
"starting fresh",
|
|
file=sys.stderr,
|
|
)
|
|
|
|
config["hasCompletedOnboarding"] = True
|
|
|
|
claude_json.write_text(json.dumps(config, indent=2) + "\n", encoding="utf-8")
|
|
print(
|
|
f"[post_install] Onboarding bypass configured: {claude_json}", file=sys.stderr
|
|
)
|
|
|
|
|
|
def setup_claude_settings():
|
|
"""Configure Claude Code with bypassPermissions enabled."""
|
|
claude_dir = Path.home() / ".claude"
|
|
claude_dir.mkdir(parents=True, exist_ok=True)
|
|
|
|
settings_file = claude_dir / "settings.json"
|
|
|
|
# Load existing settings or start fresh
|
|
settings = {}
|
|
if settings_file.exists():
|
|
with contextlib.suppress(json.JSONDecodeError):
|
|
settings = json.loads(settings_file.read_text())
|
|
|
|
# Set bypassPermissions mode
|
|
if "permissions" not in settings:
|
|
settings["permissions"] = {}
|
|
settings["permissions"]["defaultMode"] = "bypassPermissions"
|
|
|
|
settings_file.write_text(json.dumps(settings, indent=2) + "\n", encoding="utf-8")
|
|
print(
|
|
f"[post_install] Claude settings configured: {settings_file}", file=sys.stderr
|
|
)
|
|
|
|
|
|
def setup_tmux_config():
|
|
"""Configure tmux with 200k history, mouse support, and vi keys."""
|
|
tmux_conf = Path.home() / ".tmux.conf"
|
|
|
|
if tmux_conf.exists():
|
|
print("[post_install] Tmux config exists, skipping", file=sys.stderr)
|
|
return
|
|
|
|
config = """\
|
|
# 200k line scrollback history
|
|
set-option -g history-limit 200000
|
|
|
|
# Enable mouse support
|
|
set -g mouse on
|
|
|
|
# Use vi keys in copy mode
|
|
setw -g mode-keys vi
|
|
|
|
# Start windows and panes at 1, not 0
|
|
set -g base-index 1
|
|
setw -g pane-base-index 1
|
|
|
|
# Renumber windows when one is closed
|
|
set -g renumber-windows on
|
|
|
|
# Faster escape time for vim
|
|
set -sg escape-time 10
|
|
|
|
# True color support
|
|
set -g default-terminal "tmux-256color"
|
|
set -ag terminal-overrides ",xterm-256color:RGB"
|
|
|
|
# Terminal features (ghostty, cursor shape in vim)
|
|
set -as terminal-features ",xterm-ghostty:RGB"
|
|
set -as terminal-features ",xterm*:RGB"
|
|
set -ga terminal-overrides ",xterm*:colors=256"
|
|
set -ga terminal-overrides '*:Ss=\\E[%p1%d q:Se=\\E[ q'
|
|
|
|
# Status bar
|
|
set -g status-style 'bg=#333333 fg=#ffffff'
|
|
set -g status-left '[#S] '
|
|
set -g status-right '%Y-%m-%d %H:%M'
|
|
"""
|
|
tmux_conf.write_text(config, encoding="utf-8")
|
|
print(f"[post_install] Tmux configured: {tmux_conf}", file=sys.stderr)
|
|
|
|
|
|
def fix_directory_ownership():
|
|
"""Fix ownership of mounted volumes that may have root ownership."""
|
|
uid = os.getuid()
|
|
gid = os.getgid()
|
|
|
|
dirs_to_fix = [
|
|
Path.home() / ".claude",
|
|
Path("/commandhistory"),
|
|
Path.home() / ".config" / "gh",
|
|
]
|
|
|
|
for dir_path in dirs_to_fix:
|
|
if dir_path.exists():
|
|
try:
|
|
# Use sudo to fix ownership if needed
|
|
stat_info = dir_path.stat()
|
|
if stat_info.st_uid != uid:
|
|
subprocess.run(
|
|
["sudo", "chown", "-R", f"{uid}:{gid}", str(dir_path)],
|
|
check=True,
|
|
capture_output=True,
|
|
)
|
|
print(
|
|
f"[post_install] Fixed ownership: {dir_path}", file=sys.stderr
|
|
)
|
|
except (PermissionError, subprocess.CalledProcessError) as e:
|
|
print(
|
|
f"[post_install] Warning: Could not fix ownership of {dir_path}: {e}",
|
|
file=sys.stderr,
|
|
)
|
|
|
|
|
|
def setup_global_gitignore():
|
|
"""Set up global gitignore and local git config.
|
|
|
|
Since ~/.gitconfig is mounted read-only from host, we create a local
|
|
config file that includes the host config and adds container-specific
|
|
settings like core.excludesfile and delta configuration.
|
|
|
|
GIT_CONFIG_GLOBAL env var (set in devcontainer.json) points git to this
|
|
local config as the "global" config.
|
|
"""
|
|
home = Path.home()
|
|
gitignore = home / ".gitignore_global"
|
|
local_gitconfig = home / ".gitconfig.local"
|
|
host_gitconfig = home / ".gitconfig"
|
|
|
|
# Create global gitignore with common patterns
|
|
patterns = """\
|
|
# Claude Code
|
|
.claude/
|
|
|
|
# macOS
|
|
.DS_Store
|
|
.AppleDouble
|
|
.LSOverride
|
|
._*
|
|
|
|
# Python
|
|
*.pyc
|
|
*.pyo
|
|
__pycache__/
|
|
*.egg-info/
|
|
.eggs/
|
|
*.egg
|
|
.venv/
|
|
venv/
|
|
.mypy_cache/
|
|
.ruff_cache/
|
|
|
|
# Node
|
|
node_modules/
|
|
.npm/
|
|
|
|
# Editors
|
|
*.swp
|
|
*.swo
|
|
*~
|
|
.idea/
|
|
.vscode/
|
|
*.sublime-*
|
|
|
|
# Misc
|
|
*.log
|
|
.env.local
|
|
.env.*.local
|
|
"""
|
|
gitignore.write_text(patterns, encoding="utf-8")
|
|
print(f"[post_install] Global gitignore created: {gitignore}", file=sys.stderr)
|
|
|
|
# Create local git config that includes host config and sets excludesfile + delta
|
|
# Delta config is included here so it works even if host doesn't have it configured
|
|
local_config = f"""\
|
|
# Container-local git config
|
|
# Includes host config (mounted read-only) and adds container settings
|
|
|
|
[include]
|
|
path = {host_gitconfig}
|
|
|
|
[core]
|
|
excludesfile = {gitignore}
|
|
pager = delta
|
|
|
|
[interactive]
|
|
diffFilter = delta --color-only
|
|
|
|
[delta]
|
|
navigate = true
|
|
light = false
|
|
line-numbers = true
|
|
side-by-side = false
|
|
|
|
[merge]
|
|
conflictstyle = diff3
|
|
|
|
[diff]
|
|
colorMoved = default
|
|
|
|
[gpg "ssh"]
|
|
program = /usr/bin/ssh-keygen
|
|
"""
|
|
local_gitconfig.write_text(local_config, encoding="utf-8")
|
|
print(
|
|
f"[post_install] Local git config created: {local_gitconfig}", file=sys.stderr
|
|
)
|
|
|
|
|
|
def main():
|
|
"""Run all post-install configuration."""
|
|
print("[post_install] Starting post-install configuration...", file=sys.stderr)
|
|
|
|
setup_onboarding_bypass()
|
|
setup_claude_settings()
|
|
setup_tmux_config()
|
|
fix_directory_ownership()
|
|
setup_global_gitignore()
|
|
|
|
print("[post_install] Configuration complete!", file=sys.stderr)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|