diff --git a/Containerfile b/Containerfile index b35e7f97..701ef51e 100644 --- a/Containerfile +++ b/Containerfile @@ -55,7 +55,14 @@ RUN apt-get update && apt-get install -y --no-install-recommends --only-upgrade openssl=3.0.19-1~deb12u2 \ && apt-get clean && rm -rf /var/lib/apt/lists/* -# Install minimal system dependencies +# Install minimal system dependencies + the agent-CLI / TUI-debug toolkit. +# Bundle includes: tmux for script-driven multiplexing (claude can `new-session +# -d`, `send-keys`, `capture-pane -p`); expect for driving interactive prompts; +# neovim for in-container quick edits; ripgrep/fd-find/bat for fast search + +# pretty file inspection (claude reaches for these constantly); fzf for +# fuzzy completion in interactive shells. +# Note: on Debian, fd-find ships as `fdfind` and bat as `batcat` — we add +# convenience symlinks below so claude can call `fd` and `bat` directly. RUN apt-get update && apt-get install -y --no-install-recommends \ curl \ git \ @@ -68,7 +75,15 @@ RUN apt-get update && apt-get install -y --no-install-recommends \ podman \ rsync \ tmux \ - && apt-get clean && rm -rf /var/lib/apt/lists/* + expect \ + neovim \ + ripgrep \ + fd-find \ + bat \ + fzf \ + && apt-get clean && rm -rf /var/lib/apt/lists/* \ + && ln -s /usr/bin/fdfind /usr/local/bin/fd \ + && ln -s /usr/bin/batcat /usr/local/bin/bat # Generate en_US.UTF-8 locale RUN echo "en_US.UTF-8 UTF-8" > /etc/locale.gen && locale-gen @@ -153,6 +168,106 @@ RUN set -eux; \ rm -f "taplo-linux-${ARCH}"; \ taplo --version; +# Install eza binary (modern ls). Not in apt for bookworm — pull from release. +RUN set -eux; \ + case "${TARGETARCH}" in \ + amd64) ARCH=x86_64-unknown-linux-musl ;; \ + arm64) ARCH=aarch64-unknown-linux-gnu ;; \ + *) echo "Unsupported architecture: ${TARGETARCH}"; exit 1 ;; \ + esac; \ + EZA_VERSION="$(curl -fsSL https://api.github.com/repos/eza-community/eza/releases/latest | sed -n 's/.*"tag_name": *"\(v[^"]*\)".*/\1/p')"; \ + URL="https://github.com/eza-community/eza/releases/download/${EZA_VERSION}"; \ + FILE="eza_${ARCH}.tar.gz"; \ + curl -fsSL "${URL}/${FILE}" -o eza.tar.gz; \ + tar -xzf eza.tar.gz; \ + install -m 0755 eza /usr/local/bin/eza; \ + rm -f eza eza.tar.gz; \ + eza --version | head -2; + +# Install delta (git-diff prettifier). Not in apt for bookworm. +RUN set -eux; \ + case "${TARGETARCH}" in \ + amd64) ARCH=x86_64-unknown-linux-gnu ;; \ + arm64) ARCH=aarch64-unknown-linux-gnu ;; \ + *) echo "Unsupported architecture: ${TARGETARCH}"; exit 1 ;; \ + esac; \ + DELTA_VERSION="$(curl -fsSL https://api.github.com/repos/dandavison/delta/releases/latest | sed -n 's/.*"tag_name": *"\([^"]*\)".*/\1/p')"; \ + URL="https://github.com/dandavison/delta/releases/download/${DELTA_VERSION}"; \ + FILE="delta-${DELTA_VERSION}-${ARCH}.tar.gz"; \ + curl -fsSL "${URL}/${FILE}" -o delta.tar.gz; \ + tar -xzf delta.tar.gz; \ + install -m 0755 "delta-${DELTA_VERSION}-${ARCH}/delta" /usr/local/bin/delta; \ + rm -rf "delta-${DELTA_VERSION}-${ARCH}" delta.tar.gz; \ + delta --version; + +# Install lazygit binary (git TUI). Useful inside the container for quick +# review during a CC session. +RUN set -eux; \ + case "${TARGETARCH}" in \ + amd64) ARCH=Linux_x86_64 ;; \ + arm64) ARCH=Linux_arm64 ;; \ + *) echo "Unsupported architecture: ${TARGETARCH}"; exit 1 ;; \ + esac; \ + LG_VERSION="$(curl -fsSL https://api.github.com/repos/jesseduffield/lazygit/releases/latest | sed -n 's/.*"tag_name": *"v\([^"]*\)".*/\1/p')"; \ + URL="https://github.com/jesseduffield/lazygit/releases/download/v${LG_VERSION}"; \ + FILE="lazygit_${LG_VERSION}_${ARCH}.tar.gz"; \ + curl -fsSL "${URL}/${FILE}" -o lazygit.tar.gz; \ + tar -xzf lazygit.tar.gz lazygit; \ + install -m 0755 lazygit /usr/local/bin/lazygit; \ + rm -f lazygit lazygit.tar.gz; \ + lazygit --version | head -2; + +# Install zoxide binary (z/cd-by-frecency). Skip the install.sh wrapper — +# pull the release tarball directly so we don't bring in a shell-init step. +RUN set -eux; \ + case "${TARGETARCH}" in \ + amd64) ARCH=x86_64-unknown-linux-musl ;; \ + arm64) ARCH=aarch64-unknown-linux-musl ;; \ + *) echo "Unsupported architecture: ${TARGETARCH}"; exit 1 ;; \ + esac; \ + ZX_VERSION="$(curl -fsSL https://api.github.com/repos/ajeetdsouza/zoxide/releases/latest | sed -n 's/.*"tag_name": *"v\([^"]*\)".*/\1/p')"; \ + URL="https://github.com/ajeetdsouza/zoxide/releases/download/v${ZX_VERSION}"; \ + FILE="zoxide-${ZX_VERSION}-${ARCH}.tar.gz"; \ + curl -fsSL "${URL}/${FILE}" -o zoxide.tar.gz; \ + tar -xzf zoxide.tar.gz zoxide; \ + install -m 0755 zoxide /usr/local/bin/zoxide; \ + rm -f zoxide zoxide.tar.gz; \ + zoxide --version; + +# Install starship prompt binary. Note: starship only ships musl builds for +# linux, no gnu variant. +RUN set -eux; \ + case "${TARGETARCH}" in \ + amd64) ARCH=x86_64-unknown-linux-musl ;; \ + arm64) ARCH=aarch64-unknown-linux-musl ;; \ + *) echo "Unsupported architecture: ${TARGETARCH}"; exit 1 ;; \ + esac; \ + SS_VERSION="$(curl -fsSL https://api.github.com/repos/starship/starship/releases/latest | sed -n 's/.*"tag_name": *"v\([^"]*\)".*/\1/p')"; \ + URL="https://github.com/starship/starship/releases/download/v${SS_VERSION}"; \ + FILE="starship-${ARCH}.tar.gz"; \ + curl -fsSL "${URL}/${FILE}" -o starship.tar.gz; \ + tar -xzf starship.tar.gz; \ + install -m 0755 starship /usr/local/bin/starship; \ + rm -f starship starship.tar.gz; \ + starship --version | head -1; + +# Install charm-freeze (render terminal output as styled PNG — claude reads +# images natively, so this gives the agent a way to "see" colored TUI state). +RUN set -eux; \ + case "${TARGETARCH}" in \ + amd64) ARCH=Linux_x86_64 ;; \ + arm64) ARCH=Linux_arm64 ;; \ + *) echo "Unsupported architecture: ${TARGETARCH}"; exit 1 ;; \ + esac; \ + FZ_VERSION="$(curl -fsSL https://api.github.com/repos/charmbracelet/freeze/releases/latest | sed -n 's/.*"tag_name": *"v\([^"]*\)".*/\1/p')"; \ + URL="https://github.com/charmbracelet/freeze/releases/download/v${FZ_VERSION}"; \ + FILE="freeze_${FZ_VERSION}_${ARCH}.tar.gz"; \ + curl -fsSL "${URL}/${FILE}" -o freeze.tar.gz; \ + tar -xzf freeze.tar.gz; \ + install -m 0755 "freeze_${FZ_VERSION}_${ARCH}/freeze" /usr/local/bin/freeze; \ + rm -rf "freeze_${FZ_VERSION}_${ARCH}" freeze.tar.gz; \ + freeze --version; + # Install cursor-agent CLI (installs to ~/.local/bin) ENV PATH="/root/.local/bin:${PATH}" RUN set -eux; \ @@ -294,8 +409,39 @@ ENV PRE_COMMIT_HOME="/opt/pre-commit-cache" ENV UV_PROJECT_ENVIRONMENT="/root/assets/workspace/.venv" ENV VIRTUAL_ENV="/root/assets/workspace/.venv" -# Create aliases for pre-commit -RUN echo 'alias precommit="pre-commit run"' >> /root/.bashrc +# IS_SANDBOX=1 lets `claude --dangerously-skip-permissions` bypass the uid-0 +# refusal that otherwise fires when claude detects it's running as root. The +# container is the trust boundary; this is the documented escape hatch. +ENV IS_SANDBOX="1" + +# Install Claude Code CLI globally so it's on PATH for every shell. Mirrors +# the cursor-agent install pattern above. Three retries because the install +# script pulls from a CDN that occasionally hiccups. +RUN set -eux; \ + INSTALLER="/tmp/claude-install.sh"; \ + for attempt in 1 2 3; do \ + if curl -fsSL https://claude.ai/install.sh -o "${INSTALLER}" \ + && bash "${INSTALLER}" \ + && [ -x /root/.local/bin/claude ]; then \ + ln -s /root/.local/bin/claude /usr/local/bin/claude; \ + claude --version; \ + rm -f "${INSTALLER}"; \ + exit 0; \ + fi; \ + rm -f "${INSTALLER}"; \ + echo "claude install attempt ${attempt} failed, retrying in 10s..."; \ + sleep 10; \ + done; \ + echo "WARNING: claude install failed after 3 attempts (CDN issue); skipping"; \ + echo "Install manually: curl -fsSL https://claude.ai/install.sh | bash"; + +# Create aliases for pre-commit + Claude Code variants. `cc` is the safer +# default (prompts for permissions); `cld` is the auto-approve variant for +# fully-isolated containers where the dev consciously trades off oversight +# for autonomy. Both rely on IS_SANDBOX=1 above. +RUN echo 'alias precommit="pre-commit run"' >> /root/.bashrc \ + && echo 'alias cc="claude"' >> /root/.bashrc \ + && echo 'alias cld="claude --dangerously-skip-permissions"' >> /root/.bashrc # Default command - interactive shell CMD ["/bin/bash"] diff --git a/tests/test_image.py b/tests/test_image.py index f244f58e..dc1f2495 100644 --- a/tests/test_image.py +++ b/tests/test_image.py @@ -26,14 +26,29 @@ "ruff": "0.15.", # Minor version check (installed via uv pip) "bandit": "1.9.", # Minor version check (installed via uv pip) "pip_licenses": "5.", # Major version check (installed via uv pip) - "just": "1.50.", # Minor version check (manually installed from latest release) + "just": "1.51.", # Minor version check (manually installed from latest release) "hadolint": "2.14.", # Minor version check (manually installed from pinned release) "taplo": "0.10.", # Minor version check (manually installed from latest release) - "cargo-binstall": "1.18.", # Minor version check (installed from latest release), + "cargo-binstall": "1.19.", # Minor version check (installed from latest release), "typstyle": "0.14.", # Minor version check (installed from latest release) "vig_utils": "0.1.", # Minor version check (installed via uv pip) "tmux": "3.3", # Major.minor version check (from apt package) "rsync": "3.2", # Major.minor version check (from apt package) + # ── Agent-CLI / TUI-debug toolkit (#545) ─────────────────────────────── + # Only major-line checks; latest-of-line tracked elsewhere. + "ripgrep": "13.", # apt package + "fd": "8.", # apt fd-find package, symlinked as fd + "bat": "0.", # apt bat package, symlinked as bat + "fzf": "0.", # apt package + "expect": "5.", # apt package + "nvim": "0.7", # apt neovim package (Debian bookworm pins 0.7.x) + "eza": "0.", # binary release + "delta": "0.", # binary release + "lazygit": "0.", # binary release + "zoxide": "0.", # binary release + "starship": "1.", # binary release + "freeze": "0.", # binary release (charm-freeze) + "claude": "2.", # binary install via official installer } @@ -258,6 +273,200 @@ def test_tmux_detached_session_survives(self, host): host.run(f"tmux kill-session -t {session} 2>/dev/null") +class TestAgentToolkit: + """ + Agent-CLI + TUI-debug + Claude Code toolkit (issue #545). + + Verifies the bundle that closes recurring gaps for AI-assisted development: + - modern CLI replacements (rg/fd/bat/eza/delta) — what agents reach for + - TUI debug primitives (tmux already, expect, freeze) + - in-container editor (neovim) for git commit/edit fallback + - Claude Code baked + IS_SANDBOX=1 + cc/cld aliases + + Each tool gets two asserts: presence on PATH (or expected install path) + and a successful --version invocation. Version-string checks use + EXPECTED_VERSIONS' loose major-line prefixes — tight pins live elsewhere. + """ + + # ── apt-installed essentials ───────────────────────────────────────────── + + def test_ripgrep_installed(self, host): + assert host.package("ripgrep").is_installed, "ripgrep not installed" + + def test_ripgrep_version(self, host): + result = host.run("rg --version") + assert result.rc == 0, f"rg --version failed: {result.stderr}" + expected = EXPECTED_VERSIONS["ripgrep"] + assert expected in result.stdout, ( + f"Expected rg {expected}x, got: {result.stdout}" + ) + + def test_fd_installed(self, host): + # Debian's fd-find ships as `fdfind`; we add /usr/local/bin/fd symlink. + assert host.file("/usr/local/bin/fd").exists, "fd symlink missing" + assert host.file("/usr/bin/fdfind").exists, "fdfind binary missing" + + def test_fd_version(self, host): + result = host.run("fd --version") + assert result.rc == 0, f"fd --version failed: {result.stderr}" + expected = EXPECTED_VERSIONS["fd"] + assert expected in result.stdout, ( + f"Expected fd {expected}x, got: {result.stdout}" + ) + + def test_bat_installed(self, host): + assert host.file("/usr/local/bin/bat").exists, "bat symlink missing" + assert host.file("/usr/bin/batcat").exists, "batcat binary missing" + + def test_bat_version(self, host): + result = host.run("bat --version") + assert result.rc == 0, f"bat --version failed: {result.stderr}" + expected = EXPECTED_VERSIONS["bat"] + assert expected in result.stdout, ( + f"Expected bat {expected}x, got: {result.stdout}" + ) + + def test_fzf_installed(self, host): + assert host.package("fzf").is_installed, "fzf not installed" + + def test_fzf_version(self, host): + result = host.run("fzf --version") + assert result.rc == 0, f"fzf --version failed: {result.stderr}" + expected = EXPECTED_VERSIONS["fzf"] + assert expected in result.stdout, ( + f"Expected fzf {expected}x, got: {result.stdout}" + ) + + def test_expect_installed(self, host): + assert host.package("expect").is_installed, "expect not installed" + + def test_expect_version(self, host): + # `expect -v` returns version + result = host.run("expect -v") + assert result.rc == 0, f"expect -v failed: {result.stderr}" + expected = EXPECTED_VERSIONS["expect"] + assert expected in result.stdout, ( + f"Expected expect {expected}x, got: {result.stdout}" + ) + + def test_neovim_installed(self, host): + assert host.package("neovim").is_installed, "neovim not installed" + + def test_neovim_version(self, host): + result = host.run("nvim --version") + assert result.rc == 0, f"nvim --version failed: {result.stderr}" + expected = EXPECTED_VERSIONS["nvim"] + assert expected in result.stdout, ( + f"Expected nvim {expected}x, got: {result.stdout}" + ) + + # ── binary release downloads ───────────────────────────────────────────── + + def test_eza_installed(self, host): + assert host.file("/usr/local/bin/eza").exists, "eza binary missing" + + def test_eza_version(self, host): + result = host.run("eza --version") + assert result.rc == 0, f"eza --version failed: {result.stderr}" + expected = EXPECTED_VERSIONS["eza"] + assert expected in result.stdout, ( + f"Expected eza {expected}x, got: {result.stdout}" + ) + + def test_delta_installed(self, host): + assert host.file("/usr/local/bin/delta").exists, "delta binary missing" + + def test_delta_version(self, host): + result = host.run("delta --version") + assert result.rc == 0, f"delta --version failed: {result.stderr}" + expected = EXPECTED_VERSIONS["delta"] + assert expected in result.stdout, ( + f"Expected delta {expected}x, got: {result.stdout}" + ) + + def test_lazygit_installed(self, host): + assert host.file("/usr/local/bin/lazygit").exists, "lazygit binary missing" + + def test_lazygit_version(self, host): + result = host.run("lazygit --version") + assert result.rc == 0, f"lazygit --version failed: {result.stderr}" + expected = EXPECTED_VERSIONS["lazygit"] + assert expected in result.stdout, ( + f"Expected lazygit {expected}x, got: {result.stdout}" + ) + + def test_zoxide_installed(self, host): + assert host.file("/usr/local/bin/zoxide").exists, "zoxide binary missing" + + def test_zoxide_version(self, host): + result = host.run("zoxide --version") + assert result.rc == 0, f"zoxide --version failed: {result.stderr}" + expected = EXPECTED_VERSIONS["zoxide"] + assert expected in result.stdout, ( + f"Expected zoxide {expected}x, got: {result.stdout}" + ) + + def test_starship_installed(self, host): + assert host.file("/usr/local/bin/starship").exists, "starship binary missing" + + def test_starship_version(self, host): + result = host.run("starship --version") + assert result.rc == 0, f"starship --version failed: {result.stderr}" + expected = EXPECTED_VERSIONS["starship"] + assert expected in result.stdout, ( + f"Expected starship {expected}x, got: {result.stdout}" + ) + + def test_freeze_installed(self, host): + assert host.file("/usr/local/bin/freeze").exists, "freeze binary missing" + + def test_freeze_version(self, host): + result = host.run("freeze --version") + assert result.rc == 0, f"freeze --version failed: {result.stderr}" + expected = EXPECTED_VERSIONS["freeze"] + assert expected in result.stdout, ( + f"Expected freeze {expected}x, got: {result.stdout}" + ) + + # ── Claude Code + sandbox + aliases ────────────────────────────────────── + + def test_claude_installed(self, host): + # Installed by the official installer to ~/.local/bin/claude, then + # symlinked to /usr/local/bin/claude so it's on the default PATH. + assert host.file("/usr/local/bin/claude").exists, "claude symlink missing" + + def test_claude_version(self, host): + result = host.run("claude --version") + assert result.rc == 0, f"claude --version failed: {result.stderr}" + expected = EXPECTED_VERSIONS["claude"] + assert expected in result.stdout, ( + f"Expected claude {expected}x, got: {result.stdout}" + ) + + def test_is_sandbox_env_set(self, host): + # IS_SANDBOX=1 is what lets `claude --dangerously-skip-permissions` + # run as root inside the container without the uid-0 refusal. Set as + # a layer ENV so it's present in every shell + every claude invocation. + result = host.run("printenv IS_SANDBOX") + assert result.rc == 0, "IS_SANDBOX env var not set" + assert result.stdout.strip() == "1", ( + f"Expected IS_SANDBOX=1, got: {result.stdout!r}" + ) + + def test_cc_alias_in_bashrc(self, host): + # The cc/cld aliases are user-facing ergonomics; verify the literal + # strings landed in /root/.bashrc rather than executing the aliases + # (testinfra `host.run` runs non-interactively; aliases would not + # be expanded). + bashrc = host.file("/root/.bashrc") + assert bashrc.exists, "/root/.bashrc missing" + content = bashrc.content_string + assert 'alias cc="claude"' in content, "cc alias not in /root/.bashrc" + assert 'alias cld="claude --dangerously-skip-permissions"' in content, ( + "cld alias not in /root/.bashrc" + ) + + class TestPythonEnvironment: """Test Python environment setup."""