---
name: docker-setup
description: Generates Docker scaffolding and walks the operator through the full deployment — token setup, build, start, MCP plugin configuration, workspace trust, and verification. Offers to back up and overwrite existing Docker files. Run after /hatch.
---
# Docker Setup

Generate Docker scaffolding for running hermit as an always-on autonomous agent in a container. Docker provides isolation, crash recovery, and a reproducible environment. The default `auto` mode (classifier-reviewed autonomy) works well for most Docker hermits. Operators who need zero prompts for fully unattended operation can opt into `bypassPermissions` via `/hermit-settings permissions`.

**Tone:** Friendly guided wizard. Celebrate progress. When something fails, help fix it.

**Important:** Run all checks and commands sequentially — do not use parallel tool calls.

Templates live in `${CLAUDE_SKILL_DIR}/../../state-templates/docker/`.

## Plan

### 0. Refuse to run inside the hermit container

This skill is host-only — it generates Docker scaffolding on the host filesystem and drives `docker compose up`.

Run: `[ -f /.dockerenv ] || [ -f /run/.containerenv ] && echo container || echo host`

If the output is `container`, **stop immediately** — do not proceed to step 1. Print:

> This skill generates host-side Docker scaffolding and then drives `docker compose up`. Run it from your host shell in the project root. To check what's already configured *inside* the running container, run `/claude-code-hermit:hermit-doctor`.

### 1. Prerequisites

1. Run `docker --version`. If missing: "Docker isn't installed — grab it from https://docs.docker.com/get-docker/ and come back!"
2. Verify `.claude-code-hermit/config.json` exists. If missing: "Run `/claude-code-hermit:hatch` first, then come back."
3. If working dir starts with `/mnt/c/` or `/mnt/d/`: abort — "Clone inside WSL2 (e.g. `/home/you/project`) and run from there."
4. Check for existing `Dockerfile.hermit`, `docker-entrypoint.hermit.sh`, `docker-compose.hermit.yml` at project root. If any found:
   - List them, then ask with `AskUserQuestion` (header: "Docker files"): **No — keep existing** (abort; remove or rename manually, then re-run) / **Yes — back up** (move to docker-backup/ and regenerate).
   - "Yes — back up" → move to `docker-backup/`, continue
   - "No — keep existing" → abort
   - Never overwrite `.env` — only append missing vars (step 5).

### 1.5. Setup mode gate

Determine whether to run in **Quick** or **Advanced** mode.

**Argument-driven**: if invoked with the positional argument `quick` (e.g. `/claude-code-hermit:docker-setup quick`), skip the question below and run Quick directly. This is what `hatch` chains to at the end of a Quick hatch run. The operator can always re-invoke `/claude-code-hermit:docker-setup` without the arg later to drop into Advanced.

**Otherwise, ask:**

```
questions: [
  {
    header: "Setup mode",
    question: "How would you like to configure Docker?",
    options: [
      { label: "Quick", description: "OAuth + bridge networking, auto-mirror trusted plugins, build immediately" },
      { label: "Advanced", description: "Full wizard — pick auth, networking, every plugin, every package" }
    ]
  }
]
```

**Quick-mode contract — security non-negotiables.** Quick mode never bulk-accepts third-party plugins, never weakens the safelist, never skips the public-repo pre-flight, and never bypasses the write-time assertion. Those gates exist because that's the line where defaults stop being safe. Quick only auto-defaults the choices that have one obviously-correct answer (auth, networking, build-now) and the SAFE plugin batch (claude-plugins-official + gtapps/* — already vetted by the safelist).

Branch on the choice for the rest of the skill:
- **Advanced** → run today's flow unchanged from Step 2.
- **Quick** → apply Quick defaults silently throughout (Step 2 auth+network, Step 7b SAFE mirror, Step 7b.packages safelisted-source auto-accept, Step 8 build-now). The Quick-specific behavior is called out inline at each downstream step.

### 2. Ask operator and analyze project

**Quick:** silently apply `auth = oauth`, `docker.network_mode = "bridge"`. Skip the `AskUserQuestion` below. Continue at the project-dependencies scan below.

**Advanced:** ask auth method and networking in a single `AskUserQuestion` call:

```
questions: [
  {
    header: "Auth",
    question: "Authentication method for the container?",
    options: [
      { label: "OAuth", description: "Run claude /login inside the container — recommended" },
      { label: "API Key", description: "Set ANTHROPIC_API_KEY in .env" }
    ],
  },
  {
    header: "Networking",
    question: "Container network mode?",
    options: [
      { label: "Bridge", description: "Isolated from host-local services (default)" },
      { label: "Host", description: "Direct access to localhost services — use if hermit needs host-bound services" }
    ]
  }
]
```

Record networking choice as `docker.network_mode` (`"bridge"` or `"host"`).

**Project dependencies scan:** Scan for signals that suggest extra system packages:
   - `package.json` native addons (sqlite3, sharp, canvas, bcrypt, etc.)
   - Python files (`requirements.txt`, `pyproject.toml`, `Pipfile`)
   - Build files (`Makefile`, `CMakeLists.txt`), version managers (`.tool-versions`)
   - Database config (prisma, knex, sequelize)
   - OPERATOR.md mentions of tools/stack
   - `.claude/settings.json` and `.claude/settings.local.json` `permissions.allow` entries — allowed Bash commands reveal tools the project expects (e.g. `Bash(docker *)` → docker CLI, `Bash(python *)` → python3, `Bash(psql *)` → postgresql-client). Check for commands that need system packages not in the base image.

   Present findings conversationally with reasoning. If nothing found, say so. Collect as provisional candidates — confirmation is deferred to step 7b.packages after plugin selection, where plugin-declared deps are unioned in.

### 3. Read config and prepare

Read `.claude-code-hermit/config.json` and extract:
- `tmux_session_name` — resolve `{project_name}` with actual directory name
- `agent_name`
- Detect project path from `pwd`

