skill
Container Bootstrap
Public Made by Adomby adom
Guide for building and maintaining one-liner bootstrap scripts that set up fresh Adom containers with Claude Code, Gallia, and Claude Squad.
{
"schema_version": 1,
"type": "skill",
"slug": "container-bootstrap",
"title": "Container Bootstrap",
"brief": "Guide for building and maintaining one-liner bootstrap scripts that set up fresh Adom containers with Claude Code, Gallia, and Claude Squad.",
"version": "1.0.0",
"tags": [],
"license": "MIT",
"source_path": "SKILL.md",
"readme": "---\nname: container-bootstrap\ndescription: Use when the user wants to build a container bootstrap/installer script, set up Claude Code OAuth in a container, troubleshoot container auth issues, create a one-liner install, or asks about how the Adom bootstrap works. Covers the full bootstrap architecture, OAuth PKCE auth in containers, PATH setup, and deployment.\n---\n\n# Container Bootstrap Installer\n\nGuide for building and maintaining one-liner bootstrap scripts that set up fresh Adom containers with Claude Code, Gallia, and Claude Squad.\n\n**Reference implementation:** `~/gallia/scripts/bootstrap.sh`\n\n## Architecture Overview\n\nThe bootstrap is a single bash script designed for `curl | bash` one-liner execution:\n\n```bash\n# Full install (all workspaces, ~233MB node_modules):\nbash <(curl -fsSL <gallia-host>/bootstrap.sh)\n\n# Service container (only server + named workspace, ~12MB node_modules):\nbash <(curl -fsSL <gallia-host>/bootstrap.sh) --service mouser\nbash <(curl -fsSL <gallia-host>/bootstrap.sh) --service jlcpcb\n```\n\nThe `<gallia-host>` is the proxy URL of any running gallia container (no auth required):\n```\nhttps://coder.john-service-jlcpcb-9a8b6c0328533a9b.containers.adom.inc/proxy/8775\n```\n\nWhen the gallia repo goes public, use `https://raw.githubusercontent.com/adom-inc/gallia/main/scripts` instead.\n\nThe `--service <name>` flag tells the bootstrap to install only the `server` + `viewer` + named npm workspace instead of all workspaces. This cuts node_modules from ~233MB to ~30MB by skipping heavy deps like Babylon.js (79MB) and three.js (24MB). The viewer workspace is always included so Gallia Viewer (GV) is available on service containers for displaying content.\n\nIt runs in 4 sequential phases, each idempotent (safe to re-run):\n\n| Phase | Purpose |\n|-------|---------|\n| **1. Prerequisites** | Install VS Code extension, Claude Code CLI, GitHub CLI, configure VS Code settings |\n| **2. Authentication** | GitHub OAuth (web-based), Claude Code OAuth (PKCE script + CLI fallback) |\n| **3. Install Gallia** | Clone repo, npm install (scoped if `--service`), run installer |\n| **3b. Start Service** | *(only with `--service`)* Run `start-<name>.sh` and verify health |\n| **4. Claude Squad** | Launch 4 RC sessions in named tmux sessions |\n\n### Design Principles\n\n- **Idempotent:** Every step checks if already done before acting. Safe to run multiple times.\n- **Progressive:** Each phase builds on the previous. If auth fails, the user can re-run.\n- **Dual-method auth:** PKCE script first (fast), Claude Code CLI fallback (reliable).\n- **Visible progress:** Color-coded output with `✓`, `⚠`, `✗` status indicators.\n\n## Claude Code OAuth in Containers (The Hard Part)\n\nStandard OAuth flows redirect to `localhost`, which doesn't work inside containers. The solution is a manual PKCE (Proof Key for Code Exchange) flow.\n\n**Reference implementation:** `~/gallia/scripts/claude-auth.mjs`\n\n### Why It's Tricky\n\n1. **No localhost callback** — containers don't have a browser, and the port isn't accessible from outside\n2. **Cloudflare blocks server-side requests** — the token endpoint may return a Cloudflare challenge page instead of JSON\n3. **Two different OAuth implementations in the CLI** — the MCP OAuth uses form-encoded bodies, but Claude Code's own auth uses JSON. You MUST use JSON.\n4. **The authorization code includes state** — when the user copies the code from the browser, it's `CODE#STATE` and the `#STATE` suffix must be stripped\n5. **The `state` parameter is required in the token body** — omitting it causes `Invalid request format`\n\n### OAuth Constants\n\nThese are extracted from the Claude Code CLI bundle. They are stable but may change with major updates.\n\n```javascript\nconst CLIENT_ID = '9d1c250a-e61b-44d9-88ed-5944d1962f5e';\nconst REDIRECT_URI = 'https://platform.claude.com/oauth/code/callback';\nconst TOKEN_URL = 'https://platform.claude.com/v1/oauth/token';\nconst SCOPES = 'org:create_api_key user:profile user:inference user:sessions:claude_code user:mcp_servers';\n```\n\n### PKCE Flow (Step by Step)\n\n#### 1. Generate PKCE verifier and challenge\n\n```javascript\nimport { createHash, randomBytes } from 'crypto';\n\nconst verifier = randomBytes(48).toString('base64url').slice(0, 64);\nconst challenge = createHash('sha256').update(verifier).digest('base64url');\nconst state = randomBytes(24).toString('base64url');\n```\n\n#### 2. Build the authorization URL\n\n```javascript\nconst params = new URLSearchParams({\n code: 'true', // Tells the server to show a copyable code instead of redirecting\n client_id: CLIENT_ID,\n response_type: 'code',\n redirect_uri: REDIRECT_URI,\n scope: SCOPES,\n code_challenge: challenge,\n code_challenge_method: 'S256',\n state: state,\n});\nconst authUrl = `https://claude.ai/oauth/authorize?${params}`;\n```\n\nThe `code: 'true'` parameter is critical — it tells the authorization server to display the code on screen instead of redirecting to localhost.\n\n#### 3. User opens URL, signs in, copies code\n\nThe user sees a code like `gvESNNGPEqIV...`. When they copy it, it may include `#evCTWEQr0f6g...` (the state value) appended after a `#`.\n\n**Always strip the `#state` suffix:**\n```javascript\nconst code = rawCode.trim().split('#')[0];\n```\n\n#### 4. Exchange code for tokens\n\n**CRITICAL:** Use `application/json`, NOT `application/x-www-form-urlencoded`. Include `state` in the body.\n\n```javascript\nconst tokenBody = JSON.stringify({\n grant_type: 'authorization_code',\n code,\n redirect_uri: REDIRECT_URI,\n client_id: CLIENT_ID,\n code_verifier: verifier,\n state, // ← REQUIRED — omitting causes \"Invalid request format\"\n});\n\nconst req = request({\n hostname: 'platform.claude.com',\n path: '/v1/oauth/token',\n method: 'POST',\n headers: {\n 'Content-Type': 'application/json', // ← NOT form-encoded\n 'Content-Length': Buffer.byteLength(tokenBody),\n 'Accept': 'application/json',\n },\n}, callback);\n```\n\nWrong content types and their errors:\n| Content-Type | Result |\n|-------------|--------|\n| `application/json` (with `state`) | Works |\n| `application/json` (without `state`) | `Invalid request format` |\n| `application/x-www-form-urlencoded` | `Invalid request format` |\n\n#### 5. Handle the response\n\nSuccessful response:\n```json\n{\n \"access_token\": \"sk-ant-oat01-...\",\n \"refresh_token\": \"...\",\n \"expires_in\": 3600,\n \"scope\": \"org:create_api_key user:profile ...\"\n}\n```\n\nError responses:\n```json\n{ \"error\": { \"type\": \"invalid_grant\", \"message\": \"Code expired or already used\" } }\n{ \"error\": { \"type\": \"invalid_request_error\", \"message\": \"Invalid request format\" } }\n```\n\nNote: `error` can be either a string (standard OAuth) or an object (Claude's custom format). Handle both:\n```javascript\nconst errType = typeof r.error === 'object' ? r.error.type || JSON.stringify(r.error) : r.error;\nconst errMsg = typeof r.error === 'object' ? r.error.message : r.error_description;\n```\n\n#### 6. Write credentials\n\n```javascript\nconst creds = {\n claudeAiOauth: {\n accessToken: r.access_token,\n refreshToken: r.refresh_token || '',\n expiresAt: Date.now() + (r.expires_in || 3600) * 1000,\n scopes: typeof r.scope === 'string' ? r.scope.split(' ') : (r.scope || []),\n subscriptionType: null,\n rateLimitTier: null,\n },\n};\n\nmkdirSync(join(homedir(), '.claude'), { recursive: true });\nwriteFileSync(join(homedir(), '.claude', '.credentials.json'), JSON.stringify(creds), { mode: 0o600 });\n```\n\n### Cloudflare Fallback Methods\n\nWhen Cloudflare blocks the server-side token exchange, offer these fallbacks:\n\n1. **Browser console snippet** — The user pastes a `fetch()` call into the browser console on `platform.claude.com`, which does the token exchange client-side (same origin, no Cloudflare issues). The snippet copies the credentials to clipboard.\n\n2. **Existing token** — From `claude setup-token` on another machine where Claude Code is already authenticated. Tokens start with `sk-ant-oat01-`.\n\n3. **API key** — From `console.anthropic.com/settings/keys`. Goes in `ANTHROPIC_API_KEY` env var in `.bashrc`, not `.credentials.json`.\n\n### CLI Fallback\n\nIf the PKCE script fails, fall back to the Claude Code CLI's built-in auth:\n\n```bash\nclaude < /dev/tty # The < /dev/tty is required in curl|bash context for interactive input\n```\n\nThe CLI shows an interactive auth prompt that handles the full flow. It works because it opens a browser URL with `code: true` and prompts the user to paste the code — essentially the same PKCE flow but built into the CLI.\n\n## Container Environment Gotchas\n\n### `hostname` Returns Docker Container ID\n\nInside Docker, `hostname` returns something like `bb2913f64500`, NOT the Adom hostname (`john-service-jlcpcb-9a8b6c0328533a9b`).\n\nTo get the repo name, parse from `VSCODE_PROXY_URI`:\n\n```bash\n# VSCODE_PROXY_URI = https://coder.john-service-jlcpcb-9a8b6c0328533a9b.containers.adom.inc/proxy/{{port}}/\n# Pattern: coder.{user}-{repo}-{hash}.containers.adom.inc\n\nREPO_NAME=\"\"\nif [ -n \"${VSCODE_PROXY_URI:-}\" ]; then\n ADOM_HOST=\"${VSCODE_PROXY_URI#*coder.}\" # john-service-jlcpcb-9a8b6c0328533a9b\n ADOM_HOST=\"${ADOM_HOST%%.*}\" # same (strip .containers...)\n REPO_NAME=\"${ADOM_HOST#*-}\" # service-jlcpcb-9a8b6c0328533a9b\n REPO_NAME=\"${REPO_NAME%-*}\" # service-jlcpcb\nfi\n[ -z \"$REPO_NAME\" ] && REPO_NAME=\"adom\"\n```\n\n### PATH Not Persisted After Script Exits\n\n`export PATH=...` in a script only affects the script's subshell. To persist:\n\n```bash\nexport PATH=\"$HOME/.local/bin:$HOME/.claude/bin:$PATH\"\n# Also persist in .bashrc for future shells\nif ! grep -q '/.local/bin' \"$HOME/.bashrc\" 2>/dev/null; then\n echo 'export PATH=\"$HOME/.local/bin:$HOME/.claude/bin:$PATH\"' >> \"$HOME/.bashrc\"\nfi\n```\n\nThe Claude Code CLI installs to `~/.local/bin/claude` (not `~/.claude/bin/claude`). Include both paths.\n\n### Interactive Input in `curl | bash`\n\nWhen running via `curl | bash`, stdin is the curl stream, not the terminal. For interactive prompts:\n\n```bash\n# Redirect from /dev/tty for interactive input\nnode \"$AUTH_SCRIPT\" < /dev/tty\nclaude < /dev/tty\ngh auth login -p https -h github.com -w # -w flag uses web-based auth (device flow)\n```\n\n### VS Code Settings\n\nConfigure Claude Code defaults and kill Copilot Chat. The settings file is at:\n```\n$HOME/.local/share/code-server/User/settings.json\n```\n\n**Important:** Settings are split into two categories:\n- **Forced** — always overwritten (security, Copilot suppression). Use `s.get(k) != v` check.\n- **Defaults** — only set if missing (theme, model). Use `k not in s` check.\n\nIf you only use `if k not in s` for everything, code-server's defaults (e.g. `chat.agent.enabled: true`) will never get overwritten.\n\n```python\n# Force-set critical settings (overwrite even if present)\nforced = {\n 'github.copilot.chat.enabled': False,\n 'github.copilot.enable': {'*': False},\n 'chat.agent.enabled': False,\n 'chat.agentsControl.enabled': False,\n 'chat.unifiedAgentsBar.enabled': False,\n 'workbench.secondarySideBar.defaultVisibility': 'hidden',\n 'workbench.navigationControl.enabled': False,\n 'claudeCode.allowDangerouslySkipPermissions': True,\n 'claudeCode.initialPermissionMode': 'bypassPermissions',\n}\nfor k, v in forced.items():\n if s.get(k) != v:\n s[k] = v\n changed = True\n# Defaults (only set if missing — user can customize)\ndefaults = {\n 'workbench.secondarySideBar.visible': False,\n 'workbench.colorTheme': 'Default Dark Modern',\n 'claudeCode.preferredLocation': 'panel',\n 'claudeCode.selectedModel': 'opus',\n}\nfor k, v in defaults.items():\n if k not in s:\n s[k] = v\n changed = True\n```\n\n#### Killing the Copilot Chat Panel\n\nThe \"Build with Agent\" / Copilot Chat panel is **built into code-server** — it's not an extension you can uninstall. Key settings:\n\n| Setting | What it does |\n|---------|-------------|\n| `workbench.secondarySideBar.defaultVisibility: \"hidden\"` | Hides the entire secondary sidebar (where Chat lives) |\n| `chat.agent.enabled: false` | Disables Agent mode (but doesn't hide the panel alone) |\n| `chat.agentsControl.enabled: false` | Removes agent controls from title bar |\n| `workbench.navigationControl.enabled: false` | Removes the Copilot sparkle icon from title bar |\n| `github.copilot.enable: {\"*\": false}` | Disables completions |\n\n**Note:** `github.copilot.chat.enabled` is NOT a real built-in setting — it has no effect. The real panel is controlled by `workbench.secondarySideBar.defaultVisibility`.\n\n#### Correct Setting Keys (Common Mistakes)\n\n| Wrong | Correct |\n|-------|---------|\n| `claudeCode.dangerouslySkipPermissions` | `claudeCode.allowDangerouslySkipPermissions` |\n| `github.copilot.chat.enabled` (no effect) | `workbench.secondarySideBar.defaultVisibility: \"hidden\"` |\n\n## Phase-by-Phase Implementation Notes\n\n### Phase 1: Prerequisites\n\nCheck-then-install pattern for each tool:\n\n```bash\nif command -v claude &>/dev/null; then\n ok \"Claude Code CLI installed ($(claude --version 2>/dev/null | head -1))\"\nelse\n echo \" Installing Claude Code CLI...\"\n curl -fsSL https://claude.ai/install.sh | bash 2>/dev/null\n # ... PATH setup ...\nfi\n```\n\nOrder matters:\n1. VS Code extension (so the panel is available)\n2. VS Code settings (disable Copilot, configure Claude Code)\n3. Claude Code CLI (needed for auth fallback and squad)\n4. GitHub CLI (needed to clone private repos)\n\n### Phase 2: Authentication\n\nOrder matters:\n1. GitHub first (needed to clone gallia repo, and to download the PKCE auth script if gallia isn't cloned yet)\n2. Claude Code second (PKCE script → CLI fallback)\n\nThe auth script can come from three places (checked in order):\n1. Same directory as bootstrap (if run from a local checkout)\n2. `~/gallia/scripts/claude-auth.mjs` (if gallia was already cloned)\n3. Downloaded via `gh api` or `curl` to `/tmp/` (first run)\n\n### Phase 3: Install Gallia\n\n```bash\ngh repo clone adom-inc/gallia ~/gallia\ncd ~/gallia\n\n# Full install (default — all workspaces):\nnpm install --no-audit --no-fund\n\n# OR service container (--service flag — server + viewer + named workspace):\nnpm install --workspace=server --workspace=viewer --workspace=mouser --no-audit --no-fund\n\nnode install.mjs --project ~/project\n```\n\nThe installer deploys skills, MCP servers, and CLAUDE.md to the project directory.\n\n### Phase 3b: Start Service (if --service)\n\nWhen `--service <name>` is passed, the bootstrap auto-starts the service:\n\n```bash\nbash ~/gallia/services/<name>/start-<name>.sh\n# Wait 2s, then check health endpoint\ncurl -sf http://127.0.0.1:<port>/health\n```\n\nThe port is auto-detected from `services/<name>/server.js`.\n\n### Phase 4: Claude Squad\n\nLaunch 4 tmux sessions with repo-named identifiers:\n\n```bash\nSESSIONS=(\"${REPO_NAME}-alpha\" \"${REPO_NAME}-beta\" \"${REPO_NAME}-gamma\" \"${REPO_NAME}-delta\")\nfor s in \"${SESSIONS[@]}\"; do\n tmux new-session -d -s \"$s\" -c /home/adom/project \"$CLAUDE_BIN --dangerously-skip-permissions rc\"\ndone\n```\n\nWait 5 seconds for RC sessions to initialize, then capture URLs from each pane.\n\n## Troubleshooting\n\n| Symptom | Cause | Fix |\n|---------|-------|-----|\n| `Invalid request format` on token exchange | Wrong Content-Type or missing `state` | Must use `application/json` with `state` in body |\n| `invalid_grant` error | Code expired or already used | Codes are single-use and expire quickly. Re-run auth to get a new code |\n| `[object Object]` in error output | Error field is an object, not string | Use `typeof r.error === 'object'` check |\n| Code includes extra characters | User copied `CODE#STATE` | Strip with `code.split('#')[0]` |\n| `claude: command not found` after bootstrap | PATH not persisted to `.bashrc` | Add `export PATH=\"$HOME/.local/bin:...\"` to `.bashrc` |\n| Squad sessions named with container ID | Using `hostname` instead of `VSCODE_PROXY_URI` | Parse repo name from `VSCODE_PROXY_URI` |\n| Cloudflare challenge instead of JSON | Token exchange blocked by WAF | Use browser console fallback or CLI fallback |\n| `gh auth login` hangs | Stdin consumed by curl pipe | Use `< /dev/tty` or `-w` flag for web/device auth |\n| Settings not taking effect | VS Code needs reload | Tell user: Ctrl+Shift+P → \"Reload Window\" |\n| `set -euo pipefail` exits on optional failure | Unguarded command that can fail | Use `|| true` for optional commands |\n\n## Running the Bootstrap on a New Container\n\nThe gallia repo is private, so `curl` from `raw.githubusercontent.com` won't work without auth. **Serve the bootstrap from any existing gallia container instead.**\n\n### Recommended: Serve from an existing gallia container\n\nOn any container that already has gallia installed, start a file server:\n\n```bash\ncd ~/gallia/scripts && python3 -m http.server 9999 --bind 0.0.0.0 &\n```\n\nThe current production URL (no auth required, served from service-jlcpcb on port 8775):\n```\nhttps://coder.john-service-jlcpcb-9a8b6c0328533a9b.containers.adom.inc/proxy/8775/bootstrap.sh\n```\n\nOr serve from any container with a file server. Then on the **new** container, run:\n\n```bash\n# Full install (dev containers):\nbash <(curl -fsSL https://coder.<existing-container-slug>.containers.adom.inc/proxy/9999/bootstrap.sh)\n\n# Service container (lightweight — only needed workspaces):\nbash <(curl -fsSL https://coder.<existing-container-slug>.containers.adom.inc/proxy/9999/bootstrap.sh) --service mouser\n```\n\nFor example, if your gallia dev container is `john-gallia-f280e93ffec7e79d`:\n\n```bash\n# Full install:\nbash <(curl -fsSL https://coder.john-gallia-f280e93ffec7e79d.containers.adom.inc/proxy/9999/bootstrap.sh)\n\n# Service container:\nbash <(curl -fsSL https://coder.john-gallia-f280e93ffec7e79d.containers.adom.inc/proxy/9999/bootstrap.sh) --service mouser\nbash <(curl -fsSL https://coder.john-gallia-f280e93ffec7e79d.containers.adom.inc/proxy/9999/bootstrap.sh) --service wiki\n```\n\nAny gallia container works — your dev container, the JLCPCB service container, etc. Just pick one that's running.\n\n**Important:** The gallia repo is private on GitHub. You cannot use `raw.githubusercontent.com` URLs to fetch the bootstrap — they require auth. Always serve the bootstrap from an existing gallia container via the `/proxy/9999/` static file server (started with `cd ~/gallia/scripts && python3 -m http.server 9999`).\n\n### Alternative: Install gh first, then bootstrap\n\nIf no gallia container is available, install the GitHub CLI manually, authenticate, then fetch the bootstrap via the GitHub API:\n\n```bash\n# 1. Install gh\n(type -p wget &>/dev/null || sudo apt install wget -y) && sudo mkdir -p -m 755 /etc/apt/keyrings && wget -qO- https://cli.github.com/packages/githubcli-archive-keyring.gpg | sudo tee /etc/apt/keyrings/githubcli-archive-keyring.gpg > /dev/null && echo \"deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/githubcli-archive-keyring.gpg] https://cli.github.com/packages stable main\" | sudo tee /etc/apt/sources.list.d/github-cli.list > /dev/null && sudo apt update -qq && sudo apt install gh -y -qq\n\n# 2. Auth (opens browser)\ngh auth login -p https -h github.com -w\n\n# 3. Run bootstrap\nbash <(gh api repos/adom-inc/gallia/contents/scripts/bootstrap.sh --jq '.content' | base64 -d)\n```\n\n## Updating a Running Service Container\n\nThe deployment flow for standalone services (JLCPCB, Mouser, Wiki, etc.):\n\n1. **Edit code** in gallia on your dev container\n2. **Push to GitHub:** `cd ~/gallia && git push`\n3. **On the service container,** pull and restart:\n ```bash\n cd ~/gallia && git pull\n npm install --workspace=server --workspace=viewer --workspace=<name> --no-audit --no-fund\n # Restart the service (kill old process + start new one)\n pkill -f 'node.*services/<name>/server.js' || true\n bash ~/gallia/services/<name>/start-<name>.sh\n ```\n\nOr re-run the full bootstrap — it's idempotent and will pull latest + restart.\n\nThe gallia repo is **always the source of truth**. Service containers don't have local edits — they just run whatever's in the repo.\n\n## How to Find OAuth Constants\n\nIf the constants change in a future Claude Code update, find them in the CLI bundle:\n\n```bash\n# Find the CLI bundle\nCLI_JS=$(ls -t ~/.local/share/code-server/extensions/anthropic.claude-code-*/resources/claude-code/cli.js | head -1)\n\n# Extract constants\ngrep -oP 'CLIENT_ID[\"\\s:=]+[\"\\x27]([^\"\\x27]+)' \"$CLI_JS\"\ngrep -oP 'TOKEN_URL[\"\\s:=]+[\"\\x27]([^\"\\x27]+)' \"$CLI_JS\"\ngrep -oP 'MANUAL_REDIRECT_URL[\"\\s:=]+[\"\\x27]([^\"\\x27]+)' \"$CLI_JS\"\n```\n\nThe CLI bundle is minified (~15MB). Key patterns:\n- `F$8` / `exchangeCodeForTokens` — the function that does Claude Code's own auth (JSON body)\n- `N6Y` — MCP OAuth helper (form-encoded body, different function — do NOT use this format)\n- `g8` = `axios` — the HTTP client used internally\n",
"author": {
"name": "Kyle Bergstedt",
"email": "[email protected]"
},
"visibility": {
"public": true
},
"hero": null,
"sample_prompts": [],
"discovery_triggers": [],
"discovery_pitch": null,
"metadata": {},
"created_at": "2026-05-28T05:30:30.265Z",
"updated_at": "2026-05-28T05:30:30.265Z",
"sub_skills": [],
"parent_app": null,
"org": "adom"
}