commit 82a6c59bce107e3893a517b6abaf6a063230adad Author: Björn Pöttker Date: Sun Aug 23 20:41:03 2026 +0200 Add Claude Code LXC deployer script for Proxmox Co-Authored-By: Claude Opus 5 (1M context) diff --git a/agentic.sh b/agentic.sh new file mode 100644 index 0000000..ce80218 --- /dev/null +++ b/agentic.sh @@ -0,0 +1,982 @@ +#!/usr/bin/env bash +# ============================================================================ +# Claude Code LXC Deployer for Proxmox — angepasste Version +# Creates a fully provisioned Ubuntu 26.04 LXC container ready for Claude Code +# +# Änderungen gegenüber dem Original (serversathome): +# - FIX: Template-Erkennung nahm mit awk '{print $NF}' die letzte Spalte +# (Architektur) statt des Template-Namens -> jetzt $2 +# - FIX: grep-Pipeline unter `set -e` abgesichert (|| true), sonst bricht +# das Script kommentarlos ab, wenn kein Template gefunden wird +# - Storage-Default: data (statt truenas-lvm) +# - Zeitzone: Europe/Berlin (statt America/New_York) — auch Cron & CLAUDE.md +# - Zusätzlich de_DE.UTF-8 Locale generiert +# - pnpm global installiert (zusätzlich zu npm) +# - Optionale Git-Identität (user.name/user.email) wird abgefragt und gesetzt +# +# Run on your Proxmox host: bash agentic.sh +# ============================================================================ + +set -euo pipefail + +# ── Colors & Helpers ──────────────────────────────────────────────────────── +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +CYAN='\033[0;36m' +BOLD='\033[1m' +NC='\033[0m' + +info() { echo -e "${CYAN}[INFO]${NC} $*"; } +success() { echo -e "${GREEN}[OK]${NC} $*"; } +warn() { echo -e "${YELLOW}[WARN]${NC} $*"; } +error() { echo -e "${RED}[ERROR]${NC} $*"; exit 1; } + +header() { + echo "" + echo -e "${BOLD}╔══════════════════════════════════════════════════╗${NC}" + echo -e "${BOLD}║ Claude Code LXC Deployer (Proxmox) ║${NC}" + echo -e "${BOLD}╚══════════════════════════════════════════════════╝${NC}" + echo "" +} + +# ── Pre-flight checks ────────────────────────────────────────────────────── +preflight() { + [[ $(id -u) -eq 0 ]] || error "This script must be run as root on the Proxmox host." + command -v pct &>/dev/null || error "pct not found. Are you running this on a Proxmox host?" + command -v pveam &>/dev/null || error "pveam not found. Are you running this on a Proxmox host?" +} + +# ── Template Resolution ───────────────────────────────────────────────────── +resolve_template() { + info "Resolving latest Ubuntu 26.04 LXC template from catalog..." + pveam update >/dev/null 2>&1 || true + local found + # FIX: $2 statt $NF (pveam available liefert: Sektion, Name, Arch) und + # || true, damit ein leerer grep-Treffer unter `set -e` nicht das ganze + # Script beendet, bevor der Fallback greifen kann. + found=$(pveam available --section system 2>/dev/null \ + | awk '{print $2}' \ + | grep -E '^ubuntu-26\.04-standard' \ + | sort -V | tail -n1 || true) + if [[ -n "$found" ]]; then + TEMPLATE="$found" + success "Using template: $TEMPLATE" + else + TEMPLATE="ubuntu-26.04-standard_26.04-1_amd64.tar.zst" + warn "No 26.04 template found in catalog; using fallback name: $TEMPLATE" + warn "Verify with: pveam available --section system | grep ubuntu-26.04" + fi +} + +# ── Configuration ─────────────────────────────────────────────────────────── +get_config() { + # Find next available CT ID + local next_id + next_id=$(pvesh get /cluster/nextid 2>/dev/null || echo "100") + + # Resolve newest Ubuntu 26.04 template (sets $TEMPLATE) + resolve_template + + echo -e "${BOLD}Container Configuration${NC}" + echo "─────────────────────────────────────────────────" + + read -rp "Container ID [$next_id]: " CT_ID + CT_ID="${CT_ID:-$next_id}" + [[ "$CT_ID" =~ ^[0-9]+$ ]] || error "Container ID must be a number." + pct status "$CT_ID" &>/dev/null && error "Container ID $CT_ID already exists." + + read -rp "Hostname [claude-code]: " CT_HOSTNAME + CT_HOSTNAME="${CT_HOSTNAME:-claude-code}" + + read -rsp "Root password: " CT_PASSWORD + echo "" + [[ -n "$CT_PASSWORD" ]] || error "Password cannot be empty." + + read -rp "CPU cores [4]: " CT_CORES + CT_CORES="${CT_CORES:-4}" + + read -rp "RAM in MB [10240]: " CT_RAM + CT_RAM="${CT_RAM:-10240}" + + read -rp "Swap in MB [2048]: " CT_SWAP + CT_SWAP="${CT_SWAP:-2048}" + + read -rp "Disk size in GB [30]: " CT_DISK + CT_DISK="${CT_DISK:-30}" + + read -rp "Storage [data]: " CT_STORAGE + CT_STORAGE="${CT_STORAGE:-data}" + + # Network - default DHCP + read -rp "IP address (DHCP or x.x.x.x/xx) [dhcp]: " CT_IP + CT_IP="${CT_IP:-dhcp}" + if [[ "$CT_IP" != "dhcp" ]]; then + read -rp "Gateway: " CT_GW + [[ -n "$CT_GW" ]] || error "Gateway is required for static IP." + fi + + read -rp "DNS server [1.1.1.1]: " CT_DNS + CT_DNS="${CT_DNS:-1.1.1.1}" + + # SSH key (optional) + read -rp "Path to SSH public key (optional, press Enter to skip): " CT_SSH_KEY + + # Git identity (optional) — wird im Container als git config --global gesetzt + read -rp "Git user.name (optional, press Enter to skip): " GIT_NAME + read -rp "Git user.email (optional, press Enter to skip): " GIT_EMAIL + + echo "" + echo -e "${BOLD}Summary${NC}" + echo "─────────────────────────────────────────────────" + echo " CT ID: $CT_ID" + echo " Hostname: $CT_HOSTNAME" + echo " Template: $TEMPLATE" + echo " CPU: $CT_CORES cores" + echo " RAM: $CT_RAM MB ($(( CT_RAM / 1024 )) GB)" + echo " Swap: $CT_SWAP MB" + echo " Disk: ${CT_DISK}G on $CT_STORAGE" + echo " Network: $CT_IP" + echo " DNS: $CT_DNS" + [[ -n "${GIT_NAME:-}" ]] && echo " Git: $GIT_NAME <${GIT_EMAIL:-}>" + echo "─────────────────────────────────────────────────" + echo "" + read -rp "Proceed? (y/N): " confirm + [[ "$confirm" =~ ^[Yy]$ ]] || { echo "Aborted."; exit 0; } +} + +# ── Download Ubuntu 26.04 Template ───────────────────────────────────────── +get_template() { + info "Checking for template: $TEMPLATE" + if ! pveam list local 2>/dev/null | grep -q "$TEMPLATE"; then + info "Downloading $TEMPLATE ..." + pveam download local "$TEMPLATE" || error "Failed to download template. Run 'pveam update' and try again." + else + success "Template already downloaded: $TEMPLATE" + fi + TEMPLATE_PATH="local:vztmpl/$TEMPLATE" +} + +# ── Create Container ─────────────────────────────────────────────────────── +create_container() { + info "Creating LXC container $CT_ID..." + + # Build network string + local net_str="name=eth0,bridge=vmbr0" + if [[ "$CT_IP" == "dhcp" ]]; then + net_str+=",ip=dhcp" + else + net_str+=",ip=$CT_IP,gw=$CT_GW" + fi + + # Build pct create command + local cmd=( + pct create "$CT_ID" "$TEMPLATE_PATH" + --hostname "$CT_HOSTNAME" + --password "$CT_PASSWORD" + --cores "$CT_CORES" + --memory "$CT_RAM" + --swap "$CT_SWAP" + --rootfs "$CT_STORAGE:$CT_DISK" + --net0 "$net_str" + --nameserver "$CT_DNS" + --ostype ubuntu + --unprivileged 0 + --features nesting=1,keyctl=1 + --onboot 1 + --start 0 + ) + + # Add SSH key if provided + if [[ -n "${CT_SSH_KEY:-}" && -f "$CT_SSH_KEY" ]]; then + cmd+=(--ssh-public-keys "$CT_SSH_KEY") + fi + + "${cmd[@]}" + success "Container $CT_ID created." + + # Disable AppArmor for Docker-in-LXC compatibility + info "Setting AppArmor profile to unconfined (required for Docker)..." + echo "lxc.apparmor.profile: unconfined" >> "/etc/pve/lxc/${CT_ID}.conf" +} + +# ── Start & Wait for Network ────────────────────────────────────────────── +start_container() { + info "Starting container $CT_ID..." + pct start "$CT_ID" + sleep 3 + + info "Waiting for network..." + local attempts=0 + while ! pct exec "$CT_ID" -- ping -c1 -W2 1.1.1.1 &>/dev/null; do + ((attempts++)) + [[ $attempts -lt 30 ]] || error "Container failed to get network after 60s." + sleep 2 + done + success "Container is online." +} + +# ── Provision Container ─────────────────────────────────────────────────── +provision_container() { + info "Provisioning container (this takes a few minutes)..." + + # Write provision script to host, then push into container + cat > /tmp/provision-${CT_ID}.sh << 'PROVISION_EOF' +#!/bin/bash +set -e +export DEBIAN_FRONTEND=noninteractive + +echo ">>> Setting timezone to Europe/Berlin..." +ln -sf /usr/share/zoneinfo/Europe/Berlin /etc/localtime +echo "Europe/Berlin" > /etc/timezone +dpkg-reconfigure -f noninteractive tzdata + +echo ">>> Generating locales..." +apt-get update -qq +apt-get install -y -qq locales +sed -i '/en_US.UTF-8/s/^# //g' /etc/locale.gen +sed -i '/de_DE.UTF-8/s/^# //g' /etc/locale.gen +locale-gen en_US.UTF-8 de_DE.UTF-8 > /dev/null 2>&1 +update-locale LANG=en_US.UTF-8 LC_ALL=en_US.UTF-8 +export LANG=en_US.UTF-8 +export LC_ALL=en_US.UTF-8 + +echo ">>> Updating system..." +apt-get upgrade -y -qq + +echo ">>> Installing core packages..." +apt-get install -y -qq \ + git curl wget unzip zip \ + ca-certificates gnupg lsb-release apt-transport-https software-properties-common \ + bash-completion locales \ + htop nano vim tmux screen \ + jq yq tree \ + net-tools iproute2 iputils-ping dnsutils \ + openssh-server \ + cron logrotate + +echo ">>> Installing build tools & dev libraries..." +apt-get install -y -qq \ + build-essential make cmake pkg-config autoconf automake libtool \ + python3 python3-pip python3-venv python3-dev \ + libssl-dev libffi-dev libsqlite3-dev zlib1g-dev \ + libreadline-dev libbz2-dev libncurses-dev liblzma-dev libxml2-dev libxslt-dev + +echo ">>> Installing search & productivity tools..." +apt-get install -y -qq \ + ripgrep fd-find fzf bat \ + rsync \ + sqlite3 + +echo ">>> Installing database clients..." +apt-get install -y -qq \ + postgresql-client redis-tools + +echo ">>> Installing Node.js (latest LTS via NodeSource)..." +# setup_lts.x tracks the current LTS line, so fresh deploys get the newest +# *safe* Node automatically (Node 24 today; it rolls to the next LTS on its own +# when one lands — no script edit needed). We deliberately track LTS, not the +# odd/Current line (short-lived, not recommended here). apt upgrades keep it +# patched within the line; a major-version jump stays a deliberate rebuild +# rather than an unattended 4 a.m. surprise. +curl -fsSL https://deb.nodesource.com/setup_lts.x | bash - +apt-get install -y -qq nodejs +# Quiet flags reused for every provisioning npm install: drop the funding/audit +# lines and the deprecation warnings that come from these packages' upstream +# transitive deps (not fixable from here). Scoped to provisioning only — your +# own installs under /project behave normally. +NPM_QUIET=(--no-fund --no-audit --loglevel=error) +# Take npm to its own latest release — the bundled npm lags behind and otherwise +# prints an "update available" notice on every install. +npm install -g "${NPM_QUIET[@]}" npm@latest || echo " [WARN] npm self-update failed; using bundled npm" +echo " Node.js $(node --version) / npm $(npm --version)" +# npm 12+ blocks dependencies' install scripts behind an allowlist (separate from +# ignore-scripts), so native modules like CloudCLI's better-sqlite3/node-pty/ +# bcrypt never compile and the service crash-loops with no bind on :3001. We just +# took npm to latest (>=12), so set the allowlist NOW, before any native install +# below (CloudCLI, Playwright, the language servers). It persists to /root/.npmrc +# under npm 12+; harmless if npm is still <12. +# HINWEIS: gilt damit global (auch für eigene Installs unter /project) — +# Install-Scripts fremder Pakete laufen dann ungefragt. +npm config set dangerously-allow-all-scripts true 2>/dev/null || true + +echo ">>> Installing global npm packages..." +npm install -g "${NPM_QUIET[@]}" typescript ts-node eslint prettier + +echo ">>> Installing pnpm..." +# Für Projekte mit pnpm-Workflow. Zusätzlich corepack aktivieren, damit +# Projekte mit "packageManager"-Feld in der package.json ihre gepinnte +# pnpm/yarn-Version automatisch bekommen. +npm install -g "${NPM_QUIET[@]}" pnpm || echo " [WARN] pnpm install failed" +export COREPACK_ENABLE_DOWNLOAD_PROMPT=0 +corepack enable 2>/dev/null || true +echo " pnpm $(pnpm --version 2>/dev/null || echo 'n/a')" + +echo ">>> Installing Go..." +GO_VERSION=$(curl -fsSL "https://go.dev/VERSION?m=text" | head -1) +curl -fsSL "https://go.dev/dl/${GO_VERSION}.linux-amd64.tar.gz" -o /tmp/go.tar.gz +rm -rf /usr/local/go +tar -C /usr/local -xzf /tmp/go.tar.gz +rm /tmp/go.tar.gz +echo 'export PATH=$PATH:/usr/local/go/bin' >> /etc/profile.d/go.sh +echo " Go $(/usr/local/go/bin/go version | awk '{print $3}')" + +echo ">>> Installing Rust..." +curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y +source "$HOME/.cargo/env" +echo " Rust $(rustc --version | awk '{print $2}')" + +echo ">>> Installing Docker..." +curl -fsSL https://get.docker.com | sh +systemctl enable docker +echo " Docker $(docker --version | awk '{print $3}' | tr -d ',')" + +echo ">>> Installing Docker Compose plugin..." +apt-get install -y -qq docker-compose-plugin 2>/dev/null || true +echo " Compose $(docker compose version --short 2>/dev/null || echo 'included with Docker')" + +echo ">>> Installing Claude Code (native installer)..." +curl -fsSL https://claude.ai/install.sh | bash +# Ensure claude is on PATH for all sessions +if [[ -f "$HOME/.local/bin/claude" ]]; then + ln -sf "$HOME/.local/bin/claude" /usr/local/bin/claude 2>/dev/null || true +elif [[ -f "$HOME/.claude/bin/claude" ]]; then + ln -sf "$HOME/.claude/bin/claude" /usr/local/bin/claude 2>/dev/null || true +fi +CLAUDE_BIN="$(command -v claude || echo /usr/local/bin/claude)" +echo " Claude Code installed: $("$CLAUDE_BIN" --version 2>/dev/null || echo 'version unknown')" + +echo ">>> Configuring Claude Code settings (permissions + env)..." +# NOTE on enabledPlugins: we DO declare it here. Earlier belief was that +# enabledPlugins is ignored in non-interactive/container contexts — that's +# wrong. `claude plugin install X@mkt` installs the plugin but leaves it +# Status: disabled; the enabled-state lives in enabledPlugins in THIS file +# (user settings, ~/.claude/settings.json), which IS honored. Without this +# block every plugin below (including the LSP servers) stays disabled and +# CloudCLI's /project chat shows only the 1 local skill. Enabled skills show +# up live after the cloudcli restart — no fresh session needed. +# +# Verified keys (checked against code.claude.com/docs, 2026-07): the env vars +# below are all valid current settings. CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS +# must live inside "env" (it's an environment variable, not a top-level key). +# Removed: CLAUDE_CODE_ENABLE_AUTO_MODE (dead no-op since Claude Code v2.1.207 — +# auto mode works from permissions.defaultMode alone) and alwaysThinkingEnabled +# (current models use adaptive thinking; MAX_THINKING_TOKENS still caps the +# budget). The old "enableRemoteControl" key was never real — Remote Control is +# enabled per-session with /rc or for all sessions via /config (Pro/Max). +mkdir -p /root/.claude +# Permissions model: auto mode instead of a blanket allow-list. +# defaultMode "auto" auto-approves actions but routes them through a +# background safety classifier (still blocks rm -rf / , rm -rf ~ , etc.). +# Unlike bypassPermissions, auto mode is NOT refused when running as root, +# and defaultMode is honored from user settings (~/.claude/settings.json). +# The "deny" list applies in EVERY mode (including auto) — it's the hard +# floor protecting credentials/secrets and a few destructive commands. +# Env extras: DISABLE_AUTOUPDATER stops Claude's background auto-updater (we +# self-manage the version via agentic-update, so the background check just +# risks version drift/races); CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC is the +# umbrella that silences telemetry/error-reporting/feedback/surveys for a quiet +# headless box (trade-off: also disables error reporting). +cat > /root/.claude/settings.json << 'SETTINGS' +{ + "$schema": "https://json.schemastore.org/claude-code-settings.json", + "permissions": { + "defaultMode": "auto", + "deny": [ + "Read(**/.env)", + "Read(**/.env.*)", + "Read(**/.credentials.json)", + "Read(/root/.claude/.credentials.json)", + "Read(**/secrets/**)", + "Read(**/*.pem)", + "Read(**/id_rsa)", + "Read(**/id_ed25519)", + "Bash(dd:*)", + "Bash(mkfs:*)", + "Bash(rm -rf /:*)", + "Bash(rm -rf ~:*)" + ] + }, + "env": { + "CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS": "1", + "CLAUDE_CODE_MAX_OUTPUT_TOKENS": "64000", + "MAX_THINKING_TOKENS": "31999", + "DISABLE_AUTOUPDATER": "1", + "CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC": "1" + }, + "enabledPlugins": { + "code-review@claude-plugins-official": true, + "commit-commands@claude-plugins-official": true, + "context7@claude-plugins-official": true, + "frontend-design@claude-plugins-official": true, + "security-guidance@claude-plugins-official": true, + "typescript-lsp@claude-plugins-official": true, + "pyright-lsp@claude-plugins-official": true, + "gopls-lsp@claude-plugins-official": true, + "rust-analyzer-lsp@claude-plugins-official": true, + "superpowers@superpowers-marketplace": true + } +} +SETTINGS + +echo ">>> Adding plugin marketplaces..." +# The official marketplace is auto-registered on first INTERACTIVE launch only, +# so a non-interactive provisioning run must add it explicitly. We use ONLY the +# first-party marketplace now — the old 'anthropics/claude-code' demo repo +# (registers as 'claude-code-plugins') ships same-named example copies that +# shadow the official ones, and boxes were silently landing on those. +"$CLAUDE_BIN" plugin marketplace add anthropics/claude-plugins-official \ + || echo " [WARN] could not add claude-plugins-official" +# Refresh the index BEFORE installing — a stale cache makes name@official +# silently fall through, which is how boxes ended up on demo copies. +"$CLAUDE_BIN" plugin marketplace update claude-plugins-official \ + || echo " [WARN] could not update claude-plugins-official index" +# Third-party marketplace for Superpowers. +"$CLAUDE_BIN" plugin marketplace add obra/superpowers-marketplace \ + || echo " [WARN] could not add superpowers-marketplace" + +echo ">>> Installing Claude Code plugins (official CLI)..." +# Install pinned to the official marketplace only (no demo fallthrough). +# Non-fatal — a single failed plugin won't abort provisioning. Enabled-state is +# declared in settings.json above (enabledPlugins); install just stages files. +install_plugin() { + local name="$1" + if "$CLAUDE_BIN" plugin install "${name}@claude-plugins-official" 2>/dev/null; then + echo " installed ${name}@claude-plugins-official" + else + echo " [WARN] could not install plugin: ${name}@claude-plugins-official" + fi + return 0 +} + +install_plugin frontend-design +install_plugin code-review +install_plugin commit-commands +install_plugin security-guidance +install_plugin context7 +# LSP plugins — real-time diagnostics + navigation. Thin wrappers around the +# language servers installed below; they need the server binary on PATH. +install_plugin typescript-lsp +install_plugin pyright-lsp +install_plugin gopls-lsp +install_plugin rust-analyzer-lsp + +# Superpowers lives in its own marketplace. +"$CLAUDE_BIN" plugin install superpowers@superpowers-marketplace 2>/dev/null \ + && echo " installed superpowers@superpowers-marketplace" \ + || echo " [WARN] could not install superpowers" + +# Sanity check — list what actually landed. +echo ">>> Installed plugins:" +"$CLAUDE_BIN" plugin list 2>/dev/null || echo " (plugin list unavailable)" + +echo ">>> Installing language servers (backends for the *-lsp plugins)..." +# The *-lsp plugins are thin wrappers that shell out to a language server on +# PATH — they do NOT self-install the binary. Toolchains (Node/Go/Rust) are +# already installed above, so this just adds the servers. All non-fatal. +command -v typescript-language-server >/dev/null 2>&1 \ + || npm install -g "${NPM_QUIET[@]}" typescript-language-server typescript \ + || echo " [WARN] typescript-language-server install failed" +command -v pyright-langserver >/dev/null 2>&1 \ + || npm install -g "${NPM_QUIET[@]}" pyright \ + || echo " [WARN] pyright install failed" +if [ ! -x /usr/local/bin/gopls ] && [ -x /usr/local/go/bin/go ]; then + GOBIN=/usr/local/bin /usr/local/go/bin/go install golang.org/x/tools/gopls@latest \ + || echo " [WARN] gopls install failed" +fi +if ! command -v rust-analyzer >/dev/null 2>&1 && [ -x "$HOME/.cargo/bin/rustup" ]; then + "$HOME/.cargo/bin/rustup" component add rust-analyzer >/dev/null 2>&1 \ + && ln -sf "$("$HOME/.cargo/bin/rustup" which rust-analyzer 2>/dev/null)" /usr/local/bin/rust-analyzer \ + || echo " [WARN] rust-analyzer install failed" +fi + +# --------------------------------------------------------------------------- +# FALLBACK if the above installs don't persist in your Claude Code version: +# Headless plugin install has been historically finicky. The container-intended +# mechanism is the (currently undocumented) CLAUDE_CODE_PLUGIN_SEED_DIR env var, +# which seeds pre-staged plugins on first launch. If `claude plugin list` above +# is empty after provisioning, stage the plugin repos into a seed dir and export +# CLAUDE_CODE_PLUGIN_SEED_DIR= in /root/.bashrc, or simply run the +# `claude plugin install` commands above once inside an interactive session. +# --------------------------------------------------------------------------- + +echo ">>> Installing webapp-testing skill (from anthropics/skills)..." +# Installed as a plain user skill (NOT a marketplace plugin). A SKILL.md placed +# under ~/.claude/skills/ is picked up automatically with no plugin entry needed. +git clone --depth 1 --filter=blob:none --sparse https://github.com/anthropics/skills.git /tmp/anthropic-skills +cd /tmp/anthropic-skills && git sparse-checkout set skills/webapp-testing +mkdir -p /root/.claude/skills/ +cp -r /tmp/anthropic-skills/skills/webapp-testing /root/.claude/skills/webapp-testing +rm -rf /tmp/anthropic-skills +cd /root + +echo ">>> Installing Playwright (Python) for the webapp-testing skill..." +# The webapp-testing skill imports the PYTHON playwright package +# ("from playwright.sync_api import ..."), so the Node playwright we install for +# CloudCLI's Browser feature is NOT enough — the pip package must be present too, +# or the skill dies at import. Both share the same browser cache +# (~/.cache/ms-playwright), so Chromium is downloaded once for both. +# Playwright doesn't support Ubuntu 26.04 yet (microsoft/playwright#40117) and +# hard-errors instead of falling back, so force the ubuntu24.04 build, which runs +# fine on 26.04's newer glibc. This whole block is non-fatal: a browser-install +# failure must NEVER abort provisioning (it previously killed everything after +# it — Docker services, SSH, cron — because the script runs under `set -e`). +set +e +# The override MUST include the arch suffix — Playwright's build keys are +# "ubuntu24.04-x64", not "ubuntu24.04". Without "-x64" it can't find a build. +export PLAYWRIGHT_HOST_PLATFORM_OVERRIDE="ubuntu24.04-x64" +# Make the override durable for interactive sessions too (systemd services read +# their own Environment=, but 'claude' launched over SSH reads /etc/environment), +# so the skill can re-install browsers on 26.04 later without hitting the error. +grep -q PLAYWRIGHT_HOST_PLATFORM_OVERRIDE /etc/environment 2>/dev/null \ + || echo 'PLAYWRIGHT_HOST_PLATFORM_OVERRIDE=ubuntu24.04-x64' >> /etc/environment +pip install --break-system-packages -q playwright \ + && echo " python playwright package installed" \ + || echo " [WARN] pip install playwright failed (webapp-testing skill will error on import)" +if python3 -m playwright install --with-deps chromium; then + echo " Playwright chromium + OS deps installed (ubuntu24.04-x64 build, running on 26.04)" +elif python3 -m playwright install chromium; then + echo " Playwright chromium installed (browser only; some OS deps may be missing)" +else + echo " [WARN] Playwright browser install failed." + echo " Ubuntu 26.04 isn't supported by Playwright yet (microsoft/playwright#40117)." + echo " Re-run this once support lands, or to retry the 24.04-build workaround:" + echo " PLAYWRIGHT_HOST_PLATFORM_OVERRIDE=ubuntu24.04-x64 python3 -m playwright install --with-deps chromium" +fi +unset PLAYWRIGHT_HOST_PLATFORM_OVERRIDE +set -e + +echo ">>> Setting up /project directory..." +mkdir -p /project +cat > /project/CLAUDE.md << 'CLAUDEMD' +# Claude Code Workspace + +## Environment +- **OS**: Ubuntu 26.04 LXC container on Proxmox +- **Working directory**: /project +- **Timezone**: Europe/Berlin +- **User**: root + +## Available Tools +- **Languages**: Node.js (latest LTS), Python 3 (system default), Go (latest), Rust (latest) +- **Package managers**: npm, pnpm (+ corepack), pip (use --break-system-packages), cargo, go install +- **Docker**: Docker Engine + Compose plugin, running and ready +- **Web UI**: CloudCLI UI (claudecodeui) on port 3001 — chat, file explorer/editor, git, shell +- **Search tools**: ripgrep (rg), fd-find (fdfind), fzf +- **Databases**: PostgreSQL client (psql), Redis client (redis-cli), SQLite3 + +## Permissions +Permission mode is "auto" (permissions.defaultMode). Actions are auto-approved +but pass through a background safety classifier that still blocks catastrophic +commands (rm -rf /, rm -rf ~). A deny floor in ~/.claude/settings.json applies in +every mode and blocks reading credentials/secrets (.env, *.pem, id_rsa, +.credentials.json) — do not try to work around it. + +## Agent Teams +Agent teams are enabled. You can spawn parallel teammates for complex tasks: +- Use agent teams for work that benefits from parallel exploration +- Use subagents (Task tool) for quick focused work that reports back +- tmux is installed for split-pane team visualization + +## Remote Control +Remote Control lets you steer a live local session from the Claude mobile app or +web. It is NOT enabled via settings.json — turn it on per session with `/rc` +(or `claude remote-control`), or for all sessions via `/config` → +"Enable Remote Control for all sessions". Requires a Pro/Max login (research +preview), so it only applies once someone signs in interactively. + +## Docker Usage +Docker compose files should go in /docker//docker-compose.yml. +There is no always-on Watchtower daemon. Container images are refreshed one-shot +by the weekly `agentic-update` run (or on demand: `agentic-update`), so give +containers you want updated the usual `restart: unless-stopped`. +All Docker containers in this LXC need `security_opt: [apparmor=unconfined]`. + +## Conventions +- Prefer creating files over printing long code blocks +- Use git for version control on all projects in /project/src/ +- When installing Python packages, use: pip install --break-system-packages +- Thinking is adaptive — the model decides when to think; lean into it for complex work + +## Installed Plugins / Skills +Plugins are staged via the `claude plugin` CLI at provision time and enabled via +the `enabledPlugins` block in ~/.claude/settings.json. Run `claude plugin list` +to confirm what's active. +- **frontend-design**: Production-grade UI with distinctive aesthetics (auto-activates on frontend tasks) +- **code-review**: Multi-agent PR review with confidence scoring +- **commit-commands**: Git commit, push, and PR workflows (/commit, /push, /pr) +- **security-guidance**: Security warnings when editing sensitive files +- **context7**: Live, version-specific library docs lookup (reduces API hallucinations) +- **typescript-lsp / pyright-lsp / gopls-lsp / rust-analyzer-lsp**: real-time + diagnostics + navigation (backed by the language servers on PATH) +- **superpowers**: Development workflow framework — brainstorm → plan → implement with TDD + - /superpowers:brainstorm — Refine ideas before coding + - /superpowers:write-plan — Create implementation plans + - /superpowers:execute-plan — Execute plans in batches via subagents + - Auto-activating skills: test-driven-development, systematic-debugging, verification-before-completion +- **webapp-testing** (local skill, not a marketplace plugin): Playwright-based + browser testing for UI verification and debugging +CLAUDEMD + +echo ">>> Configuring SSH..." +sed -i "s/^#*PermitRootLogin.*/PermitRootLogin yes/" /etc/ssh/sshd_config +sed -i "s/^#*PasswordAuthentication.*/PasswordAuthentication yes/" /etc/ssh/sshd_config +systemctl enable ssh +systemctl restart ssh + +echo ">>> Setting up shell environment..." +cat >> /root/.bashrc << 'BASHRC' + +# ── Claude Code Container ────────────────────────────────── +export EDITOR=nano +export LANG=en_US.UTF-8 +export TZ=Europe/Berlin +export PATH="$HOME/.local/bin:$HOME/.claude/bin:$HOME/.cargo/bin:/usr/local/go/bin:$PATH" + +# Aliases +alias ll="ls -lah --color=auto" +alias cls="clear" +alias ..="cd .." +alias ...="cd ../.." +alias gs="git status" +alias gl="git log --oneline -20" +alias dc="docker compose" +alias dps="docker ps --format 'table {{.Names}}\t{{.Status}}\t{{.Ports}}'" + +# Always start in /project +cd /project 2>/dev/null || true +BASHRC + +echo ">>> Setting up Git defaults..." +git config --global init.defaultBranch main +git config --global core.editor nano +git config --global pull.rebase false + +echo ">>> Docker ready (no always-on service containers)..." +# code-server was removed — CloudCLI UI (below) already provides a file +# explorer/editor, so it was redundant (and dropped its :8443 + hardcoded +# 'admin' password surface). Watchtower is no longer a scheduled daemon; image +# updates for any containers YOU create now run one-shot from the coordinated +# 'agentic-update' script (see below). + +echo ">>> Installing CloudCLI UI (claudecodeui web front end)..." +# Web front end for Claude Code: chat UI, file explorer/editor, git panel, and a +# built-in shell, served on :3001. It drives the same `claude` CLI and reads +# /root/.claude, so it inherits this container's login and auto-mode settings. +# It ships with its own login/auth (first-run account setup on the login page). +# Installed from the published npm package (latest, unpinned); the package +# includes a prebuilt server, so there is no client/server build step here. +npm install -g "${NPM_QUIET[@]}" @cloudcli-ai/cloudcli \ + || echo " [WARN] CloudCLI UI install failed (needs Node 22+ and build tools for node-pty)" +# Self-heal in case the native build was still skipped (better-sqlite3 binding +# missing) — rebuild in place so the service can bind :3001. Gated by the +# allowlist set right after the npm self-update above. +CCUI_PKG="$(npm root -g 2>/dev/null)/@cloudcli-ai/cloudcli" +if [ -d "$CCUI_PKG/node_modules/better-sqlite3" ] && ! ls "$CCUI_PKG/node_modules/better-sqlite3/build/Release/"*.node >/dev/null 2>&1; then + echo " rebuilding CloudCLI native modules (better-sqlite3/node-pty/bcrypt)" + ( cd "$CCUI_PKG" && npm rebuild >/dev/null 2>&1 ) || echo " [WARN] CloudCLI native rebuild failed" +fi +CCUI_BIN="$(command -v cloudcli || echo /usr/bin/cloudcli)" +mkdir -p /root/.cloudcli +echo " CloudCLI UI installed: $("$CCUI_BIN" version 2>/dev/null || echo 'version unknown')" + +# Browser feature runtime. CloudCLI detects Playwright via require('playwright') +# from its GLOBAL package dir, but its in-app "Install Runtime" button installs +# into the server's cwd (/project) — which require() can't resolve, so the button +# silently never takes effect. Fix: install Playwright GLOBALLY (resolvable) plus +# its Chromium using the ubuntu24.04-x64 fallback build (26.04 isn't officially +# supported by Playwright yet), so the Browser tab is ready with no button click. +echo ">>> Installing Playwright runtime for CloudCLI Browser feature..." +export PLAYWRIGHT_HOST_PLATFORM_OVERRIDE=ubuntu24.04-x64 +npm install -g "${NPM_QUIET[@]}" playwright \ + || echo " [WARN] global Playwright install failed (CloudCLI Browser feature won't work)" +playwright install chromium \ + || echo " [WARN] Chromium download failed; retry later with: PLAYWRIGHT_HOST_PLATFORM_OVERRIDE=ubuntu24.04-x64 playwright install chromium" +unset PLAYWRIGHT_HOST_PLATFORM_OVERRIDE + +# systemd unit so the UI survives reboots and restarts on failure. +cat > /etc/systemd/system/cloudcli.service << EOF +[Unit] +Description=CloudCLI UI (claudecodeui) - web front end for Claude Code +After=network-online.target docker.service +Wants=network-online.target + +[Service] +Type=simple +User=root +Environment=NODE_ENV=production +# NODE_ENV=production makes child 'npm install' omit devDependencies, which +# breaks CloudCLI's in-app plugin installs (their 'npm run build' is 'tsc' and +# needs typescript/@types/node). Force npm to include dev deps so plugin builds +# succeed. +Environment=NPM_CONFIG_INCLUDE=dev +# CloudCLI's "Install Runtime" (Playwright/Chromium for the Browser feature) +# hard-fails on Ubuntu 26.04, which Playwright doesn't officially support yet +# (microsoft/playwright#40117). Same override we use for the webapp-testing +# skill: force the ubuntu24.04-x64 fallback build, which runs on 26.04's glibc. +Environment=PLAYWRIGHT_HOST_PLATFORM_OVERRIDE=ubuntu24.04-x64 +Environment=HOME=/root +Environment=HOST=0.0.0.0 +Environment=SERVER_PORT=3001 +Environment=CLAUDE_CLI_PATH=/usr/local/bin/claude +Environment=DATABASE_PATH=/root/.cloudcli/auth.db +Environment=PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin:/root/.local/bin +WorkingDirectory=/project +ExecStart=${CCUI_BIN} start +Restart=on-failure +RestartSec=5 + +[Install] +WantedBy=multi-user.target +EOF + +systemctl daemon-reload +systemctl enable cloudcli >/dev/null 2>&1 || true +systemctl start cloudcli \ + || echo " [WARN] cloudcli service failed to start; check 'journalctl -u cloudcli'" + +echo ">>> Installing coordinated update tooling (agentic-update / agentic-doctor)..." +# Single coordinated update pass, modeled on homelabhero's hh-update + hh doctor: +# OS packages -> Claude Code -> CloudCLI UI -> one-shot Watchtower for any Docker +# containers -> restart UI -> health check -> log. Re-runnable on demand +# ('agentic-update'), no container recreation required. Everything tracks latest +# (unpinned); the post-update health check is the safety net, and results land +# in /var/log/agentic-update.log. + +cat > /usr/local/bin/agentic-doctor << 'DOCTOR' +#!/usr/bin/env bash +# Health check for the Claude Code container. Exit 0 = healthy, non-zero = problems. +LOG_TAG="[doctor]" +rc=0 +check() { if eval "$2" >/dev/null 2>&1; then echo " OK $1"; else echo " FAIL $1"; rc=1; fi; } +# note() is informational only — it never fails the health check. Used for +# user-actionable states (like "not logged in yet") that aren't container faults. +note() { if eval "$2" >/dev/null 2>&1; then echo " OK $1"; else echo " WARN $1"; fi; } + +echo "$LOG_TAG $(date '+%Y-%m-%d %H:%M:%S %Z')" +check "claude binary present" "command -v claude" +check "claude reports a version" "claude --version" +note "claude logged in (creds on disk; run 'claude' /login if WARN)" "test -s /root/.claude/.credentials.json" +check "cloudcli service active" "systemctl is-active --quiet cloudcli" +check "UI listening on :3001" "ss -ltn | grep -q ':3001'" +check "docker daemon running" "systemctl is-active --quiet docker" +check "disk under 90% on /" "test $(df --output=pcent / | tail -1 | tr -dc 0-9) -lt 90" + +if [ $rc -eq 0 ]; then echo "$LOG_TAG all checks passed"; else echo "$LOG_TAG one or more checks FAILED"; fi +exit $rc +DOCTOR +chmod +x /usr/local/bin/agentic-doctor + +cat > /usr/local/bin/agentic-update << 'UPDATE' +#!/usr/bin/env bash +# Coordinated update pass for the Claude Code container. +# Run on demand: agentic-update +# Runs weekly via /etc/cron.d/agentic-update. Log: /var/log/agentic-update.log +# Deliberately NOT 'set -e': one component failing must not abort the rest. +set -uo pipefail +export DEBIAN_FRONTEND=noninteractive +LOG=/var/log/agentic-update.log +exec >>"$LOG" 2>&1 +echo "===================================================================" +echo ">>> agentic-update starting: $(date '+%Y-%m-%d %H:%M:%S %Z')" + +echo ">>> [1/6] OS packages (includes Node.js patches within its LTS line)..." +apt-get update -qq && apt-get upgrade -y -qq && apt-get autoremove -y -qq && apt-get clean -qq + +echo ">>> [2/6] npm (latest)..." +npm install -g --no-fund --no-audit --loglevel=error npm@latest || echo " [WARN] npm self-update failed" +# npm 12+ blocks dependencies' install scripts behind an allowlist (separate +# from ignore-scripts), so CloudCLI's native deps (better-sqlite3/node-pty/ +# bcrypt) never compile -> cloudcli crash-loops and nothing binds :3001. Boxes +# deploy on npm 10 (builds fine) but this weekly run upgrades npm above, so the +# config MUST be set here -- AFTER npm@latest lands (it only persists to +# /root/.npmrc under npm 12+) and BEFORE the cloudcli reinstall/rebuild below. +npm config set dangerously-allow-all-scripts true 2>/dev/null || true + +echo ">>> [3/6] Claude Code..." +curl -fsSL https://claude.ai/install.sh | bash || echo " [WARN] Claude Code update failed" + +echo ">>> [4/6] CloudCLI UI (claudecodeui)..." +npm install -g --no-fund --no-audit --loglevel=error @cloudcli-ai/cloudcli@latest || echo " [WARN] CloudCLI UI update failed" +# Self-heal: if the native build was skipped (better-sqlite3 binding missing), +# rebuild in place before restart -- else cloudcli loops on "Could not locate +# the bindings file". npm rebuild is itself gated by the config set above. +CCUI_PKG="$(npm root -g 2>/dev/null)/@cloudcli-ai/cloudcli" +if [ -d "$CCUI_PKG/node_modules/better-sqlite3" ] && ! ls "$CCUI_PKG/node_modules/better-sqlite3/build/Release/"*.node >/dev/null 2>&1; then + echo " rebuilding CloudCLI native modules (better-sqlite3/node-pty/bcrypt)" + ( cd "$CCUI_PKG" && npm rebuild >/dev/null 2>&1 ) || echo " [WARN] CloudCLI native rebuild failed" +fi +systemctl restart cloudcli || echo " [WARN] cloudcli restart failed" + +echo ">>> [5/6] Docker images (one-shot Watchtower)..." +if command -v docker >/dev/null 2>&1 && [ -n "$(docker ps -q 2>/dev/null)" ]; then + docker run --rm -v /var/run/docker.sock:/var/run/docker.sock \ + containrrr/watchtower --run-once --cleanup \ + || echo " [WARN] Watchtower one-shot run failed" +else + echo " (no running containers; skipped)" +fi + +echo ">>> [6/6] Health check..." +if agentic-doctor; then + echo ">>> agentic-update finished OK: $(date '+%Y-%m-%d %H:%M:%S %Z')" +else + echo ">>> agentic-update finished WITH HEALTH-CHECK FAILURES: $(date '+%Y-%m-%d %H:%M:%S %Z')" +fi +echo "" +UPDATE +chmod +x /usr/local/bin/agentic-update + +cat > /etc/cron.d/agentic-update << 'CRON' +# Weekly coordinated update - Sunday 4:00 AM (Europe/Berlin, container local time) +# (OS + Claude Code + CloudCLI UI + container images, then a health check) +SHELL=/bin/bash +PATH=/usr/local/sbin:/usr/local/bin:/sbin:/bin:/usr/sbin:/usr/bin +0 4 * * 0 root /usr/local/bin/agentic-update +CRON +chmod 0644 /etc/cron.d/agentic-update + +cat > /etc/logrotate.d/agentic-update << 'LOGROTATE' +/var/log/agentic-update.log { + monthly + rotate 3 + compress + missingok + notifempty +} +LOGROTATE + +echo ">>> Cleaning up..." +apt-get autoremove -y -qq +apt-get clean -qq +rm -rf /var/lib/apt/lists/* + +echo "" +echo "╔══════════════════════════════════════════════════╗" +echo "║ Provisioning Complete! ║" +echo "╚══════════════════════════════════════════════════╝" +PROVISION_EOF + + chmod +x /tmp/provision-${CT_ID}.sh + pct push "$CT_ID" /tmp/provision-${CT_ID}.sh /tmp/provision.sh + pct exec "$CT_ID" -- chmod +x /tmp/provision.sh + pct exec "$CT_ID" -- /tmp/provision.sh + rm -f /tmp/provision-${CT_ID}.sh +} + +# ── Configure Git Identity (optional) ────────────────────────────────────── +configure_git_identity() { + if [[ -n "${GIT_NAME:-}" ]]; then + info "Setting git user.name..." + pct exec "$CT_ID" -- git config --global user.name "$GIT_NAME" \ + || warn "Could not set git user.name" + fi + if [[ -n "${GIT_EMAIL:-}" ]]; then + info "Setting git user.email..." + pct exec "$CT_ID" -- git config --global user.email "$GIT_EMAIL" \ + || warn "Could not set git user.email" + fi +} + +# ── Write Proxmox Notes ───────────────────────────────────────────────────── +# Drops a Markdown "card" into the container's Notes panel in the Proxmox UI +# (rendered as Markdown on PVE 7+). Uses placeholders so the heredoc can stay +# single-quoted (no accidental expansion of backticks/$ in the Markdown). +write_notes() { + local ct_ip + ct_ip=$(pct exec "$CT_ID" -- hostname -I 2>/dev/null | awk '{print $1}') + ct_ip="${ct_ip:-}" + + local notes + notes=$(cat <<'EOF' +# 🤖 Claude Code Container + +**Web UI (CloudCLI UI):** http://__IP__:3001 — _create a login on first visit_ +**SSH:** `ssh root@__IP__` | **Console:** `pct enter __CTID__` + +## Start Claude Code +Log in, then run `claude` (the shell auto-cd's to `/project`). + +## Update & health +- `agentic-update` — one coordinated pass: OS → Claude Code → Web UI → container images → health check +- `agentic-doctor` — run the health check on its own +- Auto-runs weekly (Sunday 4 AM, Europe/Berlin). Log: `/var/log/agentic-update.log` + +## Service & config +- `systemctl status cloudcli` — the Web UI service (`journalctl -u cloudcli` for logs) +- Permissions: **auto mode** + secret deny-floor — `/root/.claude/settings.json` + +--- +_IP above is the address at deploy time; on DHCP it may change (check with `pct exec __CTID__ -- hostname -I`)._ +EOF +) + notes=${notes//__IP__/$ct_ip} + notes=${notes//__CTID__/$CT_ID} + + if pct set "$CT_ID" --description "$notes" >/dev/null 2>&1; then + success "Wrote container notes to the Proxmox UI." + else + warn "Could not set container notes (non-fatal)." + fi +} + +# ── Print Summary ───────────────────────────────────────────────────────── +print_summary() { + local ct_ip + ct_ip=$(pct exec "$CT_ID" -- hostname -I 2>/dev/null | awk '{print $1}') + + echo "" + echo -e "${GREEN}${BOLD}╔══════════════════════════════════════════════════╗${NC}" + echo -e "${GREEN}${BOLD}║ Claude Code LXC Ready! ║${NC}" + echo -e "${GREEN}${BOLD}╚══════════════════════════════════════════════════╝${NC}" + echo "" + echo -e " ${BOLD}Container:${NC} $CT_ID ($CT_HOSTNAME)" + echo -e " ${BOLD}IP:${NC} ${ct_ip:-pending (DHCP)}" + echo -e " ${BOLD}Resources:${NC} ${CT_CORES} CPU / $(( CT_RAM / 1024 )) GB RAM / ${CT_DISK} GB disk" + echo -e " ${BOLD}Storage:${NC} $CT_STORAGE" + echo -e " ${BOLD}Timezone:${NC} Europe/Berlin" + echo "" + echo -e " ${BOLD}Connect:${NC}" + echo -e " Console: ${CYAN}pct enter $CT_ID${NC}" + [[ -n "${ct_ip:-}" ]] && echo -e " SSH: ${CYAN}ssh root@${ct_ip}${NC}" + [[ -n "${ct_ip:-}" ]] && echo -e " Web UI: ${CYAN}http://${ct_ip}:3001${NC} (CloudCLI UI — create a login on first visit)" + echo "" + echo -e " ${BOLD}Start Claude Code:${NC}" + echo -e " ${CYAN}claude${NC} (shell auto-cd's to /project on login)" + echo "" + echo -e " ${BOLD}Verify plugins:${NC} ${CYAN}claude plugin list${NC}" + echo "" + echo -e " ${BOLD}Installed:${NC}" + echo " • Claude Code (native) • Node.js (latest LTS) + npm/pnpm" + echo " • Python 3 + pip + venv • Go (latest)" + echo " • Rust (via rustup) • Docker + Compose" + echo " • Git, ripgrep, fzf, fd • Build essentials" + echo " • PostgreSQL & Redis CLI • CloudCLI UI web front end (port 3001)" + echo "" + echo -e " ${BOLD}Permissions:${NC} Auto mode (classifier-guarded) + deny floor for secrets" + echo -e " ${BOLD}Config:${NC} ~/.claude/settings.json" + echo -e " ${BOLD}Features:${NC} Agent teams (experimental), adaptive thinking, 64k output tokens" + echo -e " ${BOLD}Remote Control:${NC} enable per-session with ${CYAN}/rc${NC} or all sessions via ${CYAN}/config${NC} (Pro/Max)" + echo -e " ${BOLD}Plugins:${NC} frontend-design, code-review, commit-commands, security-guidance," + echo -e " context7, superpowers, + LSP (typescript/pyright/gopls/rust-analyzer)" + echo -e " ${BOLD}Skills:${NC} webapp-testing (local, Playwright)" + echo -e " ${BOLD}Updates:${NC} Sonntags 4 Uhr (Europe/Berlin) — coordinated (OS + Claude + UI + containers)" + echo -e " then a health check. Run anytime: ${CYAN}agentic-update${NC} / ${CYAN}agentic-doctor${NC}" + echo "" +} + +# ── Main ────────────────────────────────────────────────────────────────── +main() { + header + preflight + get_config + get_template + create_container + start_container + provision_container + configure_git_identity + write_notes + print_summary +} + +main "$@" \ No newline at end of file