Do NOT set `AGENT_HOOK_PROFILE` in `config.json` `env` — it stays as `standard` (the host default). The strict profile is rendered into the docker-compose environment block in step 4 via `{{AGENT_HOOK_PROFILE}}`.

### 4. Template placeholder reference (rendering deferred to Step 7b.6)

> **Execution moved.** Template rendering is now performed at Step 7b.6 — AFTER plugin resolution (Step 7b) and apt-package union (Step 7b.packages) have finalized `docker.packages`. The placeholder definitions are documented here for reference; the actual `Read` + `Write` calls happen later, with all values resolved.
>
> Why: `{{PACKAGES_BLOCK}}` substitution depends on `docker.packages`, which isn't finalized until Step 7b.packages. Rendering at Step 4 (the original position) consumed an empty/pre-resolution package list. Channel placeholders (`{{CHANNEL_ENV_LINES}}`, `{{CHANNEL_VOLUME_LINES}}`) similarly depend on Step 7 channel-token configuration.

The three templates live in `${CLAUDE_SKILL_DIR}/../../state-templates/docker/`. Placeholder substitution rules per file (do NOT read or write here — all file I/O happens at Step 7b.6):

**Dockerfile.hermit** (from `Dockerfile.hermit.template`):
- `{{PACKAGES_BLOCK}}` — if `docker.packages` is non-empty, replace with:
  ```
  # Project-specific packages (from config.json docker.packages)
  # To modify: /hermit-settings docker, then rebuild
  RUN apt-get update && apt-get install -y --no-install-recommends \
        <space-separated packages> && \
      rm -rf /var/lib/apt/lists/*
  ```
  If empty, remove the `{{PACKAGES_BLOCK}}` line entirely. The block is placed after the npm install layer so changing project packages doesn't bust the npm cache.

**docker-entrypoint.hermit.sh** (from `docker-entrypoint.hermit.sh.template`):
- `{{TMUX_SESSION_NAME}}` — resolved session name

**docker-compose.hermit.yml** (from `docker-compose.hermit.yml.template`):
- `{{AUTH_ENV_LINE}}` — If apikey: `      - ANTHROPIC_API_KEY=${ANTHROPIC_API_KEY}\n`. If oauth: empty string (remove the line entirely — OAuth credentials live in `.credentials.json` inside the named volume, written by `claude /login`).
- `{{CHANNEL_ENV_LINES}}` — for each enabled channel, derive the `*_STATE_DIR` value from `channels.<name>.state_dir` in config.json and add an indented environment line:
  - `      - DISCORD_STATE_DIR=${PWD}/.claude.local/channels/discord`
  - `      - TELEGRAM_STATE_DIR=${PWD}/.claude.local/channels/telegram`
  Remove `{{CHANNEL_ENV_LINES}}` entirely if no channels configured.
- `{{CHANNEL_VOLUME_LINES}}` — for each configured channel, add an indented volume bind-mount that maps the project-local state dir into the container's `~/.claude/channels/` path. This ensures channel plugin writes stay inside the project tree (no permission prompts with `bypassPermissions`):
  - `      - ${PWD}/.claude.local/channels/discord:/home/claude/.claude/channels/discord`
  - `      - ${PWD}/.claude.local/channels/telegram:/home/claude/.claude/channels/telegram`
  Remove `{{CHANNEL_VOLUME_LINES}}` entirely if no channels configured.
