diff --git a/README.md b/README.md index f7ae3b9..6e63d71 100644 --- a/README.md +++ b/README.md @@ -114,6 +114,22 @@ cd client-repo-1 claude # Ready to work ``` +## Token-Based Auth (Headless) + +For headless servers or to skip the interactive login wizard: + +```bash +claude setup-token # run on host, one-time +export CLAUDE_CODE_OAUTH_TOKEN=sk-ant-oat01-... +devc rebuild # rebuilds with token +``` + +The token is forwarded into the container. On each container creation, `post_install.py` runs a one-shot auth handshake so `claude` starts without the login wizard. + +This works around Claude Code's interactive onboarding wizard always showing in containers, even with valid credentials ([#8938](https://github.com/anthropics/claude-code/issues/8938)). + +If you don't set a token, the interactive login flow works as before. + ## CLI Helper Commands ``` diff --git a/devcontainer.json b/devcontainer.json index a6b66ea..b10a841 100644 --- a/devcontainer.json +++ b/devcontainer.json @@ -63,6 +63,10 @@ "PYTHONDONTWRITEBYTECODE": "1", "PIP_DISABLE_PIP_VERSION_CHECK": "1" }, + "remoteEnv": { + "CLAUDE_CODE_OAUTH_TOKEN": "${localEnv:CLAUDE_CODE_OAUTH_TOKEN:}", + "ANTHROPIC_API_KEY": "${localEnv:ANTHROPIC_API_KEY:}" + }, "initializeCommand": "test -f \"$HOME/.gitconfig\" || touch \"$HOME/.gitconfig\"", "workspaceMount": "source=${localWorkspaceFolder},target=/workspace,type=bind,consistency=delegated", "workspaceFolder": "/workspace", diff --git a/post_install.py b/post_install.py index 7309fb5..a6d325c 100644 --- a/post_install.py +++ b/post_install.py @@ -2,6 +2,7 @@ """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 @@ -15,6 +16,80 @@ 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" @@ -34,7 +109,9 @@ def setup_claude_settings(): 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) + print( + f"[post_install] Claude settings configured: {settings_file}", file=sys.stderr + ) def setup_tmux_config(): @@ -106,7 +183,9 @@ def fix_directory_ownership(): check=True, capture_output=True, ) - print(f"[post_install] Fixed ownership: {dir_path}", file=sys.stderr) + 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}", @@ -204,13 +283,16 @@ node_modules/ 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) + 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()