#!/usr/bin/env python3 """Post-install configuration for AI coding agent devcontainer. Runs on container creation to set up: - Onboarding bypass (when CLAUDE_CODE_OAUTH_TOKEN is set) - Claude settings (bypassPermissions mode) - Codex settings and optional headless auth - 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 # When `CLAUDE_CONFIG_DIR` is set, as is done in `devcontainer.json`, `claude` unexpectedly # looks for `.claude.json` in *that* folder, instead of in `~`, contradicting the documentation. # See https://github.com/anthropics/claude-code/issues/3833#issuecomment-3694918874 claude_json_dir = Path(os.environ.get("CLAUDE_CONFIG_DIR", Path.home())) claude_json = claude_json_dir / ".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(os.environ.get("CLAUDE_CONFIG_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 has_top_level_toml_key(text: str, key: str) -> bool: """Return whether a TOML document has a top-level key.""" for line in text.splitlines(): stripped = line.strip() if not stripped or stripped.startswith("#"): continue if stripped.startswith("["): return False if stripped.startswith(f"{key} ") or stripped.startswith(f"{key}="): return True return False def insert_top_level_toml_defaults(text: str, defaults: dict[str, str]) -> str: """Insert missing top-level TOML defaults before the first table.""" missing = { key: value for key, value in defaults.items() if not has_top_level_toml_key(text, key) } if not missing: return text default_lines = [ "# Container defaults: the devcontainer is the sandbox boundary.", *[f'{key} = "{value}"' for key, value in missing.items()], ] lines = text.splitlines() insert_at = len(lines) for index, line in enumerate(lines): if line.strip().startswith("["): insert_at = index break if insert_at == 0: lines = default_lines + [""] + lines elif insert_at == len(lines): if lines and lines[-1].strip(): lines.append("") lines.extend(default_lines) else: lines = lines[:insert_at] + default_lines + [""] + lines[insert_at:] return "\n".join(lines) + "\n" def setup_codex_settings(): """Configure Codex CLI for the externally sandboxed devcontainer.""" codex_home = Path(os.environ.get("CODEX_HOME", Path.home() / ".codex")) codex_home.mkdir(parents=True, exist_ok=True) config_file = codex_home / "config.toml" defaults = { "approval_policy": "never", "sandbox_mode": "danger-full-access", "cli_auth_credentials_store": "file", } existing = config_file.read_text(encoding="utf-8") if config_file.exists() else "" updated = insert_top_level_toml_defaults(existing, defaults) if updated != existing: config_file.write_text(updated, encoding="utf-8") print( f"[post_install] Codex settings configured: {config_file}", file=sys.stderr, ) else: print( f"[post_install] Codex settings already configured: {config_file}", file=sys.stderr, ) def setup_codex_auth(): """Persist Codex auth when a headless token or API key is provided.""" access_token = os.environ.get("CODEX_ACCESS_TOKEN", "").strip() api_key = os.environ.get("OPENAI_API_KEY", "").strip() if access_token: run_codex_login("--with-access-token", access_token, "CODEX_ACCESS_TOKEN") elif api_key: run_codex_login("--with-api-key", api_key, "OPENAI_API_KEY") else: print( "[post_install] No Codex auth env var set, skipping Codex login", file=sys.stderr, ) def run_codex_login(flag: str, secret: str, source_name: str): """Run codex login without printing credential material.""" print(f"[post_install] Running codex login from {source_name}...", file=sys.stderr) try: result = subprocess.run( ["codex", "login", flag], input=secret, capture_output=True, text=True, timeout=60, ) except subprocess.TimeoutExpired: print("[post_install] Warning: codex login timed out", file=sys.stderr) return except (FileNotFoundError, OSError) as e: print( f"[post_install] Warning: could not run codex ({e}) - Codex login skipped", file=sys.stderr, ) return if result.returncode != 0: print( f"[post_install] Warning: codex login exited {result.returncode}; " "Codex auth was not persisted", file=sys.stderr, ) return print("[post_install] Codex auth configured", 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.home() / ".codex", 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 = """\ # Agent CLIs .claude/ .codex/ # 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) fix_directory_ownership() setup_onboarding_bypass() setup_claude_settings() setup_codex_settings() setup_codex_auth() setup_tmux_config() setup_global_gitignore() print("[post_install] Configuration complete!", file=sys.stderr) if __name__ == "__main__": main()