- `{{AGENT_HOOK_PROFILE}}` — always `strict` for Docker (enforces `always_on` deny patterns inside the container)
- `{{TMUX_SESSION_NAME}}` — resolved session name
- `{{NETWORK_MODE_LINE}}` — If `docker.network_mode` is `"host"`: replace with `    # WARNING: host networking exposes all host-local services to the container.\n    network_mode: host`. If `"bridge"` (default): remove the line entirely (bridge is Docker's default — no directive needed).
- **Git identity:** Check if `~/.gitconfig` exists on the host. If it does not exist, remove the `.gitconfig` bind-mount line from the rendered file and add a note in the summary: "No ~/.gitconfig found — git commits inside the container will have no author identity. Create one on the host and re-run docker-setup, or set git config manually inside the container."

### 5. Auto-memory seed

Resolve the path key: `pwd | sed 's|/|-|g'` (keeps leading dash — matches Claude Code's format, e.g. `-home-user-myproject`).

Check if `~/.claude/projects/<path-key>/memory/MEMORY.md` exists on the host:

- **If it exists:** Copy to `.claude-code-hermit/MEMORY-SEED.md`. Tell the operator: "Found existing Claude Code memory for this project — seeding it into the container so your hermit starts with full context." Then ensure `.claude-code-hermit/MEMORY-SEED.md` is in `.gitignore` (append if missing).
- **If it doesn't exist:** Do nothing. No message, no prompt.

Only the top-level project memory is seeded — not agent-scoped memories at `<path-key>/<agent-name>/`.

### 6. Environment and protection

1. If `.env` doesn't exist, create it. If it does, read it first.
2. **If apikey:** Check if `ANTHROPIC_API_KEY=` is present in `.env`. If missing, append:
   ```
   # --- claude-code-hermit ---
   ANTHROPIC_API_KEY=your-api-key-here
   ```
   If already present, leave it — note for step 8.
   **If oauth:** No auth var needed in `.env`. Check whether `ANTHROPIC_API_KEY` is set (non-empty) in `.env`. If so, warn and ask with `AskUserQuestion` (header: "API key"): **Yes — comment out** (prefix with # to disable it) / **No — keep** (container will run in API key mode).
   Also check for and offer to remove any `CLAUDE_CODE_OAUTH_TOKEN` — this env var is not used in the new flow.
3. Ensure `.env` is listed in both `.gitignore` and `.dockerignore` (create the files if needed, append if missing).
4. **Deny patterns:** Write the `default` set from `state-templates/deny-patterns.json` into the target settings file's `permissions.deny`.

   **Resolve `hatch_target`** using the same fallback chain as `hermit-evolve` SKILL.md §2a (`.claude-code-hermit/state/hatch-options.json` `"target"` field → marker scan of `CLAUDE.local.md` / `CLAUDE.md` → `claude plugin list --json` scope detection). Do not silently default to committed when the state file is absent — that can leak operator-personal hardening into the repo. Map: `hatch_target == "local"` → `.claude/settings.local.json`; `hatch_target == "committed"` → `.claude/settings.json`.

   Do NOT include the `always_on` set — those patterns (docker, ssh, kubectl, git push --force, etc.) are enforced by the `enforce-deny-patterns.js` hook when `AGENT_HOOK_PROFILE=strict`, which the container runs with. Writing them to settings would block docker commands needed by later steps in this skill.

   If the target settings file already has `permissions.deny`, merge (never remove existing entries). Tell the operator: "Added safety deny rules for always-on operation — protects against destructive commands and credential exposure. Container-specific rules (docker, ssh, kubectl) are enforced by the hook at runtime. Written to {.claude/settings.local.json | .claude/settings.json}."

5. **Sandbox note:** The hermit Docker base image ships `bubblewrap` and `socat` (added in v1.1.2), so the Claude Code sandbox can run inside the container. `hermit-start.py` automatically writes `sandbox.enableWeakerNestedSandbox: true` to `.claude/settings.local.json` on every Docker boot — this is required for bwrap to start inside an unprivileged container. The sandbox profile itself (enabled/disabled, filesystem denies) is set at `/hatch` time and lives in the operator's settings file; docker-setup does not modify it.

### 7. Channel setup

Skip if no enabled channels in `config.json`. Read `.claude-code-hermit/config.json` from the project root (same root verified in Step 1 — do not rely on cwd). A channel counts as present when `config.channels[<name>].enabled !== false` (missing `enabled` is treated as enabled). Empty `channels: {}` skips.

**Token locations in Docker:** Always use `.claude.local/channels/<plugin>/.env` (project-local scope). Never read or copy from global `~/.claude/channels/`.

| Plugin   | Token var            | Docker compatible |
|----------|----------------------|-------------------|
| discord  | `DISCORD_BOT_TOKEN`  | Yes               |
| telegram | `TELEGRAM_BOT_TOKEN` | Yes               |
| imessage | —                    | No (macOS only)   |

If iMessage detected, note it won't work in Docker and skip.

For each configured channel:

1. **Install plugin locally** (if not already): `claude plugin install <plugin>@claude-plugins-official --scope local`
2. **Ensure state dir:** Record `channels.<channel>.state_dir` as a relative path (e.g. `.claude.local/channels/discord`). `hermit-start` resolves it against the project root at boot and derives the `*_STATE_DIR` env var — no need to add it to `env`. Absolute paths also work.
3. **Check local token file** (`.claude.local/channels/<plugin>/.env`):
   - Already configured → "Discord is ready — connects automatically on start."
   - Not configured → ask with `AskUserQuestion` (header: "Bot token"): **Skip** (write the token to `.claude.local/channels/{channel}/.env` later and restart) / **Enter now** (paste token via Other).
     - If "Enter now" and a token was typed via Other: `mkdir -p`, write `.env`, `chmod 600`, ensure `.claude.local/` in `.gitignore`, clean stale token from `.claude/settings.local.json` `env` if present. Confirm saved.
     - If "Skip": "Write it to `.claude.local/channels/<plugin>/.env` later and restart."

### 7b. Recommended plugins

Mirror host-installed plugins into the container so the container starts with the same plugin set the operator already curated — including any domain hermit (e.g. `claude-code-homeassistant-hermit`) that may have triggered this setup flow.

> **SECURITY-CRITICAL STEP.** Every plugin written here is auto-installed on container boot with whatever `permission_mode` the operator chose — for `bypassPermissions` hermits, this means full unrestricted execution. Do not shortcut the safelist, validation, or deselection rules below. If you find yourself about to skip one, stop and re-read this section.

1. Run `claude plugin list --json` to enumerate plugins. Each entry includes `id` (form `<plugin>@<marketplace_name>`), `scope`, `enabled`, `projectPath`, and `installPath`. Apply the **project-or-local + enabled filter**:
   - Keep `enabled == true` AND (`scope == "project"` OR `scope == "local"`) AND `projectPath` equals the current project root.
   - Drop user-scope, managed-scope, disabled, and cross-project entries entirely.

   `claude plugin list --json` lists across the operator's full config — local-scope plugins from sibling repos carry their own `projectPath` and must be excluded.

   **Dedupe rule.** After filtering, dedupe surviving entries by `(plugin, marketplace_name)`. If both a `project` and a `local` scope entry survive for the same `(plugin, marketplace_name)`, keep the `local` entry only — local is the override scope in Claude Code's overlay model. Different marketplaces remain distinct entries.

   If the operator wants a user-scope plugin in the container, they install it at `project` scope on the host first and re-run this skill.
2. Filter out additionally:
   - `claude-code-hermit@claude-code-hermit` — the entrypoint already handles it unconditionally.
   - Any channel plugins already picked up via `config.channels` (they flow through the entrypoint's channel branch).
3. Run `claude plugin marketplace list --json` and build a `marketplace_name → repo` map from each entry's `name` and `repo` fields. Split each plugin `id` as `<plugin>@<marketplace_name>` and look up `marketplace_name` in the map to get the `org/repo`. Only the `org/repo` is recorded on each `docker.recommended_plugins` entry — the entrypoint resolves the canonical marketplace name at boot via the same `marketplace list --json` lookup, so storing it twice would just duplicate the source of truth.
   - If a marketplace entry has `source != "github"` or no `repo` field (e.g. a locally-added marketplace), ask: "What's the GitHub source for the `<marketplace_name>` marketplace? (e.g. `org/repo`)" before presenting the plugin list.

4. **Validation gate — apply to every `org/repo` before it reaches the plugin list or `config.json`:**
   - Regex: `^[A-Za-z0-9][\w.-]*/[A-Za-z0-9][\w.-]*$`
   - Applies to: `repo` values read from the marketplace JSON AND values typed by the operator.
   - If a value does not match: reject, explain why, re-prompt. Never write a failing value to config.
   - Purpose: defense-in-depth against malformed JSON, typos, or injected strings landing in `config.json` and being passed to `claude plugin marketplace add` on boot.

5. **Partition the filtered list using the safelist:**

   ```
   def is_safelisted(marketplace):
       # marketplace is always org/repo after the step 7b.3 lookup — no literal-name shortcut.
       return (
           marketplace == "anthropics/claude-plugins-official"
           or marketplace.startswith("gtapps/")
       )
   ```

   The `marketplace` value passed to `is_safelisted` is always the `org/repo` resolved in step 7b.3 — never the marketplace name and never the slug from the `<plugin>@<name>` id. If you're about to pass `claude-code-homeassistant-hermit` (a slug) to `is_safelisted`, you skipped the lookup — go back to step 3.

   Split into two groups:
   - **SAFE** — `is_safelisted(marketplace)` is `True`. Examples: `anthropics/claude-plugins-official`, `gtapps/claude-code-homeassistant-hermit`.
   - **THIRD-PARTY** — everything else. Examples: `obra/superpowers-marketplace`, any non-`gtapps` `org/repo`, any locally-added marketplace without a GitHub source.

   The SAFE group can be accepted as a batch. The THIRD-PARTY group **must be confirmed one-by-one**. Do not bulk-accept third-party plugins for the operator's convenience — the host list is the **candidate** set, not the trusted set.

6. **Present the choices in plain text first**, so the operator sees the full list before any prompt (`AskUserQuestion` caps options at 4, so we cannot enumerate plugins in a single question):

   ```
   Host-installed plugins detected (project + local scope only):

   Safelisted (trusted sources):
     - claude-code-setup @ anthropics/claude-plugins-official (project)
     - claude-code-homeassistant-hermit @ gtapps/claude-code-homeassistant-hermit (project)

   Third-party (require individual opt-in):
     - superpowers @ obra/superpowers-marketplace (local)
   ```

7. **If the SAFE group is non-empty**:

   **Quick:** silently treat as if `"Mirror all"` was chosen — add every SAFE plugin to `docker.recommended_plugins` with `enabled: true`. No prompt.

   **Advanced:** ask once with `AskUserQuestion` (header: `"Plugins"`):
   - Options: `"Mirror all"` (Recommended) / `"Pick each"` / `"Skip all"`.
   - On `Mirror all`: add every SAFE plugin to `docker.recommended_plugins` with `enabled: true`.
   - On `Pick each`: loop through the SAFE plugins; for each, ask a 2-option yes/no (`"Include"` / `"Skip"`). Only include the ones the operator confirms.
   - On `Skip all`: add nothing from this group.

8. **For the THIRD-PARTY group**, loop through each plugin individually. For each, ask a 2-option `AskUserQuestion` (header: `"Third-party"`) — `"Include"` / `"Skip"`. Include only when the operator explicitly confirms. Never batch-accept this group.

8b. **Public-repo pre-flight (for every non-`claude-plugins-official` entry that the operator has tentatively confirmed in steps 7 or 8).** From the host, run an unauthenticated check against `https://github.com/<org>/<repo>` (e.g. `curl -fsI -o /dev/null -w '%{http_code}' https://github.com/<org>/<repo>`). If the response is not `200` (i.e. `404`, redirect-to-login, or network error), the repo is private or unreachable. Tell the operator:

   > `<org>/<repo>` appears private or unreachable. The container cannot clone private repos automatically.

   Then ask with `AskUserQuestion` (header: `"Private repo"`) — `"Include anyway"` / `"Skip"`. On `Skip`: drop the entry. On `Include anyway`: keep it. Do not suggest remediations.

9. After both groups are processed: any plugin not explicitly confirmed must **not** be written to `docker.recommended_plugins`. Write only confirmed entries with `enabled: true`.

10. **Write-time assertion — run this before handing entries to step 7c.** For each confirmed entry:
    - Assert `marketplace` matches `^[A-Za-z0-9][\w.-]*/[A-Za-z0-9][\w.-]*$` (has a slash; is `org/repo`). Uniform across official and third-party entries — official stores `anthropics/claude-plugins-official`, no more literal-name shortcut.
    - If any entry fails: **stop, do not write config.** Re-derive the failing entry's `org/repo` from the `claude plugin marketplace list --json` output (look up the marketplace name from the plugin's `id` to get the `repo`). If the lookup cannot produce a valid `org/repo`, prompt the operator (same prompt as step 3). Re-run the assertion. Only proceed to step 7c once every entry passes.

11. If the filtered list is **empty** (no extras installed on the host), skip silently — no prompt, no entries written.

Record the confirmed selection as `docker.recommended_plugins`. Each entry has:
```json
{"plugin": "<plugin-name>", "marketplace": "<org/repo>", "scope": "<scope>", "enabled": true}
```
The entrypoint resolves the canonical marketplace name at boot via `claude plugin marketplace list --json` (no need to store it on the entry), adds the marketplace if missing, and installs every enabled entry on first boot. See [Recommended Plugins](../../docs/recommended-plugins.md) for the full policy.

**Legacy config note.** Re-running this skill against a pre-v1.0.34 config rebuilds `docker.recommended_plugins` from scratch from the current host plugin list — legacy entries (e.g. `marketplace == "claude-plugins-official"` literal) are replaced cleanly. The entrypoint warns and skips any legacy entry whose `marketplace` is not an `org/repo` until the operator re-runs this skill once.

**On container-side `claude plugin marketplace add` / `plugin install` failure (either in entrypoint logs or when re-running the command manually after boot):** if the error mentions SSH auth, HTTPS credentials, `gh` not found, or `.gitconfig` read-only, **stop immediately — do not attempt workarounds inside the container.** The container has no SSH client, no `gh` CLI, and `.gitconfig` is bind-mounted read-only by design. Iterating on `GIT_CONFIG_NOSYSTEM`, `git config --global url...insteadOf`, or similar is guaranteed to fail and wastes the operator's time. Surface the error to the operator verbatim and move on — no retry unless the operator changes something host-side (makes the repo public, mirrors it, etc.) and asks to retry.

If the operator selected plugins that have corresponding `scheduled_checks` entries in hatch Phase 4 (claude-code-setup, claude-md-management, skill-creator, feature-dev), also record those `scheduled_checks` entries if not already present.

### 7b.packages: Plugin-declared apt dependencies

After plugin selection is finalized, union the two sources of apt package candidates before writing to config:

1. **Project candidates** (from step 2.3 scan) — project-owned signals (native addons, Python stack, build tools, etc.). Live-scanned from project files each run.

2. **Plugin declarations** — for each confirmed entry in `docker.recommended_plugins`, locate the plugin's installed root via `claude plugin list --json` (inspect path fields). In the plugin root:
   - Check for a `## Docker apt dependencies` section in `skills/hatch/SKILL.md`.
   - Also check for a `DOCKER.md` file at the plugin root with the same section.
   - Extract the bullet-list entries under that heading (stop at the next `##` heading). Ignore lines starting with `#`.
   - **Validate every extracted package name** against `^[a-z0-9][a-z0-9+\-.]+$`. Entries that fail are dropped with a clear warning to the operator — never passed to Dockerfile rendering.
   - Track provenance: `<package-name> — declared by <plugin-name>`.
   - If no section is found for a plugin, skip silently (backward-compatible with plugins that predate this convention).

3. **Unified confirmation prompt** — present the combined deduped list with origin labels:
   ```
   Proposed image packages:
     libsqlite3-dev       — project signal (package.json sqlite3)
     <package-a>          — declared by <plugin-name>
     <package-b>          — declared by <plugin-name>
   ```
   Operator approves, removes, or adds entries. Write the approved set as the final `docker.packages` (passed to step 7c for config.json write).

   **Quick mode auto-accept**: if every entry in the combined list (a) passed the validator regex and (b) came from a safelisted source — meaning project-signal entries OR entries declared by plugins where `is_safelisted(marketplace)` is true (claude-plugins-official + gtapps/*) — skip the prompt and accept the list silently. Print a one-line summary so the operator sees what landed: `Quick auto-accepted N image packages (all from safelisted sources). Configure later via /hermit-settings docker.` If ANY entry came from a third-party plugin (operator-confirmed in Step 7b), fall through to the prompt — defaults stop applying once a third-party source contributed apt deps.

### 7b.6. Render templates (deferred from Step 4)

Now that `docker.packages` (Step 7b.packages), `docker.recommended_plugins` (Step 7b), channel state dirs (Step 7), and the auth/network choices (Step 2) are all finalized, perform the template rendering described in Step 4 above. Write the three rendered files to project root:

- `Dockerfile.hermit` — substitute `{{PACKAGES_BLOCK}}` with the finalized `docker.packages`.
- `docker-entrypoint.hermit.sh` — substitute `{{TMUX_SESSION_NAME}}`.
- `docker-compose.hermit.yml` — substitute `{{AUTH_ENV_LINE}}`, `{{CHANNEL_ENV_LINES}}`, `{{CHANNEL_VOLUME_LINES}}`, `{{AGENT_HOOK_PROFILE}}`, `{{TMUX_SESSION_NAME}}`, `{{NETWORK_MODE_LINE}}`, plus the git-identity bind-mount handling.

Use the placeholder rules from Step 4 verbatim.

### 7c. Write Docker settings to config.json

**Pre-write gate for `docker.recommended_plugins`:** Re-run the step 7b.10 assertion on every entry. If any fails, do **not** write — return to step 7b.3 to re-resolve. This gate is the final backstop; do not bypass it even "just this once."

Write all collected Docker settings to config.json in a single update:
- `docker.network_mode` (from Step 2)
- `docker.packages` (from Step 7b.packages)
- `docker.recommended_plugins` (from Step 7b, post-gate)
- `channels.<channel>.state_dir` (from Step 7, if applicable)

Merge into existing config — never remove existing keys.

### 8. Guided deployment

Print generated files summary:
```
Dockerfile.hermit              — container image definition
docker-entrypoint.hermit.sh    — startup script
docker-compose.hermit.yml      — orchestration config
.env                           — auth credentials
```

**Auth token (apikey only):** If operator chose apikey and `.env` already has a real key (not placeholder), skip. Otherwise ask with `AskUserQuestion` (header: "API key"): **Skip** (add `ANTHROPIC_API_KEY` to `.env` manually before starting) / **Enter now** (paste key via Other). If "Enter now" and a key was typed via Other: update `.env`.

**Build and start:**

**Quick:** silently treat as `Yes — build now` and proceed directly to the build sub-section below. No prompt.

**Advanced:** ask with `AskUserQuestion` (header: "Deploy"): **Yes — build now** (run hermit-docker up immediately) / **No — manual** (print commands to run later).

**If "No — manual":** Print the full manual deployment guide below, then skip directly to Step 9 (do not attempt Login, Workspace trust, or Channel pairing — the container is not running yet):

```
Manual deployment guide
──────────────────────
1. Start the container:
   .claude-code-hermit/bin/hermit-docker up

2. (OAuth only) From a second terminal, complete login:
   .claude-code-hermit/bin/hermit-docker login

3. Accept first-run prompts (press Ctrl+B D to detach when done):
   .claude-code-hermit/bin/hermit-docker attach

   Screen 1 — Workspace trust: press Enter to accept.
   Screen 2 — Bypass Permissions (only appears when permission_mode is bypassPermissions; skip if using auto or any other mode).

4. (Channels only) Pair each bot — DM it to get a 6-char code, then run
   (send text and Enter as two separate calls with a 0.5s pause — one-shot
   text+Enter is swallowed as bracketed paste):
   docker exec <container> tmux send-keys -t <session> \
     '/<plugin>:access pair <code> — save access.json to <project_path>/.claude.local/channels/<plugin>/ not ~/.claude'
   sleep 0.5
   docker exec <container> tmux send-keys -t <session> Enter
   docker exec <container> tmux send-keys -t <session> \
     '/<plugin>:access policy allowlist'
   sleep 0.5
   docker exec <container> tmux send-keys -t <session> Enter
   Verify access.json landed at: .claude.local/channels/<plugin>/access.json

5. Verify everything is healthy:
   .claude-code-hermit/bin/hermit-status

Re-run /claude-code-hermit:docker-setup any time you want guided help.
```

**If "Yes — build now":**
1. Pre-create all channel state directories on the host so Docker doesn't create them as root on first mount — if a bind-mount source doesn't exist, `docker compose up` creates it owned by root, making it unwritable by the `claude` user inside the container. For each configured channel run `mkdir -p .claude.local/channels/<plugin>`. Then run `mkdir -p .claude-code-hermit/state && touch .claude-code-hermit/state/.setup-mode` to put the container in setup mode (suppresses the bootstrap prompt so channel pairing commands land on an idle REPL, not a busy session turn). Then run `docker compose -f docker-compose.hermit.yml up -d --build` — builds and starts. Help fix errors (daemon not running, network, disk). Do **not** use `.claude-code-hermit/bin/hermit-docker up` here — its trailing echo prints attach/detach instructions that look like imperative commands and can mislead the LLM running this skill into executing them mid-setup. The final hand-off at step 9 provides the canonical attach guidance.
2. **Verify the container stayed running:** Poll `docker compose -f docker-compose.hermit.yml ps --status running --format '{{.Service}}'` every 2s for up to 10s. If the service appears — continue to the next sub-section. If it never appears after 10s, run `docker compose -f docker-compose.hermit.yml logs --tail=30 hermit` and show the output. Help diagnose (common: image build failure, missing `.env` var, port conflict, Docker daemon not fully ready). **Do not continue to Login / Workspace trust / Channel pairing while the container is down — stop here and ask the operator to fix and re-run the skill.**

**Login (oauth only):** If operator chose oauth, proceed only once the container is confirmed running. Guide them through login:
1. Tell them: "The container is waiting for you to log in. Run this from another terminal:"
   ```
   .claude-code-hermit/bin/hermit-docker login
   ```
   This opens a claude REPL inside the container. Type `/login`, follow the URL in a browser, then paste the code back when prompted. Type `/exit` when done — the hermit starts automatically.
2. Ask with `AskUserQuestion` (header: `"Login"`) — `"Done — login succeeded"` / `"Failed — couldn't complete login"`. Do **not** poll logs in a loop. Do **not** rebuild or restart the container. `hermit-docker login` already verifies `.credentials.json` and exits non-zero if absent, so a "Done" answer means creds are present.
3. On `"Failed"`: run `docker compose -f docker-compose.hermit.yml logs --tail=30 hermit` and show output, then **stop** — operator re-runs `/claude-code-hermit:docker-setup` after resolving the issue.

**First-run acceptance (workspace trust + bypass mode):** Before asking the operator to attach, verify the tmux session exists inside the container (the entrypoint may still be installing plugins):
```
docker compose -f docker-compose.hermit.yml exec -T hermit tmux has-session -t <TMUX_SESSION_NAME>
```
If the session is not ready, wait and retry every 5s for up to 30s. If still absent after 30s, show the last 30 lines of entrypoint logs (`docker compose logs --tail=30 hermit`) to help diagnose, then stop.

Note: a running tmux session does **not** mean claude has finished booting — it may still be sitting on an acceptance screen waiting for input.

Once the session exists, tell the operator:

> "Attach now and accept two prompts in order — claude will look frozen until you do, and later steps will misdiagnose it as a crash if you skip this:"
> ```
> .claude-code-hermit/bin/hermit-docker attach
> ```
> **Screen 1 — Workspace trust** (always): You'll see "Accessing workspace … Quick safety check: Is this a project you created or one you trust?" — press **Enter** to accept.
>
> **Screen 2 — Bypass Permissions mode** (only if `permission_mode: bypassPermissions`): You'll then see the `--dangerously-skip-permissions` acknowledgement. Use the **arrow keys** to select **"Yes, I accept"**, then press **Enter**.
>
> After accepting, you'll see a **blank claude prompt** — that's expected during setup. The skill will send pair commands from here; don't type `/session` yourself.
>
> Then press **Ctrl+B, D** to detach.

Read `config.permission_mode` from `.claude-code-hermit/config.json`. If it is NOT `bypassPermissions`, omit the "Screen 2" paragraph entirely.

Wait for the operator to confirm they have detached before continuing.

**Channel pairing** (skip if no channels or no tokens configured):

Before pairing, confirm the operator has completed the first-run acceptance step above — if they haven't, `tmux send-keys` commands will be swallowed by the consent screen and appear to do nothing.

Confirm the tmux session still exists (reuse the `has-session` check from the acceptance step). If it's gone, surface container logs and stop.

For each channel, **first verify the token is configured** — check that `.claude.local/channels/<plugin>/.env` exists and contains the expected `*_BOT_TOKEN` var. If missing, skip pairing for this channel and tell the operator: "No token configured for `<channel>` — write it to `.claude.local/channels/<plugin>/.env`, restart the container, then re-run `/claude-code-hermit:docker-setup` to pair." Move to the next channel.

If the token is present, ask if already paired. If not:
1. Ask with `AskUserQuestion` (header: `"<channel> pairing"`) — `"I have the code"` / `"Skip this channel"`. On `"I have the code"`: ask for the 6-char code via `Other` (header: `"Bot code"`).
2. Send pair command into tmux — append the local state dir so the LLM writes there instead of the default user path. Send text and `Enter` as **two separate `send-keys` calls** with a `sleep 0.5` between them — Claude Code's TUI treats text+Enter in one burst as bracketed paste and turns `Enter` into a literal newline instead of submit (same fix as `scripts/hermit-start.py:611-617`):
   ```
   docker compose -f docker-compose.hermit.yml exec -T hermit \
     tmux send-keys -t <session> '/<plugin>:access pair <code> — save access.json to <project_path>/.claude.local/channels/<plugin>/ not ~/.claude'
   sleep 0.5
   docker compose -f docker-compose.hermit.yml exec -T hermit \
     tmux send-keys -t <session> Enter
   ```
3. Set policy (same two-call pattern):
   ```
   docker compose -f docker-compose.hermit.yml exec -T hermit tmux send-keys -t <session> '/<plugin>:access policy allowlist'
   sleep 0.5
   docker compose -f docker-compose.hermit.yml exec -T hermit tmux send-keys -t <session> Enter
   ```
4. Ask with `AskUserQuestion` (header: `"Pair result"`) — `"Bot confirmed paired"` / `"No response"`. On `"No response"`: run `docker compose exec -T hermit tmux capture-pane -t <session> -p`, show output, and skip `access.json` verification for this channel — don't fail the whole setup.
5. **Verify `access.json` landed in the right place** (only on `"Bot confirmed paired"`): Check `.claude.local/channels/<plugin>/access.json`. If absent, run `docker compose exec -T hermit tmux capture-pane -t <session> -p` and show output. If it landed in `~/.claude/channels/<plugin>/` instead, move it:
   ```
   docker compose exec -T hermit bash -c 'src="${CLAUDE_CONFIG_DIR:-/home/claude/.claude}/channels/<plugin>/access.json"; dst="<project_path>/.claude.local/channels/<plugin>/"; [ -f "$src" ] && mkdir -p "$dst" && mv "$src" "$dst" && echo moved'
   ```
6. **Default delivery settings** (skip if `"Already paired"` was chosen or pairing was skipped this run):
   1. Use the `Read` tool on `<project_path>/.claude.local/channels/<plugin>/access.json` (host file). If `ackReaction` is already non-empty, skip — preserve operator customization.
   2. Otherwise use the `Edit` tool to set the `ackReaction` value to `"👀"` while preserving every other key in the file unchanged (read-modify-write a single field — do NOT overwrite the file with a fresh object). The bind-mount makes the change visible inside the container immediately — no tmux round-trip, no LLM turn, no race with the Step 8b shutdown.
   3. Idempotent: re-running docker-setup leaves customized values alone (sub-step 1 short-circuits when `ackReaction` is non-empty).
7. **Server channel / group chat (optional)** (run even if `"Already paired"` was chosen; skip only if `imessage`, if `"Skip this channel"` was chosen, or if pairing returned `"No response"`):
   1. Ask with `AskUserQuestion` — label and prompt vary by channel:
      - `discord`: header `"Server channel"` — "Want the hermit to also listen in a Discord server channel? Channel ID: enable Developer Mode in Discord settings → right-click the channel → Copy Channel ID."
      - `telegram`: header `"Group chat"` — "Want the hermit to also listen in a Telegram group? Group ID: forward a message from the group to `@userinfobot` or use `@RawDataBot`. Group IDs are negative integers (e.g. `-1001234567890`)."
      - Options: `"Yes — add a channel"` (discord) / `"Yes — add a group"` (telegram) with ID captured via `Other`; `"Skip — DMs only"`.
   2. If **Skip**: continue to sub-step 8.
   3. **For each ID provided** (the first ID comes from step 1's `Other`; each subsequent ID from step 3d's `Other` — loop until "Done"):
      a. Ask with `AskUserQuestion` (header: `"Mention required"`) for this ID:
         - `"Yes — require @mention"` (default — safer for noisy channels)
         - `"No — respond to all messages"`
      b. Send `group add` into tmux using the **same two-call send-keys pattern** as sub-step 2 (text, `sleep 0.5`, `Enter`):
         - With `"Yes — require @mention"`:
           ```
           docker compose -f docker-compose.hermit.yml exec -T hermit \
             tmux send-keys -t <session> '/<plugin>:access group add <channelId> — save access.json to <project_path>/.claude.local/channels/<plugin>/ not ~/.claude'
           sleep 0.5
           docker compose -f docker-compose.hermit.yml exec -T hermit \
             tmux send-keys -t <session> Enter
           ```
         - With `"No — respond to all messages"`: same but with `--no-mention` appended before the ` —` hint.
      c. Confirm (text only): "Sent `group add` for `<channelId>`. Will verify after all channels are added."
      d. Ask with `AskUserQuestion` (header: `"Add another?"`) — `"Yes — add another"` with the next ID via `Other`; `"Done — continue"`. On `"Done — continue"`: exit the loop.
   4. **Verify all added channels** (one `Read` after the loop): open `<project_path>/.claude.local/channels/<plugin>/access.json`. For each ID added in step 3, confirm `groups.<channelId>` is present with the expected `requireMention` value. For any missing: run `docker compose exec -T hermit tmux capture-pane -t <session> -p`, surface the output, and warn — do not fail the whole setup. Then proceed to sub-step 8.
8. Confirm: "Paired and locked down. If the bot doesn't respond to your first message, give it up to 2 minutes — the hermit may still be booting or running initial checks (plugin installs, workspace trust, auto-memory seeding)."

If "skip": tell them to DM the bot later and run the commands manually.

### 8b. Finalize — clean restart

**Skip this step entirely if the operator chose "No — manual" at step 8.** The container isn't running, so there's nothing to restart.

Tell the operator (set expectations — this step takes ~30-60s and otherwise looks like a hang):

> "Finalizing setup with a clean restart so the first real hermit session starts with everything configured and plugins fully loaded."

Then run, in sequence:

1. `.claude-code-hermit/bin/hermit-docker down` — sends `/session-close --shutdown` via tmux and polls for graceful close up to 60s before removing the container. If it prints "Timed out waiting for graceful close; forcing stop", flag it in the final summary (session that witnessed setup didn't close cleanly — not blocking but worth noting).

2. `docker compose -f docker-compose.hermit.yml up -d` — recreates the container. Do **not** use `hermit-docker up` here for the same LLM-misleading-echo reason as step 8. Docker's named volume preserves credentials, plugins, workspace trust. Bind-mounts preserve `.claude.local/channels/<plugin>/access.json` and `.claude-code-hermit/`.

3. Re-verify the container stayed running (same poll as step 8.2). If it failed to come back up, surface `docker compose logs --tail=30 hermit` and stop — don't proceed to step 9.

Why this step exists: mid-setup, claude REPL starts before plugins are fully enabled and channel pairing completes. The session it was running is a "bootstrap session" full of setup chatter. A clean restart gives the operator a first *real* session with correctly-loaded plugins, fresh tmux state, and no config-time noise.

Why not `hermit-docker restart`: Docker's default stop_grace_period is 10s, which is shorter than the entrypoint's 30-iteration session-close poll — SIGKILL can land mid-close. (We raised `stop_grace_period` to 60s in the compose template, so `restart` is now also safe in principle, but `down+up` gives a recreated container which is stronger: clears ephemeral container-layer state and re-runs the entrypoint from a clean slate.)

### 9. Verify

Run `.claude-code-hermit/bin/hermit-status` and show output. `no session` is the expected output on a fresh setup — it means the container is up and will start its first session on the next cron routine or channel message. Do **not** add `sleep` before `hermit-status`; if you need to wait for a session to appear, use `Monitor` with an `until`-loop (not chained sleeps).

If healthy:
```
You're all set! Your hermit is live and running autonomously.

  .claude-code-hermit/bin/hermit-docker up        — start container
  .claude-code-hermit/bin/hermit-docker down      — graceful stop (--force to skip)
  .claude-code-hermit/bin/hermit-docker attach    — connect to tmux session
  .claude-code-hermit/bin/hermit-docker bash      — shell into container
  .claude-code-hermit/bin/hermit-docker login     — OAuth login
  .claude-code-hermit/bin/hermit-docker logs -f   — follow logs
  .claude-code-hermit/bin/hermit-docker restart   — restart container
  .claude-code-hermit/bin/hermit-docker update    — rebuild image + refresh plugin marketplaces
  .claude-code-hermit/bin/hermit-status           — quick check
```

If something looks wrong, help diagnose — suggest concrete next steps.

---

## Notes

**Why `.hermit` suffix?** The project may already have its own `Dockerfile` / `docker-compose.yml`. Hermit-namespaced files avoid conflicts.

**Why `*_STATE_DIR` as OS env vars?** MCP servers (channel plugins) are separate processes that inherit OS env — they don't read `settings.local.json`. Without these env vars, the MCP server defaults to `~/.claude/channels/<plugin>/` which inside the container resolves to `/home/claude/.claude/channels/` — not bind-mounted and lost on restart.

**Why bind-mounts for channel state?** Channel plugins hardcode writes to `~/.claude/channels/<plugin>/`. In Docker, that path is on the named config volume — outside the project tree. Even with `bypassPermissions`, Claude Code's path boundary check triggers a permission prompt for writes outside the project dir. Bind-mounting the project-local state dir into `~/.claude/channels/<plugin>/` makes the kernel present both paths as the same filesystem, avoiding symlink resolution issues and keeping writes inside the project boundary.

**Why a named volume for config?** The container gets its own Claude Code config (`/home/claude/.claude`) via a Docker named volume instead of sharing the host's `~/.claude`. This prevents container state (onboarding, auto-memory, plugin cache) from leaking into host interactive sessions. The volume persists across restarts — onboarding bypass and channel plugins survive `docker compose restart`. First run is slower while the volume is populated.

**Want shared config instead?** Replace the `claude-config` named volume with a bind-mount in `docker-compose.hermit.yml`: `- ${HOME}/.claude:${HOME}/.claude` and set `CLAUDE_CONFIG_DIR=${HOME}/.claude`. Not recommended — changes in either direction leak.

**Domain-plugin apt dependencies.** Declare system packages your plugin needs in a `## Docker apt dependencies` section in the plugin's hatch SKILL.md or a `DOCKER.md` at the plugin root. See step 7b.packages and [Creating Your Own Hermit — Docker dependencies](../../docs/creating-your-own-hermit.md#docker-dependencies).

**Why the three hardening stanzas (`no-new-privileges`, `cap_drop`, `pids_limit`)?** The container may run with `bypassPermissions` (or the default `auto` mode), and the recommended-plugins flow accepts third-party marketplaces — defense in depth matters here. The load-bearing one is `no-new-privileges:true` — it blocks setuid escalation at the kernel level, which is a real vector against future supply-chain compromise of any installed plugin. `cap_drop: ALL` is incremental: the container already runs as non-root `claude` so most caps were already unreachable, but dropping them explicitly closes the kernel-enforced ceiling. `pids_limit: 2048` is a resource bound, not a security primitive — it caps fork-bomb-style payloads with comfortable headroom over hermit's ~80 PID steady state. Hermit's runtime needs none of what's removed (verified across tmux, claude CLI, python3, jq, npm, git+HTTPS plugin installs). Operators extending the container with services on privileged ports (<1024), setuid helpers, or high-PID workloads must relax the relevant stanza explicitly. See [Security — Container Hardening](../../docs/security.md#container-hardening) for the full rationale.

**Want stronger isolation?** v1.0.26 ships an opt-in advanced wizard, `/claude-code-hermit:docker-security`, that adds LAN containment with DNS policy (firewall + DNS sidecar with port-53 redirect for actual enforcement), read-only root filesystem with smoke test, resource bounds with kernel hygiene sysctls, and a boot-time plugin-install audit log. Each toggle is opt-in with honest cost/benefit framing, runs verification against the live container, and is fully reversible. **Note:** the LAN containment toggle is hard-skipped when `docker.network_mode: "host"` is in use — bridge networking required. See [Docker Security](../../docs/docker-security.md).
