diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 78fc41e..4feedde 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,129 +1,54 @@ +--- name: CI on: push: - branches: [ main, enhs ] + # TODO: remove redesign-python after merge + branches: [main, redesign-python] pull_request: - branches: [ main ] + branches: [main] jobs: - shellcheck: - name: ShellCheck + lint: runs-on: ubuntu-latest steps: - - name: Checkout code - uses: actions/checkout@v4 - - - name: Run shellcheck on setup-yolo.sh - run: shellcheck setup-yolo.sh + - uses: actions/checkout@v4 + - uses: astral-sh/setup-uv@v5 + - run: uv venv && uv pip install -e ".[dev]" + - uses: pre-commit/action@v3.0.1 - - name: Run shellcheck on bin/yolo - run: shellcheck bin/yolo - - unit-tests: - name: Unit Tests (BATS) - strategy: - matrix: - os: [ubuntu-latest, macos-latest] - runs-on: ${{ matrix.os }} + codespell: + runs-on: ubuntu-latest steps: - - name: Checkout code - uses: actions/checkout@v4 + - uses: actions/checkout@v4 + - uses: codespell-project/actions-codespell@v2 with: - submodules: true - - - name: Install bats - run: | - if command -v brew &>/dev/null; then - brew install bats-core - else - sudo apt-get update && sudo apt-get install -y bats - fi - - - name: Run tests - run: bats tests/ + skip: ./design/legacy - test-setup: - name: Test Setup Script + shellcheck: runs-on: ubuntu-latest steps: - - name: Checkout code - uses: actions/checkout@v4 - - - name: Install podman - run: | - sudo apt-get update - sudo apt-get install -y podman - - - name: Test setup-yolo.sh (build only) - run: | - # Run setup with no input to skip installation prompt - # This will build the image and exit when asking about installation - echo "n" | ./setup-yolo.sh || true - - # Verify the image was built - podman image exists con-bomination-claude-code - - - name: Verify yolo script syntax - run: | - bash -n bin/yolo - echo "✓ Yolo script has valid syntax" - - - name: Test yolo script help (dry run) - run: | - # Create a mock podman command that just echoes what would run - mkdir -p ~/test-bin - cat > ~/test-bin/podman << 'EOF' - #!/bin/bash - echo "PODMAN COMMAND:" - echo "podman $@" - exit 0 - EOF - chmod +x ~/test-bin/podman - - # Add to PATH and test - export PATH="$HOME/test-bin:$PATH" - - # Verify our mock is used - which podman - - # Test various yolo invocations - echo "Testing: yolo" - ./bin/yolo || true - - echo "Testing: yolo -- \"help with code\"" - ./bin/yolo -- "help with code" || true - - echo "Testing: yolo -v /data:/data --" - ./bin/yolo -v /data:/data -- || true + - uses: actions/checkout@v4 + - uses: ludeeus/action-shellcheck@master + with: + scandir: image-extras - integration-test: - name: Integration Test + unit-tests: runs-on: ubuntu-latest - needs: [shellcheck, test-setup] steps: - - name: Checkout code - uses: actions/checkout@v4 - - - name: Install podman - run: | - sudo apt-get update - sudo apt-get install -y podman - - - name: Run full setup (automated) - run: | - # Answer 'no' to installation prompt - echo "n" | ./setup-yolo.sh + - uses: actions/checkout@v4 + - uses: astral-sh/setup-uv@v5 + - run: uv venv && uv pip install -e ".[dev]" + - run: .venv/bin/pytest -m "not integration" -v - - name: Verify container image - run: | - podman images - podman image exists con-bomination-claude-code - echo "✓ Container image successfully built" - - - name: Test container can start - run: | - # Test that the container starts and can run a basic command - # We'll use --help which doesn't require authentication - podman run --rm con-bomination-claude-code claude --help - echo "✓ Container runs successfully" + integration-tests: + runs-on: ubuntu-latest + needs: [lint, unit-tests] + steps: + - uses: actions/checkout@v4 + - uses: astral-sh/setup-uv@v5 + - run: uv venv && uv pip install -e ".[dev]" + - name: Build base image + run: podman build -f images/Containerfile.base -t yolo-base images/ + - name: Run integration tests + run: .venv/bin/pytest -m integration -v diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..9fa9483 --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +.venv/ +__pycache__/ +*.egg-info/ +.local-notes/ +.yolo/ +!src/yolo/demo/.yolo/ diff --git a/.gitmodules b/.gitmodules index d74af07..3793f04 100644 --- a/.gitmodules +++ b/.gitmodules @@ -1,6 +1,6 @@ [submodule "tests/test_helper/bats-support"] - path = tests/test_helper/bats-support + path = legacy/tests/test_helper/bats-support url = https://github.com/bats-core/bats-support.git [submodule "tests/test_helper/bats-assert"] - path = tests/test_helper/bats-assert + path = legacy/tests/test_helper/bats-assert url = https://github.com/bats-core/bats-assert.git diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..20b8d73 --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,21 @@ +--- +repos: + - repo: https://github.com/astral-sh/ruff-pre-commit + rev: v0.11.6 + hooks: + - id: ruff + - id: ruff-format + + - repo: https://github.com/adrienverge/yamllint + rev: v1.37.1 + hooks: + - id: yamllint + args: [--strict] + exclude: ^design/legacy/ + + - repo: https://github.com/shellcheck-py/shellcheck-py + rev: v0.10.0.1 + hooks: + - id: shellcheck + files: \.sh$ + exclude: ^design/legacy/ diff --git a/.yamllint.yml b/.yamllint.yml new file mode 100644 index 0000000..189f9a5 --- /dev/null +++ b/.yamllint.yml @@ -0,0 +1,6 @@ +--- +extends: default +rules: + truthy: + # YAML spec treats bare 'on' as true, which breaks GitHub Actions 'on:' key + check-keys: false diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..a405ba1 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,45 @@ +**Before writing files, check if CLAUDE.md, design docs, or tests need updating to reflect your changes.** + +## Git workflow + +- Make clean, atomic commits — one feature or fix per commit. +- Run `pre-commit run --files ` before attempting `git commit` to catch formatting issues early. + +## Python + +- Always import at the top of the file unless there's a good reason not to. + +## Project + +yolo runs Claude Code safely in a rootless Podman container with full autonomy. + +Currently being rewritten from bash to Python. Legacy code is in `design/legacy/`. + +## Key files + +- `SPEC.md` — current specification (source of truth for behavior) +- `design/HACK_DECISIONS.md` — locked design decisions, do not revisit +- `design/LEGACY_SPEC.md` — spec of the legacy bash implementation +- `tests/features_to_test.md` — checklist of things that need tests + +## Development + +``` +uv venv .venv && source .venv/bin/activate +uv pip install -e ".[dev]" +pytest +``` + +Entry point is `yo` (temporary, becomes `yolo` at cutover). + +## Architecture + +- `src/yolo/config.py` — YAML config loading from 5 locations (defaults + 4 user) +- `src/yolo/builder.py` — resolves extras, assembles build context, invokes podman +- `src/yolo/cli.py` — click CLI (yo build, yo run, yo clip, yo demo) +- `src/yolo/launcher.py` — assembles podman run command +- `src/yolo/defaults/config.yaml` — default image-extras shipped with package +- `images/Containerfile.base` — minimal debian base image +- `images/Containerfile.extras` — layers image-extras on top +- `image-extras/` — composable install scripts (apt.sh, python.sh, etc.) +- `.local-notes/` — gitignored local working notes (issues, PRs, etc.) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..3084ca2 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,54 @@ +# Contributing + +## Setup + +``` +git clone https://github.com/con/yolo +cd yolo +uv venv .venv && source .venv/bin/activate +uv pip install -e ".[dev]" +pre-commit install +``` + +## Running tests + +``` +pytest +``` + +## Pre-commit hooks + +Hooks run automatically on commit: +- **ruff** — Python lint and format +- **yamllint** — YAML validation +- **shellcheck** — shell script lint + +Run manually against all files: + +``` +pre-commit run --all-files +``` + +## Writing image-extras scripts + +Scripts live in `image-extras/`. Each script is a self-contained +bash installer. Parameters are passed as env vars prefixed with +`YOLO_{SCRIPTNAME}_{KEY}`: + +```bash +#!/bin/bash +# Env: YOLO_APT_PACKAGES (required) +set -eu +[ -z "${YOLO_APT_PACKAGES:-}" ] && { echo "apt.sh: YOLO_APT_PACKAGES required"; exit 1; } +sudo apt-get install -y $YOLO_APT_PACKAGES +``` + +Config references scripts by name: + +```yaml +image-extras: + - name: apt + packages: [zsh, fzf] +``` + +See `design/HACK_DECISIONS.md` for the full contract. diff --git a/README.md b/README.md index 7c88cb3..e80aeaa 100644 --- a/README.md +++ b/README.md @@ -1,232 +1,114 @@ -# Running Claude Code in a Container +# yolo -This guide shows how to run claude-code in a Podman container while preserving your configuration and working directory access. +Run Claude Code safely in a rootless Podman container with full autonomy. -## Easy Setup (Recommended) +## Prerequisites -Clone the repository and run the setup script to build the container and optionally create a `YOLO` command: +- [Podman](https://podman.io/docs/installation) (rootless) +- Python 3.11+ -```bash -git clone https://github.com/con/yolo -cd yolo -./setup-yolo.sh -``` - -This will: -1. Build the container image if it doesn't exist -2. Optionally create a `YOLO` shell function -3. Configure everything for you +## Install -After setup, just run `yolo` from any directory to start Claude Code in YOLO mode! + -By default, `yolo` preserves your original host paths to ensure session compatibility with native Claude Code. This means: -- Your `~/.claude` directory is mounted at its original path -- Your current directory is mounted at its original path (not `/workspace`) -- Sessions created in the container can be resumed in your native environment and vice versa - -If you prefer the old behavior with anonymized paths (`/claude` and `/workspace`), use the `--anonymized-paths` flag: ```bash -yolo --anonymized-paths +uv tool install "con-yolo @ git+https://github.com/asmacdo/yolo@redesign-python" ``` -### Git Worktree Support - -When running in a git worktree, `yolo` can detect and optionally bind mount the original repository. This allows Claude to access git objects and perform operations like commit and fetch. Control this behavior with the `--worktree` option: - -- `--worktree=ask` (default): Prompts whether to bind mount the original repo -- `--worktree=bind`: Automatically bind mounts the original repo -- `--worktree=skip`: Skip bind mounting and continue normally -- `--worktree=error`: Exit with error if running in a worktree +## Quick start ```bash -# Prompt for bind mount decision (default) -yolo - -# Always bind mount in worktrees -yolo --worktree=bind - -# Skip bind mounting, continue normally -yolo --worktree=skip - -# Disallow running in worktrees -yolo --worktree=error +cd your-project +yo run ``` -**Security note**: Bind mounting the original repo exposes more files and allows modifications. The prompt helps prevent unintended access. - -### Project Configuration +That's it. First run builds the image automatically, then drops you +into Claude Code with full autonomy inside a container. -You can create a per-project configuration file to avoid repeating command line options. The config is auto-created on first run, or you can use `yolo --install-config`: +Customize your project with `yo init` to create a `.yolo/config.yaml`: ```bash -# Auto-creates .git/yolo/config on first run in a git repo -yolo - -# Or manually install/view config -yolo --install-config - -# Edit with your preferences -vi .git/yolo/config +yo init ``` -The configuration file is stored in `.git/yolo/` which means: -- It won't be tracked by git -- It won't be destroyed by `git clean` -- It works correctly with git worktrees (they all reference the same `.git` directory) +Try the interactive demo: -**Example configuration** (`.git/yolo/config`): ```bash -# Volume mounts with shorthand syntax -YOLO_PODMAN_VOLUMES=( - "~/projects" # Mounts ~/projects at same path in container - "~/data::ro" # Mounts ~/data read-only at same path -) - -# Additional podman options -YOLO_PODMAN_OPTIONS=( - "--env=DEBUG=1" -) - -# Arguments for claude -YOLO_CLAUDE_ARGS=( - "--model=claude-3-opus-20240229" -) - -# Default flags -USE_NVIDIA=1 +yo demo ``` -**Volume shorthand syntax:** -- `"~/projects"` → `~/projects:~/projects:Z` (1-to-1 mount) -- `"~/data::ro"` → `~/data:~/data:ro,Z` (1-to-1 with options) -- `"~/data:/data:Z"` → `~/data:/data:Z` (explicit, unchanged) - -Command line options always override configuration file settings. Use `--no-config` to ignore the configuration file entirely. - -See `config.example` for a complete configuration template with detailed comments. - -> **TODO**: Add curl-based one-liner setup once this PR is merged +## Git worktrees -## First-Time Login +When running in a git worktree, `yo run` detects it and asks whether +to bind-mount the original repository (needed for git operations like +commit and fetch). Control with `--worktree=ask|bind|skip|error` or +set `worktree:` in config. -On your first run, you'll need to authenticate: +**Security note**: binding the original repo exposes more files than +the worktree alone. -1. Claude Code will display a URL like `https://claude.ai/oauth/authorize?...` -2. Copy the URL and paste it into a browser on your host machine -3. Complete the authentication in your browser -4. Copy the code from the browser and paste it back into the container terminal +## TODO -Your credentials are stored in `~/.claude` on your host, so you only need to login once. Subsequent runs will use the stored credentials automatically. - -## Manual Setup - -If you prefer to run commands manually, first build the image from the `images/` directory: - -```bash -podman build --build-arg TZ=$(timedatectl show --property=Timezone --value) -t con-bomination-claude-code images/ -``` +- [ ] Configuration reference -Then run (with original host paths preserved by default): +## Image extras -```bash -podman run -it --rm \ - --userns=keep-id \ - -v "$HOME/.claude:$HOME/.claude:Z" \ - -v ~/.gitconfig:/tmp/.gitconfig:ro,Z \ - -v "$(pwd):$(pwd):Z" \ - -w "$(pwd)" \ - -e CLAUDE_CONFIG_DIR="$HOME/.claude" \ - -e GIT_CONFIG_GLOBAL=/tmp/.gitconfig \ - con-bomination-claude-code \ - claude --dangerously-skip-permissions -``` +Builtin extras: `apt`, `python`, `git-delta`, `pip`, `datalad`, `jj`, +`playwright`. -Or with anonymized paths (old behavior): +Write your own — drop a bash script in `.yolo/image-extras/` or +`~/.config/yolo/image-extras/` and reference it by name in your config: -```bash -podman run -it --rm \ - --userns=keep-id \ - -v ~/.claude:/claude:Z \ - -v ~/.gitconfig:/tmp/.gitconfig:ro,Z \ - -v "$(pwd):/workspace:Z" \ - -w /workspace \ - -e CLAUDE_CONFIG_DIR=/claude \ - -e GIT_CONFIG_GLOBAL=/tmp/.gitconfig \ - con-bomination-claude-code \ - claude --dangerously-skip-permissions +```yaml +images: + - name: default + extras: + - name: my-tool ``` -⚠️ **Note**: This uses `--dangerously-skip-permissions` to bypass all permission prompts. This is safe in containerized environments where the container provides isolation from your host system. - -## What's Included - -The Dockerfile is based on [Anthropic's official setup](https://github.com/anthropics/claude-code/blob/07e13937b2d6e798ce1880b22ad6bd22115478e4/.devcontainer/Dockerfile) and includes Claude Code CLI plus common development tools. See the Dockerfile for the complete list. - -## Command Breakdown - -### Default Behavior (Preserved Host Paths) - -- `--userns=keep-id`: Maps your host user ID inside the container so files are owned correctly -- `-v "$HOME/.claude:$HOME/.claude:Z"`: Bind mounts your Claude configuration directory at its original path with SELinux relabeling -- `-v ~/.gitconfig:/tmp/.gitconfig:ro,Z`: Mounts git config read-only for commits (push operations not supported) -- `-v "$(pwd):$(pwd):Z"`: Bind mounts your current working directory at its original path -- `-w "$(pwd)"`: Sets the working directory inside the container to match your host path -- `-e CLAUDE_CONFIG_DIR="$HOME/.claude"`: Tells Claude Code where to find its configuration (at original path) -- `-e GIT_CONFIG_GLOBAL=/tmp/.gitconfig`: Points git to the mounted config -- `claude --dangerously-skip-permissions`: Skips all permission prompts (safe in containers) -- `--rm`: Automatically removes the container when it exits -- `-it`: Interactive terminal - -This default behavior ensures that session histories and project paths are compatible between containerized and native Claude Code environments. - -### Anonymized Paths (Old Behavior with --anonymized-paths) +Scripts are self-contained bash. See `image-extras/` for examples. -When using `--anonymized-paths`, paths are mapped to generic container locations: -- `-v ~/.claude:/claude:Z`: Mounts to `/claude` in container -- `-v "$(pwd):/workspace:Z"`: Mounts to `/workspace` in container -- `-w /workspace`: Working directory is `/workspace` -- `-e CLAUDE_CONFIG_DIR=/claude`: Config directory is `/claude` +## Security -## Tips +yolo runs Claude Code with `--dangerously-skip-permissions` — the +container is the sandbox. Isolation is provided by rootless Podman: -1. **Persist configuration**: The `~/.claude` bind mount ensures your settings, API keys, and session history persist between container runs +- **Filesystem**: only `~/.claude`, `~/.gitconfig`, and your working + directory are mounted. No SSH keys, no cloud credentials. +- **Process**: rootless Podman with `--userns=keep-id` — user namespace + isolation, not real root on the host. +- **Config**: YAML only, not sourced bash. Config can't execute code. -2. **Session compatibility**: By default, paths are preserved to match your host environment. This means: - - Sessions created in the container can be resumed outside the container using `claude --continue` in your native environment - - Each project maintains its own session history based on its actual path (e.g., `/home/user/project`) - - You can seamlessly switch between containerized and native Claude Code for the same project +**Not restricted:** - **Note**: With `--anonymized-paths`, all projects appear to be in `/workspace`, which allows `claude --continue` to retain context across different projects that were also run with this flag. This can be useful for maintaining conversation context when working on related codebases. +- **Network**: containers have full network access (package registries, + APIs, etc.). This is a known gap. -3. **File ownership**: The `--userns=keep-id` flag ensures files created or modified inside the container will be owned by your host user, regardless of your UID - -4. **Git operations**: Git config is mounted read-only, so Claude Code can read your identity and make commits. However, **SSH keys are not mounted**, so `git push` operations will fail. You'll need to push from your host after Claude Code commits your changes. - -5. **Multiple directories**: Mount additional directories as needed: - ```bash - yolo -v ~/projects:~/projects -v ~/data:~/data -- "help with this code" - ``` - Or with anonymized paths: - ```bash - yolo --anonymized-paths -v ~/projects:/projects -v ~/data:/data -- "help with this code" - ``` - -## Security Considerations - -YOLO mode runs Claude Code with `--dangerously-skip-permissions`, providing unrestricted command execution within the container. The container provides isolation through: +**When to be cautious:** -- **Filesystem boundaries**: Only `~/.claude`, `~/.gitconfig`, and your current working directory are accessible to Claude -- **Process isolation**: Rootless podman user namespace isolation (`--userns=keep-id`) -- **Limited host access**: SSH keys and other sensitive files are not mounted +- Untrusted repositories — prompt injection via code comments or docs + could trick Claude into exfiltrating data over the network. +- Mounting directories with sensitive data (credentials, private keys). +- Projects that access production systems or databases. +- **Config as escape vector**: Claude can edit `.yolo/config.yaml` or + `.git/yolo/config.yaml` inside the container. A modified config takes + effect on the next `yo run` — e.g. adding volume mounts to expose + host paths. The `.git/` location is especially easy to miss. Review + config changes after sessions with untrusted code. -**What is NOT restricted:** +See SPEC.md § Security for the full threat model. -- **Network access**: Claude can make arbitrary network connections from within the container (to package registries, APIs, external services, etc.) +## Development install -**When to be cautious:** +```bash +uv venv .venv && source .venv/bin/activate +uv pip install -e ".[dev]" +``` -- **Untrusted repositories**: Malicious code comments or documentation could exploit prompt injection to trick Claude into executing harmful commands or exfiltrating data -- Mounting directories with sensitive data (credentials, private keys, confidential files) -- Projects that access production systems or databases +Or to put `yo` on your PATH globally without a venv: -**For higher security needs**, consider running untrusted code in a separate container without mounting sensitive directories, or wait for integration with Anthropic's modern sandbox runtime which provides network-level restrictions. +```bash +uv tool install -e . +``` diff --git a/SPEC.md b/SPEC.md new file mode 100644 index 0000000..8938669 --- /dev/null +++ b/SPEC.md @@ -0,0 +1,229 @@ +# YOLO Specification + +Working spec for the Python rewrite. Locked decisions in `design/HACK_DECISIONS.md`. + +## Overview + +yolo runs Claude Code inside a rootless Podman container with +`--dangerously-skip-permissions`. The container is the sandbox — no +permission prompts needed. + +## Components + +| Component | Path | Purpose | +|-----------|------|---------| +| CLI | `src/yolo/cli.py` | `yo build`, `yo run` | +| Config | `src/yolo/config.py` | YAML loading from 5 locations | +| Builder | `src/yolo/builder.py` | Resolves extras, assembles build context, invokes podman | +| Launcher | `src/yolo/launcher.py` | Assembles podman run command | +| Base image | `images/Containerfile.base` | Minimal debian + Claude Code | +| Extras image | `images/Containerfile.extras` | Layers image-extras on base | +| Scripts | `image-extras/` | Composable install scripts | +| Defaults | `src/yolo/defaults/config.yaml` | Default image config | + +--- + +## Config + +### Format + +YAML. Not sourced bash — declarative only to prevent prompt injection +across sessions. + +### Locations (later overrides earlier) + +| # | Path | Scope | +|---|------|-------| +| 0 | Package defaults | Builtin | +| 1 | `/etc/yolo/config.yaml` | System/org | +| 2 | `~/.config/yolo/config.yaml` | User | +| 3 | `.yolo/config.yaml` | Project (committed) | +| 4 | `.git/yolo/config.yaml` | Project (local) | + +CLI args override everything. + +### Merge rules + +- **Lists**: append +- **Dicts**: recurse +- **Scalars**: replace +- **`!replace` tag**: TBD — per-key override to replace instead of append + +--- + +## Images + +Images are defined in config as a named list. Each image has a name, +optional `from` (base image), and a list of extras to install. + +```yaml +images: + - name: default + extras: + - name: apt + packages: [zsh, fzf, shellcheck] + - name: python + version: "3.12" + + - name: myproject:heavy + from: myproject + extras: + - name: cuda +``` + +### Image naming + +Image tags are derived from the project dirname + image name: +`yolo--`. Project dirname comes from git toplevel or cwd. + +### `from` key + +Overrides the `BASE_IMAGE` build arg in `Containerfile.extras`. Podman +handles composition natively via `FROM` — no inheritance system needed. +Default is `yolo-base`. + +### No image inheritance in config + +Images do not inherit extras from each other through config merging. +Composition is done through podman's `FROM` mechanism. If image B +needs everything from image A plus more, set `from: ` +and podman layers B on top. + +--- + +## Container-extras + +### Contract + +Every extras entry uses the `name:` form. Additional params become +environment variables prefixed with `YOLO_{SCRIPTNAME}_{KEY}` (uppercased, +hyphens replaced with underscores). + +```yaml +- name: apt + packages: [zsh, fzf] +- name: python + version: "3.12" +- name: datalad +``` + +Becomes: +- `YOLO_APT_PACKAGES="zsh fzf" bash apt.sh` +- `YOLO_PYTHON_VERSION=3.12 bash python.sh` +- `bash datalad.sh` + +### Script resolution + +Search path (later wins): + +1. `/image-extras/` — builtins +2. `~/.config/yolo/image-extras/` — user +3. `.yolo/image-extras/` — project (committed) +4. `.git/yolo/image-extras/` — project (local) + +### Script requirements + +- Self-contained bash scripts +- Validate own env vars, fail with clear message if missing +- No cross-script dependencies (duplicate is OK, idempotent is better) + +### Build mechanism + +Static Containerfiles, dynamic build context. No Dockerfile generation. + +`Containerfile.base`: minimal debian + Claude Code + tini + essential tools. + +`Containerfile.extras`: `FROM ${BASE_IMAGE}`, copies build context, +runs `run.sh` manifest. The manifest is the only generated file — a +list of `bash script.sh` calls with env var prefixes. + +--- + +## Launcher + +### Default behavior + +Mounts claude config (rw), gitconfig (ro), workspace (rw). Sets up +env vars. Runs `claude --dangerously-skip-permissions`. + +### Config keys + +```yaml +volumes: + - /host/path:/container/path:opts +``` + +### CLI flags + +| Flag | Description | +|------|-------------| +| `-v, --volume` | Extra bind mount (repeatable) | +| `--entrypoint` | Override container command | +| `--image` | Run a specific named image | +| `[CLAUDE_ARGS]` | Passed through to claude | + +### Entrypoint override + +Custom entrypoint skips `--dangerously-skip-permissions`. +TODO: make skip_permissions a separate config value. + +### Clipboard bridge + +Host-side clipboard access for container Claude. A shared directory +(`~/.local/share/yolo/clip/`) is mounted at `/tmp/yolo-clip` in the +container. Container writes to `/tmp/yolo-clip/content`, host reads +via `yo clip`. + +`host_clipboard_command` config key (default: `xclip -selection clipboard`) +controls what the host pipes into. + +Not multi-instance safe — multiple containers share one clip file. +Practically fine: user only has one clipboard. + +### Container naming + +`-` with `$HOME/` stripped and non-alphanumeric chars +replaced with `_`. + +### Multiple simultaneous containers + +Multiple `yo run` instances may run concurrently. Each gets a unique +container name (`-`). Shared state (clipboard bridge, claude +config dir) is not isolated between instances. + +--- + +## Security + +### Posture + +Secure by default. Flexible enough to weaken deliberately. + +- No SSH keys mounted +- No cloud credentials accessible +- Workspace mounted read-write +- Network unrestricted (known gap) + +### Config as attack surface + +Claude can write to `.yolo/config.yaml` inside the container. +Modified config takes effect on next launch — between-session escape +vector. Mitigations under consideration (none implemented): + +- Mount `.yolo/` read-only inside container +- Diff-on-launch warning +- Exit warning if config modified during session + +--- + +## Still open + +- pip.sh may be unnecessary if project venv is bound in +- Singularity/apptainer runtime abstraction +- Registry story (GHCR for base image) +- `!replace` YAML tag for per-key merge override +- Hooks / extensible launch behavior +- Context injection (tell Claude about its environment) — DONE +- Claude-to-Claude messaging via clip bridge (needs per-instance UUID or named channels) +- Installer redesign (setup-yolo.sh successor) +- `dangerously_skip_permissions` as separate config value diff --git a/context-injection-notes.md b/context-injection-notes.md new file mode 100644 index 0000000..f9935e0 --- /dev/null +++ b/context-injection-notes.md @@ -0,0 +1,109 @@ +# Should we tell the AI it's in a yolo container? + +Yes. Strong yes. Here's why and what to include. + +## Why context helps + +An AI working blind inside a container will: + +1. **Waste turns discovering its environment.** It'll run `which python`, + check for tools, probe what's mounted — all things we already know at + launch time. + +2. **Hallucinate capabilities it doesn't have.** Without knowing its + boundaries, it'll try to `ssh` somewhere, access credentials that + aren't mounted, or install packages without knowing what's already + available. + +3. **Miss capabilities it does have.** If we installed datalad, delta, + fzf, or Python 3.12 via container-extras, Claude won't know to use + them unless told. The whole point of extras is to give the agent + better tools — but only if it knows they're there. + +4. **Misunderstand file boundaries.** It needs to know what's a bind + mount (persistent, shared with host) vs container filesystem + (ephemeral, dies on exit). Writing important output to `/tmp` inside + the container is a silent data loss bug. + +## What to include + +### Environment awareness +- "You are running inside a yolo container (rootless Podman)" +- Container is ephemeral — filesystem outside of mounts is lost on exit +- No network restrictions (unless configured) +- Running as your host UID (rootless, userns=keep-id) + +### Installed tools (from container-extras) +- List exactly what was installed: "Available tools: python 3.12, + git-delta 0.18.2, fzf, gh, shellcheck, zsh, ..." +- This is generated at launch from the resolved config — always accurate + +### Mount map +- Which directories are bind-mounted and where +- Which are read-only vs read-write +- "Your work directory is mounted at /workspace" (or wherever) +- "Changes outside mounted volumes are lost when the container exits" + +### Boundaries (what you can't do) +- No SSH keys (unless explicitly mounted) +- No access to host services (Docker socket, DBus, etc.) +- Can't persist data outside mounted volumes +- `.yolo/` directory is read-only inside the container (if we do that) + +### Operational guidance +- "Save all important output within mounted volumes" +- "You can install additional packages with apt, but they won't persist" +- "To add persistent tools, tell the user to add them to container-extras" + +## How to deliver it + +**CLAUDE.md in the container.** Claude Code already reads CLAUDE.md files. +Generate one at launch time and place it at the workspace root (or +`/etc/yolo/CLAUDE.md` to avoid clobbering a project's own CLAUDE.md). + +Options: +1. **Workspace CLAUDE.md** — simple, but conflicts if the project has one +2. **`~/.claude/CLAUDE.md` inside the container** — user-level, no conflict +3. **Append to existing** — fragile, modifies user files +4. **`/etc/yolo/context.md` + symlink** — clean separation + +Option 2 seems cleanest: the container's `~/.claude/CLAUDE.md` is +generated fresh each launch, contains the yolo context, and doesn't +touch the project's CLAUDE.md. + +## What this looks like in practice + +Generated at launch, something like: + +```markdown +# Container Environment (yolo) + +You are running inside a yolo container — an isolated, ephemeral +Podman container for safe autonomous coding. + +## Installed tools +- Python 3.12 (system) +- git-delta 0.18.2 +- apt packages: fzf, gh, less, man-db, shellcheck, zsh + +## Mounted volumes +- /home/user/projects/myrepo → /workspace (read-write) +- /home/user/data → /data (read-only) + +## Important +- The container filesystem is ephemeral. Only files in mounted + volumes persist after exit. +- No SSH keys or host credentials are available. +- To add persistent tools, ask the user to add container-extras + to .yolo/config.yaml and rebuild. +``` + +## Edge: should we tell it about security constraints? + +Debatable. Telling Claude "you can't access SSH keys" is informational +and helps it avoid wasting turns. But explicitly listing security +boundaries could also be read as a challenge or a map of what to probe. + +Pragmatic take: state what's available, not what's restricted. "Here are +your tools and mounts" naturally implies everything else is absent. +Don't enumerate attack surface. diff --git a/design/HACK_DECISIONS.md b/design/HACK_DECISIONS.md new file mode 100644 index 0000000..980a787 --- /dev/null +++ b/design/HACK_DECISIONS.md @@ -0,0 +1,70 @@ +# Design Decisions (Locked) + +Decisions recorded here are final. Stop revisiting them. + +## 1. Declarative config, not sourced bash + +Config files must be declarative, not sourced as bash scripts. Sourced +bash means a committed `.yolo/config` in a cloned repo executes arbitrary +code on the host before the container starts — a prompt-injection vector +where a compromised agent poisons config for the next invocation. + +## 2. Config format: YAML + +YAML because: +- Scientists already read it (conda, Jupyter, CI files) +- Native arrays and nesting +- No arbitrary code execution +- Clean, readable syntax + +No env var override mechanism — CLI flags already cover that use case. + +## 3. Config file locations + +Precedence (later overrides earlier): + +1. `/etc/yolo/config.yaml` — system-wide (org defaults) +2. `${XDG_CONFIG_HOME:-~/.config}/yolo/config.yaml` — user preferences +3. `.yolo/config.yaml` — project, committed to git (shareable) +4. `.git/yolo/config.yaml` — project, local/untracked (personal) + +CLI args override everything. + +## 4. CLI in Python, distributed via PyPI + +Package name: `con-yolo` (TODO: contact maintainers to get `yolo`). +Recommend install via `uv tool install con-yolo`. + +Python because: +- Target users (scientists) already know it +- Contributors can read and modify it +- pytest for testing +- click/typer for CLI + +## 5. Container-extras contract: one format, env vars + +Every entry uses the `name:` form. All params become env vars +prefixed with `YOLO_{NAME}_{KEY}` uppercased: + +```yaml +image-extras: + - name: apt + packages: [zsh, fzf] + - name: python + version: "3.12" + - name: datalad +``` + +→ `YOLO_APT_PACKAGES="zsh fzf" bash apt.sh` +→ `YOLO_PYTHON_VERSION=3.12 bash python.sh` +→ `bash datalad.sh` + +No prefix syntax (`apt:vim`), no simple dicts (`python: "3.12"`). +One format, one contract. Scripts validate their own env vars. + +## 6. Container runtime: podman-first + +Podman, specifically for rootless behavior (`--userns=keep-id`). The +architecture should allow other runtimes (docker, singularity/apptainer) +in the future, but podman is the starting point and the only one we +implement now. diff --git a/design/LEGACY_SPEC.md b/design/LEGACY_SPEC.md new file mode 100644 index 0000000..1b0e25c --- /dev/null +++ b/design/LEGACY_SPEC.md @@ -0,0 +1,437 @@ +# YOLO Specification + +## Overview + +YOLO runs Claude Code inside a rootless Podman container with +`--dangerously-skip-permissions`, relying on container isolation rather than +per-action approval to keep the host safe. + +## Components + +| Component | Path | Purpose | +|--------------------|------------------|--------------------------------------------------| +| `bin/yolo` | CLI wrapper | Parses args, loads config, invokes `podman run` | +| `setup-yolo.sh` | Setup script | Builds the container image and installs `bin/yolo` | +| `images/Dockerfile` | Image definition | Development environment with Claude Code | +| `config.example` | Template | Documented config file template | + +--- + +## 1. CLI: `bin/yolo` + +### Usage + +``` +yolo [OPTIONS] [-- CLAUDE_ARGS...] +``` + +Everything before `--` is routed to podman. Everything after `--` is routed to +claude. If no `--` is present, all positional arguments go to claude. + +### Flags + +| Flag | Default | Description | +|----------------------|----------|--------------------------------------------------------------| +| `-h`, `--help` | — | Show help and exit | +| `--anonymized-paths` | off | Use `/claude` and `/workspace` instead of host paths | +| `--entrypoint=CMD` | `claude` | Override container entrypoint | +| `--entrypoint CMD` | `claude` | Same, space-separated form | +| `--worktree=MODE` | `ask` | Git worktree handling: `ask`, `bind`, `skip`, `error` | +| `--nvidia` | off | Enable NVIDIA GPU passthrough via CDI | +| `--no-config` | off | Ignore all configuration files | +| `--install-config` | — | Create or display `.git/yolo/config` template, then exit | + +### Argument Routing + +1. Parse flags (`--help`, `--anonymized-paths`, etc.) consuming them from the argument list. +2. If `--` is found, everything after it becomes `CLAUDE_ARGS`. +3. Remaining arguments before `--` become `PODMAN_ARGS`. +4. If no `--` was found, all positional args are reassigned to `CLAUDE_ARGS` and `PODMAN_ARGS` is emptied. + +--- + +## 2. Configuration System + +### File Locations + +| Scope | Path | Precedence | +|-------------|--------------------------------------------|------------| +| User-wide | `${XDG_CONFIG_HOME:-~/.config}/yolo/config` | Lower | +| Per-project | `.git/yolo/config` | Higher | + +Both files are sourced as bash scripts. + +### Auto-creation + +On first run in a git repo, if `.git/yolo/config` does not exist, it is +auto-created from the built-in template and a message is printed to stderr. + +### Config Keys + +#### Arrays (merged: user-wide + project) + +| Key | Type | Description | +|------------------------|------------|------------------------------------| +| `YOLO_PODMAN_VOLUMES` | `string[]` | Volume mount specifications | +| `YOLO_PODMAN_OPTIONS` | `string[]` | Additional `podman run` options | +| `YOLO_CLAUDE_ARGS` | `string[]` | Arguments passed to claude | + +User-wide and project arrays are concatenated (user-wide first). + +#### Scalars (project overrides user-wide; CLI overrides both) + +| Key | Type | Default | Description | +|------------------------|----------|---------|--------------------------------| +| `USE_ANONYMIZED_PATHS` | `0\|1` | `0` | Use anonymized container paths | +| `USE_NVIDIA` | `0\|1` | `0` | Enable NVIDIA GPU passthrough | +| `WORKTREE_MODE` | `string` | `ask` | Git worktree handling mode | + +### Loading Order + +1. Parse CLI flags (sets defaults and overrides). +2. Source user-wide config (if exists and `--no-config` not set). +3. Locate `.git` directory (traverses up from `$PWD`; handles worktrees). +4. Auto-create `.git/yolo/config` if `.git/yolo/` directory doesn't exist. +5. Source project config (if exists). +6. Merge arrays: `user-wide + project`. +7. Expand volumes via `expand_volume()` and prepend to `PODMAN_ARGS`. +8. Prepend `YOLO_PODMAN_OPTIONS` to `PODMAN_ARGS`. +9. Prepend `YOLO_CLAUDE_ARGS` to `CLAUDE_ARGS`. + +--- + +## 3. Volume Mount Handling + +### Shorthand Expansion (`expand_volume`) + +| Input | Output | Rule | +|-------------------------|-------------------------------------|----------------------------------------------| +| `~/projects` | `$HOME/projects:$HOME/projects:Z` | 1-to-1 with `:Z` | +| `~/data::ro` | `$HOME/data:$HOME/data:ro` | 1-to-1 with custom options (no `:Z` appended) | +| `/host:/container` | `/host:/container:Z` | Partial form, `:Z` appended | +| `/host:/container:opts` | `/host:/container:opts` | Full form, passed through unchanged | + +Tilde (`~`) is expanded to `$HOME` in shorthand and `::` forms. + +### Default Mounts + +| Mount | Host Path | Container Path | Options | +|---------------|----------------------|------------------------------|----------------------| +| Claude home | `~/.claude` | `~/.claude` or `/claude` | `:Z` (rw) | +| Git config | `~/.gitconfig` | `/tmp/.gitconfig` | `ro,Z` | +| Workspace | `$(pwd)` | `$(pwd)` or `/workspace` | `:Z` (rw) | +| Worktree repo | `$original_repo_dir` | `$original_repo_dir` | `:Z` (rw, conditional) | + +The `~/.claude` directory is auto-created if missing. + +--- + +## 4. Path Modes + +### Preserved Paths (default) + +| Variable | Value | +|-------------------|---------------------------------| +| `CLAUDE_DIR` | `$HOME/.claude` | +| `WORKSPACE_DIR` | `$(pwd)` | +| `CLAUDE_MOUNT` | `$HOME/.claude:$HOME/.claude:Z` | +| `WORKSPACE_MOUNT` | `$(pwd):$(pwd):Z` | + +Sessions are compatible between container and native Claude Code. + +### Anonymized Paths (`--anonymized-paths`) + +| Variable | Value | +|-------------------|----------------------------| +| `CLAUDE_DIR` | `/claude` | +| `WORKSPACE_DIR` | `/workspace` | +| `CLAUDE_MOUNT` | `$HOME/.claude:/claude:Z` | +| `WORKSPACE_MOUNT` | `$(pwd):/workspace:Z` | + +All projects appear at `/workspace`, enabling cross-project session context. + +--- + +## 5. Git Worktree Support + +### Detection + +1. If `.git` is a symlink: resolve via `realpath`. +2. If `.git` is a file: parse `gitdir: ` line. +3. Resolve relative gitdir paths to absolute. +4. Match pattern `^(.+/\.git)/worktrees/` to identify worktree. +5. Only flag as worktree if original repo dir differs from `$(pwd)`. + +### Handling Modes + +| Mode | Behavior | +|---------|--------------------------------------------------| +| `ask` | Prompt user; warn about security implications | +| `bind` | Automatically mount original repo | +| `skip` | Do not mount original repo; continue normally | +| `error` | Exit with error if worktree detected | + +When mounted, the original repo is bind-mounted at its host path with `:Z`. + +--- + +## 6. Container Naming + +``` +name=$( echo "$PWD-$$" | sed -e "s,^$HOME/,,g" -e "s,[^a-zA-Z0-9_.-],_,g" -e "s,^[._]*,," ) +``` + +- Strips `$HOME/` prefix. +- Replaces non-alphanumeric characters with `_`. +- Strips leading periods and underscores (not allowed by podman). +- Appends PID for uniqueness. + +--- + +## 7. NVIDIA GPU Support + +### Prerequisites + +1. `nvidia-container-toolkit` installed on host. +2. CDI spec generated: `sudo nvidia-ctk cdi generate --output=/etc/cdi/nvidia.yaml`. + +### Behavior + +When `USE_NVIDIA=1`: + +1. Check for CDI spec at `/etc/cdi/nvidia.yaml` or `/var/run/cdi/nvidia.yaml`. +2. Warn to stderr if not found (does not fail). +3. Add `--device nvidia.com/gpu=all` to podman args. +4. Add `--security-opt label=disable` to allow GPU device access with SELinux. + +--- + +## 8. Container Runtime + +### Fixed `podman run` Arguments + +| Argument | Value | Purpose | +|----------------|----------------------|------------------------------------| +| `--log-driver` | `none` | No container logging | +| `-it` | — | Interactive + TTY | +| `--rm` | — | Auto-remove on exit | +| `--user` | `$(id -u):$(id -g)` | Run as host user | +| `--userns` | `keep-id` | Map host user ID into container | +| `--name` | generated | Container name from PWD + PID | +| `-w` | `$WORKSPACE_DIR` | Working directory | + +### Environment Variables + +| Variable | Value | Purpose | +|------------------------------------------|--------------------|-------------------------------| +| `CLAUDE_CONFIG_DIR` | `$CLAUDE_DIR` | Claude config location | +| `GIT_CONFIG_GLOBAL` | `/tmp/.gitconfig` | Git identity | +| `CLAUDE_CODE_OAUTH_TOKEN` | passthrough | Auth token (if set on host) | +| `CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS` | passthrough | Agent teams (if set on host) | + +### Container Command + +| Entrypoint | Command | +|--------------------------|------------------------------------------------------------| +| Default (`claude`) | `claude --dangerously-skip-permissions [CLAUDE_ARGS]` | +| Custom (`--entrypoint=X`) | `X [CLAUDE_ARGS]` (no `--dangerously-skip-permissions`) | + +### Image + +`con-bomination-claude-code` + +--- + +## 9. Container Image (`images/Dockerfile`) + +### Base + +`node:22` + +### Init Process + +`tini` (PID 1) — reaps zombie processes from forked children. + +``` +ENTRYPOINT ["/usr/bin/tini", "--"] +CMD ["claude"] +``` + +### Non-root User + +Runs as `node` user (UID typically 1000). Host UID mapped via `--userns=keep-id`. + +### Core Packages + +dnsutils, fzf, gh, git, gnupg2, iproute2, jq, less, man-db, mc, moreutils, +nano, ncdu, parallel, procps, shellcheck, sudo, tini, tree, unzip, vim, zsh + +### Always-installed Tools + +| Tool | Install Method | +|----------------------|---------------------------------------------------------------| +| Claude Code | `npm install -g @anthropic-ai/claude-code@${CLAUDE_CODE_VERSION}` | +| git-delta | deb package from GitHub release (v0.18.2) | +| git-annex | `uv tool install git-annex` | +| uv | curl installer from astral.sh | +| zsh + powerlevel10k | zsh-in-docker v1.2.0 with git, fzf plugins | + +### Build Arguments + +| Arg | Default | Description | +|------------------------|----------|--------------------------------------------| +| `TZ` | from host | Timezone | +| `CLAUDE_CODE_VERSION` | `latest` | Claude Code npm version | +| `EXTRA_PACKAGES` | `""` | Space-separated apt packages | +| `EXTRA_CUDA` | `""` | Set to `"1"` to enable CUDA toolkit | +| `EXTRA_PLAYWRIGHT` | `""` | Set to `"1"` to enable Playwright + Chromium | +| `EXTRA_DATALAD` | `""` | Set to `"1"` to enable DataLad | +| `EXTRA_JJ` | `""` | Set to `"1"` to enable Jujutsu | +| `JJ_VERSION` | `0.38.0` | Jujutsu version | +| `GIT_DELTA_VERSION` | `0.18.2` | git-delta version | +| `ZSH_IN_DOCKER_VERSION` | `1.2.0` | zsh-in-docker version | + +### Optional Extras + +| Extra | What's Installed | +|--------------|-------------------------------------------------------------------------| +| `cuda` | `nvidia-cuda-toolkit` (enables non-free/contrib apt sources) | +| `playwright` | System deps + `npm install -g playwright` + Chromium browser | +| `datalad` | `uv tool install --with datalad-container --with datalad-next datalad` | +| `jj` | Musl binary from GitHub release + zsh completion | + +### Container Environment + +| Variable | Value | +|----------------------|--------------------------------| +| `DEVCONTAINER` | `true` | +| `SHELL` | `/bin/zsh` | +| `EDITOR` | `vim` | +| `VISUAL` | `vim` | +| `NPM_CONFIG_PREFIX` | `/usr/local/share/npm-global` | +| `PATH` | Includes npm-global/bin, `~/.local/bin` | + +--- + +## 10. Setup Script: `setup-yolo.sh` + +### Usage + +``` +setup-yolo.sh [OPTIONS] +``` + +### Flags + +| Flag | Default | Values | Description | +|--------------------|---------|----------------------------------------------|------------------------| +| `-h`, `--help` | — | — | Show help and exit | +| `--build=MODE` | `auto` | `auto`, `yes`, `no` | Image build control | +| `--install=MODE` | `auto` | `auto`, `yes`, `no` | Script install control | +| `--packages=PKGS` | `""` | comma/space-separated | Extra apt packages | +| `--extras=EXTRAS` | `""` | `cuda`, `playwright`, `datalad`, `jj`, `all` | Predefined extras | + +### Build Behavior + +| Mode | Image Exists | Image Missing | +|--------|--------------|---------------| +| `auto` | Skip | Build | +| `yes` | Rebuild | Build | +| `no` | OK | Error | + +### Install Behavior + +Installs `bin/yolo` to `$HOME/.local/bin/yolo`. + +| Mode | Script Exists | Script Missing | +|--------|----------------------------------------|--------------------| +| `auto` | Prompt if differs; skip if identical | Prompt to install | +| `yes` | Overwrite | Install | +| `no` | Skip | Skip | + +After install, checks if `~/.local/bin` is in `$PATH` and warns if not. + +### Build Arguments Passed + +- `TZ` from `timedatectl` (falls back to `UTC`). +- `EXTRA_PACKAGES` (space-separated). +- Each extra as `EXTRA_$(UPPERCASE)=1`. + +--- + +## 11. Security Boundaries + +### Mounted (accessible inside container) + +- `~/.claude` — credentials, session history (read-write) +- `~/.gitconfig` — git identity (read-only) +- `$(pwd)` — current project (read-write) +- Additional volumes from `YOLO_PODMAN_VOLUMES` config +- Original git repo (only if worktree mode permits) + +### Not Mounted (inaccessible) + +- `~/.ssh` — SSH keys (prevents `git push` by design) +- `~/.gnupg` — GPG keys (unless explicitly mounted) +- `~/.aws`, `~/.kube`, etc. — cloud credentials +- Rest of the filesystem + +### Isolation Mechanisms + +| Mechanism | Technology | What It Protects | +|------------------|--------------------|--------------------------------| +| Filesystem | Podman mount-only | Only mounted dirs visible | +| User namespace | `--userns=keep-id` | No privilege escalation | +| Process | Rootless podman | Isolated from host processes | +| Network | **None** | Unrestricted outbound access | + +### Deliberate Non-restrictions + +- Network access is unrestricted. The container can reach any host/port. +- `--dangerously-skip-permissions` auto-approves all Claude actions within the container. + +--- + +## 12. Testing + +### Framework + +BATS (Bash Automated Testing System) with `bats-assert` and `bats-support`. + +### Test Infrastructure + +- Mock podman binary captures all arguments to a file for inspection. +- Isolated test environment: `$BATS_TEST_TMPDIR` with fake `$HOME`, git repo, and PATH. +- Helper functions: `run_yolo()`, `get_podman_args()`, `podman_args_contain()`, `refute_podman_arg()`, `write_user_config()`, `write_project_config()`. +- `bin/yolo` is sourceable without side effects via `BASH_SOURCE` guard. + +### Test Coverage + +- Volume expansion (shorthand, options, full form, partial form) +- All CLI flags (`--help`, `--no-config`, `--anonymized-paths`, `--nvidia`, `--entrypoint`, `--worktree`) +- Argument routing (with and without `--` separator) +- Configuration loading and merging (user + project arrays, scalar overrides) +- `XDG_CONFIG_HOME` override +- Environment variable passthrough +- Container naming +- Config template generation + +--- + +## 13. CI/CD + +### Triggers + +- Push to `main`. +- Pull requests targeting `main`. + +### Jobs + +| Job | Runner | What It Does | +|--------------|-----------------------------|------------------------------------------------------| +| ShellCheck | ubuntu-latest | Lints `setup-yolo.sh` and `bin/yolo` | +| Unit Tests | ubuntu-latest, macos-latest | Runs BATS test suite | +| Test Setup | ubuntu-latest | Builds image via `setup-yolo.sh`, verifies syntax | +| Integration | ubuntu-latest | Full build + `podman run --rm ... claude --help` | + +Integration test depends on ShellCheck and Test Setup passing. diff --git a/design/legacy/.github-ci.yml b/design/legacy/.github-ci.yml new file mode 100644 index 0000000..78fc41e --- /dev/null +++ b/design/legacy/.github-ci.yml @@ -0,0 +1,129 @@ +name: CI + +on: + push: + branches: [ main, enhs ] + pull_request: + branches: [ main ] + +jobs: + shellcheck: + name: ShellCheck + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Run shellcheck on setup-yolo.sh + run: shellcheck setup-yolo.sh + + - name: Run shellcheck on bin/yolo + run: shellcheck bin/yolo + + unit-tests: + name: Unit Tests (BATS) + strategy: + matrix: + os: [ubuntu-latest, macos-latest] + runs-on: ${{ matrix.os }} + steps: + - name: Checkout code + uses: actions/checkout@v4 + with: + submodules: true + + - name: Install bats + run: | + if command -v brew &>/dev/null; then + brew install bats-core + else + sudo apt-get update && sudo apt-get install -y bats + fi + + - name: Run tests + run: bats tests/ + + test-setup: + name: Test Setup Script + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Install podman + run: | + sudo apt-get update + sudo apt-get install -y podman + + - name: Test setup-yolo.sh (build only) + run: | + # Run setup with no input to skip installation prompt + # This will build the image and exit when asking about installation + echo "n" | ./setup-yolo.sh || true + + # Verify the image was built + podman image exists con-bomination-claude-code + + - name: Verify yolo script syntax + run: | + bash -n bin/yolo + echo "✓ Yolo script has valid syntax" + + - name: Test yolo script help (dry run) + run: | + # Create a mock podman command that just echoes what would run + mkdir -p ~/test-bin + cat > ~/test-bin/podman << 'EOF' + #!/bin/bash + echo "PODMAN COMMAND:" + echo "podman $@" + exit 0 + EOF + chmod +x ~/test-bin/podman + + # Add to PATH and test + export PATH="$HOME/test-bin:$PATH" + + # Verify our mock is used + which podman + + # Test various yolo invocations + echo "Testing: yolo" + ./bin/yolo || true + + echo "Testing: yolo -- \"help with code\"" + ./bin/yolo -- "help with code" || true + + echo "Testing: yolo -v /data:/data --" + ./bin/yolo -v /data:/data -- || true + + integration-test: + name: Integration Test + runs-on: ubuntu-latest + needs: [shellcheck, test-setup] + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Install podman + run: | + sudo apt-get update + sudo apt-get install -y podman + + - name: Run full setup (automated) + run: | + # Answer 'no' to installation prompt + echo "n" | ./setup-yolo.sh + + - name: Verify container image + run: | + podman images + podman image exists con-bomination-claude-code + echo "✓ Container image successfully built" + + - name: Test container can start + run: | + # Test that the container starts and can run a basic command + # We'll use --help which doesn't require authentication + podman run --rm con-bomination-claude-code claude --help + echo "✓ Container runs successfully" diff --git a/design/legacy/README.md b/design/legacy/README.md new file mode 100644 index 0000000..7c88cb3 --- /dev/null +++ b/design/legacy/README.md @@ -0,0 +1,232 @@ +# Running Claude Code in a Container + +This guide shows how to run claude-code in a Podman container while preserving your configuration and working directory access. + +## Easy Setup (Recommended) + +Clone the repository and run the setup script to build the container and optionally create a `YOLO` command: + +```bash +git clone https://github.com/con/yolo +cd yolo +./setup-yolo.sh +``` + +This will: +1. Build the container image if it doesn't exist +2. Optionally create a `YOLO` shell function +3. Configure everything for you + +After setup, just run `yolo` from any directory to start Claude Code in YOLO mode! + +By default, `yolo` preserves your original host paths to ensure session compatibility with native Claude Code. This means: +- Your `~/.claude` directory is mounted at its original path +- Your current directory is mounted at its original path (not `/workspace`) +- Sessions created in the container can be resumed in your native environment and vice versa + +If you prefer the old behavior with anonymized paths (`/claude` and `/workspace`), use the `--anonymized-paths` flag: +```bash +yolo --anonymized-paths +``` + +### Git Worktree Support + +When running in a git worktree, `yolo` can detect and optionally bind mount the original repository. This allows Claude to access git objects and perform operations like commit and fetch. Control this behavior with the `--worktree` option: + +- `--worktree=ask` (default): Prompts whether to bind mount the original repo +- `--worktree=bind`: Automatically bind mounts the original repo +- `--worktree=skip`: Skip bind mounting and continue normally +- `--worktree=error`: Exit with error if running in a worktree + +```bash +# Prompt for bind mount decision (default) +yolo + +# Always bind mount in worktrees +yolo --worktree=bind + +# Skip bind mounting, continue normally +yolo --worktree=skip + +# Disallow running in worktrees +yolo --worktree=error +``` + +**Security note**: Bind mounting the original repo exposes more files and allows modifications. The prompt helps prevent unintended access. + +### Project Configuration + +You can create a per-project configuration file to avoid repeating command line options. The config is auto-created on first run, or you can use `yolo --install-config`: + +```bash +# Auto-creates .git/yolo/config on first run in a git repo +yolo + +# Or manually install/view config +yolo --install-config + +# Edit with your preferences +vi .git/yolo/config +``` + +The configuration file is stored in `.git/yolo/` which means: +- It won't be tracked by git +- It won't be destroyed by `git clean` +- It works correctly with git worktrees (they all reference the same `.git` directory) + +**Example configuration** (`.git/yolo/config`): +```bash +# Volume mounts with shorthand syntax +YOLO_PODMAN_VOLUMES=( + "~/projects" # Mounts ~/projects at same path in container + "~/data::ro" # Mounts ~/data read-only at same path +) + +# Additional podman options +YOLO_PODMAN_OPTIONS=( + "--env=DEBUG=1" +) + +# Arguments for claude +YOLO_CLAUDE_ARGS=( + "--model=claude-3-opus-20240229" +) + +# Default flags +USE_NVIDIA=1 +``` + +**Volume shorthand syntax:** +- `"~/projects"` → `~/projects:~/projects:Z` (1-to-1 mount) +- `"~/data::ro"` → `~/data:~/data:ro,Z` (1-to-1 with options) +- `"~/data:/data:Z"` → `~/data:/data:Z` (explicit, unchanged) + +Command line options always override configuration file settings. Use `--no-config` to ignore the configuration file entirely. + +See `config.example` for a complete configuration template with detailed comments. + +> **TODO**: Add curl-based one-liner setup once this PR is merged + +## First-Time Login + +On your first run, you'll need to authenticate: + +1. Claude Code will display a URL like `https://claude.ai/oauth/authorize?...` +2. Copy the URL and paste it into a browser on your host machine +3. Complete the authentication in your browser +4. Copy the code from the browser and paste it back into the container terminal + +Your credentials are stored in `~/.claude` on your host, so you only need to login once. Subsequent runs will use the stored credentials automatically. + +## Manual Setup + +If you prefer to run commands manually, first build the image from the `images/` directory: + +```bash +podman build --build-arg TZ=$(timedatectl show --property=Timezone --value) -t con-bomination-claude-code images/ +``` + +Then run (with original host paths preserved by default): + +```bash +podman run -it --rm \ + --userns=keep-id \ + -v "$HOME/.claude:$HOME/.claude:Z" \ + -v ~/.gitconfig:/tmp/.gitconfig:ro,Z \ + -v "$(pwd):$(pwd):Z" \ + -w "$(pwd)" \ + -e CLAUDE_CONFIG_DIR="$HOME/.claude" \ + -e GIT_CONFIG_GLOBAL=/tmp/.gitconfig \ + con-bomination-claude-code \ + claude --dangerously-skip-permissions +``` + +Or with anonymized paths (old behavior): + +```bash +podman run -it --rm \ + --userns=keep-id \ + -v ~/.claude:/claude:Z \ + -v ~/.gitconfig:/tmp/.gitconfig:ro,Z \ + -v "$(pwd):/workspace:Z" \ + -w /workspace \ + -e CLAUDE_CONFIG_DIR=/claude \ + -e GIT_CONFIG_GLOBAL=/tmp/.gitconfig \ + con-bomination-claude-code \ + claude --dangerously-skip-permissions +``` + +⚠️ **Note**: This uses `--dangerously-skip-permissions` to bypass all permission prompts. This is safe in containerized environments where the container provides isolation from your host system. + +## What's Included + +The Dockerfile is based on [Anthropic's official setup](https://github.com/anthropics/claude-code/blob/07e13937b2d6e798ce1880b22ad6bd22115478e4/.devcontainer/Dockerfile) and includes Claude Code CLI plus common development tools. See the Dockerfile for the complete list. + +## Command Breakdown + +### Default Behavior (Preserved Host Paths) + +- `--userns=keep-id`: Maps your host user ID inside the container so files are owned correctly +- `-v "$HOME/.claude:$HOME/.claude:Z"`: Bind mounts your Claude configuration directory at its original path with SELinux relabeling +- `-v ~/.gitconfig:/tmp/.gitconfig:ro,Z`: Mounts git config read-only for commits (push operations not supported) +- `-v "$(pwd):$(pwd):Z"`: Bind mounts your current working directory at its original path +- `-w "$(pwd)"`: Sets the working directory inside the container to match your host path +- `-e CLAUDE_CONFIG_DIR="$HOME/.claude"`: Tells Claude Code where to find its configuration (at original path) +- `-e GIT_CONFIG_GLOBAL=/tmp/.gitconfig`: Points git to the mounted config +- `claude --dangerously-skip-permissions`: Skips all permission prompts (safe in containers) +- `--rm`: Automatically removes the container when it exits +- `-it`: Interactive terminal + +This default behavior ensures that session histories and project paths are compatible between containerized and native Claude Code environments. + +### Anonymized Paths (Old Behavior with --anonymized-paths) + +When using `--anonymized-paths`, paths are mapped to generic container locations: +- `-v ~/.claude:/claude:Z`: Mounts to `/claude` in container +- `-v "$(pwd):/workspace:Z"`: Mounts to `/workspace` in container +- `-w /workspace`: Working directory is `/workspace` +- `-e CLAUDE_CONFIG_DIR=/claude`: Config directory is `/claude` + +## Tips + +1. **Persist configuration**: The `~/.claude` bind mount ensures your settings, API keys, and session history persist between container runs + +2. **Session compatibility**: By default, paths are preserved to match your host environment. This means: + - Sessions created in the container can be resumed outside the container using `claude --continue` in your native environment + - Each project maintains its own session history based on its actual path (e.g., `/home/user/project`) + - You can seamlessly switch between containerized and native Claude Code for the same project + + **Note**: With `--anonymized-paths`, all projects appear to be in `/workspace`, which allows `claude --continue` to retain context across different projects that were also run with this flag. This can be useful for maintaining conversation context when working on related codebases. + +3. **File ownership**: The `--userns=keep-id` flag ensures files created or modified inside the container will be owned by your host user, regardless of your UID + +4. **Git operations**: Git config is mounted read-only, so Claude Code can read your identity and make commits. However, **SSH keys are not mounted**, so `git push` operations will fail. You'll need to push from your host after Claude Code commits your changes. + +5. **Multiple directories**: Mount additional directories as needed: + ```bash + yolo -v ~/projects:~/projects -v ~/data:~/data -- "help with this code" + ``` + Or with anonymized paths: + ```bash + yolo --anonymized-paths -v ~/projects:/projects -v ~/data:/data -- "help with this code" + ``` + +## Security Considerations + +YOLO mode runs Claude Code with `--dangerously-skip-permissions`, providing unrestricted command execution within the container. The container provides isolation through: + +- **Filesystem boundaries**: Only `~/.claude`, `~/.gitconfig`, and your current working directory are accessible to Claude +- **Process isolation**: Rootless podman user namespace isolation (`--userns=keep-id`) +- **Limited host access**: SSH keys and other sensitive files are not mounted + +**What is NOT restricted:** + +- **Network access**: Claude can make arbitrary network connections from within the container (to package registries, APIs, external services, etc.) + +**When to be cautious:** + +- **Untrusted repositories**: Malicious code comments or documentation could exploit prompt injection to trick Claude into executing harmful commands or exfiltrating data +- Mounting directories with sensitive data (credentials, private keys, confidential files) +- Projects that access production systems or databases + +**For higher security needs**, consider running untrusted code in a separate container without mounting sensitive directories, or wait for integration with Anthropic's modern sandbox runtime which provides network-level restrictions. diff --git a/bin/yolo b/design/legacy/bin/yolo similarity index 100% rename from bin/yolo rename to design/legacy/bin/yolo diff --git a/config.example b/design/legacy/config.example similarity index 100% rename from config.example rename to design/legacy/config.example diff --git a/images/Dockerfile b/design/legacy/images/Dockerfile similarity index 100% rename from images/Dockerfile rename to design/legacy/images/Dockerfile diff --git a/setup-yolo.sh b/design/legacy/setup-yolo.sh similarity index 100% rename from setup-yolo.sh rename to design/legacy/setup-yolo.sh diff --git a/design/legacy/test-arg-parsing-fixed.sh b/design/legacy/test-arg-parsing-fixed.sh new file mode 100755 index 0000000..932c505 --- /dev/null +++ b/design/legacy/test-arg-parsing-fixed.sh @@ -0,0 +1,59 @@ +#!/bin/bash +# Test script with FIXED argument parsing logic + +echo "=== Testing FIXED argument parsing logic ===" +echo + +test_args() { + echo "Test: $@" + echo "---" + + PODMAN_ARGS=() + CLAUDE_ARGS=() + found_separator=0 + USE_GLOBAL_CLAUDE=0 + + for arg in "$@"; do + if [ "$arg" = "--global-claude" ]; then + USE_GLOBAL_CLAUDE=1 + elif [ "$found_separator" -eq 0 ] && [ "$arg" = "--" ]; then # FIXED: exact match + found_separator=1 + echo " Separator found at: '$arg'" + elif [ "$found_separator" -eq 1 ]; then + CLAUDE_ARGS+=("$arg") + else + PODMAN_ARGS+=("$arg") + fi + done + + if [ "$found_separator" = 0 ]; then + CLAUDE_ARGS=("${PODMAN_ARGS[@]}") + PODMAN_ARGS=() + fi + + echo " PODMAN_ARGS: ${PODMAN_ARGS[*]}" + echo " CLAUDE_ARGS: ${CLAUDE_ARGS[*]}" + echo " USE_GLOBAL_CLAUDE: $USE_GLOBAL_CLAUDE" + echo +} + +echo "TEST 1: Basic separator" +test_args -- "help me" + +echo "TEST 2: FIXED - --rm should go to podman" +test_args --rm -- "help me" + +echo "TEST 3: FIXED - --env should go to podman" +test_args --env FOO=bar -- "process files" + +echo "TEST 4: FIXED - Multiple podman flags" +test_args -v /tmp:/tmp --network host -- "debug this" + +echo "TEST 5: FIXED - With --global-claude flag" +test_args --global-claude --rm -- "help" + +echo "TEST 6: No separator - all args go to claude" +test_args "just help me" + +echo "TEST 7: No separator with flags - all go to claude" +test_args "help with --verbose output" diff --git a/design/legacy/test-arg-parsing.sh b/design/legacy/test-arg-parsing.sh new file mode 100755 index 0000000..134e0a8 --- /dev/null +++ b/design/legacy/test-arg-parsing.sh @@ -0,0 +1,53 @@ +#!/bin/bash +# Test script to demonstrate argument parsing behavior + +echo "=== Testing argument parsing logic ===" +echo + +test_args() { + echo "Test: $@" + echo "---" + + PODMAN_ARGS=() + CLAUDE_ARGS=() + found_separator=0 + USE_GLOBAL_CLAUDE=0 + + for arg in "$@"; do + if [ "$arg" = "--global-claude" ]; then + USE_GLOBAL_CLAUDE=1 + elif [ "$found_separator" -eq 0 ] && [ "${arg:0:2}" = "--" ]; then + found_separator=1 + echo " Separator found at: '$arg'" + elif [ "$found_separator" -eq 1 ]; then + CLAUDE_ARGS+=("$arg") + else + PODMAN_ARGS+=("$arg") + fi + done + + if [ "$found_separator" = 0 ]; then + CLAUDE_ARGS=("${PODMAN_ARGS[@]}") + PODMAN_ARGS=() + fi + + echo " PODMAN_ARGS: ${PODMAN_ARGS[*]}" + echo " CLAUDE_ARGS: ${CLAUDE_ARGS[*]}" + echo " USE_GLOBAL_CLAUDE: $USE_GLOBAL_CLAUDE" + echo +} + +echo "TEST 1: Expected behavior - should work" +test_args -- "help me" + +echo "TEST 2: Bug demonstration - --rm should go to podman, not be separator" +test_args --rm -- "help me" + +echo "TEST 3: Another example with --env" +test_args --env FOO=bar -- "process files" + +echo "TEST 4: Multiple podman flags" +test_args -v /tmp:/tmp --network host -- "debug this" + +echo "TEST 5: With --global-claude flag" +test_args --global-claude --rm -- "help" diff --git a/design/legacy/test-worktree-feature.sh b/design/legacy/test-worktree-feature.sh new file mode 100755 index 0000000..7f2f3dc --- /dev/null +++ b/design/legacy/test-worktree-feature.sh @@ -0,0 +1,179 @@ +#!/bin/bash +# Test script for --worktree feature and argument parsing + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +YOLO="$SCRIPT_DIR/bin/yolo" + +pass=0 +fail=0 + +test_pass() { + echo "✓ PASS: $1" + ((pass++)) +} + +test_fail() { + echo "✗ FAIL: $1" + ((fail++)) +} + +echo "=== Testing script syntax ===" + +if bash -n "$YOLO"; then + test_pass "bin/yolo has valid bash syntax" +else + test_fail "bin/yolo has valid bash syntax" +fi + +echo "" +echo "=== Testing --worktree argument parsing ===" + +# Test: Invalid worktree value rejected +if $YOLO --worktree=invalid 2>&1 | grep -q "Invalid --worktree value"; then + test_pass "Invalid --worktree value rejected" +else + test_fail "Invalid --worktree value rejected" +fi + +# Test: --worktree=create (bare) rejected with helpful message +if $YOLO --worktree=create 2>&1 | grep -q "requires a branch name"; then + test_pass "--worktree=create (bare) rejected with helpful message" +else + test_fail "--worktree=create (bare) rejected with helpful message" +fi + +# Test: --worktree=create:branch passes validation (exits with different error, not "Invalid") +output=$(timeout 1 $YOLO --worktree=create:test-branch 2>&1 /dev/null || true +git branch -d test-branch 2>/dev/null || true + +# Test: Each valid mode passes validation (exits with different error, not "Invalid") +for mode in ask bind skip error; do + output=$(timeout 1 $YOLO --worktree=$mode 2>&1 &1 | grep -q "Running in a git worktree is not allowed"; then + test_pass "--worktree=error exits with error in worktree" + else + test_fail "--worktree=error exits with error in worktree" + fi +else + echo "Note: Current directory is NOT a worktree (.git is a directory)" + is_worktree=0 + + # Create a temporary worktree to test detection + echo "Creating temporary worktree for testing..." + temp_dir=$(mktemp -d) + worktree_dir="$temp_dir/test-worktree" + + if git worktree add "$worktree_dir" HEAD 2>/dev/null; then + test_pass "Created temporary worktree" + + # Test --worktree=error in the temporary worktree + cd "$worktree_dir" + if "$YOLO" --worktree=error 2>&1 | grep -q "Running in a git worktree is not allowed"; then + test_pass "--worktree=error exits with error in worktree" + else + test_fail "--worktree=error exits with error in worktree" + fi + cd "$SCRIPT_DIR" + + # Cleanup + git worktree remove "$worktree_dir" 2>/dev/null || rm -rf "$worktree_dir" + rmdir "$temp_dir" 2>/dev/null || true + else + echo "Could not create temporary worktree, skipping worktree detection tests" + fi +fi + +echo "" +echo "=== Testing --entrypoint option ===" + +# Test --entrypoint with space syntax (just parsing, will timeout before podman) +output=$(timeout 1 $YOLO --worktree=error --entrypoint testcmd 2>&1 &1 &1 /dev/null && podman image exists con-bomination-claude-code 2>/dev/null; then + echo "Container available, running integration tests..." + + # Determine worktree flag to use (ok if in worktree, omit otherwise) + if [ "$is_worktree" -eq 1 ]; then + wt_flag="--worktree=skip" + else + wt_flag="" + fi + + # Test: --entrypoint echo works + if timeout 30 $YOLO $wt_flag --entrypoint echo -- "integration test" 2>&1 | grep -q "integration test"; then + test_pass "--entrypoint echo works" + else + test_fail "--entrypoint echo works" + fi + + # Test: --entrypoint bash works + if timeout 30 $YOLO $wt_flag --entrypoint bash -- -c "echo hello" 2>&1 | grep -q "hello"; then + test_pass "--entrypoint bash works" + else + test_fail "--entrypoint bash works" + fi +else + echo "Skipping integration tests (podman or image not available)" +fi + +echo "" +echo "=== Summary ===" +echo "Passed: $pass" +echo "Failed: $fail" + +if [ "$fail" -gt 0 ]; then + exit 1 +fi diff --git a/tests/test_helper/common.bash b/design/legacy/tests/test_helper/common.bash similarity index 100% rename from tests/test_helper/common.bash rename to design/legacy/tests/test_helper/common.bash diff --git a/tests/yolo.bats b/design/legacy/tests/yolo.bats similarity index 100% rename from tests/yolo.bats rename to design/legacy/tests/yolo.bats diff --git a/image-extras/apt.sh b/image-extras/apt.sh new file mode 100644 index 0000000..dec972b --- /dev/null +++ b/image-extras/apt.sh @@ -0,0 +1,17 @@ +#!/bin/bash +# Install apt packages +# Env: YOLO_APT_PACKAGES (space-separated, required) +set -eu + +if [ "${YOLO_VERIFY:-}" = "1" ]; then + YOLO_APT_PACKAGES="figlet" +else + [ -z "${YOLO_APT_PACKAGES:-}" ] && { echo "apt.sh: YOLO_APT_PACKAGES required"; exit 1; } +fi + +# shellcheck disable=SC2086 # intentional word splitting +sudo apt-get install -y --no-install-recommends $YOLO_APT_PACKAGES + +if [ "${YOLO_VERIFY:-}" = "1" ]; then + command -v figlet || { echo "FAIL: figlet not found after install"; exit 1; } +fi diff --git a/image-extras/datalad.sh b/image-extras/datalad.sh new file mode 100644 index 0000000..5717172 --- /dev/null +++ b/image-extras/datalad.sh @@ -0,0 +1,16 @@ +#!/bin/bash +# Install DataLad with extensions via uv tool +# Env: none required +set -eu + +if ! command -v uv &>/dev/null; then + curl -LsSf https://astral.sh/uv/install.sh | sh + export PATH="$HOME/.local/bin:$PATH" +fi + +uv tool install --with datalad-container --with datalad-next datalad +uv cache clean + +if [ "${YOLO_VERIFY:-}" = "1" ]; then + datalad --version || { echo "FAIL: datalad not found"; exit 1; } +fi diff --git a/image-extras/git-delta.sh b/image-extras/git-delta.sh new file mode 100644 index 0000000..326e075 --- /dev/null +++ b/image-extras/git-delta.sh @@ -0,0 +1,19 @@ +#!/bin/bash +# Install git-delta from GitHub releases +# Env: YOLO_GIT_DELTA_VERSION (required) +set -eu + +if [ "${YOLO_VERIFY:-}" = "1" ]; then + YOLO_GIT_DELTA_VERSION="0.18.2" +else + [ -z "${YOLO_GIT_DELTA_VERSION:-}" ] && { echo "git-delta.sh: YOLO_GIT_DELTA_VERSION required"; exit 1; } +fi + +ARCH=$(dpkg --print-architecture) +wget -q "https://github.com/dandavison/delta/releases/download/${YOLO_GIT_DELTA_VERSION}/git-delta_${YOLO_GIT_DELTA_VERSION}_${ARCH}.deb" +sudo dpkg -i "git-delta_${YOLO_GIT_DELTA_VERSION}_${ARCH}.deb" +rm "git-delta_${YOLO_GIT_DELTA_VERSION}_${ARCH}.deb" + +if [ "${YOLO_VERIFY:-}" = "1" ]; then + delta --version || { echo "FAIL: delta not found"; exit 1; } +fi diff --git a/image-extras/jj.sh b/image-extras/jj.sh new file mode 100644 index 0000000..43497b4 --- /dev/null +++ b/image-extras/jj.sh @@ -0,0 +1,26 @@ +#!/bin/bash +# Install Jujutsu (jj) from GitHub releases +# Env: YOLO_JJ_VERSION (required) +set -eu + +if [ "${YOLO_VERIFY:-}" = "1" ]; then + YOLO_JJ_VERSION="0.38.0" +else + [ -z "${YOLO_JJ_VERSION:-}" ] && { echo "jj.sh: YOLO_JJ_VERSION required"; exit 1; } +fi + +ARCH=$(uname -m) +wget -qO /tmp/jj.tar.gz "https://github.com/jj-vcs/jj/releases/download/v${YOLO_JJ_VERSION}/jj-v${YOLO_JJ_VERSION}-${ARCH}-unknown-linux-musl.tar.gz" +mkdir -p ~/.local/bin +tar -xzf /tmp/jj.tar.gz -C ~/.local/bin ./jj +rm /tmp/jj.tar.gz + +# zsh completions +if command -v zsh &>/dev/null; then + mkdir -p ~/.zfunc + ~/.local/bin/jj util completion zsh > ~/.zfunc/_jj +fi + +if [ "${YOLO_VERIFY:-}" = "1" ]; then + jj version || { echo "FAIL: jj not found"; exit 1; } +fi diff --git a/image-extras/pip.sh b/image-extras/pip.sh new file mode 100644 index 0000000..36268ec --- /dev/null +++ b/image-extras/pip.sh @@ -0,0 +1,25 @@ +#!/bin/bash +# Install pip packages via uv tool +# Env: YOLO_PIP_PACKAGES (space-separated, required) +set -eu + +if [ "${YOLO_VERIFY:-}" = "1" ]; then + YOLO_PIP_PACKAGES="cowsay" +else + [ -z "${YOLO_PIP_PACKAGES:-}" ] && { echo "pip.sh: YOLO_PIP_PACKAGES required"; exit 1; } +fi + +if ! command -v uv &>/dev/null; then + curl -LsSf https://astral.sh/uv/install.sh | sh + export PATH="$HOME/.local/bin:$PATH" +fi + +# shellcheck disable=SC2086 # intentional word splitting +for pkg in $YOLO_PIP_PACKAGES; do + uv tool install "$pkg" +done +uv cache clean + +if [ "${YOLO_VERIFY:-}" = "1" ]; then + command -v cowsay || { echo "FAIL: cowsay not found after install"; exit 1; } +fi diff --git a/image-extras/playwright.sh b/image-extras/playwright.sh new file mode 100644 index 0000000..12a6444 --- /dev/null +++ b/image-extras/playwright.sh @@ -0,0 +1,16 @@ +#!/bin/bash +# Install Playwright with Chromium +# Env: none required +set -eu + +sudo apt-get install -y --no-install-recommends nodejs npm + +# System deps (needs root) +sudo npx playwright install-deps chromium + +sudo npm install -g playwright +npx playwright install chromium + +if [ "${YOLO_VERIFY:-}" = "1" ]; then + npx playwright --version || { echo "FAIL: playwright not found"; exit 1; } +fi diff --git a/image-extras/python.sh b/image-extras/python.sh new file mode 100644 index 0000000..2a5c8bc --- /dev/null +++ b/image-extras/python.sh @@ -0,0 +1,33 @@ +#!/bin/bash +# Install Python via uv +# Env: YOLO_PYTHON_VERSION (optional, default: latest stable) +set -eu + +if [ "${YOLO_VERIFY:-}" = "1" ]; then + YOLO_PYTHON_VERSION="3.12" +fi + +# Install uv if not present +if ! command -v uv &>/dev/null; then + curl -LsSf https://astral.sh/uv/install.sh | sh + export PATH="$HOME/.local/bin:$PATH" +fi + +if [ -n "${YOLO_PYTHON_VERSION:-}" ]; then + uv python install "$YOLO_PYTHON_VERSION" +else + uv python install +fi + +# Create python3 and python symlinks +PYTHON_BIN="$(uv python find ${YOLO_PYTHON_VERSION:+$YOLO_PYTHON_VERSION} 2>/dev/null)" +if [ -n "$PYTHON_BIN" ]; then + mkdir -p "$HOME/.local/bin" + ln -sf "$PYTHON_BIN" "$HOME/.local/bin/python3" + ln -sf "$PYTHON_BIN" "$HOME/.local/bin/python" +fi + +if [ "${YOLO_VERIFY:-}" = "1" ]; then + python --version || { echo "FAIL: python not found"; exit 1; } + python3 --version || { echo "FAIL: python3 not found"; exit 1; } +fi diff --git a/images/Containerfile.base b/images/Containerfile.base new file mode 100644 index 0000000..999660e --- /dev/null +++ b/images/Containerfile.base @@ -0,0 +1,36 @@ +# Minimal base image for yolo: Claude Code + essential tools +# No language runtimes, no opinions — those come from container-extras +FROM debian:bookworm + +ARG TZ +ENV TZ="$TZ" + +RUN apt-get update && apt-get install -y --no-install-recommends \ + ca-certificates \ + curl \ + git \ + jq \ + sudo \ + tini \ + tree \ + vim \ + wget \ + && apt-get clean && rm -rf /var/lib/apt/lists/* + +# Non-root user +RUN useradd -m -s /bin/bash -G sudo yolo && \ + echo "yolo ALL=(ALL) NOPASSWD:ALL" >> /etc/sudoers.d/yolo + +RUN mkdir -p /workspace && chown yolo:yolo /workspace +WORKDIR /workspace + +USER yolo + +# Claude Code +ARG CLAUDE_CODE_VERSION=stable +RUN curl -fsSL https://claude.ai/install.sh | bash -s ${CLAUDE_CODE_VERSION} +ENV DISABLE_AUTOUPDATER=1 +ENV PATH="/home/yolo/.local/bin:$PATH" + +ENTRYPOINT ["/usr/bin/tini", "--"] +CMD ["claude"] diff --git a/images/Containerfile.extras b/images/Containerfile.extras new file mode 100644 index 0000000..df35d29 --- /dev/null +++ b/images/Containerfile.extras @@ -0,0 +1,16 @@ +# Layer container-extras on top of yolo-base +# yolo assembles a build context with scripts and a run.sh manifest +ARG BASE_IMAGE=yolo-base +FROM ${BASE_IMAGE} + +USER root +RUN apt-get update + +USER yolo +COPY build/ /tmp/yolo-build/ +RUN bash /tmp/yolo-build/run.sh + +USER root +RUN apt-get clean && rm -rf /var/lib/apt/lists/* /tmp/yolo-build + +USER yolo diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..0c73454 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,32 @@ +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[project] +name = "con-yolo" +version = "0.1.0" +description = "Run Claude Code safely in a container with full autonomy" +requires-python = ">=3.11" +dependencies = [ + "click", + "ruamel.yaml", +] + +[project.optional-dependencies] +dev = [ + "pytest", + "pre-commit", +] + +[project.scripts] +yo = "yolo.cli:main" + +[tool.hatch.build.targets.wheel] +packages = ["src/yolo"] + + +[tool.pytest.ini_options] +testpaths = ["tests"] +markers = [ + "integration: slow tests that need podman (deselect with -m 'not integration')", +] diff --git a/src/yolo/__init__.py b/src/yolo/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/yolo/builder.py b/src/yolo/builder.py new file mode 100644 index 0000000..dfe7cb6 --- /dev/null +++ b/src/yolo/builder.py @@ -0,0 +1,242 @@ +"""Build container images with image-extras.""" + +import os +import shutil +import subprocess +import tempfile +from pathlib import Path + +REPO_ROOT = Path(__file__).resolve().parent.parent.parent +CONTAINERFILE_EXTRAS = REPO_ROOT / "images" / "Containerfile.extras" +BUILTIN_EXTRAS = REPO_ROOT / "image-extras" + +BASE_IMAGE = "yolo-base" + + +def _project_dirname() -> str: + """Get project dirname from git toplevel or cwd.""" + try: + result = subprocess.run( + ["git", "rev-parse", "--show-toplevel"], + capture_output=True, + text=True, + check=True, + ) + return Path(result.stdout.strip()).name + except (subprocess.CalledProcessError, FileNotFoundError): + return Path.cwd().name + + +def image_tag(image_name: str) -> str: + """Derive podman image tag from image name.""" + project = _project_dirname() + project = "".join(c if c.isalnum() or c in "-_" else "-" for c in project) + return f"yolo-{project}-{image_name}" + + +def _extras_search_path() -> list[Path]: + """Return image-extras directories in precedence order (lowest first).""" + paths = [BUILTIN_EXTRAS] + + xdg = os.environ.get("XDG_CONFIG_HOME", "") + if xdg: + paths.append(Path(xdg) / "yolo" / "image-extras") + else: + paths.append(Path.home() / ".config" / "yolo" / "image-extras") + + paths.append(Path.cwd() / ".yolo" / "image-extras") + + from yolo.config import _find_git_dir + + git_dir = _find_git_dir() + if git_dir: + paths.append(git_dir / "yolo" / "image-extras") + + return paths + + +def _resolve_script(name: str, search_path: list[Path]) -> Path | None: + """Find a script by name in the search path. Later paths win.""" + found = None + for directory in search_path: + candidate = directory / f"{name}.sh" + if candidate.is_file(): + found = candidate + return found + + +def _parse_extra(entry) -> tuple[str, dict[str, str]]: + """Parse a single image-extras entry into (name, env_vars). + + Entry must be a dict with a 'name' key, or a string (name only): + {"name": "apt", "packages": "zsh fzf"} -> ("apt", {"YOLO_APT_PACKAGES": "zsh fzf"}) + {"name": "python", "version": "3.12"} -> ("python", {"YOLO_PYTHON_VERSION": "3.12"}) + {"name": "datalad"} -> ("datalad", {}) + """ + if isinstance(entry, str): + return (entry, {}) + + if not isinstance(entry, dict) or "name" not in entry: + raise ValueError(f"Invalid container-extra: {entry!r} (must have 'name' key)") + + name = entry["name"] + env_vars = {} + for key, value in entry.items(): + if key == "name": + continue + env_key = f"YOLO_{name}_{key}".upper().replace("-", "_") + if isinstance(value, list): + env_vars[env_key] = " ".join(str(v) for v in value) + else: + env_vars[env_key] = str(value) + + return (name, env_vars) + + +def assemble_build_context(extras_config: list, verify: bool = False) -> Path: + """Create a temp directory with scripts and run.sh for podman build. + + Returns the path to the temp directory. Caller must clean up. + """ + search_path = _extras_search_path() + + build_dir = Path(tempfile.mkdtemp(prefix="yolo-build-")) + scripts_dir = build_dir / "build" / "scripts" + scripts_dir.mkdir(parents=True) + + run_lines = ["#!/bin/bash", "export PS4='+ [yolo] '", "set -eux"] + if verify: + run_lines.append("export YOLO_VERIFY=1") + + for entry in extras_config: + name, env_vars = _parse_extra(entry) + + script = _resolve_script(name, search_path) + if script is None: + raise FileNotFoundError( + f"No script found for '{name}' in search path: " + + ", ".join(str(p) for p in search_path) + ) + + dest = scripts_dir / f"{name}.sh" + if not dest.exists(): + shutil.copy2(script, dest) + + run_lines.append(f"echo '==> {name}'") + env_prefix = " ".join(f'{k}="{v}"' for k, v in env_vars.items()) + if env_prefix: + run_lines.append(f"{env_prefix} bash /tmp/yolo-build/scripts/{name}.sh") + else: + run_lines.append(f"bash /tmp/yolo-build/scripts/{name}.sh") + + run_sh = build_dir / "build" / "run.sh" + run_sh.write_text("\n".join(run_lines) + "\n") + + return build_dir + + +def _image_exists(tag: str) -> bool: + """Check if a podman image exists locally.""" + result = subprocess.run( + ["podman", "image", "exists", tag], + capture_output=True, + ) + return result.returncode == 0 + + +def _build_base() -> None: + """Build yolo-base from Containerfile.base.""" + containerfile = REPO_ROOT / "images" / "Containerfile.base" + print(f"Building {BASE_IMAGE}...") + subprocess.run( + [ + "podman", + "build", + "-f", + str(containerfile), + "-t", + BASE_IMAGE, + str(REPO_ROOT / "images"), + ], + check=True, + ) + print(f"Built {BASE_IMAGE}") + + +def _ensure_base(base: str, images_config: list) -> None: + """Ensure a base image exists. Build it if we know how.""" + if _image_exists(base): + return + + if base == BASE_IMAGE: + _build_base() + return + + # Check if it's an image defined in our config + for entry in images_config: + name = entry.get("name", "default") + if image_tag(name) == base: + build_image(entry, images_config) + return + + raise RuntimeError(f"Base image '{base}' not found and not defined in config") + + +def build_image( + image_entry: dict, images_config: list | None = None, verify: bool = False +) -> str: + """Build a single image from an images list entry. Returns the tag.""" + name = image_entry.get("name", "default") + extras = image_entry.get("extras", []) + tag = image_tag(name) + + if not extras: + print(f"No extras for image '{name}', skipping.") + return tag + + base = image_entry.get("from", BASE_IMAGE) + + print(f"\n Image: {tag}", flush=True) + print(f" Base: {base}", flush=True) + print(" Extras:", flush=True) + for extra in extras: + extra_name = extra["name"] if isinstance(extra, dict) else extra + script = _resolve_script(extra_name, _extras_search_path()) + source = str(script.parent) if script else "not found" + print(f" - {extra_name} ({source})", flush=True) + print(flush=True) + + _ensure_base(base, images_config or []) + + build_dir = assemble_build_context(extras, verify=verify) + try: + cmd = [ + "podman", + "build", + "--build-arg", + f"BASE_IMAGE={base}", + "-f", + str(CONTAINERFILE_EXTRAS), + "-t", + tag, + str(build_dir), + ] + subprocess.run(cmd, check=True) + print(f"\n Built {tag}\n") + finally: + shutil.rmtree(build_dir) + + return tag + + +def build(images_config: list, only: str | None = None, verify: bool = False) -> None: + """Build images from config. Optionally filter by name.""" + if not images_config: + print("No images configured, nothing to build.") + return + + for entry in images_config: + name = entry.get("name", "default") + if only and name != only: + continue + build_image(entry, images_config, verify=verify) diff --git a/src/yolo/cli.py b/src/yolo/cli.py new file mode 100644 index 0000000..26b4325 --- /dev/null +++ b/src/yolo/cli.py @@ -0,0 +1,169 @@ +"""CLI entry point for yolo.""" + +import shutil +import subprocess +from pathlib import Path + +import click + +from yolo.builder import build as builder_build, image_tag +from yolo.config import load_config +from yolo.launcher import run as launcher_run + +CONFIG_TEMPLATE = Path(__file__).parent / "defaults" / "config.template.yaml" + + +@click.group() +@click.option( + "--no-config", is_flag=True, default=False, help="Ignore all config files" +) +@click.pass_context +def main(ctx, no_config): + """Run Claude Code safely in a container with full autonomy.""" + ctx.ensure_object(dict) + ctx.obj["no_config"] = no_config + + +@main.command() +@click.option("--image", default=None, help="Build only this named image") +@click.option("--verify", is_flag=True, default=False, help="Run extras in verify mode") +@click.pass_context +def build(ctx, image, verify): + """Build the container image with configured extras.""" + config = load_config(no_config=ctx.obj["no_config"]) + images = config.get("images", []) + builder_build(images, only=image, verify=verify) + + +@main.command() +@click.option("--local", "target", flag_value="local", help="Write to .git/yolo/") +@click.option("--user", "target", flag_value="user", help="Write to ~/.config/yolo/") +@click.option("--path", "custom_path", default=None, help="Write to custom location") +@click.option( + "--project", + "target", + flag_value="project", + default=True, + help="Write to .yolo/ (default)", +) +def init(target, custom_path): + """Create a config file from the default template.""" + if custom_path: + dest = Path(custom_path) / "config.yaml" + elif target == "local": + from yolo.config import _find_git_dir + + git_dir = _find_git_dir() + if not git_dir: + raise click.ClickException("Not in a git repository") + dest = git_dir / "yolo" / "config.yaml" + elif target == "user": + import os + + xdg = os.environ.get("XDG_CONFIG_HOME", "") + base = Path(xdg) if xdg else Path.home() / ".config" + dest = base / "yolo" / "config.yaml" + else: + dest = Path.cwd() / ".yolo" / "config.yaml" + + if dest.exists(): + click.echo(f"Config already exists: {dest}") + return + + dest.parent.mkdir(parents=True, exist_ok=True) + shutil.copy2(CONFIG_TEMPLATE, dest) + click.echo(f"Created {dest}") + + +@main.command() +@click.pass_context +def images(ctx): + """List configured images and their build status.""" + config = load_config(no_config=ctx.obj["no_config"]) + for entry in config.get("images", []): + name = entry.get("name", "default") + tag = image_tag(name) + result = subprocess.run(["podman", "image", "exists", tag], capture_output=True) + status = "built" if result.returncode == 0 else "not built" + click.echo(f" {name} ({tag}) — {status}") + + +@main.command() +@click.pass_context +def clip(ctx): + """Copy container clipboard content to host clipboard.""" + clip_file = Path.home() / ".local" / "share" / "yolo" / "clip" / "content" + if not clip_file.exists(): + raise click.ClickException("Nothing to clip (no content written yet)") + config = load_config(no_config=ctx.obj["no_config"]) + clipboard_cmd = config.get("host_clipboard_command", "xclip -selection clipboard") + content = clip_file.read_text() + subprocess.run(clipboard_cmd.split(), input=content, text=True, check=True) + click.echo(f"Copied {len(content)} chars to clipboard") + + +@main.command() +def demo(): + """Run the interactive yolo demo.""" + import os + import tempfile + from importlib.resources import files + + demo_src = files("yolo") / "demo" + if not (demo_src / "demo.md").is_file(): + raise click.ClickException("Demo files not found in package") + + with tempfile.TemporaryDirectory(prefix="yolo-demo-") as tmp: + tmp_path = Path(tmp) / "demo" + shutil.copytree(str(demo_src), str(tmp_path)) + os.chdir(tmp_path) + # Mount yolo source tree so demo can show real code/config + repo_root = Path(__file__).resolve().parent.parent.parent + extra_volumes = [] + if ( + repo_root / "image-extras" + ).is_dir(): # sanity check: are we in a source tree? + extra_volumes.append(f"{repo_root}:/opt/yolo:ro") + launcher_run( + ["Read demo.md and follow it."], + extra_volumes=extra_volumes, + ) + + +@main.command(context_settings={"ignore_unknown_options": True}) +@click.option( + "-v", "--volume", multiple=True, help="Extra bind mount (host:container[:opts])" +) +@click.option("--entrypoint", default=None, help="Override container entrypoint") +@click.option("--image", default=None, help="Run a specific named image") +@click.option( + "--worktree", + type=click.Choice(["ask", "bind", "skip", "error"]), + default=None, + help="Git worktree handling mode", +) +@click.option( + "--nvidia", + is_flag=True, + default=False, + help="Enable NVIDIA GPU passthrough via CDI", +) +@click.option( + "--container-arg", + multiple=True, + help="Pass raw arg to container engine (repeatable)", +) +@click.argument("claude_args", nargs=-1, type=click.UNPROCESSED) +@click.pass_context +def run(ctx, volume, entrypoint, image, worktree, nvidia, container_arg, claude_args): + """Launch Claude Code in a container.""" + launcher_run( + list(claude_args), + no_config=ctx.obj["no_config"], + extra_volumes=list(volume), + entrypoint=entrypoint, + image_name=image, + worktree=worktree, + nvidia=nvidia, + container_args=list(container_arg), + ) diff --git a/src/yolo/config.py b/src/yolo/config.py new file mode 100644 index 0000000..1d31dc3 --- /dev/null +++ b/src/yolo/config.py @@ -0,0 +1,121 @@ +"""Load and merge YAML config from all locations.""" + +from pathlib import Path +import os + +from ruamel.yaml import YAML + + +CONFIG_FILENAME = "config.yaml" +DEFAULTS_CONFIG = Path(__file__).parent / "defaults" / CONFIG_FILENAME + + +class _Replace: + """Wrapper that signals _merge to replace instead of append.""" + + def __init__(self, value): + self.value = value + + +def _replace_constructor(loader, node): + value = loader.construct_sequence(node) + return _Replace(value) + + +_yaml = YAML() +_yaml.preserve_quotes = True +_yaml.Constructor.add_constructor("!replace", _replace_constructor) + +# Precedence: later overrides earlier +# 0. Package defaults (src/yolo/defaults/config.yaml) +# 1. /etc/yolo/config.yaml +# 2. ~/.config/yolo/config.yaml (XDG) +# 3. .yolo/config.yaml (project, committed) +# 4. .git/yolo/config.yaml (project, local) + + +def _find_git_dir() -> Path | None: + """Walk up from cwd to find .git directory, handling worktrees.""" + current = Path.cwd() + while current != current.parent: + dot_git = current / ".git" + if dot_git.is_dir(): + return dot_git + if dot_git.is_file(): + # Worktree — parse gitdir: line + text = dot_git.read_text().strip() + if text.startswith("gitdir: "): + gitdir = Path(text[len("gitdir: ") :]) + if not gitdir.is_absolute(): + gitdir = current / gitdir + gitdir = gitdir.resolve() + # Extract the main .git dir from .git/worktrees/ + for parent in gitdir.parents: + if parent.name == ".git": + return parent + current = current.parent + return None + + +def _config_paths() -> list[Path]: + """Return config file paths in precedence order (lowest first).""" + paths = [Path("/etc/yolo") / CONFIG_FILENAME] + + xdg = os.environ.get("XDG_CONFIG_HOME", "") + if xdg: + paths.append(Path(xdg) / "yolo" / CONFIG_FILENAME) + else: + paths.append(Path.home() / ".config" / "yolo" / CONFIG_FILENAME) + + # Project configs + project_root = Path.cwd() + paths.append(project_root / ".yolo" / CONFIG_FILENAME) + + git_dir = _find_git_dir() + if git_dir: + paths.append(git_dir / "yolo" / CONFIG_FILENAME) + + return paths + + +def _load_yaml(path: Path) -> dict: + """Load a YAML file, returning empty dict if missing or empty.""" + if not path.is_file(): + return {} + data = _yaml.load(path) + return dict(data) if data else {} + + +def _merge(base: dict, override: dict) -> dict: + """Merge override into base. Lists append, dicts recurse, scalars replace. + + Values tagged !replace in YAML are wrapped in _Replace and + always replace the base value instead of appending. + """ + merged = dict(base) + for key, value in override.items(): + if isinstance(value, _Replace): + merged[key] = value.value + elif ( + key in merged and isinstance(merged[key], list) and isinstance(value, list) + ): + merged[key] = merged[key] + value + elif ( + key in merged and isinstance(merged[key], dict) and isinstance(value, dict) + ): + merged[key] = _merge(merged[key], value) + else: + merged[key] = value + return merged + + +def load_config(no_config: bool = False) -> dict: + """Load and merge config from all locations.""" + config = _load_yaml(DEFAULTS_CONFIG) + if no_config: + return config + for path in _config_paths(): + layer = _load_yaml(path) + if layer: + config = _merge(config, layer) + return config diff --git a/src/yolo/defaults/config.template.yaml b/src/yolo/defaults/config.template.yaml new file mode 100644 index 0000000..c5d8573 --- /dev/null +++ b/src/yolo/defaults/config.template.yaml @@ -0,0 +1,67 @@ +--- +# yolo configuration +# +# Config is loaded from these locations (later overrides earlier): +# 0. Package defaults +# 1. /etc/yolo/config.yaml — system/org +# 2. ~/.config/yolo/config.yaml — user preferences +# 3. .yolo/config.yaml — project (committed to git) +# 4. .git/yolo/config.yaml — project (local, untracked) +# +# Merge rules: +# - Lists: append across layers +# - Dicts: recurse and merge +# - Scalars: later value wins +# - Use !replace to override a list instead of appending: +# volumes: !replace +# - ~/only-this + +# ── Env vars ──────────────────────────────────────────────── +# Passed into the container. +# Bare name = passthrough from host. KEY=VALUE = set explicitly. +# env: +# - MY_CUSTOM_VAR +# - MY_SECRET=hunter2 + +# ── Volumes ───────────────────────────────────────────────── +# Extra bind mounts. Shorthand: +# ~/data → ~/data:~/data:z (1-to-1) +# ~/data::ro → ~/data:~/data:ro (1-to-1, read-only) +# /host:/container → /host:/container:z (partial, :z added) +# /h:/c:opts → /h:/c:opts (full, unchanged) +# volumes: +# - ~/data +# - ~/projects::ro + +# ── Launcher ──────────────────────────────────────────────── +# Git worktree handling: ask (default), bind, skip, error +# worktree: ask + +# NVIDIA GPU passthrough via CDI +# nvidia: false + +# ── Images ────────────────────────────────────────────────── +# Container images to build. Each has a name and a list of extras. +# +# from: override the base image (default: yolo-base). +# Any image reference works: yolo-base, ghcr.io/org/img:tag, etc. +# +# extras: scripts that run at build time to install tools. +# Each entry needs a 'name' that maps to a .sh script. +# Additional keys become YOLO_{NAME}_{KEY} env vars. +# +# Script search path (later wins): +# /image-extras/ — builtins +# ~/.config/yolo/image-extras/ — user scripts +# .yolo/image-extras/ — project (committed) +# .git/yolo/image-extras/ — project (local) +# +# images: +# - name: default +# # from: yolo-base +# extras: +# - name: apt +# packages: [zsh, fzf] +# - name: python +# version: "3.12" +# - name: datalad diff --git a/src/yolo/defaults/config.yaml b/src/yolo/defaults/config.yaml new file mode 100644 index 0000000..aa6942b --- /dev/null +++ b/src/yolo/defaults/config.yaml @@ -0,0 +1,28 @@ +--- +host_clipboard_command: "xclip -selection clipboard" + +context: + - >- + To copy text to the host clipboard, write to + /tmp/yolo-clip/content. Tell the user to run + 'yo clip' on the host to retrieve it. + +env: + - CLAUDE_CODE_OAUTH_TOKEN + - CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS + +images: + - name: default + extras: + - name: apt + packages: + - fzf + - gh + - less + - man-db + - shellcheck + - zsh + - name: git-delta + version: "0.18.2" + - name: python + version: "3.12" diff --git a/src/yolo/demo/.yolo/config.yaml b/src/yolo/demo/.yolo/config.yaml new file mode 100644 index 0000000..94dc1cd --- /dev/null +++ b/src/yolo/demo/.yolo/config.yaml @@ -0,0 +1,20 @@ +--- +context: + - >- + IMPORTANT: The audience cannot see tool output. You + MUST quote or reproduce any terminal output directly + in your response text. Every command result that + matters should appear as a fenced code block in your + message. + - >- + Abort procedure: if something goes wrong or isn't + smooth, stop the demo. Write a prompt to + /tmp/yolo-clip/content addressed to the Claude + instance working on yolo with the user, explaining + what went wrong and suggesting a fix. Tell the user + to run 'yo clip' and paste it into their other + session. + - >- + Do NOT use AskUserQuestion for pacing. Just stop + after each step and wait for the user to type + something before continuing. diff --git a/src/yolo/demo/demo.md b/src/yolo/demo/demo.md new file mode 100644 index 0000000..3d397f3 --- /dev/null +++ b/src/yolo/demo/demo.md @@ -0,0 +1,79 @@ +# YOLO Demo + +You are Claude Code running inside a yolo container. Be yourself — +casual, fun, a little cheeky. You can use **some** emojis. + +CRITICAL: Tool output is hidden from the user. After every Bash call, +you MUST paste the output into your response as a fenced code block. +Never assume the user can see tool results. + +## Pacing + +Short steps. Pause between steps. After each step, stop and wait for the user to +type something before continuing. + +## Getting started + +Ask if they're familiar with Claude Code. If not, briefly explain. + +Then ask if they've used yolo before. + +- If yes: welcome them to the rewrite and offer a tour of what's new. +- If no: explain the concept — Claude Code running in a container + with full autonomy, no permission prompts, the container is the + sandbox — and show them why this is more powerful. + +Let the conversation flow naturally from here. + +## Things to demonstrate + +Don't follow a script. Use your judgment based on what the user is +interested in. But here's what you have to work with: + +**Autonomy** — write files, run code, install packages with sudo, +all without permission prompts. Show, don't tell. When you write +something, clip a command so the user can verify on the host: +`printf '%s' "cd $(pwd) && ls -lah && python