From 180d540b789eafea6578f884f308c01b64793580 Mon Sep 17 00:00:00 2001 From: Austin Macdonald Date: Sat, 28 Mar 2026 12:06:13 -0500 Subject: [PATCH 01/55] Move existing code to legacy/, organize workspace for rewrite - Move bin/, images/, tests/, setup-yolo.sh, config.example, README.md to legacy/ - Add design/ with HACK_DECISIONS.md, REDESIGN_HACKIN.md, LEGACY_SPEC.md - Add notes/ with issue and PR summaries from GitHub - Organize test scripts into legacy/ Co-Authored-By: Claude Opus 4.6 (1M context) --- .gitmodules | 4 +- design/HACK_DECISIONS.md | 49 + design/LEGACY_SPEC.md | 437 +++++++ design/REDESIGN_HACKIN.md | 264 +++++ README.md => legacy/README.md | 0 {bin => legacy/bin}/yolo | 0 config.example => legacy/config.example | 0 {images => legacy/images}/Dockerfile | 0 setup-yolo.sh => legacy/setup-yolo.sh | 0 legacy/test-arg-parsing-fixed.sh | 59 + legacy/test-arg-parsing.sh | 53 + legacy/test-worktree-feature.sh | 179 +++ .../tests}/test_helper/bats-assert | 0 .../tests}/test_helper/bats-support | 0 .../tests}/test_helper/common.bash | 0 {tests => legacy/tests}/yolo.bats | 0 notes/TODO.md | 8 + notes/issues/issue-12.md | 5 + notes/issues/issue-23.md | 19 + notes/issues/issue-27.md | 20 + notes/issues/issue-3.md | 108 ++ notes/issues/issue-33.md | 11 + notes/issues/issue-36.md | 5 + notes/issues/issue-39.md | 5 + notes/issues/issue-4.md | 14 + notes/issues/issue-42.md | 18 + notes/issues/issue-46.md | 674 +++++++++++ notes/issues/issue-47.md | 115 ++ notes/issues/issue-49.md | 96 ++ notes/issues/issue-5.md | 8 + notes/issues/issue-51-selinux.md | 28 + notes/issues/issue-54.md | 18 + .../issues/issue-draft-config-environments.md | 100 ++ notes/issues/non-home-worktree-issue.md | 36 + notes/prs/pr-48.md | 1013 +++++++++++++++++ notes/prs/pr-51.md | 17 + notes/prs/pr-53.md | 21 + notes/prs/pr-55.md | 37 + notes/prs/pr-56.md | 25 + notes/prs/pr-57.md | 37 + notes/sandbox-comparison.md | 77 ++ 41 files changed, 3558 insertions(+), 2 deletions(-) create mode 100644 design/HACK_DECISIONS.md create mode 100644 design/LEGACY_SPEC.md create mode 100644 design/REDESIGN_HACKIN.md rename README.md => legacy/README.md (100%) rename {bin => legacy/bin}/yolo (100%) rename config.example => legacy/config.example (100%) rename {images => legacy/images}/Dockerfile (100%) rename setup-yolo.sh => legacy/setup-yolo.sh (100%) create mode 100755 legacy/test-arg-parsing-fixed.sh create mode 100755 legacy/test-arg-parsing.sh create mode 100755 legacy/test-worktree-feature.sh rename {tests => legacy/tests}/test_helper/bats-assert (100%) rename {tests => legacy/tests}/test_helper/bats-support (100%) rename {tests => legacy/tests}/test_helper/common.bash (100%) rename {tests => legacy/tests}/yolo.bats (100%) create mode 100644 notes/TODO.md create mode 100644 notes/issues/issue-12.md create mode 100644 notes/issues/issue-23.md create mode 100644 notes/issues/issue-27.md create mode 100644 notes/issues/issue-3.md create mode 100644 notes/issues/issue-33.md create mode 100644 notes/issues/issue-36.md create mode 100644 notes/issues/issue-39.md create mode 100644 notes/issues/issue-4.md create mode 100644 notes/issues/issue-42.md create mode 100644 notes/issues/issue-46.md create mode 100644 notes/issues/issue-47.md create mode 100644 notes/issues/issue-49.md create mode 100644 notes/issues/issue-5.md create mode 100644 notes/issues/issue-51-selinux.md create mode 100644 notes/issues/issue-54.md create mode 100644 notes/issues/issue-draft-config-environments.md create mode 100644 notes/issues/non-home-worktree-issue.md create mode 100644 notes/prs/pr-48.md create mode 100644 notes/prs/pr-51.md create mode 100644 notes/prs/pr-53.md create mode 100644 notes/prs/pr-55.md create mode 100644 notes/prs/pr-56.md create mode 100644 notes/prs/pr-57.md create mode 100644 notes/sandbox-comparison.md 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/design/HACK_DECISIONS.md b/design/HACK_DECISIONS.md new file mode 100644 index 0000000..12f0392 --- /dev/null +++ b/design/HACK_DECISIONS.md @@ -0,0 +1,49 @@ +# 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 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/REDESIGN_HACKIN.md b/design/REDESIGN_HACKIN.md new file mode 100644 index 0000000..d2d2288 --- /dev/null +++ b/design/REDESIGN_HACKIN.md @@ -0,0 +1,264 @@ +# YOLO Redesign Hackpad + +Working design notes from brainstorming session 2026-03-28. +This is a living document — ideas, not commitments. +Locked decisions live in `HACK_DECISIONS.md`. + +## Core problem + +The current architecture has no extension points. Every new capability — +a tool in the image, a worktree strategy, a container runtime — requires +modifying the core. This is unsustainable. + +The `--extras` pattern is the proof: every new tool is a PR to the +Dockerfile, a flag in `setup-yolo.sh`, a debate about what belongs in the +base image. The architecture forces everything through the center. + +**The goal is an architecture where the core is small and stable, and +growth happens at the edges.** Adding datalad support, a new worktree +strategy, or singularity as a runtime should not require touching the core. + +## What is yolo? + +Two core components, plus an installer: + +1. **Launcher** — assemble mounts/env/command, invoke the container runtime +2. **Environment builder** — resolve features, build a derived image +3. **Installer** (`setup-yolo.sh` successor) — deferred for now + +These are decoupled. The launcher doesn't know how the image was built. +The builder doesn't know how the image will be run. + +## User stories + +**"I just want to run it"** +Scientist clones a repo, types `yolo`, it works. No build step, no config +editing, no container knowledge required. + +**"I need datalad too"** +Scientist adds `datalad` to a list in project config. A pre-written install +script runs behind the scenes. They didn't need to know what that script does. + +**"I do worktrees differently"** +Yarik has opinions about worktree layout. He drops a script into a known +location that overrides the default worktree behavior. No fork needed. + +**"Here's a repo, it just works"** +A PI commits yolo config to a repo. Collaborators clone it, run `yolo`, and +the environment is ready — right features, right mounts, right setup. + +--- + +## Features (environment builder) + +Composable install units. Users compose by name, yolo resolves and runs them. + +### Syntax (YAML config) + +```yaml +features: + - datalad # finds install_datalad.sh in feature path → runs it + - ffmpeg # no script found → falls back to apt + - apt:imagemagick # explicit: apt-get install + - uv:con-duct # explicit: uv tool install + - pip:numpy # explicit: pip install +``` + +### Resolution for bare names + +1. Search the feature path for `install_.sh` → run it if found +2. No script? → `apt-get install -y ` + +Prefixed names (`apt:`, `uv:`, `pip:`) skip the search, go straight to the +package manager script with args. + +Curated scripts only need to exist where the install is non-trivial (datalad +needs extra `--with` flags, CUDA needs apt sources modified, playwright needs +both system deps and npm). + +### Feature path (resolution order) + +``` +/features/ ← ships with yolo (builtins) +~/.config/yolo/features/ ← user local +.yolo/features/ ← project (committed) +.git/yolo/features/ ← project (local/untracked) +``` + +Later wins if names collide. + +### Build mechanism + +Static Dockerfiles, dynamic build context. No Dockerfile generation. + +- `Dockerfile.base` — the base image (Claude Code + core tools) +- `Dockerfile.custom` — layers features on top of base + +`Dockerfile.custom` is dumb — it copies in a build context and runs +a manifest script. yolo assembles the build context: + +``` +build/ + scripts/ + install_datalad.sh ← resolved from feature path + apt.sh ← builtin package manager script + uv.sh ← builtin package manager script + run.sh ← generated manifest +``` + +`run.sh` is just: +```bash +bash /tmp/yolo-build/scripts/install_datalad.sh +bash /tmp/yolo-build/scripts/apt.sh imagemagick visidata +bash /tmp/yolo-build/scripts/uv.sh con-duct +``` + +Package manager scripts are simple. `apt.sh` is literally: +```bash +apt-get install -y "$@" +``` + +`apt-get update` happens once in the Dockerfile before running scripts, +not in each script. + +### Build-time vs run-time + +- **Primary: build-time.** Features baked into the derived image. +- **Secondary: run-time.** For things like `pip install -e .` that are + inherently per-session. Configured separately (e.g. `startup` key). +- **Explicit rebuild.** No auto-detection of staleness. User runs + `yolo --rebuild` when they change features. + +### OPEN: Sharp edges + +- What if a bare name matches a script AND is a valid apt package? + (Script wins — is that always right?) +- Is the prefix syntax extensible enough? (`npm:`, `cargo:`, etc.) +- Error messages when an install fails — which script broke? + +--- + +## Hooks / extensible launch behavior + +Same resolution pattern as features, same override mechanism. +A hook is just a script in a known location that runs at a specific phase. + +### Unified with features via phases + +``` +.yolo/ + build/ ← feature install scripts (build phase) + launch/ ← runtime behavior scripts (launch phase) + config.yaml ← the thing users actually edit +``` + +No separate "hook framework." If a script with a known name exists, +it runs at the right phase. The phase is implied by location. + +### Hook points (launch phase) + +| Hook | What it does by default | Why someone might override | +|-----------------|--------------------------------|-----------------------------------| +| `worktree` | detect, prompt/bind/skip/error | custom worktree layout/naming | +| `volumes` | assemble the mount list | SSH keys, conditional mounts | +| `container-name`| `$PWD-$$` sanitized | org naming conventions | +| `pre-launch` | nothing | env setup, credential injection | +| `post-exit` | nothing | cleanup, sync, notifications | +| `entrypoint` | `claude --dangerously-skip-permissions` | different agent, wrapper | + +### OPEN: Hook contract + +- Override vs wrap? Does a user hook replace the default or run around it? +- Data return: how does a hook communicate back (e.g. "add these mounts")? +- We want to stay on the simple side of: `exit code → stdout → JSON → plugin API` + +--- + +## Launcher + +The launcher speaks in **intent**, not container runtime flags. + +### Vocabulary (YAML config keys) + +```yaml +volumes: + - ~/projects + - ~/data::ro + +nvidia: true +worktree: ask +``` + +NOT raw podman flags. Intent is portable across runtimes. + +The launcher's job: +1. **Mounts** — default secure set + user additions from config +2. **Env vars** — default set + user additions +3. **Command** — claude with skip-permissions, or custom entrypoint +4. **Translate** — turn intent into `podman run` invocation + +Raw passthrough (`--`) exists as an escape hatch, not the normal path. + +--- + +## Config + +See `HACK_DECISIONS.md` for locked decisions on format and locations. + +### Layering (later overrides earlier) + +1. `/etc/yolo/config.yaml` — system-wide (org defaults) +2. `~/.config/yolo/config.yaml` — user preferences +3. `.yolo/config.yaml` — project, committed (shareable) +4. `.git/yolo/config.yaml` — project, local (personal) +5. CLI args + +### OPEN: Array merging vs replacement + +With 4 config layers, does `volumes: [~/data]` in project config +replace or append to `volumes: [~/tools]` in user config? + +--- + +## Context injection + +yolo generates a context file bound into the container at launch, +telling Claude about its environment: + +- "You're in a yolo container" +- "Mounted volumes: X, Y, Z" +- "You cannot: access SSH keys, write outside mounted volumes" +- "Installed features: datalad, ffmpeg" + +Helps Claude work effectively AND respects boundaries. + +--- + +## Security + +### Posture + +Secure by default. Flexible enough to weaken deliberately. + +### Escape vector: config as attack surface + +Claude has write access to `.yolo/config.yaml` (in the workspace). +Could modify config to mount sensitive directories on next launch. +Between-session escape, not within-session. + +Mitigations under consideration: +- Mount `.yolo/` read-only inside container +- Diff-on-launch: show config changes, ask to confirm +- Exit warning if `.yolo/` was modified during session +- Trust prompt for committed config in unfamiliar repos +- Document honestly, don't pretend it's fully solved + +--- + +## Still open + +- Image naming/tagging strategy for derived images +- Singularity/apptainer runtime abstraction (#33) +- Registry story (GHCR for base image?) — deferred, build locally for now +- How extensions repo works (if at all) +- Installer redesign (setup-yolo.sh successor) — deferred diff --git a/README.md b/legacy/README.md similarity index 100% rename from README.md rename to legacy/README.md diff --git a/bin/yolo b/legacy/bin/yolo similarity index 100% rename from bin/yolo rename to legacy/bin/yolo diff --git a/config.example b/legacy/config.example similarity index 100% rename from config.example rename to legacy/config.example diff --git a/images/Dockerfile b/legacy/images/Dockerfile similarity index 100% rename from images/Dockerfile rename to legacy/images/Dockerfile diff --git a/setup-yolo.sh b/legacy/setup-yolo.sh similarity index 100% rename from setup-yolo.sh rename to legacy/setup-yolo.sh diff --git a/legacy/test-arg-parsing-fixed.sh b/legacy/test-arg-parsing-fixed.sh new file mode 100755 index 0000000..932c505 --- /dev/null +++ b/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/legacy/test-arg-parsing.sh b/legacy/test-arg-parsing.sh new file mode 100755 index 0000000..134e0a8 --- /dev/null +++ b/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/legacy/test-worktree-feature.sh b/legacy/test-worktree-feature.sh new file mode 100755 index 0000000..7f2f3dc --- /dev/null +++ b/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/bats-assert b/legacy/tests/test_helper/bats-assert similarity index 100% rename from tests/test_helper/bats-assert rename to legacy/tests/test_helper/bats-assert diff --git a/tests/test_helper/bats-support b/legacy/tests/test_helper/bats-support similarity index 100% rename from tests/test_helper/bats-support rename to legacy/tests/test_helper/bats-support diff --git a/tests/test_helper/common.bash b/legacy/tests/test_helper/common.bash similarity index 100% rename from tests/test_helper/common.bash rename to legacy/tests/test_helper/common.bash diff --git a/tests/yolo.bats b/legacy/tests/yolo.bats similarity index 100% rename from tests/yolo.bats rename to legacy/tests/yolo.bats diff --git a/notes/TODO.md b/notes/TODO.md new file mode 100644 index 0000000..cf4b5aa --- /dev/null +++ b/notes/TODO.md @@ -0,0 +1,8 @@ +1. --global-claude doesnt seem like the right name to me (--anonymized-paths) +2. bin/yolo:16 - argument parsing bug: `[ "${arg:0:2}" = "--" ]` matches any long option (--rm, --env, etc.) not just the separator `--`. Should be exact match: `[ "$arg" = "--" ]` +3. README.md - manual examples use `~/.claude:~/.claude:Z` but bash only expands first tilde, not the one after colon. Should use `$HOME/.claude:$HOME/.claude:Z` or fully expanded paths + +TODOs later (not part of this PR/review) +1. add shellcheck to CI (lets do in separate PR/branch) +1. add some sanity tests (hard to do without user to authenticate though) + diff --git a/notes/issues/issue-12.md b/notes/issues/issue-12.md new file mode 100644 index 0000000..5d647b2 --- /dev/null +++ b/notes/issues/issue-12.md @@ -0,0 +1,5 @@ +https://github.com/con/yolo/issues/12 + +# Restrict network for additional safety + +Originally discussed on https://github.com/con/yolo/pull/11 which removed an unused script that restricted the network (when used by Anthropic with VS code) but allowed access to the claude API. diff --git a/notes/issues/issue-23.md b/notes/issues/issue-23.md new file mode 100644 index 0000000..1d3f253 --- /dev/null +++ b/notes/issues/issue-23.md @@ -0,0 +1,19 @@ +https://github.com/con/yolo/issues/23 + +# Create collection of "skills" or prompts for typical use cases + +e.g. currently trying smth like + +```shell +❯ yolo "using the most recent logs of the test runs under .duct/logs/ please prepare to submit (using gh) an issue about failing tests against the repository of the 'origin' git remote. Before that check also for existing issues in that repo on whether already filed or relevant somehow present and to be mentioned in the new issue. While filing issue make sure to include information from 'git describe --tags' about the version of this package and how testing was done (from duct logs)" +``` + +so potentially we could establish a collection of similar prompts... in principle it is not yolo specific at all and there might be already such a project or we could create it outside of yolo and then use here... + +## Comments + +### asmacdo (2025-12-06T17:49:02Z) + +Sounds like a job for a Claude code plugin! + +https://code.claude.com/docs/en/plugins diff --git a/notes/issues/issue-27.md b/notes/issues/issue-27.md new file mode 100644 index 0000000..6ce47e8 --- /dev/null +++ b/notes/issues/issue-27.md @@ -0,0 +1,20 @@ +https://github.com/con/yolo/issues/27 + +# Include/setup mcp server for driving/testing in a browser + +not yet sure what is the best setup, but needed for any web ui driven development or documentation websites. Some hits: + +- https://developer.chrome.com/blog/chrome-devtools-mcp +- https://www.reddit.com/r/ClaudeAI/comments/1jf4hnt/setting_up_mcp_servers_in_claude_code_a_tech/ + +not yet 100% sure we could do it in the container (which imho would be better for "fully packaged setup") as opposed to some local `~/.local` setup so claude from container just picks it up + +edit: while working on https://github.com/yarikoptic/strava-backup it seemed to do pretty well (without mcp) although not sure if actually ran any playwright, and then stated + +``` + I was unable to test it fully because the environment is missing system libraries for Playwright. You'll need to run: + + sudo playwright install-deps chromium +``` + +so may be that is what we need - just to install playwright and install chromium with it inside container? to be tested... diff --git a/notes/issues/issue-3.md b/notes/issues/issue-3.md new file mode 100644 index 0000000..d907aaf --- /dev/null +++ b/notes/issues/issue-3.md @@ -0,0 +1,108 @@ +https://github.com/con/yolo/issues/3 + +# Does not work "out of the box" + +thought to give it a shot to address #2 but seems to need some /claude folder which it does not have there (I did run the setup-yolo.sh to build image) + +```shell +❯ yolo "modify setup ther to not modify shell for adding YOLO function to shell but rather installing yolo script like the one I crated under ~/.local/bin/yolo" +node:fs:2425 + return binding.writeFileUtf8( + ^ + +Error: EACCES: permission denied, open '/claude/debug/9343a0ce-8273-49bd-b8dd-a167cbdeb9fe.txt' + at Object.writeFileSync (node:fs:2425:20) + at Module.appendFileSync (node:fs:2507:6) + at Object.appendFileSync (file:///usr/local/share/npm-global/lib/node_modules/@anthropic-ai/claude-code/cli.js:9:868) + at m (file:///usr/local/share/npm-global/lib/node_modules/@anthropic-ai/claude-code/cli.js:11:87) + at Object.xr9 [as initialize] (file:///usr/local/share/npm-global/lib/node_modules/@anthropic-ai/claude-code/cli.js:555:32517) + at file:///usr/local/share/npm-global/lib/node_modules/@anthropic-ai/claude-code/cli.js:3775:34514 + at Q (file:///usr/local/share/npm-global/lib/node_modules/@anthropic-ai/claude-code/cli.js:8:15070) + at _4I (file:///usr/local/share/npm-global/lib/node_modules/@anthropic-ai/claude-code/cli.js:4039:1654) + at h4I (file:///usr/local/share/npm-global/lib/node_modules/@anthropic-ai/claude-code/cli.js:4097:13087) { + errno: -13, + code: 'EACCES', + syscall: 'open', + path: '/claude/debug/9343a0ce-8273-49bd-b8dd-a167cbdeb9fe.txt' +} + +Node.js v22.21.1 +yolo 1,54s user 39,59s system 369% cpu 11,131 total +❯ cat `which yolo` +#!/bin/sh + +if [ -z "$*" ] ; then + echo "E: specify you invocation for claude" + exit 1 +fi + +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 "$@" +❯ git remote -v +origin git@github.com:con/yolo (fetch) +origin git@github.com:con/yolo (push) + +``` + +and indeed there is no that `CLAUDE_CONFIG_DIR` there + +```shell +❯ podman run -it --rm --entrypoint ls con-bomination-claude-code -l / +total 16 +lrwxrwxrwx 1 root root 7 Nov 3 15:44 bin -> usr/bin +drwxr-xr-x 1 root root 0 Aug 24 12:05 boot +drwxr-sr-x 1 node root 26 Nov 11 13:51 commandhistory +drwxr-xr-x 5 root root 360 Nov 11 14:00 dev +drwxr-sr-x 1 root root 18 Nov 11 14:00 etc +drwxr-xr-x 1 root root 8 Nov 4 06:03 home +lrwxrwxrwx 1 root root 7 Nov 3 15:44 lib -> usr/lib +lrwxrwxrwx 1 root root 9 Nov 3 15:44 lib64 -> usr/lib64 +drwxr-xr-x 1 root root 0 Nov 3 15:44 media +drwxr-xr-x 1 root root 0 Nov 3 15:44 mnt +drwxr-xr-x 1 root root 26 Nov 4 06:03 opt +dr-xr-xr-x 771 nobody nogroup 0 Nov 11 14:00 proc +drwx------ 1 root root 20 Nov 11 13:51 root +drwxr-xr-x 1 root root 26 Nov 11 14:00 run +lrwxrwxrwx 1 root root 8 Nov 3 15:44 sbin -> usr/sbin +drwxr-xr-x 1 root root 0 Nov 3 15:44 srv +dr-xr-xr-x 13 nobody nogroup 0 Nov 11 14:00 sys +drwxrwxrwt 1 root root 36 Nov 11 13:51 tmp +drwxr-xr-x 1 root root 10 Nov 3 15:44 usr +drwxr-xr-x 1 root root 12 Nov 3 15:44 var +drwxr-sr-x 1 node node 0 Nov 11 13:51 workspace +``` + +so what was it intended to be -- generated, or be the `workspace/` or ?? + +## Comments + +### yarikoptic (2025-11-11T19:07:06Z) + +my bad -- I see now that it is a bind mount! I guess it might be due to a little more restrictive permissions I might be having than usual + +``` +❯ lsp ~/.claude/debug +PATH: /home/yoh/.claude/debug +0 drwx--S--- 1 yoh yoh 5052 Nov 11 13:26 /home/yoh/.claude/debug/ +0 drwxrwsr-x 1 yoh yoh 330 Nov 11 13:50 /home/yoh/.claude/ +0 drwxr-s--x 1 yoh yoh 17114 Nov 11 14:05 /home/yoh/ +0 drwxr-xr-x 1 root root 264 Jul 7 16:45 /home/ +``` + +### yarikoptic (2025-11-11T19:09:36Z) + +doing `chmod g+rXw ~/.claude/debug` makes it start! so may be the `./setup-yolo.sh` could do the check/chmod as needed. + +But then it requests configuration and subscription -- is that expected or it should have used what is already known to the user locally? (might also be simply a permissions issue) + +### asmacdo (2025-11-20T23:15:02Z) + +I suspect https://github.com/con/yolo/issues/9 fixed a lot of the issue here (hopefully also the auth check too), but setup should still check that ~/.claude has appropriate permissions. IMO should not chmod, but if permissions are not sufficient, should provide a command for user to run. diff --git a/notes/issues/issue-33.md b/notes/issues/issue-33.md new file mode 100644 index 0000000..908826a --- /dev/null +++ b/notes/issues/issue-33.md @@ -0,0 +1,11 @@ +https://github.com/con/yolo/issues/33 + +# add singularity/apptainer as a possible containerization tech + +To make claude usable on HPC and other shared envs where no podman. I am not sure though if always would be possible to build an image, yet to research + +## Comments + +### satra (2026-03-04T15:47:54Z) + ++1 this issue as our cluster does not have podman. diff --git a/notes/issues/issue-36.md b/notes/issues/issue-36.md new file mode 100644 index 0000000..589431e --- /dev/null +++ b/notes/issues/issue-36.md @@ -0,0 +1,5 @@ +https://github.com/con/yolo/issues/36 + +# Makes current directory $HOME thus leading to appearance of various folders + +like `~/.cache` , `~/.npm` and others in current folder ... should not be happening! likely needs to do what we do in repronim/containers and create a fake (tmp) folder to be bind mounted as HOME, unless we want it persistent and thus, if started where there is .git, could be `.git/yolo/home` or alike diff --git a/notes/issues/issue-39.md b/notes/issues/issue-39.md new file mode 100644 index 0000000..f1f2f11 --- /dev/null +++ b/notes/issues/issue-39.md @@ -0,0 +1,5 @@ +https://github.com/con/yolo/issues/39 + +# need newer git in the environment + +wanted to use `worktree add --orphan` but claude said that shipped 2.39.5 does not have it. I have 2.51.0 on the system and it has that option. Review in which it was added and most ecological way (e.g. backports) to add it into the image diff --git a/notes/issues/issue-4.md b/notes/issues/issue-4.md new file mode 100644 index 0000000..c0ec53b --- /dev/null +++ b/notes/issues/issue-4.md @@ -0,0 +1,14 @@ +https://github.com/con/yolo/issues/4 + +# Setup CI to give some basic smoke testing! + +to avoid +- #3 + +not yet sure if would be easily to provide some dedicated "cheap" token + +## Comments + +### asmacdo (2025-11-11T22:02:07Z) + +Not sure how to make this work-- the first run requires user interaction https://github.com/con/yolo?tab=readme-ov-file#first-time-login diff --git a/notes/issues/issue-42.md b/notes/issues/issue-42.md new file mode 100644 index 0000000..34e4bf0 --- /dev/null +++ b/notes/issues/issue-42.md @@ -0,0 +1,18 @@ +https://github.com/con/yolo/issues/42 + +# Extract a SPEC.md from current features + +This project started as a POC without much consideration for expanding into a multi-user/multi-project flexible, configurable design. + +Additional features are needed, which I think *may* benefit from some thinking about higher level architecture. + - multiple containers with various environments + - user level configuration + - per project configuration + +I think we should start by developing a SPEC for what we currently do, and what we'd like to do, and consider if/how the architecture should change to improve flexibility and prevent bloat. + +## Comments + +### yarikoptic (2026-02-12T01:47:14Z) + +I think it would be worthwhile if we get more than 1 user (me) ;) diff --git a/notes/issues/issue-46.md b/notes/issues/issue-46.md new file mode 100644 index 0000000..d8f42bf --- /dev/null +++ b/notes/issues/issue-46.md @@ -0,0 +1,674 @@ +https://github.com/con/yolo/issues/46 + +# Login not persistent: $HOME mismatch between host and container + +## Problem + + The README states that credentials are stored in `~/.claude` and login only needs to happen once. However, login is not persistent across container + restarts because the host `$HOME` path differs from the container user `$HOME`. + + ## Details + + The container launch uses: + -v "$HOME/.claude:$HOME/.claude:Z" + + `$HOME` is expanded **on the host** (e.g., `/home/meng`), so credentials are mounted at `/home/meng/.claude` inside the container. + + However, inside the container the user is `node` with `HOME=/home/node`. Claude Code looks for credentials at `$HOME/.claude` → `/home/node/.claude`, + which is empty. So every session requires a fresh `/login`. + + ## Workaround + + ```bash + ln -s /home/meng/.claude /home/node/.claude + + (Replace /home/meng with your actual host $HOME.) + + Suggested fix + + Either: + 1. Mount to the container user home: -v "$HOME/.claude:/home/node/.claude:Z" + 2. Set HOME inside the container to match the host: --env HOME=$HOME + + Option 2 is probably simplest. + + Environment + + - Host user: meng ($HOME=/home/meng) + - Container user: node ($HOME=/home/node) + - Using --userns=keep-id + +## Comments + +### yarikoptic (2026-03-03T00:04:18Z) + +are you running on linux and is above your analysis or of claude as to state `- Container user: node ($HOME=/home/node)`? + +1. for credentials I just blamed + +- https://github.com/anthropics/claude-code/issues/1757 + +for which my workaround was just to point to the file with the key via + +``` +CLAUDE_CODE_OAUTH_TOKEN=$(cat ~/...secretlocation) yolo ... +``` + +2. +The option 2, if situation is actually as you describe, could also likely address + +- https://github.com/con/yolo/issues/36 + +which kept annoying me by creating all those `.cache/` and `.npm/` folders around! But here is what I see in my `yolo` session: + +``` +! pwd + ⎿ /home/yoh/.tmp + +! echo $HOME + ⎿ /home/yoh/.tmp + +! echo $USER + ⎿ (No output) + +! whoami + ⎿ yoh + +! ls /home/ + ⎿ node + yoh + +! ls /home/node/ + ⎿ (No output) + +! ls -a /home/node/ + ⎿ . + .. + .bash_logout + … +10 lines (ctrl+o to expand) + + +! ls -a /home/node/.claude/ + ⎿ . + .. + + +! ls -a /home/yoh/.claude/ + ⎿ . + .. + .claude.json + .claude.json.backup + .credentials.json + .git + .npm + CLAUDE.md + CLAUDE.visidata.md + agents + +! touch /home/node/something + ⎿ touch: cannot touch '/home/node/something': Permission denied + +``` + +so due to `--userns=keep-id` (I believe) I am "myself" inside! My HOME though is screwed up as pointing to current folder, and I cannot change anything under `/home/node` since it belongs not to me but to node: + +``` +! ls -l /home + ⎿ total 4 + drwxr-xr-x 1 node node 166 Mar 1 09:11 node + drwxr-xr-t 1 root root 30 Mar 2 18:52 yoh +``` + +since I do not think I care/want to keep any of those `~/.npm` etc around, most logical would be to prep/use some temp folder for the `$HOME`... + +and I wonder if we should + +- make `images/Dockerfile` to operate with the outside user identity instead of `node` so we just map the two identities into one somehow? (will not attempt trying ATM; let's figure your details). + - if that doesn't work I would just create a temp folder to bind mount as HOME and populate with copy of files from `/home/node` for a good measure. +- indeed pass `--env HOME=$HOME` if still would be needed + +### just-meng (2026-03-03T08:41:00Z) + +> are you running on linux and is above your analysis or of claude as to state - Container user: node ($HOME=/home/node)? + +yes, linux, no, did not set user to node myself + +it likely boils down to the podman version running here: 4.3.1 (relatively old, from Ubuntu 22.04 repos). apparently, the --userns=keep-id behavior around HOME has changed across versions. + +i have not confirmed yet that -e HOME fixes my problem (not allowed to run yolo --resume, because it won't start up a new container for the setting to take effect; and cannot trigger the auth request so i guess i wait ...) + +### asmacdo (2026-03-03T12:30:09Z) + +In the meantime for a workaround once you're logged in you can /resume to get to the previous conversations. + +### yarikoptic (2026-03-03T14:36:15Z) + +as for overall login workaround you can do what I do and described in [now folded comment](https://github.com/anthropics/claude-code/issues/1757#issuecomment-3811354846): + +> you need a dedicated run of claude setup-token to produce the one with duration of 1 year (seems to require providing it via `CLAUDE_CODE_OAUTH_TOKEN` env var. + +and it would indeed be nice to "solidify" behavior around `HOME`. + +### just-meng (2026-03-04T11:24:54Z) + +Still getting the auth err: +``` + ⎿ API Error: 401 + {"type":"error","error":{"type":"authentication_error","message":"OAuth + token has expired. Please obtain a new token or refresh your existing + token."},"request_id":"req_011CYhwsYS2YK2i3ByTPGtLJ"} · Please run /login +``` + +After setting `-e HOME`: +``` +podman run --log-driver=none -it --rm \ + --user="$(id -u):$(id -g)"\ + --userns=keep-id \ + --name="$name" \ + -v "$CLAUDE_MOUNT" \ + -v "$HOME/.gitconfig:/tmp/.gitconfig:ro,Z" \ + -v "$WORKSPACE_MOUNT" \ + "${WORKTREE_MOUNTS[@]}" \ + -w "$WORKSPACE_DIR" \ + -e HOME \ + -e CLAUDE_CONFIG_DIR="$CLAUDE_DIR" \ + -e GIT_CONFIG_GLOBAL=/tmp/.gitconfig \ + -e CLAUDE_CODE_OAUTH_TOKEN \ + -e CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS \ + "${NVIDIA_ARGS[@]}" \ + "${PODMAN_ARGS[@]}" \ + con-bomination-claude-code \ + "${CONTAINER_CMD[@]}" +``` +For now I'll try with updating podman. + +
Full `~/.local/bin/yolo` here: +``` +#!/bin/bash +# Claude Code YOLO mode - auto-approve all actions in containerized environment + +set -e + +show_help() { + cat << 'EOF' +Usage: yolo [OPTIONS] [-- CLAUDE_ARGS...] + +Run Claude Code in YOLO mode (auto-approve all actions) inside a container. + +OPTIONS: + -h, --help Show this help message + --anonymized-paths Use anonymized paths (/claude, /workspace) instead of + preserving host paths + --entrypoint=CMD Override the container entrypoint (default: claude) + --worktree=MODE Git worktree handling: ask, bind, skip, error + (default: ask) + --nvidia Enable NVIDIA GPU passthrough via CDI + Requires nvidia-container-toolkit on host + --no-config Ignore project configuration file + --install-config Create/display .git/yolo/config template + + Additional podman options can be passed before -- + +EXAMPLES: + yolo # Basic usage + yolo --nvidia # With GPU support + yolo -v /data:/data # Extra mount + yolo -- "help with this code" # Pass args to claude + yolo --nvidia -- --resume # GPU + claude args + +NVIDIA GPU SETUP: + The --nvidia flag uses CDI (Container Device Interface) for GPU access. + Prerequisites: + 1. Install nvidia-container-toolkit on your host + 2. Generate CDI spec: sudo nvidia-ctk cdi generate --output=/etc/cdi/nvidia.yaml + 3. Use: yolo --nvidia + +PROJECT CONFIGURATION: + Create .git/yolo/config to set per-project defaults. + Run 'yolo --install-config' to create a template. + + The config file is a bash script that can set: + - YOLO_PODMAN_VOLUMES: array of volume mounts + - YOLO_PODMAN_OPTIONS: array of podman options + - YOLO_CLAUDE_ARGS: array of arguments for claude + - USE_ANONYMIZED_PATHS: 0 or 1 + - USE_NVIDIA: 0 or 1 + - WORKTREE_MODE: ask, bind, skip, or error + +EOF + exit 0 +} + +# Function to print the default config template +print_config_template() { + cat << 'EOF' +# YOLO Project Configuration +# This file is sourced as a bash script by yolo +# Location: .git/yolo/config + +# Volume mounts - array of volumes to mount +# Syntax options: +# "~/projects" -> ~/projects:~/projects:Z (1-to-1 mount) +# "~/projects::ro" -> ~/projects:~/projects:ro,Z (1-to-1 with options) +# "~/data:/data:Z" -> ~/data:/data:Z (explicit mapping) +YOLO_PODMAN_VOLUMES=( + # "~/projects" + # "~/data::ro" +) + +# Additional podman options - array of options +YOLO_PODMAN_OPTIONS=( + # "--env=DEBUG=1" + # "--network=host" +) + +# Claude arguments - array of arguments passed to claude +YOLO_CLAUDE_ARGS=( + # "--model=claude-3-opus-20240229" +) + +# Default flags (0 or 1) +# USE_ANONYMIZED_PATHS=0 +# USE_NVIDIA=0 +# WORKTREE_MODE="ask" +EOF +} + +# Function to install config +install_config() { + # Find .git directory + local git_dir="" + local current_dir="$(pwd)" + + while [ "$current_dir" != "/" ]; do + if [ -d "$current_dir/.git" ]; then + git_dir="$current_dir/.git" + break + elif [ -f "$current_dir/.git" ]; then + # Worktree - read gitdir path + local gitdir_line=$(cat "$current_dir/.git") + if [[ "$gitdir_line" =~ ^gitdir:\ (.+)$ ]]; then + local gitdir_path="${BASH_REMATCH[1]}" + if [[ "$gitdir_path" != /* ]]; then + gitdir_path="$current_dir/$gitdir_path" + fi + if [[ "$gitdir_path" =~ ^(.+/\.git)/worktrees/ ]]; then + git_dir="${BASH_REMATCH[1]}" + break + fi + fi + fi + current_dir="$(dirname "$current_dir")" + done + + if [ -z "$git_dir" ]; then + echo "Error: Not in a git repository" >&2 + exit 1 + fi + + local config_dir="$git_dir/yolo" + local config_file="$config_dir/config" + + if [ -f "$config_file" ]; then + echo "Config file already exists at: $config_file" + echo "" + cat "$config_file" + else + mkdir -p "$config_dir" + print_config_template > "$config_file" + echo "Created config file at: $config_file" + echo "" + echo "Edit with: vi $config_file" + fi + + exit 0 +} + +# Function to expand volume shorthand syntax +expand_volume() { + local vol="$1" + + # Check for :: syntax first (shorthand with options) + if [[ "$vol" == *::* ]]; then + # Shorthand with options: ~/projects::ro,Z + local path="${vol%%::*}" + local opts="${vol#*::}" + # Expand ~ to $HOME + path="${path/#\~/$HOME}" + echo "${path}:${path}:${opts}" + elif [[ "$vol" == *:*:* ]]; then + # Full form: host:container:options + echo "$vol" + elif [[ "$vol" == *:* ]]; then + # Partial form: host:container (add :Z) + echo "${vol}:Z" + else + # Shorthand: ~/projects + # Expand ~ to $HOME and create 1-to-1 mapping + local path="${vol/#\~/$HOME}" + echo "${path}:${path}:Z" + fi +} + +# Parse arguments: everything before -- goes to podman, everything after goes to claude +# Also check for --anonymized-paths, --entrypoint, --worktree, --nvidia, --no-config, and --install-config flags +PODMAN_ARGS=() +CLAUDE_ARGS=() +found_separator=0 +USE_ANONYMIZED_PATHS=0 +ENTRYPOINT="claude" +WORKTREE_MODE="ask" +USE_NVIDIA=0 +USE_CONFIG=1 + +# Initialize config arrays +YOLO_PODMAN_VOLUMES=() +YOLO_PODMAN_OPTIONS=() +YOLO_CLAUDE_ARGS=() + +while [ $# -gt 0 ]; do + case "$1" in + -h|--help) + show_help + ;; + --install-config) + install_config + ;; + --entrypoint) + ENTRYPOINT="$2" + shift 2 + ;; + --entrypoint=*) + ENTRYPOINT="${1#--entrypoint=}" + shift + ;; + --worktree=*) + WORKTREE_MODE="${1#--worktree=}" + # Validate worktree mode + if [[ ! "$WORKTREE_MODE" =~ ^(ask|bind|skip|error)$ ]]; then + echo "Error: Invalid --worktree value: $WORKTREE_MODE" >&2 + echo "Valid values are: ask, bind, skip, error" >&2 + exit 1 + fi + shift + ;; + --anonymized-paths) + USE_ANONYMIZED_PATHS=1 + shift + ;; + --nvidia) + USE_NVIDIA=1 + shift + ;; + --no-config) + USE_CONFIG=0 + shift + ;; + --) + shift + found_separator=1 + CLAUDE_ARGS=("$@") + break + ;; + *) + if [ "$found_separator" -eq 1 ]; then + CLAUDE_ARGS+=("$1") + else + PODMAN_ARGS+=("$1") + fi + shift + ;; + esac +done + +# Save original PODMAN_ARGS for later (before we potentially move them to CLAUDE_ARGS) +CLI_PODMAN_ARGS=("${PODMAN_ARGS[@]}") + +if [ "$found_separator" = 0 ]; then + # so we did not find any -- everything is actually CLAUDE_ARGS + CLAUDE_ARGS=("${PODMAN_ARGS[@]}") + PODMAN_ARGS=() +fi + +# Load configuration if requested (after separator logic) +if [ "$USE_CONFIG" -eq 1 ]; then + # Find .git directory + git_dir="" + current_dir="$(pwd)" + + while [ "$current_dir" != "/" ]; do + if [ -d "$current_dir/.git" ]; then + git_dir="$current_dir/.git" + break + elif [ -f "$current_dir/.git" ]; then + # Worktree - read gitdir path + gitdir_line=$(cat "$current_dir/.git") + if [[ "$gitdir_line" =~ ^gitdir:\ (.+)$ ]]; then + gitdir_path="${BASH_REMATCH[1]}" + if [[ "$gitdir_path" != /* ]]; then + gitdir_path="$current_dir/$gitdir_path" + fi + if [[ "$gitdir_path" =~ ^(.+/\.git)/worktrees/ ]]; then + git_dir="${BASH_REMATCH[1]}" + break + fi + fi + fi + current_dir="$(dirname "$current_dir")" + done + + if [ -n "$git_dir" ]; then + config_dir="$git_dir/yolo" + config_file="$config_dir/config" + + # Auto-create config directory and template on first run + if [ ! -d "$config_dir" ]; then + mkdir -p "$config_dir" + print_config_template > "$config_file" + echo "Created default config at: $config_file" >&2 + echo "Edit with: vi $config_file" >&2 + echo "" >&2 + fi + + if [ -f "$config_file" ]; then + # Source the config file + source "$config_file" + + # Process volumes and expand shorthand syntax + for vol in "${YOLO_PODMAN_VOLUMES[@]}"; do + expanded=$(expand_volume "$vol") + PODMAN_ARGS=("-v" "$expanded" "${PODMAN_ARGS[@]}") + done + + # Add podman options + for opt in "${YOLO_PODMAN_OPTIONS[@]}"; do + PODMAN_ARGS=("$opt" "${PODMAN_ARGS[@]}") + done + + # Add claude args + if [ ${#YOLO_CLAUDE_ARGS[@]} -gt 0 ]; then + CLAUDE_ARGS=("${YOLO_CLAUDE_ARGS[@]}" "${CLAUDE_ARGS[@]}") + fi + fi + fi +fi + +# Give a meaningful name based on PWD and the PID to help identifying +# all those podman containers +# Note: leading periods and underscores are stripped as they're not allowed in container names +name=$( echo "$PWD-$$" | sed -e "s,^$HOME/,,g" -e "s,[^a-zA-Z0-9_.-],_,g" -e "s,^[._]*,," ) + +CLAUDE_HOME_DIR="$HOME/.claude" +# must exist but might not if first start on that box +mkdir -p "$CLAUDE_HOME_DIR" + +# Detect if we're in a git worktree and find the original repo +WORKTREE_MOUNTS=() +gitdir_path="" +dot_git="$(pwd)/.git" +is_worktree=0 +original_repo_dir="" + +if [ -L "$dot_git" ]; then + # .git is a symlink - resolve it to get the gitdir path + gitdir_path=$(realpath "$dot_git" 2>/dev/null) +elif [ -f "$dot_git" ]; then + # .git is a file, likely a worktree - read the gitdir path + gitdir_line=$(cat "$dot_git") + if [[ "$gitdir_line" =~ ^gitdir:\ (.+)$ ]]; then + gitdir_path="${BASH_REMATCH[1]}" + # Resolve to absolute path if relative + if [[ "$gitdir_path" != /* ]]; then + gitdir_path="$(pwd)/$gitdir_path" + fi + gitdir_path=$(realpath "$gitdir_path" 2>/dev/null || echo "$gitdir_path") + fi +fi + +if [ -n "$gitdir_path" ]; then + # gitdir_path is typically /path/to/original/repo/.git/worktrees/ + # We need to find the original repo's .git directory + if [[ "$gitdir_path" =~ ^(.+/\.git)/worktrees/ ]]; then + original_git_dir="${BASH_REMATCH[1]}" + original_repo_dir=$(dirname "$original_git_dir") + # Only consider it a worktree if it's different from our current workspace + if [ "$original_repo_dir" != "$(pwd)" ]; then + is_worktree=1 + fi + fi +fi + +# Handle worktree based on the mode +if [ "$is_worktree" -eq 1 ]; then + case "$WORKTREE_MODE" in + error) + echo "Error: Running in a git worktree is not allowed with --worktree=error" >&2 + echo "Original repo: $original_repo_dir" >&2 + exit 1 + ;; + bind) + WORKTREE_MOUNTS+=("-v" "$original_repo_dir:$original_repo_dir:Z") + ;; + skip) + # Do nothing - skip bind mount + ;; + ask) + echo "Detected git worktree. Original repository: $original_repo_dir" >&2 + echo "Bind mounting the original repo allows git operations but may expose unintended files." >&2 + read -p "Bind mount original repository? [y/N] " -n 1 -r >&2 + echo >&2 + if [[ $REPLY =~ ^[Yy]$ ]]; then + WORKTREE_MOUNTS+=("-v" "$original_repo_dir:$original_repo_dir:Z") + fi + ;; + esac +fi + +# Determine paths based on --anonymized-paths flag +if [ "$USE_ANONYMIZED_PATHS" -eq 1 ]; then + # Old behavior: use anonymized paths + CLAUDE_DIR="/claude" + WORKSPACE_DIR="/workspace" + CLAUDE_MOUNT="$CLAUDE_HOME_DIR:/claude:Z" + WORKSPACE_MOUNT="$(pwd):/workspace:Z" +else + # New default behavior: preserve original host paths + CLAUDE_DIR="$CLAUDE_HOME_DIR" + WORKSPACE_DIR="$(pwd)" + CLAUDE_MOUNT="$CLAUDE_HOME_DIR:$CLAUDE_HOME_DIR:Z" + WORKSPACE_MOUNT="$(pwd):$(pwd):Z" +fi + +# Build the command to run inside the container +if [ "$ENTRYPOINT" = "claude" ]; then + # Default: run claude with --dangerously-skip-permissions + CONTAINER_CMD=("claude" "--dangerously-skip-permissions" "${CLAUDE_ARGS[@]}") +else + # Custom entrypoint: run as-is with any additional args + CONTAINER_CMD=("$ENTRYPOINT" "${CLAUDE_ARGS[@]}") +fi + +# NVIDIA GPU support via CDI (Container Device Interface) +# Requires nvidia-container-toolkit on host with CDI spec generated +NVIDIA_ARGS=() +if [ "$USE_NVIDIA" -eq 1 ]; then + # Check if CDI spec exists + if [ ! -f /etc/cdi/nvidia.yaml ] && [ ! -f /var/run/cdi/nvidia.yaml ]; then + echo "Warning: NVIDIA CDI spec not found at /etc/cdi/nvidia.yaml or /var/run/cdi/nvidia.yaml" >&2 + echo "GPU passthrough may not work. Install nvidia-container-toolkit and run:" >&2 + echo " sudo nvidia-ctk cdi generate --output=/etc/cdi/nvidia.yaml" >&2 + echo >&2 + fi + NVIDIA_ARGS+=(--device "nvidia.com/gpu=all") + NVIDIA_ARGS+=(--security-opt "label=disable") +fi + +podman run --log-driver=none -it --rm \ + --user="$(id -u):$(id -g)"\ + --userns=keep-id \ + --name="$name" \ + -v "$CLAUDE_MOUNT" \ + -v "$HOME/.gitconfig:/tmp/.gitconfig:ro,Z" \ + -v "$WORKSPACE_MOUNT" \ + "${WORKTREE_MOUNTS[@]}" \ + -w "$WORKSPACE_DIR" \ + -e HOME \ + -e CLAUDE_CONFIG_DIR="$CLAUDE_DIR" \ + -e GIT_CONFIG_GLOBAL=/tmp/.gitconfig \ + -e CLAUDE_CODE_OAUTH_TOKEN \ + -e CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS \ + "${NVIDIA_ARGS[@]}" \ + "${PODMAN_ARGS[@]}" \ + con-bomination-claude-code \ + "${CONTAINER_CMD[@]}" +``` +
+ +### just-meng (2026-03-04T11:41:20Z) + +Now on podman version 4.6.2, still same behavior. When I look up `~/.claude/.credentials.json`, it contains `"expiresAt":1772583776416`. I suspect it is tied to my account/rate limited plan somehow that the token itself is valid only for a limited time. + +### asmacdo (2026-03-06T17:33:40Z) + +@just-meng once its working correctly, the token will still expire, but it should be able to renew itself IIRC + +### yarikoptic (2026-03-12T15:57:24Z) + +@just-meng original issue was about HOME mismatch -- have that one being found peace with so we could close? the other was credentials which relate likely to the issue I cited and generic to claude code -? + +### just-meng (2026-03-12T16:51:51Z) + +no it has not been resolved, still running as node despite the setting + +### yarikoptic (2026-03-26T01:08:53Z) + +BTW with #55 I provide a solution which forces `$HOME` to not correspond between host and container and to be `/home/node` (instead of CWD). And I think it is a good thing. `CLAUDE_HOME_DIR="$HOME/.claude"` is defined outside of container and thus would remain mounted from original user's HOME, and have nothing to do with internal ~/ which would be largely empty anyways (besides what container provides for rudimentary user setup). + +auth issue seems "upstream", as I mentioned +- https://github.com/anthropics/claude-code/issues/1757 + +and which I just overcame via providing a persistent token (although I think this way looses some features) + +### just-meng (2026-03-27T16:43:52Z) + +I had to get a primer on containers and UIDs from claude to understand what's happening here. And that's quite interesting. Let's see if I get it right: + +Apparently my host UID is 1000 by default which is common for a single user on a Linux machine. That's likely not the case for you. Podman also uses UID 1000 by default and refers to this user as node. By accident, when I run yolo which sets --userns=keep-id, my host UID gets mapped into the container which happens to be found in in /etc/passwd due to the podman default user. So my home is set up correctly as /home/node, which means I never have seen any junk files as Yarik has reported. My home was truely ephemeral by accident. For any host UID other than 1000, a lookup in /etc/passwd won't find anything and the home gets set to the CWD which leaves a bunch of unwanted traces. + +The fact that I had to re-login so often likely has nothing to do with the $HOME variable in the first place. Claude was misled by the node user. But this accidentally helped improving yolo in a different aspect! + +### just-meng (2026-03-27T17:05:01Z) + +And my new theory: + + - Token expires after 8 hours + - While a session is running, Claude refreshes it silently ✓ + - When all sessions are closed, nothing is running to do the refresh + - You sleep, token expires, no session to catch it + - Morning: new session starts, token is stale, refresh fails on first message → 401 + +I always shut down my computer because it is too noisy. This likely explains the discrepancy we observe here. I'll try with the 1-year token. + +### just-meng (2026-03-27T17:06:09Z) + +Theory proven wrong! Token JUST expired mid-session. diff --git a/notes/issues/issue-47.md b/notes/issues/issue-47.md new file mode 100644 index 0000000..a97d96f --- /dev/null +++ b/notes/issues/issue-47.md @@ -0,0 +1,115 @@ +https://github.com/con/yolo/issues/47 + +# Config and Arbitrary Development Environments + +This isn't a firm proposal — just consolidating the discussions we've had across several issues and PRs, along with some of my thinking on where this could go. Opening this to get everyone's input in one place and encourage more! + +**Next steps:** +1. Discuss here — poke holes, raise concerns, add ideas +2. Generally agree on the shape of the approach +3. Write a design document (PR) for sharper, per-line discussion + +## Context + +yolo needs to create arbitrary, persistent development environments for each project. +Today, every time someone needs a tool in the image, we hit the same debate: add it to the Dockerfile? Make it an `--extras` flag? A separate image? + +This has come up repeatedly: +- PR #28: playwright added ~600MB, prompting "should be a separate image" +- PR #31: `--packages`/`--extras` added to setup-yolo.sh, with discussion of multiple images and runtime image selection +- PR #43: `--image` with derived Dockerfiles rejected for combinatorial explosion, landed as `--extras=datalad,jj` +- #39: newer git needed — another "what goes in the base image" question +- #33: singularity/apptainer — different container runtime entirely + +The `--extras` pattern was a good stopgap, but we can't encode install instructions for every tool every user might want. Meanwhile, yolo is fully capable of constructing environments ephemerally, but ephemeral environments aren't ideal for development — they need to be reconstructed every time. + +### Target audience + +Our primary users are scientists, not software engineers. +Most will never write a Dockerfile and shouldn't have to. +Whatever we design, the common case needs to be as simple as adding a package name to a config file. + +## Discussion: How should environment customization work? + +Some directions that have come up in prior discussions, consolidated here. + +### Pre-built base images + +Publish a base image to a registry so yolo works out of the box with no build step. +What goes in the base? Just the minimum, or opinionated with group tools like datalad? + +### Config-driven packages + +Let users list packages in config files (apt, pip, etc.) without writing a Dockerfile: + +``` +# in .git/yolo/config or ~/.config/yolo/config +YOLO_APT_PACKAGES=(ffmpeg imagemagick) +YOLO_PIP_PACKAGES=(datalad) +``` + +This could be the primary customization path for most users — a scientist who needs `ffmpeg` just adds it to their project config. + +### Custom Dockerfiles for power users + +For anything that needs custom install steps, users could provide their own Dockerfile (using our base as `FROM` or not). +This would live outside our repo. + +### yolo as the single entrypoint + +Currently `setup-yolo.sh` handles building and `yolo` handles running. +Should yolo handle both — pulling/building images as needed? With a base image in a registry, this would mean yolo works immediately after install. + +### Config precedence + +Build-time config (image name, packages, Dockerfile path, registry) could follow the same precedence as existing runtime config: + +**CLI args > project config > user-wide config > defaults** + +### Build behavior + +Build on first run if image doesn't exist. +`--rebuild` to force. +Auto-detection of config changes could come later. + +## Alternative approaches + +### Two layers only: base image + custom Dockerfile + +This is what Gitpod and Codespaces do — provide a base image, let users write a Dockerfile for customization. Simpler to implement and reason about. However, the gap between "use the base" and "write a Dockerfile" is too wide for our audience. A scientist who just needs `ffmpeg` shouldn't have to learn Docker to get it. +We're leaning away from this toward a config-driven middle path because that's where most of our potential users would actually be comfortable. + +### Other prior art + +- **devcontainer features** — composable install scripts with metadata. Well-specified but heavyweight; requires authoring feature scripts with a specific structure. +- **Nix / devenv** — declarative, reproducible. Elegant but steep learning curve. +- **Docker official image variants** — tag-based (`python:3.12-slim`). No composition, just pick one. + +## Open questions + +- **CLI rewrite?** Bash is hitting its limits for config parsing, registry logic, and the complexity ahead. Python? How much rewrite vs. incremental? +- **Registry?** GHCR, Docker Hub, Quay, multiple? +- **Base image contents?** Minimal vs. opinionated? +- **Alternative runtimes** (#33) — Singularity/Apptainer is a related concern; good architecture now would make it easier later. + +## Related + +- #42 — Extract a SPEC.md +- #33 — Singularity/Apptainer support +- #39 — Need newer git in the environment +- #46 — HOME mismatch between host and container +- PR #28, #31, #43 — Prior discussions on image customization + +## Comments + +### just-meng (2026-03-12T17:17:31Z) + +I totally agree with the your point of providing something more flexible than `--extras` and much simpler than a Dockerfile. On the technical level, I have nothing to contribute, but happy to report "user experiences" once there is a concrete design/solution. + +### yarikoptic (2026-03-13T01:01:15Z) + +on a tangential topic -- I discovered that there is a built in feature for isolation in 'claude code' , https://code.claude.com/docs/en/sandboxing , so I am yet to review/compare the two . Might even just switch away from yolo at some point + +### asmacdo (2026-03-13T08:36:27Z) + +@yarikoptic that discussion: https://github.com/con/yolo/issues/49 diff --git a/notes/issues/issue-49.md b/notes/issues/issue-49.md new file mode 100644 index 0000000..3459932 --- /dev/null +++ b/notes/issues/issue-49.md @@ -0,0 +1,96 @@ +https://github.com/con/yolo/issues/49 + +# Comparison to sandbox mode: yolo still needed? + +
yolo self-generated report: Sandbox vs. YOLO: A Comparison + +Both solve the same fundamental problem — **how to let Claude Code run autonomously without it doing something dangerous** — but they take very different architectural approaches. + +## Core Philosophy + +| | Claude Code Sandbox | YOLO | +|---|---|---| +| **Approach** | Fine-grained OS-level policy enforcement | Coarse-grained container isolation | +| **Metaphor** | "You can run freely, but these specific walls are enforced by the kernel" | "You're in a separate room; do whatever you want in there" | +| **Permission model** | Whitelist domains + filesystem paths, deny everything else | Mount only what's needed, auto-approve everything inside | +| **Technology** | macOS Seatbelt / Linux Bubblewrap + network proxy | Podman rootless containers + user namespaces | + +## What Each Restricts + +| Resource | Claude Code Sandbox | YOLO | +|---|---|---| +| **Filesystem write** | CWD only (configurable allowlist) | Only mounted volumes (CWD, `~/.claude`, `.gitconfig`) | +| **Filesystem read** | Whole machine minus denylist | Only mounted volumes | +| **Network** | Proxy-based domain allowlist | **Unrestricted** (intentional) | +| **SSH keys** | Accessible (unless denied) | **Not mounted** (no git push) | +| **Subprocess isolation** | All children inherit sandbox | All children are in the container | +| **Credentials** | Configurable deny (e.g., `~/.aws/credentials`) | Only OAuth token passed via env var | + +## Key Trade-offs + +### Claude Code Sandbox strengths + +- **Network filtering** — the biggest differentiator. YOLO has no network restrictions, meaning a prompt injection attack could exfiltrate data. The sandbox's domain-level proxy blocks this. +- **No setup overhead** — no container build step, no image maintenance. Just `bubblewrap` + `socat` on Linux. +- **Full host read access by default** — Claude can read your whole machine (minus denylists), which is useful for cross-project work. +- **Granular configurability** — per-path, per-domain rules in `settings.json`. +- **Open-source runtime** — `@anthropic-ai/sandbox-runtime` is reusable in other agent projects. + +### YOLO strengths + +- **Stronger filesystem isolation** — sandbox allows reading the whole machine by default; YOLO only exposes what you mount. A read-based side-channel attack is harder. +- **Reproducible environment** — the Dockerfile gives you a known, consistent toolchain (git, gh, shellcheck, uv, etc.). No "works on my machine" issues. +- **Customizable environments** — NVIDIA GPU support, Playwright, DataLad, jj via `--extras`. The sandbox doesn't manage your tool environment. +- **Session portability** — preserved paths mean sessions work identically in and out of the container. +- **Config layering** — user-wide + per-project config with array merging for volumes/args. +- **No SSH key exposure** — a deliberate security boundary that forces push operations to happen on the host. + +## Where YOLO Has a Gap + +The **network** is the elephant in the room. YOLO's container has unrestricted network access. This means: + +- A prompt injection in a code comment could instruct Claude to `curl` data to an attacker's server +- Malicious dependencies could phone home +- There's no domain allowlisting + +The sandbox's proxy-based network filtering directly addresses this threat model. + +## Where Claude Code Sandbox Has a Gap + +- **No environment management** — you're running on your host. If Claude needs `playwright` or `cuda`, it's installing on your machine. +- **Read access is broad by default** — `~/.ssh`, `~/.gnupg`, env files, etc. are readable unless you explicitly deny them. YOLO's mount-only approach is deny-by-default for the filesystem. +- **Platform limitations** — no native Windows support, weaker in nested Docker environments on Linux. + +## Could They Be Combined? + +Yes, and it would be the strongest posture: + +1. **YOLO for environment isolation** — reproducible toolchain, mount-only filesystem, no SSH keys +2. **Claude Code sandbox inside the container** — add network filtering and filesystem write restrictions within the already-restricted container + +This would give you defense-in-depth: container boundaries for the coarse isolation + sandbox policies for fine-grained network and filesystem control inside. + +## Summary + +If your primary threat model is **"Claude modifies the wrong files or installs something I don't want"**, both approaches work well. + +If your threat model includes **"data exfiltration via network"**, the Claude Code sandbox is strictly better today — YOLO would need network restrictions (e.g., podman `--network=none` plus an allowlist proxy, or just enabling sandbox mode inside the container). + +If your concern is **reproducible, portable environments with known toolchains**, YOLO provides something the sandbox doesn't attempt. + +They're complementary rather than competing. + +
+ +tldr; + +sandbox: You can run freely, but these specific walls are enforced by the kernel + - Global Read: Whole machine minus denylist +- Limited Write +- network protection + +yolo: You're in a separate room; do whatever you want in there + - Read: Mount only what's needed, auto-approve everything inside +- no network protection + +IMO, global read with deny list is insufficient for `--dangerously-bypass-permissions`. I will not be using sandbox mode. So I think that `yolo` continues to be worth some time and effort to maintain/improve. @yarikoptic feel free to close if satisfied. diff --git a/notes/issues/issue-5.md b/notes/issues/issue-5.md new file mode 100644 index 0000000..0de20d7 --- /dev/null +++ b/notes/issues/issue-5.md @@ -0,0 +1,8 @@ +https://github.com/con/yolo/issues/5 + +# Make setup-yolo.sh do basic testing + +could be to complement +- #4 + +as `./setup-yolo.sh` could invoke the `yolo` with some basic prompt on this repo to ensure that it all works. Then CI actually could just run `setup-yolo.sh` in that mode diff --git a/notes/issues/issue-51-selinux.md b/notes/issues/issue-51-selinux.md new file mode 100644 index 0000000..13cd155 --- /dev/null +++ b/notes/issues/issue-51-selinux.md @@ -0,0 +1,28 @@ +# Issue #51: EACCES crash when running multiple yolo instances + +## Problem +Running two yolo containers simultaneously causes the first one to crash with: +``` +EACCES: permission denied, open '/home/austin/.claude/.claude.json' +``` + +## Root Cause +All volume mounts in yolo used `:Z` (uppercase) SELinux label, which means +"private unshared label". When a second container mounts the same `~/.claude` +directory with `:Z`, the container runtime relabels the files for container #2, +revoking access from container #1. Container #1 then gets EACCES. + +This explains why two native Claude Code sessions work fine (no SELinux +relabeling) but two yolo containers don't. + +## Fix Applied +Changed `CLAUDE_MOUNT` lines (417 and 423 in bin/yolo) from `:Z` to `:z` +(lowercase = shared label, allows multiple containers to access simultaneously). + +Only the `~/.claude` mount was changed. Other mounts (workspace, worktree) still +use `:Z` since those are typically not shared between concurrent containers. + +## Status +- Fix applied, needs testing: run two yolo sessions simultaneously and confirm + the first no longer crashes. +- If it works, commit and reference issue #51. diff --git a/notes/issues/issue-54.md b/notes/issues/issue-54.md new file mode 100644 index 0000000..f56e0a8 --- /dev/null +++ b/notes/issues/issue-54.md @@ -0,0 +1,18 @@ +https://github.com/con/yolo/issues/54 + +# add support for openrouter users + +at the moment, yolo expects that I have a claude code subscription - sadly I don't. But I have a bunch of credits in an openrouter account - and I can use claude code with openrouter. + +I think this would be a relatively easy fix, afaict (and have tested locally), it just requires two extra ENV variables: + +```bash + +podman run --log-driver=none -it --rm \ + ... + -e ANTHROPIC_BASE_URL="https://openrouter.ai/api" \ + -e ANTHROPIC_AUTH_TOKEN="${OPENROUTER_API_KEY}" + ... +``` + +Would this go as an option into `setup.yolo`? diff --git a/notes/issues/issue-draft-config-environments.md b/notes/issues/issue-draft-config-environments.md new file mode 100644 index 0000000..8e10778 --- /dev/null +++ b/notes/issues/issue-draft-config-environments.md @@ -0,0 +1,100 @@ +# Config and Arbitrary Development Environments + +This isn't a firm proposal — just consolidating the discussions we've had across several issues and PRs, along with some of my thinking on where this could go. Opening this to get everyone's input in one place and encourage more! + +**Next steps:** +1. Discuss here — poke holes, raise concerns, add ideas +2. Generally agree on the shape of the approach +3. Write a design document (PR) for sharper, per-line discussion + +## Context + +yolo needs to create arbitrary, persistent development environments for each project. +Today, every time someone needs a tool in the image, we hit the same debate: add it to the Dockerfile? Make it an `--extras` flag? A separate image? + +This has come up repeatedly: +- PR #28: playwright added ~600MB, prompting "should be a separate image" +- PR #31: `--packages`/`--extras` added to setup-yolo.sh, with discussion of multiple images and runtime image selection +- PR #43: `--image` with derived Dockerfiles rejected for combinatorial explosion, landed as `--extras=datalad,jj` +- #39: newer git needed — another "what goes in the base image" question +- #33: singularity/apptainer — different container runtime entirely + +The `--extras` pattern was a good stopgap, but we can't encode install instructions for every tool every user might want. Meanwhile, yolo is fully capable of constructing environments ephemerally, but ephemeral environments aren't ideal for development — they need to be reconstructed every time. + +### Target audience + +Our primary users are scientists, not software engineers. +Most will never write a Dockerfile and shouldn't have to. +Whatever we design, the common case needs to be as simple as adding a package name to a config file. + +## Discussion: How should environment customization work? + +Some directions that have come up in prior discussions, consolidated here. + +### Pre-built base images + +Publish a base image to a registry so yolo works out of the box with no build step (#3). +What goes in the base? Just the minimum, or opinionated with group tools like datalad? + +### Config-driven packages + +Let users list packages in config files (apt, pip, etc.) without writing a Dockerfile: + +``` +# in .git/yolo/config or ~/.config/yolo/config +YOLO_APT_PACKAGES=(ffmpeg imagemagick) +YOLO_PIP_PACKAGES=(datalad) +``` + +This could be the primary customization path for most users — a scientist who needs `ffmpeg` just adds it to their project config. + +### Custom Dockerfiles for power users + +For anything that needs custom install steps, users could provide their own Dockerfile (using our base as `FROM` or not). +This would live outside our repo. + +### yolo as the single entrypoint + +Currently `setup-yolo.sh` handles building and `yolo` handles running. +Should yolo handle both — pulling/building images as needed? With a base image in a registry, this would mean yolo works immediately after install. + +### Config precedence + +Build-time config (image name, packages, Dockerfile path, registry) could follow the same precedence as existing runtime config: + +**CLI args > project config > user-wide config > defaults** + +### Build behavior + +Build on first run if image doesn't exist. +`--rebuild` to force. +Auto-detection of config changes could come later. + +## Alternative approaches + +### Two layers only: base image + custom Dockerfile + +This is what Gitpod and Codespaces do — provide a base image, let users write a Dockerfile for customization. Simpler to implement and reason about. However, the gap between "use the base" and "write a Dockerfile" is too wide for our audience. A scientist who just needs `ffmpeg` shouldn't have to learn Docker to get it. +We're leaning away from this toward a config-driven middle path because that's where most of our potential users would actually be comfortable. + +### Other prior art + +- **devcontainer features** — composable install scripts with metadata. Well-specified but heavyweight; requires authoring feature scripts with a specific structure. +- **Nix / devenv** — declarative, reproducible. Elegant but steep learning curve. +- **Docker official image variants** — tag-based (`python:3.12-slim`). No composition, just pick one. + +## Open questions + +- **CLI rewrite?** Bash is hitting its limits for config parsing, registry logic, and the complexity ahead. Python? How much rewrite vs. incremental? +- **Registry?** GHCR, Docker Hub, Quay, multiple? +- **Base image contents?** Minimal vs. opinionated? +- **Alternative runtimes** (#33) — Singularity/Apptainer is a related concern; good architecture now would make it easier later. + +## Related + +- #42 — Extract a SPEC.md +- #33 — Singularity/Apptainer support +- #3 — Does not work out of the box +- #39 — Need newer git in the environment +- #46 — HOME mismatch between host and container +- PR #28, #31, #43 — Prior discussions on image customization diff --git a/notes/issues/non-home-worktree-issue.md b/notes/issues/non-home-worktree-issue.md new file mode 100644 index 0000000..178cfea --- /dev/null +++ b/notes/issues/non-home-worktree-issue.md @@ -0,0 +1,36 @@ +# Container name invalid when running outside $HOME + +## Problem + +When running `yolo` from a directory outside `$HOME` (e.g., `/tmp/duct-worktree`), the container fails to start: + +``` +Error: running container create option: names must match [a-zA-Z0-9][a-zA-Z0-9_.-]*: invalid argument +``` + +## Cause + +The container name is generated by this line in `bin/yolo`: + +```bash +name=$( echo "$PWD-$$" | sed -e "s,^$HOME/,,g" -e "s,[^a-zA-Z0-9_.-],_,g" ) +``` + +For `/tmp/duct-worktree`, this produces `_tmp_duct-worktree-12345` which starts with `_`. Container names must start with `[a-zA-Z0-9]`. + +## Reproduction + +```bash +git -C ~/devel/duct worktree add /tmp/duct-worktree HEAD +cd /tmp/duct-worktree +~/devel/yolo/bin/yolo --worktree=skip +``` + +## Possible fix + +Prefix with a letter or strip leading underscores: + +```bash +name=$( echo "$PWD-$$" | sed -e "s,^$HOME/,,g" -e "s,[^a-zA-Z0-9_.-],_,g" -e "s,^_*,," ) +name="yolo-${name:-default}" +``` diff --git a/notes/prs/pr-48.md b/notes/prs/pr-48.md new file mode 100644 index 0000000..3f09ba3 --- /dev/null +++ b/notes/prs/pr-48.md @@ -0,0 +1,1013 @@ +https://github.com/con/yolo/pull/48 + +# Add SPEC.md documenting all current features + +**Author:** Austin Macdonald (asmacdo) +**Branch:** `add-spec` + +## Full Diff + +This is the SPEC PR -- full diff included below. + +```patch +From 4ccd294b5f5d79e8f6e8ba88882a3ad8a54c8ddc Mon Sep 17 00:00:00 2001 +From: Austin Macdonald +Date: Thu, 12 Mar 2026 14:13:00 -0500 +Subject: [PATCH 1/2] Add SPEC.md documenting all current features + +Addresses #42. Extracts a comprehensive specification from the current +implementation covering CLI, configuration, volumes, path modes, worktree +support, NVIDIA GPU, container runtime, Dockerfile, setup script, security +boundaries, testing, and CI/CD. + +Co-Authored-By: Claude Opus 4.6 +--- + SPEC.md | 440 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++ + 1 file changed, 440 insertions(+) + create mode 100644 SPEC.md + +diff --git a/SPEC.md b/SPEC.md +new file mode 100644 +index 0000000..1211fb3 +--- /dev/null ++++ b/SPEC.md +@@ -0,0 +1,440 @@ ++# YOLO Specification ++ ++Extracted from the current implementation as of 2026-03-12. ++See [issue #42](https://github.com/con/yolo/issues/42). ++ ++## 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` or `enhs` branches. ++- 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. + +From af33b113d3c4a25829017007eceef10967b540dd Mon Sep 17 00:00:00 2001 +From: Austin Macdonald +Date: Thu, 12 Mar 2026 15:03:18 -0500 +Subject: [PATCH 2/2] Address PR #48 review: align tables, add CLAUDE.md, + remove enhs branch + +- Reformat all SPEC.md tables with proper column alignment for readability +- Remove dated "Extracted from..." header (not useful long-term) +- Add CLAUDE.md with directive to consult SPEC.md for yolo work +- Remove unused `enhs` branch from CI triggers and spec + +Co-Authored-By: Yaroslav Halchenko +Co-Authored-By: Austin Macdonald +Co-Authored-By: Claude Opus 4.6 +--- + .github/workflows/ci.yml | 2 +- + CLAUDE.md | 3 + + SPEC.md | 287 +++++++++++++++++++-------------------- + 3 files changed, 146 insertions(+), 146 deletions(-) + create mode 100644 CLAUDE.md + +diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml +index 78fc41e..64d85a8 100644 +--- a/.github/workflows/ci.yml ++++ b/.github/workflows/ci.yml +@@ -2,7 +2,7 @@ name: CI + + on: + push: +- branches: [ main, enhs ] ++ branches: [ main ] + pull_request: + branches: [ main ] + +diff --git a/CLAUDE.md b/CLAUDE.md +new file mode 100644 +index 0000000..4407598 +--- /dev/null ++++ b/CLAUDE.md +@@ -0,0 +1,3 @@ ++# YOLO Development ++ ++**IMPORTANT: Always consult `SPEC.md` before making any changes to yolo.** It documents all current features, CLI flags, configuration, container runtime behavior, and security boundaries. Any modifications must stay consistent with the spec, and the spec must be updated to reflect any changes. +diff --git a/SPEC.md b/SPEC.md +index 1211fb3..1b0e25c 100644 +--- a/SPEC.md ++++ b/SPEC.md +@@ -1,8 +1,5 @@ + # YOLO Specification + +-Extracted from the current implementation as of 2026-03-12. +-See [issue #42](https://github.com/con/yolo/issues/42). +- + ## Overview + + YOLO runs Claude Code inside a rootless Podman container with +@@ -11,12 +8,12 @@ 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 | ++| 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 | + + --- + +@@ -33,16 +30,16 @@ 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 | ++| 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 + +@@ -57,10 +54,10 @@ claude. If no `--` is present, all positional arguments go to claude. + + ### File Locations + +-| Scope | Path | Precedence | +-|-------|------|------------| +-| User-wide | `${XDG_CONFIG_HOME:-~/.config}/yolo/config` | Lower | +-| Per-project | `.git/yolo/config` | Higher | ++| 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. + +@@ -73,21 +70,21 @@ auto-created from the built-in template and a message is printed to stderr. + + #### 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 | ++| 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 | ++| 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 + +@@ -107,23 +104,23 @@ User-wide and project arrays are concatenated (user-wide first). + + ### 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 | ++| 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) | ++| 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. + +@@ -133,23 +130,23 @@ The `~/.claude` directory is auto-created if missing. + + ### Preserved Paths (default) + +-| Variable | Value | +-|----------|-------| +-| `CLAUDE_DIR` | `$HOME/.claude` | +-| `WORKSPACE_DIR` | `$(pwd)` | +-| `CLAUDE_MOUNT` | `$HOME/.claude:$HOME/.claude:Z` | +-| `WORKSPACE_MOUNT` | `$(pwd):$(pwd):Z` | ++| 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` | ++| 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. + +@@ -167,12 +164,12 @@ All projects appear at `/workspace`, enabling cross-project session context. + + ### 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 | ++| 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`. + +@@ -213,31 +210,31 @@ When `USE_NVIDIA=1`: + + ### 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 | ++| 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) | ++| 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`) | ++| Entrypoint | Command | ++|--------------------------|------------------------------------------------------------| ++| Default (`claude`) | `claude --dangerously-skip-permissions [CLAUDE_ARGS]` | ++| Custom (`--entrypoint=X`) | `X [CLAUDE_ARGS]` (no `--dangerously-skip-permissions`) | + + ### Image + +@@ -271,48 +268,48 @@ 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 | ++| 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 | ++| 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 | ++| 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` | ++| 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` | + + --- + +@@ -326,31 +323,31 @@ 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 | ++| 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 | ++| 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 | ++| 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. + +@@ -381,12 +378,12 @@ After install, checks if `~/.local/bin` is in `$PATH` and warns if not. + + ### 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 | ++| 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 + +@@ -425,16 +422,16 @@ BATS (Bash Automated Testing System) with `bats-assert` and `bats-support`. + + ### Triggers + +-- Push to `main` or `enhs` branches. ++- 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` | ++| 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.``` + +## PR Description + +Addresses #42. Extracts a comprehensive specification from the current implementation covering CLI, configuration, volumes, path modes, worktree support, NVIDIA GPU, container runtime, Dockerfile, setup script, security boundaries, testing, and CI/CD. + +Produced by Claude-code + +## Comments + +### Inline: @yarikoptic on `SPEC.md` (CI/CD Triggers section) +> ``` +> +### Triggers +> + +> +- Push to `main` or `enhs` branches. +> ``` + +while at it - purge that outdated and unused `enhs` from branches, CI and here + +```suggestion +- Push to `main` branches. +``` + +*2026-03-12T19:24:33Z* + +--- + +### Inline: @yarikoptic on `SPEC.md` (Default Mounts table) +> ``` +> +### Default Mounts +> + +> +| Mount | Host Path | Container Path | Options | +> ``` + +ask claude to reformat all tables to be also for human consumption (properly indented) + +*2026-03-12T19:25:00Z* + +--- + +### Inline: @yarikoptic on `SPEC.md` (line 1) +> ``` +> +# YOLO Specification +> ``` + +add to CLAUDE.md a strong note to consult `SPEC.md` file for any work on `yolo` + +*2026-03-12T19:25:29Z* + +--- + +### Inline: @yarikoptic on `SPEC.md` (preamble) +> ``` +> +Extracted from the current implementation as of 2026-03-12. +> +See [issue #42](https://github.com/con/yolo/issues/42). +> ``` + +information not useful long run + +```suggestion +``` + +*2026-03-12T19:26:03Z* + +--- + +### Inline: @yarikoptic on `CLAUDE.md` (line 3) +> ``` +> +**IMPORTANT: Always consult `SPEC.md` before making any changes to yolo.** +> ``` + +well, if to have this, you should also instruct here to here to reflect any changes in YOLO behavior and interfaces etc mentioned in the file in that file happen they change. Otherwise this duplicated information will diverge soon! Eg. claude happily coded me #53 without changing anything in the spec, although likely he should have + +*2026-03-21T02:31:04Z* diff --git a/notes/prs/pr-51.md b/notes/prs/pr-51.md new file mode 100644 index 0000000..99094ef --- /dev/null +++ b/notes/prs/pr-51.md @@ -0,0 +1,17 @@ +branch: fix/51-selinux-multi-instance +title: Fix EACCES crash when running multiple yolo instances (#51) +body: +## Summary + +- Change SELinux mount label from `:Z` (private unshared) to `:z` (shared) on all runtime-managed volume mounts +- Fixes concurrent yolo instances revoking each other's file access, causing `EACCES: permission denied` crashes +- `--extra-volume` mounts left as `:Z` since users control those directly + +## Test plan + +- [ ] Run `./setup-yolo.sh --install=yes` to reinstall +- [ ] Open two yolo sessions in the same directory simultaneously +- [ ] Type in the first session — confirm no EACCES crash +- [ ] Confirm bash/git commands work in both sessions concurrently + +Closes #51 diff --git a/notes/prs/pr-53.md b/notes/prs/pr-53.md new file mode 100644 index 0000000..5fc14c4 --- /dev/null +++ b/notes/prs/pr-53.md @@ -0,0 +1,21 @@ +https://github.com/con/yolo/pull/53 + +# Provide --name= into claude to match the one of the container + +**Author:** Yaroslav Halchenko (yarikoptic) +**Branch:** `enh-name` + +## Diff Summary + +A small refactor in `bin/yolo` (9 added, 5 removed lines). + +**bin/yolo:** +- Moves the container name generation block (`name=$(...)`) from before the worktree/config handling section to after path mode setup (after `WORKSPACE_MOUNT` is determined). This ensures `$PWD` reflects any worktree changes before the name is computed. +- Prepends `--name=$name` to `CLAUDE_ARGS` so the Claude Code session inside the container receives the same name as the podman container. This makes it easier to correlate a Claude session with its container. +- The name can still be overridden by the user via later CLI arguments. + +## PR Description + +So then it might be much easier to identify it. It would still be possible to rename it if needed or overload on CLI by providing one more --name. + +Note that it was added only recently to claude CLI so you might need to rebuild container diff --git a/notes/prs/pr-55.md b/notes/prs/pr-55.md new file mode 100644 index 0000000..f4d94b4 --- /dev/null +++ b/notes/prs/pr-55.md @@ -0,0 +1,37 @@ +https://github.com/con/yolo/pull/55 + +# Fix HOME defaulting to CWD inside container (#36) + +**Author:** Yaroslav Halchenko (yarikoptic) +**Branch:** `bf-HOME` + +## Diff Summary + +A single-line change in `bin/yolo`. + +**bin/yolo:** +- Adds `-e HOME=/home/node` to the `podman run` invocation, explicitly setting the HOME environment variable inside the container. + +Without this, when using `--userns=keep-id`, the host UID has no `/etc/passwd` entry in the container, causing podman to default HOME to the working directory. This led to `~/.claude` resolving to `$CWD/.claude`, Node.js `os.homedir()` returning the workspace path, and workspace trust acceptance never persisting between sessions. + +## PR Description + +- Fixes #36 +- potentially relates/effects #46 (attn @just-meng) + +I tested on a few sessions - seems to work nice! Might have "unsandboxing" effect though since now ~/.claude is user's claude so changes do persist + +With --userns=keep-id, the host UID has no /etc/passwd entry in the container, so podman defaults HOME to the working directory. This causes several problems: + +- ~/.claude resolves to $CWD/.claude instead of the mounted config dir +- Node.js os.homedir() === process.cwd(), so Claude Code treats every workspace as "running from home directory" +- Workspace trust acceptance is only session-scoped (never persisted), causing the "trust this folder?" dialog on every launch + +Explicitly set HOME=/home/node (the Dockerfile's user home with shell configs) so that HOME is stable regardless of workspace. Claude Code's config is found via CLAUDE_CONFIG_DIR which is already set independently. + +## Comments + +### Comment: @just-meng +See my comment here: https://github.com/con/yolo/issues/46#issuecomment-4143892793 + +*2026-03-27T16:46:37Z* diff --git a/notes/prs/pr-56.md b/notes/prs/pr-56.md new file mode 100644 index 0000000..6b878e5 --- /dev/null +++ b/notes/prs/pr-56.md @@ -0,0 +1,25 @@ +https://github.com/con/yolo/pull/56 + +# feat: Add optional Deno runtime + +**Author:** Chris Markiewicz (effigies) +**Branch:** `deno` + +## Diff Summary + +This PR adds Deno as a new optional extra for the container image, across 2 commits. + +**images/Dockerfile:** +- Adds `EXTRA_DENO` and `DENO_VERSION` build args. +- When `EXTRA_DENO=1`, installs Deno via the official `deno.land/install.sh` script. If `DENO_VERSION` is set, pins to that version; otherwise installs latest. +- Adds Deno's bin directory to PATH in both `.zshrc` and `.bashrc`. +- Sets `ENV PATH="/home/node/.deno/bin:$PATH"` unconditionally so Deno is on the path even in non-interactive shells. + +**setup-yolo.sh:** +- Adds `deno` to the `--extras` help text. +- Adds `deno` to the `all` extras expansion list. +- Adds `deno` to the validation regex and error message for unknown extras. + +## PR Description + +Sometimes claude seems smart enough to install deno on its own, sometimes not. Just adding it to my Dockerfile and sharing upstream. diff --git a/notes/prs/pr-57.md b/notes/prs/pr-57.md new file mode 100644 index 0000000..b1ddd60 --- /dev/null +++ b/notes/prs/pr-57.md @@ -0,0 +1,37 @@ +https://github.com/con/yolo/pull/57 + +# Add --worktree=create:\ mode + +**Author:** Austin Macdonald (asmacdo) +**Branch:** `create-worktree` + +## Diff Summary + +This PR adds a new `--worktree=create:` mode to `bin/yolo`. The changes span 3 commits across `bin/yolo`, `tests/yolo.bats`, `README.md`, `config.example`, and a new `CLAUDE.md`. + +**bin/yolo (core logic):** +- Extends the `--worktree` validation regex to accept `create` and `create:` in addition to the existing `ask|bind|skip|error` values. +- After config loading and `~/.claude` directory creation, adds a new block that handles the `create` mode: + - Bare `--worktree=create` (no branch name) prints an error and exits. + - `--worktree=create:` runs `git worktree add` to create a sibling directory with a new branch, `cd`s into it, regenerates the container name for the new directory, and then falls through with `WORKTREE_MODE="bind"`. +- Updates `--help` text to show the new mode. + +**tests/yolo.bats:** +- Adds two BATS tests: one verifying that bare `--worktree=create` fails with a "requires a branch name" error, and one verifying that `--worktree=create:test-branch` passes argument validation. + +**README.md:** +- Adds documentation and an example for the new `create:` worktree mode. + +**config.example:** +- Updates the `WORKTREE_MODE` comment to mention `create:`. + +**CLAUDE.md (new file):** +- Adds a project overview for Claude Code: architecture, testing instructions, CI description, and a checklist for completing tasks. + +## PR Description + +- Adds `--worktree=create:` mode that creates a git worktree as a sibling directory, checks out a new branch from HEAD, and launches yolo with bind mode +- Enables isolated agent workflows: `yolo --worktree=create:fix-issue-99` +- Updates README, config.example, and --help text +- Adds BATS tests for the new mode +- Adds CLAUDE.md with project overview and task checklist diff --git a/notes/sandbox-comparison.md b/notes/sandbox-comparison.md new file mode 100644 index 0000000..65aa9c0 --- /dev/null +++ b/notes/sandbox-comparison.md @@ -0,0 +1,77 @@ +# Claude Code Sandbox vs. YOLO: A Comparison + +Both solve the same fundamental problem — **how to let Claude Code run autonomously without it doing something dangerous** — but they take very different architectural approaches. + +## Core Philosophy + +| | Claude Code Sandbox | YOLO | +|---|---|---| +| **Approach** | Fine-grained OS-level policy enforcement | Coarse-grained container isolation | +| **Metaphor** | "You can run freely, but these specific walls are enforced by the kernel" | "You're in a separate room; do whatever you want in there" | +| **Permission model** | Whitelist domains + filesystem paths, deny everything else | Mount only what's needed, auto-approve everything inside | +| **Technology** | macOS Seatbelt / Linux Bubblewrap + network proxy | Podman rootless containers + user namespaces | + +## What Each Restricts + +| Resource | Claude Code Sandbox | YOLO | +|---|---|---| +| **Filesystem write** | CWD only (configurable allowlist) | Only mounted volumes (CWD, `~/.claude`, `.gitconfig`) | +| **Filesystem read** | Whole machine minus denylist | Only mounted volumes | +| **Network** | Proxy-based domain allowlist | **Unrestricted** (intentional) | +| **SSH keys** | Accessible (unless denied) | **Not mounted** (no git push) | +| **Subprocess isolation** | All children inherit sandbox | All children are in the container | +| **Credentials** | Configurable deny (e.g., `~/.aws/credentials`) | Only OAuth token passed via env var | + +## Key Trade-offs + +### Claude Code Sandbox strengths + +- **Network filtering** — the biggest differentiator. YOLO has no network restrictions, meaning a prompt injection attack could exfiltrate data. The sandbox's domain-level proxy blocks this. +- **No setup overhead** — no container build step, no image maintenance. Just `bubblewrap` + `socat` on Linux. +- **Full host read access by default** — Claude can read your whole machine (minus denylists), which is useful for cross-project work. +- **Granular configurability** — per-path, per-domain rules in `settings.json`. +- **Open-source runtime** — `@anthropic-ai/sandbox-runtime` is reusable in other agent projects. + +### YOLO strengths + +- **Stronger filesystem isolation** — sandbox allows reading the whole machine by default; YOLO only exposes what you mount. A read-based side-channel attack is harder. +- **Reproducible environment** — the Dockerfile gives you a known, consistent toolchain (git, gh, shellcheck, uv, etc.). No "works on my machine" issues. +- **Customizable environments** — NVIDIA GPU support, Playwright, DataLad, jj via `--extras`. The sandbox doesn't manage your tool environment. +- **Session portability** — preserved paths mean sessions work identically in and out of the container. +- **Config layering** — user-wide + per-project config with array merging for volumes/args. +- **No SSH key exposure** — a deliberate security boundary that forces push operations to happen on the host. + +## Where YOLO Has a Gap + +The **network** is the elephant in the room. YOLO's container has unrestricted network access. This means: + +- A prompt injection in a code comment could instruct Claude to `curl` data to an attacker's server +- Malicious dependencies could phone home +- There's no domain allowlisting + +The sandbox's proxy-based network filtering directly addresses this threat model. + +## Where Claude Code Sandbox Has a Gap + +- **No environment management** — you're running on your host. If Claude needs `playwright` or `cuda`, it's installing on your machine. +- **Read access is broad by default** — `~/.ssh`, `~/.gnupg`, env files, etc. are readable unless you explicitly deny them. YOLO's mount-only approach is deny-by-default for the filesystem. +- **Platform limitations** — no native Windows support, weaker in nested Docker environments on Linux. + +## Could They Be Combined? + +Yes, and it would be the strongest posture: + +1. **YOLO for environment isolation** — reproducible toolchain, mount-only filesystem, no SSH keys +2. **Claude Code sandbox inside the container** — add network filtering and filesystem write restrictions within the already-restricted container + +This would give you defense-in-depth: container boundaries for the coarse isolation + sandbox policies for fine-grained network and filesystem control inside. + +## Summary + +If your primary threat model is **"Claude modifies the wrong files or installs something I don't want"**, both approaches work well. + +If your threat model includes **"data exfiltration via network"**, the Claude Code sandbox is strictly better today — YOLO would need network restrictions (e.g., podman `--network=none` plus an allowlist proxy, or just enabling sandbox mode inside the container). + +If your concern is **reproducible, portable environments with known toolchains**, YOLO provides something the sandbox doesn't attempt. + +They're complementary rather than competing. From d7ebdcd24236d9cc03342d6dad144d2c23321f03 Mon Sep 17 00:00:00 2001 From: Austin Macdonald Date: Sat, 28 Mar 2026 12:19:12 -0500 Subject: [PATCH 02/55] Add Containerfile.base: core image without extras Legacy Dockerfile minus all --extras flags (cuda, playwright, datalad, jj, git-annex, extra packages). Those become container-extras scripts in the new architecture. Co-Authored-By: Claude Opus 4.6 (1M context) --- images/Containerfile.base | 90 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 90 insertions(+) create mode 100644 images/Containerfile.base diff --git a/images/Containerfile.base b/images/Containerfile.base new file mode 100644 index 0000000..49f7fbf --- /dev/null +++ b/images/Containerfile.base @@ -0,0 +1,90 @@ +# Base image for yolo: Claude Code + core development tools +# Based on Anthropic's official devcontainer Dockerfile +FROM node:22 + +ARG TZ +ENV TZ="$TZ" + +ARG CLAUDE_CODE_VERSION=latest + +# Core development tools +# tini: minimal init to reap zombie processes from forked children +RUN apt-get update && apt-get install -y --no-install-recommends \ + dnsutils \ + fzf \ + gh \ + git \ + gnupg2 \ + iproute2 \ + jq \ + less \ + man-db \ + mc \ + moreutils \ + nano \ + ncdu \ + parallel \ + procps \ + shellcheck \ + sudo \ + tini \ + tree \ + unzip \ + vim \ + zsh \ + && apt-get clean && rm -rf /var/lib/apt/lists/* + +# Ensure default node user has access to /usr/local/share +RUN mkdir -p /usr/local/share/npm-global && \ + chown -R node:node /usr/local/share + +# Persist bash history +RUN mkdir /commandhistory && \ + touch /commandhistory/.bash_history && \ + chown -R node /commandhistory + +ENV DEVCONTAINER=true + +# Create workspace and config directories +RUN mkdir -p /workspace /home/node/.claude && \ + chown -R node:node /workspace /home/node/.claude + +WORKDIR /workspace + +# git-delta +ARG GIT_DELTA_VERSION=0.18.2 +RUN ARCH=$(dpkg --print-architecture) && \ + wget "https://github.com/dandavison/delta/releases/download/${GIT_DELTA_VERSION}/git-delta_${GIT_DELTA_VERSION}_${ARCH}.deb" && \ + dpkg -i "git-delta_${GIT_DELTA_VERSION}_${ARCH}.deb" && \ + rm "git-delta_${GIT_DELTA_VERSION}_${ARCH}.deb" + +# Switch to non-root user +USER node + +ENV NPM_CONFIG_PREFIX=/usr/local/share/npm-global +ENV PATH=$PATH:/usr/local/share/npm-global/bin +ENV SHELL=/bin/zsh +ENV EDITOR=vim +ENV VISUAL=vim + +# zsh + powerlevel10k +ARG ZSH_IN_DOCKER_VERSION=1.2.0 +RUN sh -c "$(wget -O- https://github.com/deluan/zsh-in-docker/releases/download/v${ZSH_IN_DOCKER_VERSION}/zsh-in-docker.sh)" -- \ + -p git \ + -p fzf \ + -a "source /usr/share/doc/fzf/examples/key-bindings.zsh" \ + -a "source /usr/share/doc/fzf/examples/completion.zsh" \ + -a "export PROMPT_COMMAND='history -a' && export HISTFILE=/commandhistory/.bash_history" \ + -x + +# Claude Code +RUN npm install -g @anthropic-ai/claude-code@${CLAUDE_CODE_VERSION} + +# uv (Python package manager) +RUN curl -LsSf https://astral.sh/uv/install.sh | sh && \ + echo 'export PATH="$HOME/.local/bin:$PATH"' >> ~/.zshrc && \ + echo 'export PATH="$HOME/.local/bin:$PATH"' >> ~/.bashrc +ENV PATH="/home/node/.local/bin:$PATH" + +ENTRYPOINT ["/usr/bin/tini", "--"] +CMD ["claude"] From 4f356125a2d3fa8bbb471427c88a6625e8d4000e Mon Sep 17 00:00:00 2001 From: Austin Macdonald Date: Sat, 28 Mar 2026 12:58:03 -0500 Subject: [PATCH 03/55] Containerfile.base: minimal debian image with native Claude Code MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - FROM debian:bookworm instead of node:22 - Native Claude Code installer (no npm/Node.js dependency) - Minimal packages: ca-certificates, curl, git, jq, sudo, tini, tree, vim, wget - Non-root 'yolo' user with passwordless sudo - Auto-update disabled (container is ephemeral) - No language runtimes — those come from container-extras Co-Authored-By: Claude Opus 4.6 (1M context) --- images/Containerfile.base | 84 +++++++-------------------------------- 1 file changed, 15 insertions(+), 69 deletions(-) diff --git a/images/Containerfile.base b/images/Containerfile.base index 49f7fbf..999660e 100644 --- a/images/Containerfile.base +++ b/images/Containerfile.base @@ -1,90 +1,36 @@ -# Base image for yolo: Claude Code + core development tools -# Based on Anthropic's official devcontainer Dockerfile -FROM node:22 +# 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" -ARG CLAUDE_CODE_VERSION=latest - -# Core development tools -# tini: minimal init to reap zombie processes from forked children RUN apt-get update && apt-get install -y --no-install-recommends \ - dnsutils \ - fzf \ - gh \ + ca-certificates \ + curl \ git \ - gnupg2 \ - iproute2 \ jq \ - less \ - man-db \ - mc \ - moreutils \ - nano \ - ncdu \ - parallel \ - procps \ - shellcheck \ sudo \ tini \ tree \ - unzip \ vim \ - zsh \ + wget \ && apt-get clean && rm -rf /var/lib/apt/lists/* -# Ensure default node user has access to /usr/local/share -RUN mkdir -p /usr/local/share/npm-global && \ - chown -R node:node /usr/local/share - -# Persist bash history -RUN mkdir /commandhistory && \ - touch /commandhistory/.bash_history && \ - chown -R node /commandhistory - -ENV DEVCONTAINER=true - -# Create workspace and config directories -RUN mkdir -p /workspace /home/node/.claude && \ - chown -R node:node /workspace /home/node/.claude +# 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 -# git-delta -ARG GIT_DELTA_VERSION=0.18.2 -RUN ARCH=$(dpkg --print-architecture) && \ - wget "https://github.com/dandavison/delta/releases/download/${GIT_DELTA_VERSION}/git-delta_${GIT_DELTA_VERSION}_${ARCH}.deb" && \ - dpkg -i "git-delta_${GIT_DELTA_VERSION}_${ARCH}.deb" && \ - rm "git-delta_${GIT_DELTA_VERSION}_${ARCH}.deb" - -# Switch to non-root user -USER node - -ENV NPM_CONFIG_PREFIX=/usr/local/share/npm-global -ENV PATH=$PATH:/usr/local/share/npm-global/bin -ENV SHELL=/bin/zsh -ENV EDITOR=vim -ENV VISUAL=vim - -# zsh + powerlevel10k -ARG ZSH_IN_DOCKER_VERSION=1.2.0 -RUN sh -c "$(wget -O- https://github.com/deluan/zsh-in-docker/releases/download/v${ZSH_IN_DOCKER_VERSION}/zsh-in-docker.sh)" -- \ - -p git \ - -p fzf \ - -a "source /usr/share/doc/fzf/examples/key-bindings.zsh" \ - -a "source /usr/share/doc/fzf/examples/completion.zsh" \ - -a "export PROMPT_COMMAND='history -a' && export HISTFILE=/commandhistory/.bash_history" \ - -x +USER yolo # Claude Code -RUN npm install -g @anthropic-ai/claude-code@${CLAUDE_CODE_VERSION} - -# uv (Python package manager) -RUN curl -LsSf https://astral.sh/uv/install.sh | sh && \ - echo 'export PATH="$HOME/.local/bin:$PATH"' >> ~/.zshrc && \ - echo 'export PATH="$HOME/.local/bin:$PATH"' >> ~/.bashrc -ENV PATH="/home/node/.local/bin:$PATH" +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"] From 74cca27b2786915aa1ba6da3b246e0f69fb1eef9 Mon Sep 17 00:00:00 2001 From: Austin Macdonald Date: Sat, 28 Mar 2026 13:14:00 -0500 Subject: [PATCH 04/55] Add Containerfile.extras, apt.sh, python.sh, and test scaffolding - Containerfile.extras: static Dockerfile that layers container-extras on top of yolo-base via a build context with scripts + run.sh manifest - apt.sh: simple wrapper around apt-get install - python.sh: installs Python via uv with version arg, creates symlinks - test-extras-build.sh: build + idempotency verification - features_to_test.md: running checklist of things to test Co-Authored-By: Claude Opus 4.6 (1M context) --- container-extras/apt.sh | 5 ++++ container-extras/python.sh | 25 +++++++++++++++++++ images/Containerfile.extras | 15 ++++++++++++ tests/features_to_test.md | 16 ++++++++++++ tests/test-extras-build.sh | 49 +++++++++++++++++++++++++++++++++++++ 5 files changed, 110 insertions(+) create mode 100644 container-extras/apt.sh create mode 100644 container-extras/python.sh create mode 100644 images/Containerfile.extras create mode 100644 tests/features_to_test.md create mode 100755 tests/test-extras-build.sh diff --git a/container-extras/apt.sh b/container-extras/apt.sh new file mode 100644 index 0000000..8841c1c --- /dev/null +++ b/container-extras/apt.sh @@ -0,0 +1,5 @@ +#!/bin/bash +# Install apt packages +# Usage: apt.sh package1 package2 ... +set -eu +sudo apt-get install -y --no-install-recommends "$@" diff --git a/container-extras/python.sh b/container-extras/python.sh new file mode 100644 index 0000000..57024ef --- /dev/null +++ b/container-extras/python.sh @@ -0,0 +1,25 @@ +#!/bin/bash +# Install Python via uv +# Usage: python.sh [version] +# Default: latest stable +set -eu + +# 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 + +VERSION="${1:-}" +if [ -n "$VERSION" ]; then + uv python install "$VERSION" +else + uv python install +fi + +# Create python3 and python symlinks +PYTHON_BIN="$(uv python find ${VERSION:+$VERSION} 2>/dev/null)" +if [ -n "$PYTHON_BIN" ]; then + ln -sf "$PYTHON_BIN" "$HOME/.local/bin/python3" + ln -sf "$PYTHON_BIN" "$HOME/.local/bin/python" +fi diff --git a/images/Containerfile.extras b/images/Containerfile.extras new file mode 100644 index 0000000..9b0574d --- /dev/null +++ b/images/Containerfile.extras @@ -0,0 +1,15 @@ +# Layer container-extras on top of yolo-base +# yolo assembles a build context with scripts and a run.sh manifest +FROM yolo-base + +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/tests/features_to_test.md b/tests/features_to_test.md new file mode 100644 index 0000000..c836c93 --- /dev/null +++ b/tests/features_to_test.md @@ -0,0 +1,16 @@ +# Features to test + +## container-extras +- [ ] apt.sh: installs packages, idempotent +- [ ] python.sh: installs python, creates python/python3 symlinks, idempotent +- [ ] python.sh: works with version arg and without +- [ ] python.sh: symlinks point to correct version when specified + +## Containerfile.base +- [ ] builds clean from scratch +- [ ] claude is on PATH and runnable + +## Containerfile.extras +- [ ] builds with empty run.sh (vanilla) +- [ ] builds with apt + python extras +- [ ] idempotent rebuild produces working image diff --git a/tests/test-extras-build.sh b/tests/test-extras-build.sh new file mode 100755 index 0000000..d5e7b61 --- /dev/null +++ b/tests/test-extras-build.sh @@ -0,0 +1,49 @@ +#!/bin/bash +# Test that container-extras build correctly and are idempotent +export PS4='> ' +set -x +set -eu + +REPO_ROOT="$(cd "$(dirname "$0")/.." && pwd)" +BUILD_CONTEXT="$(mktemp -d ${TMPDIR:-/tmp}/yolo-test-XXXXXXX)" +trap "rm -rf $BUILD_CONTEXT" EXIT + +# Assemble build context +mkdir -p "$BUILD_CONTEXT/build/scripts" +cp "$REPO_ROOT/container-extras/apt.sh" "$BUILD_CONTEXT/build/scripts/" +cp "$REPO_ROOT/container-extras/python.sh" "$BUILD_CONTEXT/build/scripts/" +cat > "$BUILD_CONTEXT/build/run.sh" << 'EOF' +#!/bin/bash +set -eu +bash /tmp/yolo-build/scripts/apt.sh zsh fzf shellcheck +bash /tmp/yolo-build/scripts/python.sh 3.12 +EOF + +IMAGE="yolo-test-extras-$$" + +# Build +podman build -f "$REPO_ROOT/images/Containerfile.extras" -t "$IMAGE" "$BUILD_CONTEXT" + +# Verify tools exist +podman run --rm "$IMAGE" bash -c " + command -v zsh + command -v fzf + command -v shellcheck + python3.12 --version +" + +# Rebuild (idempotency) +podman build --no-cache -f "$REPO_ROOT/images/Containerfile.extras" -t "$IMAGE" "$BUILD_CONTEXT" + +# Verify again +podman run --rm "$IMAGE" bash -c " + command -v zsh + command -v fzf + command -v shellcheck + python3.12 --version +" + +# Cleanup +podman rmi "$IMAGE" 2>/dev/null || true + +echo "PASS: extras build and are idempotent" From 02c05624c41b0ed03b25e75d51acb05d7c7148ef Mon Sep 17 00:00:00 2001 From: Austin Macdonald Date: Sat, 28 Mar 2026 13:23:55 -0500 Subject: [PATCH 05/55] Add Python CLI skeleton: yo build, yo run - pyproject.toml with click + pyyaml deps, pytest for dev - config.py: load and merge YAML from 4 locations with list appending, dict recursion, scalar replacement - cli.py: click entry point with stub build/run commands - Entry point 'yo' (temporary, becomes 'yolo' at cutover) Co-Authored-By: Claude Opus 4.6 (1M context) --- .gitignore | 3 ++ pyproject.toml | 27 +++++++++++++ src/yolo/__init__.py | 0 src/yolo/cli.py | 26 +++++++++++++ src/yolo/config.py | 91 ++++++++++++++++++++++++++++++++++++++++++++ 5 files changed, 147 insertions(+) create mode 100644 .gitignore create mode 100644 pyproject.toml create mode 100644 src/yolo/__init__.py create mode 100644 src/yolo/cli.py create mode 100644 src/yolo/config.py diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..3149237 --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +.venv/ +__pycache__/ +*.egg-info/ diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..534c0fd --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,27 @@ +[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", + "pyyaml", +] + +[project.optional-dependencies] +dev = [ + "pytest", +] + +[project.scripts] +yo = "yolo.cli:main" + +[tool.hatch.build.targets.wheel] +packages = ["src/yolo"] + +[tool.pytest.ini_options] +testpaths = ["tests"] diff --git a/src/yolo/__init__.py b/src/yolo/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/yolo/cli.py b/src/yolo/cli.py new file mode 100644 index 0000000..d50e5e8 --- /dev/null +++ b/src/yolo/cli.py @@ -0,0 +1,26 @@ +"""CLI entry point for yolo.""" + +import click + +from yolo.config import load_config + + +@click.group() +def main(): + """Run Claude Code safely in a container with full autonomy.""" + + +@main.command() +def build(): + """Build the container image with configured extras.""" + config = load_config() + click.echo(f"Config: {config}") + click.echo("TODO: build") + + +@main.command() +def run(): + """Launch Claude Code in a container.""" + config = load_config() + click.echo(f"Config: {config}") + click.echo("TODO: run") diff --git a/src/yolo/config.py b/src/yolo/config.py new file mode 100644 index 0000000..2c5df0e --- /dev/null +++ b/src/yolo/config.py @@ -0,0 +1,91 @@ +"""Load and merge YAML config from all locations.""" + +from pathlib import Path +import os +import subprocess + +import yaml + + +CONFIG_FILENAME = "config.yaml" + +# Precedence: later overrides earlier +# 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 {} + text = path.read_text() + return yaml.safe_load(text) or {} + + +def _merge(base: dict, override: dict) -> dict: + """Merge override into base. Lists append, dicts recurse, scalars replace.""" + merged = dict(base) + for key, value in override.items(): + if 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() -> dict: + """Load and merge config from all locations.""" + config = {} + for path in _config_paths(): + layer = _load_yaml(path) + if layer: + config = _merge(config, layer) + return config From 62ef35deb979e8cb0c81392a32a43d3ed7baa7ad Mon Sep 17 00:00:00 2001 From: Austin Macdonald Date: Sat, 28 Mar 2026 13:25:52 -0500 Subject: [PATCH 06/55] Add config tests: merge semantics, path resolution, layer precedence 17 tests covering: - _merge: scalar override, list append, dict recursion, type mismatch - _config_paths: /etc, XDG default/override, project, precedence order - load_config: empty, single file, multi-layer merge, git/yolo config, git overrides project Co-Authored-By: Claude Opus 4.6 (1M context) --- tests/test_config.py | 157 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 157 insertions(+) create mode 100644 tests/test_config.py diff --git a/tests/test_config.py b/tests/test_config.py new file mode 100644 index 0000000..8161bed --- /dev/null +++ b/tests/test_config.py @@ -0,0 +1,157 @@ +"""Tests for yolo config loading and merging.""" + +import os +from pathlib import Path + +import pytest +import yaml + +from yolo.config import _merge, _config_paths, _find_git_dir, load_config + + +# ── _merge ────────────────────────────────────────────────────── + + +class TestMerge: + def test_scalars_override(self): + assert _merge({"a": 1}, {"a": 2}) == {"a": 2} + + def test_new_keys_added(self): + assert _merge({"a": 1}, {"b": 2}) == {"a": 1, "b": 2} + + def test_lists_append(self): + assert _merge({"x": [1, 2]}, {"x": [3]}) == {"x": [1, 2, 3]} + + def test_dicts_recurse(self): + base = {"d": {"a": 1, "b": 2}} + override = {"d": {"b": 3, "c": 4}} + assert _merge(base, override) == {"d": {"a": 1, "b": 3, "c": 4}} + + def test_type_mismatch_override_wins(self): + assert _merge({"a": [1, 2]}, {"a": "replaced"}) == {"a": "replaced"} + + def test_empty_base(self): + assert _merge({}, {"a": 1}) == {"a": 1} + + def test_empty_override(self): + assert _merge({"a": 1}, {}) == {"a": 1} + + +# ── _config_paths ────────────────────────────────────────────── + + +class TestConfigPaths: + def test_includes_etc(self): + paths = _config_paths() + assert Path("/etc/yolo/config.yaml") in paths + + def test_xdg_default(self, monkeypatch): + monkeypatch.delenv("XDG_CONFIG_HOME", raising=False) + paths = _config_paths() + expected = Path.home() / ".config" / "yolo" / "config.yaml" + assert expected in paths + + def test_xdg_override(self, monkeypatch, tmp_path): + monkeypatch.setenv("XDG_CONFIG_HOME", str(tmp_path)) + paths = _config_paths() + assert tmp_path / "yolo" / "config.yaml" in paths + + def test_project_config(self): + paths = _config_paths() + expected = Path.cwd() / ".yolo" / "config.yaml" + assert expected in paths + + def test_precedence_order(self, monkeypatch): + monkeypatch.delenv("XDG_CONFIG_HOME", raising=False) + paths = _config_paths() + # etc < xdg < project < git + assert paths[0] == Path("/etc/yolo/config.yaml") + assert paths[1] == Path.home() / ".config" / "yolo" / "config.yaml" + assert paths[2] == Path.cwd() / ".yolo" / "config.yaml" + + +# ── load_config ──────────────────────────────────────────────── + + +class TestLoadConfig: + def test_empty_when_no_files(self, tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + monkeypatch.setenv("XDG_CONFIG_HOME", str(tmp_path / "no-xdg")) + config = load_config() + assert config == {} + + def test_loads_single_file(self, tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + monkeypatch.setenv("XDG_CONFIG_HOME", str(tmp_path / "xdg")) + config_dir = tmp_path / ".yolo" + config_dir.mkdir() + (config_dir / "config.yaml").write_text(yaml.dump({ + "nvidia": True, + "container-extras": ["zsh"], + })) + config = load_config() + assert config["nvidia"] is True + assert config["container-extras"] == ["zsh"] + + def test_merges_layers(self, tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + + # User config + xdg = tmp_path / "xdg" + (xdg / "yolo").mkdir(parents=True) + (xdg / "yolo" / "config.yaml").write_text(yaml.dump({ + "nvidia": False, + "container-extras": ["zsh"], + })) + monkeypatch.setenv("XDG_CONFIG_HOME", str(xdg)) + + # Project config + (tmp_path / ".yolo").mkdir() + (tmp_path / ".yolo" / "config.yaml").write_text(yaml.dump({ + "nvidia": True, + "container-extras": ["python"], + })) + + config = load_config() + # Scalar: project overrides user + assert config["nvidia"] is True + # List: appended + assert config["container-extras"] == ["zsh", "python"] + + def test_git_yolo_config(self, tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + monkeypatch.setenv("XDG_CONFIG_HOME", str(tmp_path / "no-xdg")) + + # Create a git repo + git_dir = tmp_path / ".git" + git_dir.mkdir() + config_dir = git_dir / "yolo" + config_dir.mkdir() + (config_dir / "config.yaml").write_text(yaml.dump({ + "worktree": "bind", + })) + + config = load_config() + assert config["worktree"] == "bind" + + def test_git_overrides_project(self, tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + monkeypatch.setenv("XDG_CONFIG_HOME", str(tmp_path / "no-xdg")) + + # Project config (.yolo/) + (tmp_path / ".yolo").mkdir() + (tmp_path / ".yolo" / "config.yaml").write_text(yaml.dump({ + "worktree": "ask", + })) + + # Git config (.git/yolo/) — higher precedence + git_dir = tmp_path / ".git" + git_dir.mkdir() + config_dir = git_dir / "yolo" + config_dir.mkdir() + (config_dir / "config.yaml").write_text(yaml.dump({ + "worktree": "skip", + })) + + config = load_config() + assert config["worktree"] == "skip" From 0131e740dd7323a003129f339a1ae955f3e2afaa Mon Sep 17 00:00:00 2001 From: Austin Macdonald Date: Sat, 28 Mar 2026 13:33:16 -0500 Subject: [PATCH 07/55] Add CLAUDE.md, move future ideas to notes/ideas/ Co-Authored-By: Claude Opus 4.6 (1M context) --- CLAUDE.md | 32 ++++++++++++++++++++++++ notes/ideas/read-only-mode.md | 5 ++++ notes/ideas/sandboxed-review-subagent.md | 10 ++++++++ 3 files changed, 47 insertions(+) create mode 100644 CLAUDE.md create mode 100644 notes/ideas/read-only-mode.md create mode 100644 notes/ideas/sandboxed-review-subagent.md diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..1726f9a --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,32 @@ +**Before writing files, check if CLAUDE.md, design docs, or tests need updating to reflect your changes.** + +## 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 `legacy/`. + +## Key files + +- `design/HACK_DECISIONS.md` — locked design decisions, do not revisit +- `design/REDESIGN_HACKIN.md` — working design notes +- `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 4 locations +- `src/yolo/cli.py` — click CLI (yo build, yo run) +- `images/Containerfile.base` — minimal debian base image +- `images/Containerfile.extras` — layers container-extras on top +- `container-extras/` — composable install scripts (apt.sh, python.sh, etc.) diff --git a/notes/ideas/read-only-mode.md b/notes/ideas/read-only-mode.md new file mode 100644 index 0000000..0fa11ab --- /dev/null +++ b/notes/ideas/read-only-mode.md @@ -0,0 +1,5 @@ +# --read-only mode + +Workspace mounted read-only, claude config still read-write (sessions +persist). Useful for review workflows where Claude should observe but +not modify. diff --git a/notes/ideas/sandboxed-review-subagent.md b/notes/ideas/sandboxed-review-subagent.md new file mode 100644 index 0000000..779d08c --- /dev/null +++ b/notes/ideas/sandboxed-review-subagent.md @@ -0,0 +1,10 @@ +# Sandboxed review subagent + +A skill/hook that launches a yolo container (read-only) as a subagent: +1. Mount working tree read-only +2. Claude inside reviews the diff, runs tests, checks logs +3. Container exits with structured output +4. Calling Claude gets a summary: test failures, code concerns, log issues + +Like a local CI loop / PR review that runs before you push, in a +sandbox where the reviewer can't modify your code. From 357bfc1173059d79b8eee59c24550c3fc21357df Mon Sep 17 00:00:00 2001 From: Austin Macdonald Date: Sat, 28 Mar 2026 13:47:52 -0500 Subject: [PATCH 08/55] Swap pyyaml for ruamel.yaml, add builder, wire up CLI - Replace pyyaml with ruamel.yaml for round-trip config support - Add builder.py: resolves container-extras from search path, assembles build context, invokes podman build - Wire yo build command to builder - Update tests for ruamel.yaml Co-Authored-By: Claude Opus 4.6 (1M context) --- pyproject.toml | 2 +- security-reports/audit-20260328-125651.md | 43 ++++++ src/yolo/builder.py | 158 ++++++++++++++++++++++ src/yolo/cli.py | 5 +- src/yolo/config.py | 8 +- tests/test_config.py | 58 ++++---- 6 files changed, 233 insertions(+), 41 deletions(-) create mode 100644 security-reports/audit-20260328-125651.md create mode 100644 src/yolo/builder.py diff --git a/pyproject.toml b/pyproject.toml index 534c0fd..60991f6 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -9,7 +9,7 @@ description = "Run Claude Code safely in a container with full autonomy" requires-python = ">=3.11" dependencies = [ "click", - "pyyaml", + "ruamel.yaml", ] [project.optional-dependencies] diff --git a/security-reports/audit-20260328-125651.md b/security-reports/audit-20260328-125651.md new file mode 100644 index 0000000..d48968e --- /dev/null +++ b/security-reports/audit-20260328-125651.md @@ -0,0 +1,43 @@ +# Container Security Audit — 2026-03-28 + +Performed from inside a running yolo container (rootless podman, `--userns=keep-id`). + +## What's locked down (good) + +- **Namespace isolation works.** `nsenter` into PID 1 namespaces denied — rootless podman maps container root to host UID 1000, no path to real host root. +- **Capabilities are minimal.** Standard rootless set (CHOWN, DAC_OVERRIDE, FOWNER, SETUID/GID, NET_BIND_SERVICE, SYS_CHROOT). Missing the dangerous ones (SYS_ADMIN, SYS_PTRACE, NET_RAW). +- **No block devices** visible, `/sys` is read-only. +- **Host filesystem not exposed** beyond explicit mounts — `/home/austin/devel/duct/`, `~/.ssh`, `~/.gnupg`, `~/.aws` all inaccessible. +- **`.gitconfig` mounted read-only** at `/tmp/.gitconfig`. + +## Bind mounts (attack surface) + +| Mount | Mode | Risk | +|-------|------|------| +| `/home/austin/devel/yolo` | rw | Expected — workspace | +| `/home/austin/.claude` | rw | **See below** | +| `/tmp/.gitconfig` | ro | Low — leaks name/email, not writable | + +## CRITICAL: `~/.claude` mounted rw enables cross-session prompt injection + +The entire `~/.claude` directory is bind-mounted read-write. A compromised agent inside the container can: + +1. **Modify `~/.claude/CLAUDE.md`** — injects instructions into the *global* system prompt for *all* future Claude Code sessions, across *all* projects, including sessions running directly on the host without container isolation. +2. **Modify `~/.claude/settings.json`** — alter permissions, allowlisted commands. +3. **Modify any project config** in `~/.claude/projects/` — poison per-project instructions for *other* repos the container has no business touching. +4. **Read `.credentials.json`** — exfiltrate auth tokens. + +This is a between-session escape: the agent can't break out of the container *now*, but it can plant instructions that a future uncontained session will execute. + +### Recommended mitigation + +Mount `~/.claude` selectively instead of wholesale: + +- `.credentials.json` — **ro** +- `settings.json` — **ro** +- `CLAUDE.md` — **ro** +- `projects//` — rw (session state, memory) + +Exclude everything else (other project configs, history, sessions). + +The redesign's `volumes` hook is the natural place to implement this. diff --git a/src/yolo/builder.py b/src/yolo/builder.py new file mode 100644 index 0000000..b53de17 --- /dev/null +++ b/src/yolo/builder.py @@ -0,0 +1,158 @@ +"""Build container images with container-extras.""" + +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 / "container-extras" + +BASE_IMAGE = "yolo-base" +CUSTOM_IMAGE = "yolo-custom" + + +def _extras_search_path() -> list[Path]: + """Return container-extras directories in precedence order (lowest first).""" + import os + + paths = [BUILTIN_EXTRAS] + + xdg = os.environ.get("XDG_CONFIG_HOME", "") + if xdg: + paths.append(Path(xdg) / "yolo" / "container-extras") + else: + paths.append(Path.home() / ".config" / "yolo" / "container-extras") + + paths.append(Path.cwd() / ".yolo" / "container-extras") + + # .git/yolo/container-extras + from yolo.config import _find_git_dir + + git_dir = _find_git_dir() + if git_dir: + paths.append(git_dir / "yolo" / "container-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_extras(extras_config: list) -> list[tuple[str, list[str]]]: + """Parse container-extras config into (script_name, args) pairs. + + Config entries can be: + - "datalad" → ("datalad", []) + - "apt:imagemagick" → ("apt", ["imagemagick"]) + - {"python": "3.12"} → ("python", ["3.12"]) + """ + parsed = [] + for entry in extras_config: + if isinstance(entry, str): + if ":" in entry: + prefix, _, arg = entry.partition(":") + parsed.append((prefix, [arg])) + else: + parsed.append((entry, [])) + elif isinstance(entry, dict): + for name, args in entry.items(): + if isinstance(args, list): + parsed.append((name, [str(a) for a in args])) + else: + parsed.append((name, [str(args)])) + return parsed + + +def _collect_apt_fallbacks( + parsed: list[tuple[str, list[str]]], search_path: list[Path] +) -> list[tuple[str, list[str]]]: + """For bare names with no matching script, convert to apt calls.""" + result = [] + apt_packages = [] + + for name, args in parsed: + script = _resolve_script(name, search_path) + if script or args: + # Has a script or is a prefixed entry — flush any pending apt packages + if apt_packages: + result.append(("apt", apt_packages)) + apt_packages = [] + result.append((name, args)) + else: + # No script found, no prefix — accumulate as apt package + apt_packages.append(name) + + if apt_packages: + result.append(("apt", apt_packages)) + + return result + + +def assemble_build_context(extras_config: list) -> 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() + parsed = _parse_extras(extras_config) + resolved = _collect_apt_fallbacks(parsed, 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", "set -eu"] + + for name, args in resolved: + 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) + + args_str = " ".join(args) + if args_str: + run_lines.append(f"bash /tmp/yolo-build/scripts/{name}.sh {args_str}") + 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 build(extras_config: list) -> None: + """Build the custom image with container-extras.""" + if not extras_config: + print("No container-extras configured, nothing to build.") + return + + build_dir = assemble_build_context(extras_config) + try: + cmd = [ + "podman", "build", + "-f", str(CONTAINERFILE_EXTRAS), + "-t", CUSTOM_IMAGE, + str(build_dir), + ] + print(f"Building {CUSTOM_IMAGE}...") + subprocess.run(cmd, check=True) + print(f"Built {CUSTOM_IMAGE}") + finally: + shutil.rmtree(build_dir) diff --git a/src/yolo/cli.py b/src/yolo/cli.py index d50e5e8..0bc2c08 100644 --- a/src/yolo/cli.py +++ b/src/yolo/cli.py @@ -3,6 +3,7 @@ import click from yolo.config import load_config +from yolo.builder import build as builder_build @click.group() @@ -14,8 +15,8 @@ def main(): def build(): """Build the container image with configured extras.""" config = load_config() - click.echo(f"Config: {config}") - click.echo("TODO: build") + extras = config.get("container-extras", []) + builder_build(extras) @main.command() diff --git a/src/yolo/config.py b/src/yolo/config.py index 2c5df0e..1dc12f8 100644 --- a/src/yolo/config.py +++ b/src/yolo/config.py @@ -4,10 +4,12 @@ import os import subprocess -import yaml +from ruamel.yaml import YAML CONFIG_FILENAME = "config.yaml" +_yaml = YAML() +_yaml.preserve_quotes = True # Precedence: later overrides earlier # 1. /etc/yolo/config.yaml @@ -64,8 +66,8 @@ def _load_yaml(path: Path) -> dict: """Load a YAML file, returning empty dict if missing or empty.""" if not path.is_file(): return {} - text = path.read_text() - return yaml.safe_load(text) or {} + data = _yaml.load(path) + return dict(data) if data else {} def _merge(base: dict, override: dict) -> dict: diff --git a/tests/test_config.py b/tests/test_config.py index 8161bed..8546576 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -4,11 +4,17 @@ from pathlib import Path import pytest -import yaml +from ruamel.yaml import YAML from yolo.config import _merge, _config_paths, _find_git_dir, load_config +def _write_yaml(path: Path, data: dict): + """Dump a dict to a YAML file, creating parent dirs.""" + path.parent.mkdir(parents=True, exist_ok=True) + YAML().dump(data, path) + + # ── _merge ────────────────────────────────────────────────────── @@ -83,12 +89,10 @@ def test_empty_when_no_files(self, tmp_path, monkeypatch): def test_loads_single_file(self, tmp_path, monkeypatch): monkeypatch.chdir(tmp_path) monkeypatch.setenv("XDG_CONFIG_HOME", str(tmp_path / "xdg")) - config_dir = tmp_path / ".yolo" - config_dir.mkdir() - (config_dir / "config.yaml").write_text(yaml.dump({ + _write_yaml(tmp_path / ".yolo" / "config.yaml", { "nvidia": True, "container-extras": ["zsh"], - })) + }) config = load_config() assert config["nvidia"] is True assert config["container-extras"] == ["zsh"] @@ -96,40 +100,30 @@ def test_loads_single_file(self, tmp_path, monkeypatch): def test_merges_layers(self, tmp_path, monkeypatch): monkeypatch.chdir(tmp_path) - # User config xdg = tmp_path / "xdg" - (xdg / "yolo").mkdir(parents=True) - (xdg / "yolo" / "config.yaml").write_text(yaml.dump({ + monkeypatch.setenv("XDG_CONFIG_HOME", str(xdg)) + _write_yaml(xdg / "yolo" / "config.yaml", { "nvidia": False, "container-extras": ["zsh"], - })) - monkeypatch.setenv("XDG_CONFIG_HOME", str(xdg)) + }) - # Project config - (tmp_path / ".yolo").mkdir() - (tmp_path / ".yolo" / "config.yaml").write_text(yaml.dump({ + _write_yaml(tmp_path / ".yolo" / "config.yaml", { "nvidia": True, "container-extras": ["python"], - })) + }) config = load_config() - # Scalar: project overrides user assert config["nvidia"] is True - # List: appended assert config["container-extras"] == ["zsh", "python"] def test_git_yolo_config(self, tmp_path, monkeypatch): monkeypatch.chdir(tmp_path) monkeypatch.setenv("XDG_CONFIG_HOME", str(tmp_path / "no-xdg")) - # Create a git repo - git_dir = tmp_path / ".git" - git_dir.mkdir() - config_dir = git_dir / "yolo" - config_dir.mkdir() - (config_dir / "config.yaml").write_text(yaml.dump({ + (tmp_path / ".git").mkdir() + _write_yaml(tmp_path / ".git" / "yolo" / "config.yaml", { "worktree": "bind", - })) + }) config = load_config() assert config["worktree"] == "bind" @@ -138,20 +132,14 @@ def test_git_overrides_project(self, tmp_path, monkeypatch): monkeypatch.chdir(tmp_path) monkeypatch.setenv("XDG_CONFIG_HOME", str(tmp_path / "no-xdg")) - # Project config (.yolo/) - (tmp_path / ".yolo").mkdir() - (tmp_path / ".yolo" / "config.yaml").write_text(yaml.dump({ + _write_yaml(tmp_path / ".yolo" / "config.yaml", { "worktree": "ask", - })) - - # Git config (.git/yolo/) — higher precedence - git_dir = tmp_path / ".git" - git_dir.mkdir() - config_dir = git_dir / "yolo" - config_dir.mkdir() - (config_dir / "config.yaml").write_text(yaml.dump({ + }) + + (tmp_path / ".git").mkdir() + _write_yaml(tmp_path / ".git" / "yolo" / "config.yaml", { "worktree": "skip", - })) + }) config = load_config() assert config["worktree"] == "skip" From d41f26a7324bb666135d540a0c6c1b19281a43b9 Mon Sep 17 00:00:00 2001 From: Austin Macdonald Date: Sat, 28 Mar 2026 13:51:42 -0500 Subject: [PATCH 09/55] Add builder tests: parsing, resolution, build context assembly 17 tests covering: - _parse_extras: bare names, prefixed, dict with args, mixed, empty - _resolve_script: found, missing, later-path-wins - _collect_apt_fallbacks: batching, script preservation, prefix passthrough - assemble_build_context: run.sh generation, script copying, missing script error, args in manifest Co-Authored-By: Claude Opus 4.6 (1M context) --- design/REDESIGN_HACKIN.md | 3 + tests/test_builder.py | 150 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 153 insertions(+) create mode 100644 tests/test_builder.py diff --git a/design/REDESIGN_HACKIN.md b/design/REDESIGN_HACKIN.md index d2d2288..49ba2d5 100644 --- a/design/REDESIGN_HACKIN.md +++ b/design/REDESIGN_HACKIN.md @@ -262,3 +262,6 @@ Mitigations under consideration: - Registry story (GHCR for base image?) — deferred, build locally for now - How extensions repo works (if at all) - Installer redesign (setup-yolo.sh successor) — deferred +- Organize security-reports/ (generated by yolo session test run) +- `!replace` YAML tag for per-key merge vs replace semantics +- Default container-extras config (ships with package) diff --git a/tests/test_builder.py b/tests/test_builder.py new file mode 100644 index 0000000..efc1295 --- /dev/null +++ b/tests/test_builder.py @@ -0,0 +1,150 @@ +"""Tests for yolo builder: extras parsing, script resolution, build context assembly.""" + +from pathlib import Path + +import pytest + +from yolo.builder import _parse_extras, _resolve_script, _collect_apt_fallbacks, assemble_build_context + + +# ── _parse_extras ────────────────────────────────────────────── + + +class TestParseExtras: + def test_bare_name(self): + assert _parse_extras(["datalad"]) == [("datalad", [])] + + def test_prefixed_name(self): + assert _parse_extras(["apt:imagemagick"]) == [("apt", ["imagemagick"])] + + def test_dict_with_string_arg(self): + assert _parse_extras([{"python": "3.12"}]) == [("python", ["3.12"])] + + def test_dict_with_list_arg(self): + assert _parse_extras([{"apt": ["zsh", "fzf"]}]) == [("apt", ["zsh", "fzf"])] + + def test_mixed(self): + config = ["datalad", "apt:vim", {"python": "3.12"}] + assert _parse_extras(config) == [ + ("datalad", []), + ("apt", ["vim"]), + ("python", ["3.12"]), + ] + + def test_empty(self): + assert _parse_extras([]) == [] + + +# ── _resolve_script ──────────────────────────────────────────── + + +class TestResolveScript: + def test_finds_script(self, tmp_path): + script = tmp_path / "datalad.sh" + script.write_text("#!/bin/bash\necho hi") + assert _resolve_script("datalad", [tmp_path]) == script + + def test_returns_none_when_missing(self, tmp_path): + assert _resolve_script("nope", [tmp_path]) is None + + def test_later_path_wins(self, tmp_path): + dir_a = tmp_path / "a" + dir_b = tmp_path / "b" + dir_a.mkdir() + dir_b.mkdir() + (dir_a / "tool.sh").write_text("old") + (dir_b / "tool.sh").write_text("new") + result = _resolve_script("tool", [dir_a, dir_b]) + assert result == dir_b / "tool.sh" + + +# ── _collect_apt_fallbacks ───────────────────────────────────── + + +class TestCollectAptFallbacks: + def test_bare_name_without_script_becomes_apt(self, tmp_path): + parsed = [("zsh", []), ("fzf", [])] + result = _collect_apt_fallbacks(parsed, [tmp_path]) + assert result == [("apt", ["zsh", "fzf"])] + + def test_bare_name_with_script_stays(self, tmp_path): + (tmp_path / "datalad.sh").write_text("#!/bin/bash") + parsed = [("datalad", [])] + result = _collect_apt_fallbacks(parsed, [tmp_path]) + assert result == [("datalad", [])] + + def test_prefixed_stays(self, tmp_path): + parsed = [("apt", ["vim"])] + result = _collect_apt_fallbacks(parsed, [tmp_path]) + assert result == [("apt", ["vim"])] + + def test_batches_consecutive_apt_fallbacks(self, tmp_path): + (tmp_path / "datalad.sh").write_text("#!/bin/bash") + parsed = [("zsh", []), ("fzf", []), ("datalad", []), ("vim", [])] + result = _collect_apt_fallbacks(parsed, [tmp_path]) + assert result == [ + ("apt", ["zsh", "fzf"]), + ("datalad", []), + ("apt", ["vim"]), + ] + + +# ── assemble_build_context ───────────────────────────────────── + + +class TestAssembleBuildContext: + def test_creates_run_sh(self, tmp_path, monkeypatch): + # Set up a fake extras dir with apt.sh + extras_dir = tmp_path / "extras" + extras_dir.mkdir() + (extras_dir / "apt.sh").write_text("apt-get install -y \"$@\"") + + monkeypatch.setattr("yolo.builder._extras_search_path", lambda: [extras_dir]) + + build_dir = assemble_build_context(["zsh", "fzf"]) + try: + run_sh = build_dir / "build" / "run.sh" + assert run_sh.exists() + content = run_sh.read_text() + assert "apt.sh zsh fzf" in content + finally: + import shutil + shutil.rmtree(build_dir) + + def test_copies_scripts(self, tmp_path, monkeypatch): + extras_dir = tmp_path / "extras" + extras_dir.mkdir() + (extras_dir / "apt.sh").write_text("apt-get install -y \"$@\"") + (extras_dir / "python.sh").write_text("uv python install \"$1\"") + + monkeypatch.setattr("yolo.builder._extras_search_path", lambda: [extras_dir]) + + build_dir = assemble_build_context(["apt:vim", {"python": "3.12"}]) + try: + scripts_dir = build_dir / "build" / "scripts" + assert (scripts_dir / "apt.sh").exists() + assert (scripts_dir / "python.sh").exists() + finally: + import shutil + shutil.rmtree(build_dir) + + def test_raises_on_missing_script(self, tmp_path, monkeypatch): + monkeypatch.setattr("yolo.builder._extras_search_path", lambda: [tmp_path]) + + with pytest.raises(FileNotFoundError, match="apt"): + assemble_build_context(["apt:vim"]) + + def test_run_sh_has_args(self, tmp_path, monkeypatch): + extras_dir = tmp_path / "extras" + extras_dir.mkdir() + (extras_dir / "python.sh").write_text("uv python install \"$1\"") + + monkeypatch.setattr("yolo.builder._extras_search_path", lambda: [extras_dir]) + + build_dir = assemble_build_context([{"python": "3.12"}]) + try: + content = (build_dir / "build" / "run.sh").read_text() + assert "python.sh 3.12" in content + finally: + import shutil + shutil.rmtree(build_dir) From 8a78a01a9901ac6f75a191da0eb3090d36889da0 Mon Sep 17 00:00:00 2001 From: Austin Macdonald Date: Sat, 28 Mar 2026 14:09:14 -0500 Subject: [PATCH 10/55] One format, one contract: env vars for container-extras Decision #5: all container-extras use name: + params form. Params become YOLO_{NAME}_{KEY} env vars passed to scripts. - Rewrite _parse_extra for single format (remove prefix/simple dict) - Replace $@ with env vars in apt.sh, python.sh, git-delta.sh - Scripts validate their own required env vars - Add default config with apt packages, git-delta, python 3.12 - Config loader loads package defaults before user/project configs - Fix hyphen-to-underscore in env var names - Rewrite builder tests for new contract Co-Authored-By: Claude Opus 4.6 (1M context) --- container-extras/apt.sh | 5 +- container-extras/git-delta.sh | 11 +++ container-extras/python.sh | 11 ++- design/HACK_DECISIONS.md | 23 +++++- src/yolo/builder.py | 79 +++++++------------ src/yolo/config.py | 4 +- src/yolo/defaults/config.yaml | 16 ++++ tests/features_to_test.md | 1 + tests/test_builder.py | 141 +++++++++++++++------------------- tests/test_config.py | 6 ++ 10 files changed, 159 insertions(+), 138 deletions(-) create mode 100644 container-extras/git-delta.sh create mode 100644 src/yolo/defaults/config.yaml diff --git a/container-extras/apt.sh b/container-extras/apt.sh index 8841c1c..b5330ad 100644 --- a/container-extras/apt.sh +++ b/container-extras/apt.sh @@ -1,5 +1,6 @@ #!/bin/bash # Install apt packages -# Usage: apt.sh package1 package2 ... +# Env: YOLO_APT_PACKAGES (space-separated, required) set -eu -sudo apt-get install -y --no-install-recommends "$@" +[ -z "${YOLO_APT_PACKAGES:-}" ] && { echo "apt.sh: YOLO_APT_PACKAGES required"; exit 1; } +sudo apt-get install -y --no-install-recommends $YOLO_APT_PACKAGES diff --git a/container-extras/git-delta.sh b/container-extras/git-delta.sh new file mode 100644 index 0000000..9a8a235 --- /dev/null +++ b/container-extras/git-delta.sh @@ -0,0 +1,11 @@ +#!/bin/bash +# Install git-delta from GitHub releases +# Env: YOLO_GIT_DELTA_VERSION (required) +# TODO: determine latest from GitHub API if version not provided +set -eu +[ -z "${YOLO_GIT_DELTA_VERSION:-}" ] && { echo "git-delta.sh: YOLO_GIT_DELTA_VERSION required"; exit 1; } + +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" diff --git a/container-extras/python.sh b/container-extras/python.sh index 57024ef..c1f7de7 100644 --- a/container-extras/python.sh +++ b/container-extras/python.sh @@ -1,7 +1,6 @@ #!/bin/bash # Install Python via uv -# Usage: python.sh [version] -# Default: latest stable +# Env: YOLO_PYTHON_VERSION (optional, default: latest stable) set -eu # Install uv if not present @@ -10,16 +9,16 @@ if ! command -v uv &>/dev/null; then export PATH="$HOME/.local/bin:$PATH" fi -VERSION="${1:-}" -if [ -n "$VERSION" ]; then - uv python install "$VERSION" +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 ${VERSION:+$VERSION} 2>/dev/null)" +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 diff --git a/design/HACK_DECISIONS.md b/design/HACK_DECISIONS.md index 12f0392..e40acf7 100644 --- a/design/HACK_DECISIONS.md +++ b/design/HACK_DECISIONS.md @@ -41,7 +41,28 @@ Python because: - pytest for testing - click/typer for CLI -## 5. Container runtime: podman-first +## 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 +container-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) diff --git a/src/yolo/builder.py b/src/yolo/builder.py index b53de17..778f426 100644 --- a/src/yolo/builder.py +++ b/src/yolo/builder.py @@ -28,7 +28,6 @@ def _extras_search_path() -> list[Path]: paths.append(Path.cwd() / ".yolo" / "container-extras") - # .git/yolo/container-extras from yolo.config import _find_git_dir git_dir = _find_git_dir() @@ -48,54 +47,32 @@ def _resolve_script(name: str, search_path: list[Path]) -> Path | None: return found -def _parse_extras(extras_config: list) -> list[tuple[str, list[str]]]: - """Parse container-extras config into (script_name, args) pairs. +def _parse_extra(entry) -> tuple[str, dict[str, str]]: + """Parse a single container-extras entry into (name, env_vars). - Config entries can be: - - "datalad" → ("datalad", []) - - "apt:imagemagick" → ("apt", ["imagemagick"]) - - {"python": "3.12"} → ("python", ["3.12"]) + 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", {}) """ - parsed = [] - for entry in extras_config: - if isinstance(entry, str): - if ":" in entry: - prefix, _, arg = entry.partition(":") - parsed.append((prefix, [arg])) - else: - parsed.append((entry, [])) - elif isinstance(entry, dict): - for name, args in entry.items(): - if isinstance(args, list): - parsed.append((name, [str(a) for a in args])) - else: - parsed.append((name, [str(args)])) - return parsed - - -def _collect_apt_fallbacks( - parsed: list[tuple[str, list[str]]], search_path: list[Path] -) -> list[tuple[str, list[str]]]: - """For bare names with no matching script, convert to apt calls.""" - result = [] - apt_packages = [] - - for name, args in parsed: - script = _resolve_script(name, search_path) - if script or args: - # Has a script or is a prefixed entry — flush any pending apt packages - if apt_packages: - result.append(("apt", apt_packages)) - apt_packages = [] - result.append((name, args)) + 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: - # No script found, no prefix — accumulate as apt package - apt_packages.append(name) - - if apt_packages: - result.append(("apt", apt_packages)) + env_vars[env_key] = str(value) - return result + return (name, env_vars) def assemble_build_context(extras_config: list) -> Path: @@ -104,8 +81,6 @@ def assemble_build_context(extras_config: list) -> Path: Returns the path to the temp directory. Caller must clean up. """ search_path = _extras_search_path() - parsed = _parse_extras(extras_config) - resolved = _collect_apt_fallbacks(parsed, search_path) build_dir = Path(tempfile.mkdtemp(prefix="yolo-build-")) scripts_dir = build_dir / "build" / "scripts" @@ -113,7 +88,9 @@ def assemble_build_context(extras_config: list) -> Path: run_lines = ["#!/bin/bash", "set -eu"] - for name, args in resolved: + for entry in extras_config: + name, env_vars = _parse_extra(entry) + script = _resolve_script(name, search_path) if script is None: raise FileNotFoundError( @@ -125,9 +102,9 @@ def assemble_build_context(extras_config: list) -> Path: if not dest.exists(): shutil.copy2(script, dest) - args_str = " ".join(args) - if args_str: - run_lines.append(f"bash /tmp/yolo-build/scripts/{name}.sh {args_str}") + 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") diff --git a/src/yolo/config.py b/src/yolo/config.py index 1dc12f8..4de6cfb 100644 --- a/src/yolo/config.py +++ b/src/yolo/config.py @@ -8,10 +8,12 @@ CONFIG_FILENAME = "config.yaml" +DEFAULTS_CONFIG = Path(__file__).parent / "defaults" / CONFIG_FILENAME _yaml = YAML() _yaml.preserve_quotes = True # 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) @@ -85,7 +87,7 @@ def _merge(base: dict, override: dict) -> dict: def load_config() -> dict: """Load and merge config from all locations.""" - config = {} + config = _load_yaml(DEFAULTS_CONFIG) for path in _config_paths(): layer = _load_yaml(path) if layer: diff --git a/src/yolo/defaults/config.yaml b/src/yolo/defaults/config.yaml new file mode 100644 index 0000000..f870aae --- /dev/null +++ b/src/yolo/defaults/config.yaml @@ -0,0 +1,16 @@ +# Default yolo configuration +# Override any of these in your user or project config + +container-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/tests/features_to_test.md b/tests/features_to_test.md index c836c93..6ed34bf 100644 --- a/tests/features_to_test.md +++ b/tests/features_to_test.md @@ -1,6 +1,7 @@ # Features to test ## container-extras +- [ ] extras-build to pytest not bash - [ ] apt.sh: installs packages, idempotent - [ ] python.sh: installs python, creates python/python3 symlinks, idempotent - [ ] python.sh: works with version arg and without diff --git a/tests/test_builder.py b/tests/test_builder.py index efc1295..56108c2 100644 --- a/tests/test_builder.py +++ b/tests/test_builder.py @@ -1,38 +1,45 @@ """Tests for yolo builder: extras parsing, script resolution, build context assembly.""" +import shutil from pathlib import Path import pytest -from yolo.builder import _parse_extras, _resolve_script, _collect_apt_fallbacks, assemble_build_context +from yolo.builder import _parse_extra, _resolve_script, assemble_build_context -# ── _parse_extras ────────────────────────────────────────────── +# ── _parse_extra ─────────────────────────────────────────────── -class TestParseExtras: - def test_bare_name(self): - assert _parse_extras(["datalad"]) == [("datalad", [])] +class TestParseExtra: + def test_string_entry(self): + assert _parse_extra("datalad") == ("datalad", {}) - def test_prefixed_name(self): - assert _parse_extras(["apt:imagemagick"]) == [("apt", ["imagemagick"])] + def test_name_only_dict(self): + assert _parse_extra({"name": "datalad"}) == ("datalad", {}) - def test_dict_with_string_arg(self): - assert _parse_extras([{"python": "3.12"}]) == [("python", ["3.12"])] + def test_dict_with_string_param(self): + name, env = _parse_extra({"name": "python", "version": "3.12"}) + assert name == "python" + assert env == {"YOLO_PYTHON_VERSION": "3.12"} - def test_dict_with_list_arg(self): - assert _parse_extras([{"apt": ["zsh", "fzf"]}]) == [("apt", ["zsh", "fzf"])] + def test_dict_with_list_param(self): + name, env = _parse_extra({"name": "apt", "packages": ["zsh", "fzf"]}) + assert name == "apt" + assert env == {"YOLO_APT_PACKAGES": "zsh fzf"} - def test_mixed(self): - config = ["datalad", "apt:vim", {"python": "3.12"}] - assert _parse_extras(config) == [ - ("datalad", []), - ("apt", ["vim"]), - ("python", ["3.12"]), - ] + def test_multiple_params(self): + name, env = _parse_extra({"name": "foo", "version": "1.0", "flavor": "slim"}) + assert name == "foo" + assert env == {"YOLO_FOO_VERSION": "1.0", "YOLO_FOO_FLAVOR": "slim"} + + def test_missing_name_raises(self): + with pytest.raises(ValueError, match="must have 'name'"): + _parse_extra({"packages": ["zsh"]}) - def test_empty(self): - assert _parse_extras([]) == [] + def test_non_dict_non_string_raises(self): + with pytest.raises(ValueError): + _parse_extra(42) # ── _resolve_script ──────────────────────────────────────────── @@ -58,93 +65,73 @@ def test_later_path_wins(self, tmp_path): assert result == dir_b / "tool.sh" -# ── _collect_apt_fallbacks ───────────────────────────────────── - - -class TestCollectAptFallbacks: - def test_bare_name_without_script_becomes_apt(self, tmp_path): - parsed = [("zsh", []), ("fzf", [])] - result = _collect_apt_fallbacks(parsed, [tmp_path]) - assert result == [("apt", ["zsh", "fzf"])] - - def test_bare_name_with_script_stays(self, tmp_path): - (tmp_path / "datalad.sh").write_text("#!/bin/bash") - parsed = [("datalad", [])] - result = _collect_apt_fallbacks(parsed, [tmp_path]) - assert result == [("datalad", [])] - - def test_prefixed_stays(self, tmp_path): - parsed = [("apt", ["vim"])] - result = _collect_apt_fallbacks(parsed, [tmp_path]) - assert result == [("apt", ["vim"])] - - def test_batches_consecutive_apt_fallbacks(self, tmp_path): - (tmp_path / "datalad.sh").write_text("#!/bin/bash") - parsed = [("zsh", []), ("fzf", []), ("datalad", []), ("vim", [])] - result = _collect_apt_fallbacks(parsed, [tmp_path]) - assert result == [ - ("apt", ["zsh", "fzf"]), - ("datalad", []), - ("apt", ["vim"]), - ] - - # ── assemble_build_context ───────────────────────────────────── class TestAssembleBuildContext: - def test_creates_run_sh(self, tmp_path, monkeypatch): - # Set up a fake extras dir with apt.sh + def _make_extras_dir(self, tmp_path, scripts: dict[str, str]) -> Path: extras_dir = tmp_path / "extras" extras_dir.mkdir() - (extras_dir / "apt.sh").write_text("apt-get install -y \"$@\"") + for name, content in scripts.items(): + (extras_dir / f"{name}.sh").write_text(content) + return extras_dir + def test_creates_run_sh_with_env_vars(self, tmp_path, monkeypatch): + extras_dir = self._make_extras_dir(tmp_path, {"apt": "#!/bin/bash"}) monkeypatch.setattr("yolo.builder._extras_search_path", lambda: [extras_dir]) - build_dir = assemble_build_context(["zsh", "fzf"]) + build_dir = assemble_build_context([{"name": "apt", "packages": ["zsh", "fzf"]}]) try: - run_sh = build_dir / "build" / "run.sh" - assert run_sh.exists() - content = run_sh.read_text() - assert "apt.sh zsh fzf" in content + content = (build_dir / "build" / "run.sh").read_text() + assert 'YOLO_APT_PACKAGES="zsh fzf"' in content + assert "apt.sh" in content finally: - import shutil shutil.rmtree(build_dir) - def test_copies_scripts(self, tmp_path, monkeypatch): - extras_dir = tmp_path / "extras" - extras_dir.mkdir() - (extras_dir / "apt.sh").write_text("apt-get install -y \"$@\"") - (extras_dir / "python.sh").write_text("uv python install \"$1\"") + def test_no_env_vars_for_bare_name(self, tmp_path, monkeypatch): + extras_dir = self._make_extras_dir(tmp_path, {"datalad": "#!/bin/bash"}) + monkeypatch.setattr("yolo.builder._extras_search_path", lambda: [extras_dir]) + + build_dir = assemble_build_context([{"name": "datalad"}]) + try: + content = (build_dir / "build" / "run.sh").read_text() + assert "bash /tmp/yolo-build/scripts/datalad.sh" in content + assert "YOLO_" not in content + finally: + shutil.rmtree(build_dir) + def test_copies_scripts(self, tmp_path, monkeypatch): + extras_dir = self._make_extras_dir(tmp_path, { + "apt": "#!/bin/bash", + "python": "#!/bin/bash", + }) monkeypatch.setattr("yolo.builder._extras_search_path", lambda: [extras_dir]) - build_dir = assemble_build_context(["apt:vim", {"python": "3.12"}]) + config = [ + {"name": "apt", "packages": ["vim"]}, + {"name": "python", "version": "3.12"}, + ] + build_dir = assemble_build_context(config) try: scripts_dir = build_dir / "build" / "scripts" assert (scripts_dir / "apt.sh").exists() assert (scripts_dir / "python.sh").exists() finally: - import shutil shutil.rmtree(build_dir) def test_raises_on_missing_script(self, tmp_path, monkeypatch): monkeypatch.setattr("yolo.builder._extras_search_path", lambda: [tmp_path]) - with pytest.raises(FileNotFoundError, match="apt"): - assemble_build_context(["apt:vim"]) - - def test_run_sh_has_args(self, tmp_path, monkeypatch): - extras_dir = tmp_path / "extras" - extras_dir.mkdir() - (extras_dir / "python.sh").write_text("uv python install \"$1\"") + with pytest.raises(FileNotFoundError, match="nope"): + assemble_build_context([{"name": "nope"}]) + def test_string_entry(self, tmp_path, monkeypatch): + extras_dir = self._make_extras_dir(tmp_path, {"datalad": "#!/bin/bash"}) monkeypatch.setattr("yolo.builder._extras_search_path", lambda: [extras_dir]) - build_dir = assemble_build_context([{"python": "3.12"}]) + build_dir = assemble_build_context(["datalad"]) try: content = (build_dir / "build" / "run.sh").read_text() - assert "python.sh 3.12" in content + assert "datalad.sh" in content finally: - import shutil shutil.rmtree(build_dir) diff --git a/tests/test_config.py b/tests/test_config.py index 8546576..923b8b2 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -15,6 +15,12 @@ def _write_yaml(path: Path, data: dict): YAML().dump(data, path) +@pytest.fixture(autouse=True) +def _no_defaults(monkeypatch, tmp_path): + """Point DEFAULTS_CONFIG at a nonexistent file so builtin defaults don't interfere.""" + monkeypatch.setattr("yolo.config.DEFAULTS_CONFIG", tmp_path / "no-defaults.yaml") + + # ── _merge ────────────────────────────────────────────────────── From 577e739524b4d6ef1c75f4d13f43c6bbaf4183e5 Mon Sep 17 00:00:00 2001 From: Austin Macdonald Date: Sat, 28 Mar 2026 14:14:05 -0500 Subject: [PATCH 11/55] Move issues, PRs, notes, security-reports to .local-notes/ (gitignored) Co-Authored-By: Claude Opus 4.6 (1M context) --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index 3149237..e91d481 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ .venv/ __pycache__/ *.egg-info/ +.local-notes/ From f1b2b0317be7c5f7b54082383759c7a312f7de9c Mon Sep 17 00:00:00 2001 From: Austin Macdonald Date: Sat, 28 Mar 2026 14:18:20 -0500 Subject: [PATCH 12/55] Reorganize: legacy under design/, local notes gitignored, TODOs split - Move legacy/ and ideas/ under design/ - Move issues, PRs, notes, security-reports, ideas to .local-notes/ - Break up TODO.md into individual files in .local-notes/TODO/ - Update CLAUDE.md paths Co-Authored-By: Claude Opus 4.6 (1M context) --- CLAUDE.md | 7 +- {legacy => design/legacy}/README.md | 0 {legacy => design/legacy}/bin/yolo | 0 {legacy => design/legacy}/config.example | 0 {legacy => design/legacy}/images/Dockerfile | 0 {legacy => design/legacy}/setup-yolo.sh | 0 .../legacy}/test-arg-parsing-fixed.sh | 0 {legacy => design/legacy}/test-arg-parsing.sh | 0 .../legacy}/test-worktree-feature.sh | 0 .../legacy}/tests/test_helper/common.bash | 0 {legacy => design/legacy}/tests/yolo.bats | 0 legacy/tests/test_helper/bats-assert | 1 - legacy/tests/test_helper/bats-support | 1 - notes/TODO.md | 8 - notes/ideas/read-only-mode.md | 5 - notes/ideas/sandboxed-review-subagent.md | 10 - notes/issues/issue-12.md | 5 - notes/issues/issue-23.md | 19 - notes/issues/issue-27.md | 20 - notes/issues/issue-3.md | 108 -- notes/issues/issue-33.md | 11 - notes/issues/issue-36.md | 5 - notes/issues/issue-39.md | 5 - notes/issues/issue-4.md | 14 - notes/issues/issue-42.md | 18 - notes/issues/issue-46.md | 674 ----------- notes/issues/issue-47.md | 115 -- notes/issues/issue-49.md | 96 -- notes/issues/issue-5.md | 8 - notes/issues/issue-51-selinux.md | 28 - notes/issues/issue-54.md | 18 - .../issues/issue-draft-config-environments.md | 100 -- notes/issues/non-home-worktree-issue.md | 36 - notes/prs/pr-48.md | 1013 ----------------- notes/prs/pr-51.md | 17 - notes/prs/pr-53.md | 21 - notes/prs/pr-55.md | 37 - notes/prs/pr-56.md | 25 - notes/prs/pr-57.md | 37 - notes/sandbox-comparison.md | 77 -- security-reports/audit-20260328-125651.md | 43 - 41 files changed, 5 insertions(+), 2577 deletions(-) rename {legacy => design/legacy}/README.md (100%) rename {legacy => design/legacy}/bin/yolo (100%) rename {legacy => design/legacy}/config.example (100%) rename {legacy => design/legacy}/images/Dockerfile (100%) rename {legacy => design/legacy}/setup-yolo.sh (100%) rename {legacy => design/legacy}/test-arg-parsing-fixed.sh (100%) rename {legacy => design/legacy}/test-arg-parsing.sh (100%) rename {legacy => design/legacy}/test-worktree-feature.sh (100%) rename {legacy => design/legacy}/tests/test_helper/common.bash (100%) rename {legacy => design/legacy}/tests/yolo.bats (100%) delete mode 160000 legacy/tests/test_helper/bats-assert delete mode 160000 legacy/tests/test_helper/bats-support delete mode 100644 notes/TODO.md delete mode 100644 notes/ideas/read-only-mode.md delete mode 100644 notes/ideas/sandboxed-review-subagent.md delete mode 100644 notes/issues/issue-12.md delete mode 100644 notes/issues/issue-23.md delete mode 100644 notes/issues/issue-27.md delete mode 100644 notes/issues/issue-3.md delete mode 100644 notes/issues/issue-33.md delete mode 100644 notes/issues/issue-36.md delete mode 100644 notes/issues/issue-39.md delete mode 100644 notes/issues/issue-4.md delete mode 100644 notes/issues/issue-42.md delete mode 100644 notes/issues/issue-46.md delete mode 100644 notes/issues/issue-47.md delete mode 100644 notes/issues/issue-49.md delete mode 100644 notes/issues/issue-5.md delete mode 100644 notes/issues/issue-51-selinux.md delete mode 100644 notes/issues/issue-54.md delete mode 100644 notes/issues/issue-draft-config-environments.md delete mode 100644 notes/issues/non-home-worktree-issue.md delete mode 100644 notes/prs/pr-48.md delete mode 100644 notes/prs/pr-51.md delete mode 100644 notes/prs/pr-53.md delete mode 100644 notes/prs/pr-55.md delete mode 100644 notes/prs/pr-56.md delete mode 100644 notes/prs/pr-57.md delete mode 100644 notes/sandbox-comparison.md delete mode 100644 security-reports/audit-20260328-125651.md diff --git a/CLAUDE.md b/CLAUDE.md index 1726f9a..ab5c6e4 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -4,7 +4,7 @@ yolo runs Claude Code safely in a rootless Podman container with full autonomy. -Currently being rewritten from bash to Python. Legacy code is in `legacy/`. +Currently being rewritten from bash to Python. Legacy code is in `design/legacy/`. ## Key files @@ -25,8 +25,11 @@ Entry point is `yo` (temporary, becomes `yolo` at cutover). ## Architecture -- `src/yolo/config.py` — YAML config loading from 4 locations +- `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) +- `src/yolo/defaults/config.yaml` — default container-extras shipped with package - `images/Containerfile.base` — minimal debian base image - `images/Containerfile.extras` — layers container-extras on top - `container-extras/` — composable install scripts (apt.sh, python.sh, etc.) +- `.local-notes/` — gitignored local working notes (issues, PRs, etc.) diff --git a/legacy/README.md b/design/legacy/README.md similarity index 100% rename from legacy/README.md rename to design/legacy/README.md diff --git a/legacy/bin/yolo b/design/legacy/bin/yolo similarity index 100% rename from legacy/bin/yolo rename to design/legacy/bin/yolo diff --git a/legacy/config.example b/design/legacy/config.example similarity index 100% rename from legacy/config.example rename to design/legacy/config.example diff --git a/legacy/images/Dockerfile b/design/legacy/images/Dockerfile similarity index 100% rename from legacy/images/Dockerfile rename to design/legacy/images/Dockerfile diff --git a/legacy/setup-yolo.sh b/design/legacy/setup-yolo.sh similarity index 100% rename from legacy/setup-yolo.sh rename to design/legacy/setup-yolo.sh diff --git a/legacy/test-arg-parsing-fixed.sh b/design/legacy/test-arg-parsing-fixed.sh similarity index 100% rename from legacy/test-arg-parsing-fixed.sh rename to design/legacy/test-arg-parsing-fixed.sh diff --git a/legacy/test-arg-parsing.sh b/design/legacy/test-arg-parsing.sh similarity index 100% rename from legacy/test-arg-parsing.sh rename to design/legacy/test-arg-parsing.sh diff --git a/legacy/test-worktree-feature.sh b/design/legacy/test-worktree-feature.sh similarity index 100% rename from legacy/test-worktree-feature.sh rename to design/legacy/test-worktree-feature.sh diff --git a/legacy/tests/test_helper/common.bash b/design/legacy/tests/test_helper/common.bash similarity index 100% rename from legacy/tests/test_helper/common.bash rename to design/legacy/tests/test_helper/common.bash diff --git a/legacy/tests/yolo.bats b/design/legacy/tests/yolo.bats similarity index 100% rename from legacy/tests/yolo.bats rename to design/legacy/tests/yolo.bats diff --git a/legacy/tests/test_helper/bats-assert b/legacy/tests/test_helper/bats-assert deleted file mode 160000 index 697471b..0000000 --- a/legacy/tests/test_helper/bats-assert +++ /dev/null @@ -1 +0,0 @@ -Subproject commit 697471b7a89d3ab38571f38c6c7c4b460d1f5e35 diff --git a/legacy/tests/test_helper/bats-support b/legacy/tests/test_helper/bats-support deleted file mode 160000 index 0954abb..0000000 --- a/legacy/tests/test_helper/bats-support +++ /dev/null @@ -1 +0,0 @@ -Subproject commit 0954abb9925cad550424cebca2b99255d4eabe96 diff --git a/notes/TODO.md b/notes/TODO.md deleted file mode 100644 index cf4b5aa..0000000 --- a/notes/TODO.md +++ /dev/null @@ -1,8 +0,0 @@ -1. --global-claude doesnt seem like the right name to me (--anonymized-paths) -2. bin/yolo:16 - argument parsing bug: `[ "${arg:0:2}" = "--" ]` matches any long option (--rm, --env, etc.) not just the separator `--`. Should be exact match: `[ "$arg" = "--" ]` -3. README.md - manual examples use `~/.claude:~/.claude:Z` but bash only expands first tilde, not the one after colon. Should use `$HOME/.claude:$HOME/.claude:Z` or fully expanded paths - -TODOs later (not part of this PR/review) -1. add shellcheck to CI (lets do in separate PR/branch) -1. add some sanity tests (hard to do without user to authenticate though) - diff --git a/notes/ideas/read-only-mode.md b/notes/ideas/read-only-mode.md deleted file mode 100644 index 0fa11ab..0000000 --- a/notes/ideas/read-only-mode.md +++ /dev/null @@ -1,5 +0,0 @@ -# --read-only mode - -Workspace mounted read-only, claude config still read-write (sessions -persist). Useful for review workflows where Claude should observe but -not modify. diff --git a/notes/ideas/sandboxed-review-subagent.md b/notes/ideas/sandboxed-review-subagent.md deleted file mode 100644 index 779d08c..0000000 --- a/notes/ideas/sandboxed-review-subagent.md +++ /dev/null @@ -1,10 +0,0 @@ -# Sandboxed review subagent - -A skill/hook that launches a yolo container (read-only) as a subagent: -1. Mount working tree read-only -2. Claude inside reviews the diff, runs tests, checks logs -3. Container exits with structured output -4. Calling Claude gets a summary: test failures, code concerns, log issues - -Like a local CI loop / PR review that runs before you push, in a -sandbox where the reviewer can't modify your code. diff --git a/notes/issues/issue-12.md b/notes/issues/issue-12.md deleted file mode 100644 index 5d647b2..0000000 --- a/notes/issues/issue-12.md +++ /dev/null @@ -1,5 +0,0 @@ -https://github.com/con/yolo/issues/12 - -# Restrict network for additional safety - -Originally discussed on https://github.com/con/yolo/pull/11 which removed an unused script that restricted the network (when used by Anthropic with VS code) but allowed access to the claude API. diff --git a/notes/issues/issue-23.md b/notes/issues/issue-23.md deleted file mode 100644 index 1d3f253..0000000 --- a/notes/issues/issue-23.md +++ /dev/null @@ -1,19 +0,0 @@ -https://github.com/con/yolo/issues/23 - -# Create collection of "skills" or prompts for typical use cases - -e.g. currently trying smth like - -```shell -❯ yolo "using the most recent logs of the test runs under .duct/logs/ please prepare to submit (using gh) an issue about failing tests against the repository of the 'origin' git remote. Before that check also for existing issues in that repo on whether already filed or relevant somehow present and to be mentioned in the new issue. While filing issue make sure to include information from 'git describe --tags' about the version of this package and how testing was done (from duct logs)" -``` - -so potentially we could establish a collection of similar prompts... in principle it is not yolo specific at all and there might be already such a project or we could create it outside of yolo and then use here... - -## Comments - -### asmacdo (2025-12-06T17:49:02Z) - -Sounds like a job for a Claude code plugin! - -https://code.claude.com/docs/en/plugins diff --git a/notes/issues/issue-27.md b/notes/issues/issue-27.md deleted file mode 100644 index 6ce47e8..0000000 --- a/notes/issues/issue-27.md +++ /dev/null @@ -1,20 +0,0 @@ -https://github.com/con/yolo/issues/27 - -# Include/setup mcp server for driving/testing in a browser - -not yet sure what is the best setup, but needed for any web ui driven development or documentation websites. Some hits: - -- https://developer.chrome.com/blog/chrome-devtools-mcp -- https://www.reddit.com/r/ClaudeAI/comments/1jf4hnt/setting_up_mcp_servers_in_claude_code_a_tech/ - -not yet 100% sure we could do it in the container (which imho would be better for "fully packaged setup") as opposed to some local `~/.local` setup so claude from container just picks it up - -edit: while working on https://github.com/yarikoptic/strava-backup it seemed to do pretty well (without mcp) although not sure if actually ran any playwright, and then stated - -``` - I was unable to test it fully because the environment is missing system libraries for Playwright. You'll need to run: - - sudo playwright install-deps chromium -``` - -so may be that is what we need - just to install playwright and install chromium with it inside container? to be tested... diff --git a/notes/issues/issue-3.md b/notes/issues/issue-3.md deleted file mode 100644 index d907aaf..0000000 --- a/notes/issues/issue-3.md +++ /dev/null @@ -1,108 +0,0 @@ -https://github.com/con/yolo/issues/3 - -# Does not work "out of the box" - -thought to give it a shot to address #2 but seems to need some /claude folder which it does not have there (I did run the setup-yolo.sh to build image) - -```shell -❯ yolo "modify setup ther to not modify shell for adding YOLO function to shell but rather installing yolo script like the one I crated under ~/.local/bin/yolo" -node:fs:2425 - return binding.writeFileUtf8( - ^ - -Error: EACCES: permission denied, open '/claude/debug/9343a0ce-8273-49bd-b8dd-a167cbdeb9fe.txt' - at Object.writeFileSync (node:fs:2425:20) - at Module.appendFileSync (node:fs:2507:6) - at Object.appendFileSync (file:///usr/local/share/npm-global/lib/node_modules/@anthropic-ai/claude-code/cli.js:9:868) - at m (file:///usr/local/share/npm-global/lib/node_modules/@anthropic-ai/claude-code/cli.js:11:87) - at Object.xr9 [as initialize] (file:///usr/local/share/npm-global/lib/node_modules/@anthropic-ai/claude-code/cli.js:555:32517) - at file:///usr/local/share/npm-global/lib/node_modules/@anthropic-ai/claude-code/cli.js:3775:34514 - at Q (file:///usr/local/share/npm-global/lib/node_modules/@anthropic-ai/claude-code/cli.js:8:15070) - at _4I (file:///usr/local/share/npm-global/lib/node_modules/@anthropic-ai/claude-code/cli.js:4039:1654) - at h4I (file:///usr/local/share/npm-global/lib/node_modules/@anthropic-ai/claude-code/cli.js:4097:13087) { - errno: -13, - code: 'EACCES', - syscall: 'open', - path: '/claude/debug/9343a0ce-8273-49bd-b8dd-a167cbdeb9fe.txt' -} - -Node.js v22.21.1 -yolo 1,54s user 39,59s system 369% cpu 11,131 total -❯ cat `which yolo` -#!/bin/sh - -if [ -z "$*" ] ; then - echo "E: specify you invocation for claude" - exit 1 -fi - -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 "$@" -❯ git remote -v -origin git@github.com:con/yolo (fetch) -origin git@github.com:con/yolo (push) - -``` - -and indeed there is no that `CLAUDE_CONFIG_DIR` there - -```shell -❯ podman run -it --rm --entrypoint ls con-bomination-claude-code -l / -total 16 -lrwxrwxrwx 1 root root 7 Nov 3 15:44 bin -> usr/bin -drwxr-xr-x 1 root root 0 Aug 24 12:05 boot -drwxr-sr-x 1 node root 26 Nov 11 13:51 commandhistory -drwxr-xr-x 5 root root 360 Nov 11 14:00 dev -drwxr-sr-x 1 root root 18 Nov 11 14:00 etc -drwxr-xr-x 1 root root 8 Nov 4 06:03 home -lrwxrwxrwx 1 root root 7 Nov 3 15:44 lib -> usr/lib -lrwxrwxrwx 1 root root 9 Nov 3 15:44 lib64 -> usr/lib64 -drwxr-xr-x 1 root root 0 Nov 3 15:44 media -drwxr-xr-x 1 root root 0 Nov 3 15:44 mnt -drwxr-xr-x 1 root root 26 Nov 4 06:03 opt -dr-xr-xr-x 771 nobody nogroup 0 Nov 11 14:00 proc -drwx------ 1 root root 20 Nov 11 13:51 root -drwxr-xr-x 1 root root 26 Nov 11 14:00 run -lrwxrwxrwx 1 root root 8 Nov 3 15:44 sbin -> usr/sbin -drwxr-xr-x 1 root root 0 Nov 3 15:44 srv -dr-xr-xr-x 13 nobody nogroup 0 Nov 11 14:00 sys -drwxrwxrwt 1 root root 36 Nov 11 13:51 tmp -drwxr-xr-x 1 root root 10 Nov 3 15:44 usr -drwxr-xr-x 1 root root 12 Nov 3 15:44 var -drwxr-sr-x 1 node node 0 Nov 11 13:51 workspace -``` - -so what was it intended to be -- generated, or be the `workspace/` or ?? - -## Comments - -### yarikoptic (2025-11-11T19:07:06Z) - -my bad -- I see now that it is a bind mount! I guess it might be due to a little more restrictive permissions I might be having than usual - -``` -❯ lsp ~/.claude/debug -PATH: /home/yoh/.claude/debug -0 drwx--S--- 1 yoh yoh 5052 Nov 11 13:26 /home/yoh/.claude/debug/ -0 drwxrwsr-x 1 yoh yoh 330 Nov 11 13:50 /home/yoh/.claude/ -0 drwxr-s--x 1 yoh yoh 17114 Nov 11 14:05 /home/yoh/ -0 drwxr-xr-x 1 root root 264 Jul 7 16:45 /home/ -``` - -### yarikoptic (2025-11-11T19:09:36Z) - -doing `chmod g+rXw ~/.claude/debug` makes it start! so may be the `./setup-yolo.sh` could do the check/chmod as needed. - -But then it requests configuration and subscription -- is that expected or it should have used what is already known to the user locally? (might also be simply a permissions issue) - -### asmacdo (2025-11-20T23:15:02Z) - -I suspect https://github.com/con/yolo/issues/9 fixed a lot of the issue here (hopefully also the auth check too), but setup should still check that ~/.claude has appropriate permissions. IMO should not chmod, but if permissions are not sufficient, should provide a command for user to run. diff --git a/notes/issues/issue-33.md b/notes/issues/issue-33.md deleted file mode 100644 index 908826a..0000000 --- a/notes/issues/issue-33.md +++ /dev/null @@ -1,11 +0,0 @@ -https://github.com/con/yolo/issues/33 - -# add singularity/apptainer as a possible containerization tech - -To make claude usable on HPC and other shared envs where no podman. I am not sure though if always would be possible to build an image, yet to research - -## Comments - -### satra (2026-03-04T15:47:54Z) - -+1 this issue as our cluster does not have podman. diff --git a/notes/issues/issue-36.md b/notes/issues/issue-36.md deleted file mode 100644 index 589431e..0000000 --- a/notes/issues/issue-36.md +++ /dev/null @@ -1,5 +0,0 @@ -https://github.com/con/yolo/issues/36 - -# Makes current directory $HOME thus leading to appearance of various folders - -like `~/.cache` , `~/.npm` and others in current folder ... should not be happening! likely needs to do what we do in repronim/containers and create a fake (tmp) folder to be bind mounted as HOME, unless we want it persistent and thus, if started where there is .git, could be `.git/yolo/home` or alike diff --git a/notes/issues/issue-39.md b/notes/issues/issue-39.md deleted file mode 100644 index f1f2f11..0000000 --- a/notes/issues/issue-39.md +++ /dev/null @@ -1,5 +0,0 @@ -https://github.com/con/yolo/issues/39 - -# need newer git in the environment - -wanted to use `worktree add --orphan` but claude said that shipped 2.39.5 does not have it. I have 2.51.0 on the system and it has that option. Review in which it was added and most ecological way (e.g. backports) to add it into the image diff --git a/notes/issues/issue-4.md b/notes/issues/issue-4.md deleted file mode 100644 index c0ec53b..0000000 --- a/notes/issues/issue-4.md +++ /dev/null @@ -1,14 +0,0 @@ -https://github.com/con/yolo/issues/4 - -# Setup CI to give some basic smoke testing! - -to avoid -- #3 - -not yet sure if would be easily to provide some dedicated "cheap" token - -## Comments - -### asmacdo (2025-11-11T22:02:07Z) - -Not sure how to make this work-- the first run requires user interaction https://github.com/con/yolo?tab=readme-ov-file#first-time-login diff --git a/notes/issues/issue-42.md b/notes/issues/issue-42.md deleted file mode 100644 index 34e4bf0..0000000 --- a/notes/issues/issue-42.md +++ /dev/null @@ -1,18 +0,0 @@ -https://github.com/con/yolo/issues/42 - -# Extract a SPEC.md from current features - -This project started as a POC without much consideration for expanding into a multi-user/multi-project flexible, configurable design. - -Additional features are needed, which I think *may* benefit from some thinking about higher level architecture. - - multiple containers with various environments - - user level configuration - - per project configuration - -I think we should start by developing a SPEC for what we currently do, and what we'd like to do, and consider if/how the architecture should change to improve flexibility and prevent bloat. - -## Comments - -### yarikoptic (2026-02-12T01:47:14Z) - -I think it would be worthwhile if we get more than 1 user (me) ;) diff --git a/notes/issues/issue-46.md b/notes/issues/issue-46.md deleted file mode 100644 index d8f42bf..0000000 --- a/notes/issues/issue-46.md +++ /dev/null @@ -1,674 +0,0 @@ -https://github.com/con/yolo/issues/46 - -# Login not persistent: $HOME mismatch between host and container - -## Problem - - The README states that credentials are stored in `~/.claude` and login only needs to happen once. However, login is not persistent across container - restarts because the host `$HOME` path differs from the container user `$HOME`. - - ## Details - - The container launch uses: - -v "$HOME/.claude:$HOME/.claude:Z" - - `$HOME` is expanded **on the host** (e.g., `/home/meng`), so credentials are mounted at `/home/meng/.claude` inside the container. - - However, inside the container the user is `node` with `HOME=/home/node`. Claude Code looks for credentials at `$HOME/.claude` → `/home/node/.claude`, - which is empty. So every session requires a fresh `/login`. - - ## Workaround - - ```bash - ln -s /home/meng/.claude /home/node/.claude - - (Replace /home/meng with your actual host $HOME.) - - Suggested fix - - Either: - 1. Mount to the container user home: -v "$HOME/.claude:/home/node/.claude:Z" - 2. Set HOME inside the container to match the host: --env HOME=$HOME - - Option 2 is probably simplest. - - Environment - - - Host user: meng ($HOME=/home/meng) - - Container user: node ($HOME=/home/node) - - Using --userns=keep-id - -## Comments - -### yarikoptic (2026-03-03T00:04:18Z) - -are you running on linux and is above your analysis or of claude as to state `- Container user: node ($HOME=/home/node)`? - -1. for credentials I just blamed - -- https://github.com/anthropics/claude-code/issues/1757 - -for which my workaround was just to point to the file with the key via - -``` -CLAUDE_CODE_OAUTH_TOKEN=$(cat ~/...secretlocation) yolo ... -``` - -2. -The option 2, if situation is actually as you describe, could also likely address - -- https://github.com/con/yolo/issues/36 - -which kept annoying me by creating all those `.cache/` and `.npm/` folders around! But here is what I see in my `yolo` session: - -``` -! pwd - ⎿ /home/yoh/.tmp - -! echo $HOME - ⎿ /home/yoh/.tmp - -! echo $USER - ⎿ (No output) - -! whoami - ⎿ yoh - -! ls /home/ - ⎿ node - yoh - -! ls /home/node/ - ⎿ (No output) - -! ls -a /home/node/ - ⎿ . - .. - .bash_logout - … +10 lines (ctrl+o to expand) - - -! ls -a /home/node/.claude/ - ⎿ . - .. - - -! ls -a /home/yoh/.claude/ - ⎿ . - .. - .claude.json - .claude.json.backup - .credentials.json - .git - .npm - CLAUDE.md - CLAUDE.visidata.md - agents - -! touch /home/node/something - ⎿ touch: cannot touch '/home/node/something': Permission denied - -``` - -so due to `--userns=keep-id` (I believe) I am "myself" inside! My HOME though is screwed up as pointing to current folder, and I cannot change anything under `/home/node` since it belongs not to me but to node: - -``` -! ls -l /home - ⎿ total 4 - drwxr-xr-x 1 node node 166 Mar 1 09:11 node - drwxr-xr-t 1 root root 30 Mar 2 18:52 yoh -``` - -since I do not think I care/want to keep any of those `~/.npm` etc around, most logical would be to prep/use some temp folder for the `$HOME`... - -and I wonder if we should - -- make `images/Dockerfile` to operate with the outside user identity instead of `node` so we just map the two identities into one somehow? (will not attempt trying ATM; let's figure your details). - - if that doesn't work I would just create a temp folder to bind mount as HOME and populate with copy of files from `/home/node` for a good measure. -- indeed pass `--env HOME=$HOME` if still would be needed - -### just-meng (2026-03-03T08:41:00Z) - -> are you running on linux and is above your analysis or of claude as to state - Container user: node ($HOME=/home/node)? - -yes, linux, no, did not set user to node myself - -it likely boils down to the podman version running here: 4.3.1 (relatively old, from Ubuntu 22.04 repos). apparently, the --userns=keep-id behavior around HOME has changed across versions. - -i have not confirmed yet that -e HOME fixes my problem (not allowed to run yolo --resume, because it won't start up a new container for the setting to take effect; and cannot trigger the auth request so i guess i wait ...) - -### asmacdo (2026-03-03T12:30:09Z) - -In the meantime for a workaround once you're logged in you can /resume to get to the previous conversations. - -### yarikoptic (2026-03-03T14:36:15Z) - -as for overall login workaround you can do what I do and described in [now folded comment](https://github.com/anthropics/claude-code/issues/1757#issuecomment-3811354846): - -> you need a dedicated run of claude setup-token to produce the one with duration of 1 year (seems to require providing it via `CLAUDE_CODE_OAUTH_TOKEN` env var. - -and it would indeed be nice to "solidify" behavior around `HOME`. - -### just-meng (2026-03-04T11:24:54Z) - -Still getting the auth err: -``` - ⎿ API Error: 401 - {"type":"error","error":{"type":"authentication_error","message":"OAuth - token has expired. Please obtain a new token or refresh your existing - token."},"request_id":"req_011CYhwsYS2YK2i3ByTPGtLJ"} · Please run /login -``` - -After setting `-e HOME`: -``` -podman run --log-driver=none -it --rm \ - --user="$(id -u):$(id -g)"\ - --userns=keep-id \ - --name="$name" \ - -v "$CLAUDE_MOUNT" \ - -v "$HOME/.gitconfig:/tmp/.gitconfig:ro,Z" \ - -v "$WORKSPACE_MOUNT" \ - "${WORKTREE_MOUNTS[@]}" \ - -w "$WORKSPACE_DIR" \ - -e HOME \ - -e CLAUDE_CONFIG_DIR="$CLAUDE_DIR" \ - -e GIT_CONFIG_GLOBAL=/tmp/.gitconfig \ - -e CLAUDE_CODE_OAUTH_TOKEN \ - -e CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS \ - "${NVIDIA_ARGS[@]}" \ - "${PODMAN_ARGS[@]}" \ - con-bomination-claude-code \ - "${CONTAINER_CMD[@]}" -``` -For now I'll try with updating podman. - -
Full `~/.local/bin/yolo` here: -``` -#!/bin/bash -# Claude Code YOLO mode - auto-approve all actions in containerized environment - -set -e - -show_help() { - cat << 'EOF' -Usage: yolo [OPTIONS] [-- CLAUDE_ARGS...] - -Run Claude Code in YOLO mode (auto-approve all actions) inside a container. - -OPTIONS: - -h, --help Show this help message - --anonymized-paths Use anonymized paths (/claude, /workspace) instead of - preserving host paths - --entrypoint=CMD Override the container entrypoint (default: claude) - --worktree=MODE Git worktree handling: ask, bind, skip, error - (default: ask) - --nvidia Enable NVIDIA GPU passthrough via CDI - Requires nvidia-container-toolkit on host - --no-config Ignore project configuration file - --install-config Create/display .git/yolo/config template - - Additional podman options can be passed before -- - -EXAMPLES: - yolo # Basic usage - yolo --nvidia # With GPU support - yolo -v /data:/data # Extra mount - yolo -- "help with this code" # Pass args to claude - yolo --nvidia -- --resume # GPU + claude args - -NVIDIA GPU SETUP: - The --nvidia flag uses CDI (Container Device Interface) for GPU access. - Prerequisites: - 1. Install nvidia-container-toolkit on your host - 2. Generate CDI spec: sudo nvidia-ctk cdi generate --output=/etc/cdi/nvidia.yaml - 3. Use: yolo --nvidia - -PROJECT CONFIGURATION: - Create .git/yolo/config to set per-project defaults. - Run 'yolo --install-config' to create a template. - - The config file is a bash script that can set: - - YOLO_PODMAN_VOLUMES: array of volume mounts - - YOLO_PODMAN_OPTIONS: array of podman options - - YOLO_CLAUDE_ARGS: array of arguments for claude - - USE_ANONYMIZED_PATHS: 0 or 1 - - USE_NVIDIA: 0 or 1 - - WORKTREE_MODE: ask, bind, skip, or error - -EOF - exit 0 -} - -# Function to print the default config template -print_config_template() { - cat << 'EOF' -# YOLO Project Configuration -# This file is sourced as a bash script by yolo -# Location: .git/yolo/config - -# Volume mounts - array of volumes to mount -# Syntax options: -# "~/projects" -> ~/projects:~/projects:Z (1-to-1 mount) -# "~/projects::ro" -> ~/projects:~/projects:ro,Z (1-to-1 with options) -# "~/data:/data:Z" -> ~/data:/data:Z (explicit mapping) -YOLO_PODMAN_VOLUMES=( - # "~/projects" - # "~/data::ro" -) - -# Additional podman options - array of options -YOLO_PODMAN_OPTIONS=( - # "--env=DEBUG=1" - # "--network=host" -) - -# Claude arguments - array of arguments passed to claude -YOLO_CLAUDE_ARGS=( - # "--model=claude-3-opus-20240229" -) - -# Default flags (0 or 1) -# USE_ANONYMIZED_PATHS=0 -# USE_NVIDIA=0 -# WORKTREE_MODE="ask" -EOF -} - -# Function to install config -install_config() { - # Find .git directory - local git_dir="" - local current_dir="$(pwd)" - - while [ "$current_dir" != "/" ]; do - if [ -d "$current_dir/.git" ]; then - git_dir="$current_dir/.git" - break - elif [ -f "$current_dir/.git" ]; then - # Worktree - read gitdir path - local gitdir_line=$(cat "$current_dir/.git") - if [[ "$gitdir_line" =~ ^gitdir:\ (.+)$ ]]; then - local gitdir_path="${BASH_REMATCH[1]}" - if [[ "$gitdir_path" != /* ]]; then - gitdir_path="$current_dir/$gitdir_path" - fi - if [[ "$gitdir_path" =~ ^(.+/\.git)/worktrees/ ]]; then - git_dir="${BASH_REMATCH[1]}" - break - fi - fi - fi - current_dir="$(dirname "$current_dir")" - done - - if [ -z "$git_dir" ]; then - echo "Error: Not in a git repository" >&2 - exit 1 - fi - - local config_dir="$git_dir/yolo" - local config_file="$config_dir/config" - - if [ -f "$config_file" ]; then - echo "Config file already exists at: $config_file" - echo "" - cat "$config_file" - else - mkdir -p "$config_dir" - print_config_template > "$config_file" - echo "Created config file at: $config_file" - echo "" - echo "Edit with: vi $config_file" - fi - - exit 0 -} - -# Function to expand volume shorthand syntax -expand_volume() { - local vol="$1" - - # Check for :: syntax first (shorthand with options) - if [[ "$vol" == *::* ]]; then - # Shorthand with options: ~/projects::ro,Z - local path="${vol%%::*}" - local opts="${vol#*::}" - # Expand ~ to $HOME - path="${path/#\~/$HOME}" - echo "${path}:${path}:${opts}" - elif [[ "$vol" == *:*:* ]]; then - # Full form: host:container:options - echo "$vol" - elif [[ "$vol" == *:* ]]; then - # Partial form: host:container (add :Z) - echo "${vol}:Z" - else - # Shorthand: ~/projects - # Expand ~ to $HOME and create 1-to-1 mapping - local path="${vol/#\~/$HOME}" - echo "${path}:${path}:Z" - fi -} - -# Parse arguments: everything before -- goes to podman, everything after goes to claude -# Also check for --anonymized-paths, --entrypoint, --worktree, --nvidia, --no-config, and --install-config flags -PODMAN_ARGS=() -CLAUDE_ARGS=() -found_separator=0 -USE_ANONYMIZED_PATHS=0 -ENTRYPOINT="claude" -WORKTREE_MODE="ask" -USE_NVIDIA=0 -USE_CONFIG=1 - -# Initialize config arrays -YOLO_PODMAN_VOLUMES=() -YOLO_PODMAN_OPTIONS=() -YOLO_CLAUDE_ARGS=() - -while [ $# -gt 0 ]; do - case "$1" in - -h|--help) - show_help - ;; - --install-config) - install_config - ;; - --entrypoint) - ENTRYPOINT="$2" - shift 2 - ;; - --entrypoint=*) - ENTRYPOINT="${1#--entrypoint=}" - shift - ;; - --worktree=*) - WORKTREE_MODE="${1#--worktree=}" - # Validate worktree mode - if [[ ! "$WORKTREE_MODE" =~ ^(ask|bind|skip|error)$ ]]; then - echo "Error: Invalid --worktree value: $WORKTREE_MODE" >&2 - echo "Valid values are: ask, bind, skip, error" >&2 - exit 1 - fi - shift - ;; - --anonymized-paths) - USE_ANONYMIZED_PATHS=1 - shift - ;; - --nvidia) - USE_NVIDIA=1 - shift - ;; - --no-config) - USE_CONFIG=0 - shift - ;; - --) - shift - found_separator=1 - CLAUDE_ARGS=("$@") - break - ;; - *) - if [ "$found_separator" -eq 1 ]; then - CLAUDE_ARGS+=("$1") - else - PODMAN_ARGS+=("$1") - fi - shift - ;; - esac -done - -# Save original PODMAN_ARGS for later (before we potentially move them to CLAUDE_ARGS) -CLI_PODMAN_ARGS=("${PODMAN_ARGS[@]}") - -if [ "$found_separator" = 0 ]; then - # so we did not find any -- everything is actually CLAUDE_ARGS - CLAUDE_ARGS=("${PODMAN_ARGS[@]}") - PODMAN_ARGS=() -fi - -# Load configuration if requested (after separator logic) -if [ "$USE_CONFIG" -eq 1 ]; then - # Find .git directory - git_dir="" - current_dir="$(pwd)" - - while [ "$current_dir" != "/" ]; do - if [ -d "$current_dir/.git" ]; then - git_dir="$current_dir/.git" - break - elif [ -f "$current_dir/.git" ]; then - # Worktree - read gitdir path - gitdir_line=$(cat "$current_dir/.git") - if [[ "$gitdir_line" =~ ^gitdir:\ (.+)$ ]]; then - gitdir_path="${BASH_REMATCH[1]}" - if [[ "$gitdir_path" != /* ]]; then - gitdir_path="$current_dir/$gitdir_path" - fi - if [[ "$gitdir_path" =~ ^(.+/\.git)/worktrees/ ]]; then - git_dir="${BASH_REMATCH[1]}" - break - fi - fi - fi - current_dir="$(dirname "$current_dir")" - done - - if [ -n "$git_dir" ]; then - config_dir="$git_dir/yolo" - config_file="$config_dir/config" - - # Auto-create config directory and template on first run - if [ ! -d "$config_dir" ]; then - mkdir -p "$config_dir" - print_config_template > "$config_file" - echo "Created default config at: $config_file" >&2 - echo "Edit with: vi $config_file" >&2 - echo "" >&2 - fi - - if [ -f "$config_file" ]; then - # Source the config file - source "$config_file" - - # Process volumes and expand shorthand syntax - for vol in "${YOLO_PODMAN_VOLUMES[@]}"; do - expanded=$(expand_volume "$vol") - PODMAN_ARGS=("-v" "$expanded" "${PODMAN_ARGS[@]}") - done - - # Add podman options - for opt in "${YOLO_PODMAN_OPTIONS[@]}"; do - PODMAN_ARGS=("$opt" "${PODMAN_ARGS[@]}") - done - - # Add claude args - if [ ${#YOLO_CLAUDE_ARGS[@]} -gt 0 ]; then - CLAUDE_ARGS=("${YOLO_CLAUDE_ARGS[@]}" "${CLAUDE_ARGS[@]}") - fi - fi - fi -fi - -# Give a meaningful name based on PWD and the PID to help identifying -# all those podman containers -# Note: leading periods and underscores are stripped as they're not allowed in container names -name=$( echo "$PWD-$$" | sed -e "s,^$HOME/,,g" -e "s,[^a-zA-Z0-9_.-],_,g" -e "s,^[._]*,," ) - -CLAUDE_HOME_DIR="$HOME/.claude" -# must exist but might not if first start on that box -mkdir -p "$CLAUDE_HOME_DIR" - -# Detect if we're in a git worktree and find the original repo -WORKTREE_MOUNTS=() -gitdir_path="" -dot_git="$(pwd)/.git" -is_worktree=0 -original_repo_dir="" - -if [ -L "$dot_git" ]; then - # .git is a symlink - resolve it to get the gitdir path - gitdir_path=$(realpath "$dot_git" 2>/dev/null) -elif [ -f "$dot_git" ]; then - # .git is a file, likely a worktree - read the gitdir path - gitdir_line=$(cat "$dot_git") - if [[ "$gitdir_line" =~ ^gitdir:\ (.+)$ ]]; then - gitdir_path="${BASH_REMATCH[1]}" - # Resolve to absolute path if relative - if [[ "$gitdir_path" != /* ]]; then - gitdir_path="$(pwd)/$gitdir_path" - fi - gitdir_path=$(realpath "$gitdir_path" 2>/dev/null || echo "$gitdir_path") - fi -fi - -if [ -n "$gitdir_path" ]; then - # gitdir_path is typically /path/to/original/repo/.git/worktrees/ - # We need to find the original repo's .git directory - if [[ "$gitdir_path" =~ ^(.+/\.git)/worktrees/ ]]; then - original_git_dir="${BASH_REMATCH[1]}" - original_repo_dir=$(dirname "$original_git_dir") - # Only consider it a worktree if it's different from our current workspace - if [ "$original_repo_dir" != "$(pwd)" ]; then - is_worktree=1 - fi - fi -fi - -# Handle worktree based on the mode -if [ "$is_worktree" -eq 1 ]; then - case "$WORKTREE_MODE" in - error) - echo "Error: Running in a git worktree is not allowed with --worktree=error" >&2 - echo "Original repo: $original_repo_dir" >&2 - exit 1 - ;; - bind) - WORKTREE_MOUNTS+=("-v" "$original_repo_dir:$original_repo_dir:Z") - ;; - skip) - # Do nothing - skip bind mount - ;; - ask) - echo "Detected git worktree. Original repository: $original_repo_dir" >&2 - echo "Bind mounting the original repo allows git operations but may expose unintended files." >&2 - read -p "Bind mount original repository? [y/N] " -n 1 -r >&2 - echo >&2 - if [[ $REPLY =~ ^[Yy]$ ]]; then - WORKTREE_MOUNTS+=("-v" "$original_repo_dir:$original_repo_dir:Z") - fi - ;; - esac -fi - -# Determine paths based on --anonymized-paths flag -if [ "$USE_ANONYMIZED_PATHS" -eq 1 ]; then - # Old behavior: use anonymized paths - CLAUDE_DIR="/claude" - WORKSPACE_DIR="/workspace" - CLAUDE_MOUNT="$CLAUDE_HOME_DIR:/claude:Z" - WORKSPACE_MOUNT="$(pwd):/workspace:Z" -else - # New default behavior: preserve original host paths - CLAUDE_DIR="$CLAUDE_HOME_DIR" - WORKSPACE_DIR="$(pwd)" - CLAUDE_MOUNT="$CLAUDE_HOME_DIR:$CLAUDE_HOME_DIR:Z" - WORKSPACE_MOUNT="$(pwd):$(pwd):Z" -fi - -# Build the command to run inside the container -if [ "$ENTRYPOINT" = "claude" ]; then - # Default: run claude with --dangerously-skip-permissions - CONTAINER_CMD=("claude" "--dangerously-skip-permissions" "${CLAUDE_ARGS[@]}") -else - # Custom entrypoint: run as-is with any additional args - CONTAINER_CMD=("$ENTRYPOINT" "${CLAUDE_ARGS[@]}") -fi - -# NVIDIA GPU support via CDI (Container Device Interface) -# Requires nvidia-container-toolkit on host with CDI spec generated -NVIDIA_ARGS=() -if [ "$USE_NVIDIA" -eq 1 ]; then - # Check if CDI spec exists - if [ ! -f /etc/cdi/nvidia.yaml ] && [ ! -f /var/run/cdi/nvidia.yaml ]; then - echo "Warning: NVIDIA CDI spec not found at /etc/cdi/nvidia.yaml or /var/run/cdi/nvidia.yaml" >&2 - echo "GPU passthrough may not work. Install nvidia-container-toolkit and run:" >&2 - echo " sudo nvidia-ctk cdi generate --output=/etc/cdi/nvidia.yaml" >&2 - echo >&2 - fi - NVIDIA_ARGS+=(--device "nvidia.com/gpu=all") - NVIDIA_ARGS+=(--security-opt "label=disable") -fi - -podman run --log-driver=none -it --rm \ - --user="$(id -u):$(id -g)"\ - --userns=keep-id \ - --name="$name" \ - -v "$CLAUDE_MOUNT" \ - -v "$HOME/.gitconfig:/tmp/.gitconfig:ro,Z" \ - -v "$WORKSPACE_MOUNT" \ - "${WORKTREE_MOUNTS[@]}" \ - -w "$WORKSPACE_DIR" \ - -e HOME \ - -e CLAUDE_CONFIG_DIR="$CLAUDE_DIR" \ - -e GIT_CONFIG_GLOBAL=/tmp/.gitconfig \ - -e CLAUDE_CODE_OAUTH_TOKEN \ - -e CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS \ - "${NVIDIA_ARGS[@]}" \ - "${PODMAN_ARGS[@]}" \ - con-bomination-claude-code \ - "${CONTAINER_CMD[@]}" -``` -
- -### just-meng (2026-03-04T11:41:20Z) - -Now on podman version 4.6.2, still same behavior. When I look up `~/.claude/.credentials.json`, it contains `"expiresAt":1772583776416`. I suspect it is tied to my account/rate limited plan somehow that the token itself is valid only for a limited time. - -### asmacdo (2026-03-06T17:33:40Z) - -@just-meng once its working correctly, the token will still expire, but it should be able to renew itself IIRC - -### yarikoptic (2026-03-12T15:57:24Z) - -@just-meng original issue was about HOME mismatch -- have that one being found peace with so we could close? the other was credentials which relate likely to the issue I cited and generic to claude code -? - -### just-meng (2026-03-12T16:51:51Z) - -no it has not been resolved, still running as node despite the setting - -### yarikoptic (2026-03-26T01:08:53Z) - -BTW with #55 I provide a solution which forces `$HOME` to not correspond between host and container and to be `/home/node` (instead of CWD). And I think it is a good thing. `CLAUDE_HOME_DIR="$HOME/.claude"` is defined outside of container and thus would remain mounted from original user's HOME, and have nothing to do with internal ~/ which would be largely empty anyways (besides what container provides for rudimentary user setup). - -auth issue seems "upstream", as I mentioned -- https://github.com/anthropics/claude-code/issues/1757 - -and which I just overcame via providing a persistent token (although I think this way looses some features) - -### just-meng (2026-03-27T16:43:52Z) - -I had to get a primer on containers and UIDs from claude to understand what's happening here. And that's quite interesting. Let's see if I get it right: - -Apparently my host UID is 1000 by default which is common for a single user on a Linux machine. That's likely not the case for you. Podman also uses UID 1000 by default and refers to this user as node. By accident, when I run yolo which sets --userns=keep-id, my host UID gets mapped into the container which happens to be found in in /etc/passwd due to the podman default user. So my home is set up correctly as /home/node, which means I never have seen any junk files as Yarik has reported. My home was truely ephemeral by accident. For any host UID other than 1000, a lookup in /etc/passwd won't find anything and the home gets set to the CWD which leaves a bunch of unwanted traces. - -The fact that I had to re-login so often likely has nothing to do with the $HOME variable in the first place. Claude was misled by the node user. But this accidentally helped improving yolo in a different aspect! - -### just-meng (2026-03-27T17:05:01Z) - -And my new theory: - - - Token expires after 8 hours - - While a session is running, Claude refreshes it silently ✓ - - When all sessions are closed, nothing is running to do the refresh - - You sleep, token expires, no session to catch it - - Morning: new session starts, token is stale, refresh fails on first message → 401 - -I always shut down my computer because it is too noisy. This likely explains the discrepancy we observe here. I'll try with the 1-year token. - -### just-meng (2026-03-27T17:06:09Z) - -Theory proven wrong! Token JUST expired mid-session. diff --git a/notes/issues/issue-47.md b/notes/issues/issue-47.md deleted file mode 100644 index a97d96f..0000000 --- a/notes/issues/issue-47.md +++ /dev/null @@ -1,115 +0,0 @@ -https://github.com/con/yolo/issues/47 - -# Config and Arbitrary Development Environments - -This isn't a firm proposal — just consolidating the discussions we've had across several issues and PRs, along with some of my thinking on where this could go. Opening this to get everyone's input in one place and encourage more! - -**Next steps:** -1. Discuss here — poke holes, raise concerns, add ideas -2. Generally agree on the shape of the approach -3. Write a design document (PR) for sharper, per-line discussion - -## Context - -yolo needs to create arbitrary, persistent development environments for each project. -Today, every time someone needs a tool in the image, we hit the same debate: add it to the Dockerfile? Make it an `--extras` flag? A separate image? - -This has come up repeatedly: -- PR #28: playwright added ~600MB, prompting "should be a separate image" -- PR #31: `--packages`/`--extras` added to setup-yolo.sh, with discussion of multiple images and runtime image selection -- PR #43: `--image` with derived Dockerfiles rejected for combinatorial explosion, landed as `--extras=datalad,jj` -- #39: newer git needed — another "what goes in the base image" question -- #33: singularity/apptainer — different container runtime entirely - -The `--extras` pattern was a good stopgap, but we can't encode install instructions for every tool every user might want. Meanwhile, yolo is fully capable of constructing environments ephemerally, but ephemeral environments aren't ideal for development — they need to be reconstructed every time. - -### Target audience - -Our primary users are scientists, not software engineers. -Most will never write a Dockerfile and shouldn't have to. -Whatever we design, the common case needs to be as simple as adding a package name to a config file. - -## Discussion: How should environment customization work? - -Some directions that have come up in prior discussions, consolidated here. - -### Pre-built base images - -Publish a base image to a registry so yolo works out of the box with no build step. -What goes in the base? Just the minimum, or opinionated with group tools like datalad? - -### Config-driven packages - -Let users list packages in config files (apt, pip, etc.) without writing a Dockerfile: - -``` -# in .git/yolo/config or ~/.config/yolo/config -YOLO_APT_PACKAGES=(ffmpeg imagemagick) -YOLO_PIP_PACKAGES=(datalad) -``` - -This could be the primary customization path for most users — a scientist who needs `ffmpeg` just adds it to their project config. - -### Custom Dockerfiles for power users - -For anything that needs custom install steps, users could provide their own Dockerfile (using our base as `FROM` or not). -This would live outside our repo. - -### yolo as the single entrypoint - -Currently `setup-yolo.sh` handles building and `yolo` handles running. -Should yolo handle both — pulling/building images as needed? With a base image in a registry, this would mean yolo works immediately after install. - -### Config precedence - -Build-time config (image name, packages, Dockerfile path, registry) could follow the same precedence as existing runtime config: - -**CLI args > project config > user-wide config > defaults** - -### Build behavior - -Build on first run if image doesn't exist. -`--rebuild` to force. -Auto-detection of config changes could come later. - -## Alternative approaches - -### Two layers only: base image + custom Dockerfile - -This is what Gitpod and Codespaces do — provide a base image, let users write a Dockerfile for customization. Simpler to implement and reason about. However, the gap between "use the base" and "write a Dockerfile" is too wide for our audience. A scientist who just needs `ffmpeg` shouldn't have to learn Docker to get it. -We're leaning away from this toward a config-driven middle path because that's where most of our potential users would actually be comfortable. - -### Other prior art - -- **devcontainer features** — composable install scripts with metadata. Well-specified but heavyweight; requires authoring feature scripts with a specific structure. -- **Nix / devenv** — declarative, reproducible. Elegant but steep learning curve. -- **Docker official image variants** — tag-based (`python:3.12-slim`). No composition, just pick one. - -## Open questions - -- **CLI rewrite?** Bash is hitting its limits for config parsing, registry logic, and the complexity ahead. Python? How much rewrite vs. incremental? -- **Registry?** GHCR, Docker Hub, Quay, multiple? -- **Base image contents?** Minimal vs. opinionated? -- **Alternative runtimes** (#33) — Singularity/Apptainer is a related concern; good architecture now would make it easier later. - -## Related - -- #42 — Extract a SPEC.md -- #33 — Singularity/Apptainer support -- #39 — Need newer git in the environment -- #46 — HOME mismatch between host and container -- PR #28, #31, #43 — Prior discussions on image customization - -## Comments - -### just-meng (2026-03-12T17:17:31Z) - -I totally agree with the your point of providing something more flexible than `--extras` and much simpler than a Dockerfile. On the technical level, I have nothing to contribute, but happy to report "user experiences" once there is a concrete design/solution. - -### yarikoptic (2026-03-13T01:01:15Z) - -on a tangential topic -- I discovered that there is a built in feature for isolation in 'claude code' , https://code.claude.com/docs/en/sandboxing , so I am yet to review/compare the two . Might even just switch away from yolo at some point - -### asmacdo (2026-03-13T08:36:27Z) - -@yarikoptic that discussion: https://github.com/con/yolo/issues/49 diff --git a/notes/issues/issue-49.md b/notes/issues/issue-49.md deleted file mode 100644 index 3459932..0000000 --- a/notes/issues/issue-49.md +++ /dev/null @@ -1,96 +0,0 @@ -https://github.com/con/yolo/issues/49 - -# Comparison to sandbox mode: yolo still needed? - -
yolo self-generated report: Sandbox vs. YOLO: A Comparison - -Both solve the same fundamental problem — **how to let Claude Code run autonomously without it doing something dangerous** — but they take very different architectural approaches. - -## Core Philosophy - -| | Claude Code Sandbox | YOLO | -|---|---|---| -| **Approach** | Fine-grained OS-level policy enforcement | Coarse-grained container isolation | -| **Metaphor** | "You can run freely, but these specific walls are enforced by the kernel" | "You're in a separate room; do whatever you want in there" | -| **Permission model** | Whitelist domains + filesystem paths, deny everything else | Mount only what's needed, auto-approve everything inside | -| **Technology** | macOS Seatbelt / Linux Bubblewrap + network proxy | Podman rootless containers + user namespaces | - -## What Each Restricts - -| Resource | Claude Code Sandbox | YOLO | -|---|---|---| -| **Filesystem write** | CWD only (configurable allowlist) | Only mounted volumes (CWD, `~/.claude`, `.gitconfig`) | -| **Filesystem read** | Whole machine minus denylist | Only mounted volumes | -| **Network** | Proxy-based domain allowlist | **Unrestricted** (intentional) | -| **SSH keys** | Accessible (unless denied) | **Not mounted** (no git push) | -| **Subprocess isolation** | All children inherit sandbox | All children are in the container | -| **Credentials** | Configurable deny (e.g., `~/.aws/credentials`) | Only OAuth token passed via env var | - -## Key Trade-offs - -### Claude Code Sandbox strengths - -- **Network filtering** — the biggest differentiator. YOLO has no network restrictions, meaning a prompt injection attack could exfiltrate data. The sandbox's domain-level proxy blocks this. -- **No setup overhead** — no container build step, no image maintenance. Just `bubblewrap` + `socat` on Linux. -- **Full host read access by default** — Claude can read your whole machine (minus denylists), which is useful for cross-project work. -- **Granular configurability** — per-path, per-domain rules in `settings.json`. -- **Open-source runtime** — `@anthropic-ai/sandbox-runtime` is reusable in other agent projects. - -### YOLO strengths - -- **Stronger filesystem isolation** — sandbox allows reading the whole machine by default; YOLO only exposes what you mount. A read-based side-channel attack is harder. -- **Reproducible environment** — the Dockerfile gives you a known, consistent toolchain (git, gh, shellcheck, uv, etc.). No "works on my machine" issues. -- **Customizable environments** — NVIDIA GPU support, Playwright, DataLad, jj via `--extras`. The sandbox doesn't manage your tool environment. -- **Session portability** — preserved paths mean sessions work identically in and out of the container. -- **Config layering** — user-wide + per-project config with array merging for volumes/args. -- **No SSH key exposure** — a deliberate security boundary that forces push operations to happen on the host. - -## Where YOLO Has a Gap - -The **network** is the elephant in the room. YOLO's container has unrestricted network access. This means: - -- A prompt injection in a code comment could instruct Claude to `curl` data to an attacker's server -- Malicious dependencies could phone home -- There's no domain allowlisting - -The sandbox's proxy-based network filtering directly addresses this threat model. - -## Where Claude Code Sandbox Has a Gap - -- **No environment management** — you're running on your host. If Claude needs `playwright` or `cuda`, it's installing on your machine. -- **Read access is broad by default** — `~/.ssh`, `~/.gnupg`, env files, etc. are readable unless you explicitly deny them. YOLO's mount-only approach is deny-by-default for the filesystem. -- **Platform limitations** — no native Windows support, weaker in nested Docker environments on Linux. - -## Could They Be Combined? - -Yes, and it would be the strongest posture: - -1. **YOLO for environment isolation** — reproducible toolchain, mount-only filesystem, no SSH keys -2. **Claude Code sandbox inside the container** — add network filtering and filesystem write restrictions within the already-restricted container - -This would give you defense-in-depth: container boundaries for the coarse isolation + sandbox policies for fine-grained network and filesystem control inside. - -## Summary - -If your primary threat model is **"Claude modifies the wrong files or installs something I don't want"**, both approaches work well. - -If your threat model includes **"data exfiltration via network"**, the Claude Code sandbox is strictly better today — YOLO would need network restrictions (e.g., podman `--network=none` plus an allowlist proxy, or just enabling sandbox mode inside the container). - -If your concern is **reproducible, portable environments with known toolchains**, YOLO provides something the sandbox doesn't attempt. - -They're complementary rather than competing. - -
- -tldr; - -sandbox: You can run freely, but these specific walls are enforced by the kernel - - Global Read: Whole machine minus denylist -- Limited Write -- network protection - -yolo: You're in a separate room; do whatever you want in there - - Read: Mount only what's needed, auto-approve everything inside -- no network protection - -IMO, global read with deny list is insufficient for `--dangerously-bypass-permissions`. I will not be using sandbox mode. So I think that `yolo` continues to be worth some time and effort to maintain/improve. @yarikoptic feel free to close if satisfied. diff --git a/notes/issues/issue-5.md b/notes/issues/issue-5.md deleted file mode 100644 index 0de20d7..0000000 --- a/notes/issues/issue-5.md +++ /dev/null @@ -1,8 +0,0 @@ -https://github.com/con/yolo/issues/5 - -# Make setup-yolo.sh do basic testing - -could be to complement -- #4 - -as `./setup-yolo.sh` could invoke the `yolo` with some basic prompt on this repo to ensure that it all works. Then CI actually could just run `setup-yolo.sh` in that mode diff --git a/notes/issues/issue-51-selinux.md b/notes/issues/issue-51-selinux.md deleted file mode 100644 index 13cd155..0000000 --- a/notes/issues/issue-51-selinux.md +++ /dev/null @@ -1,28 +0,0 @@ -# Issue #51: EACCES crash when running multiple yolo instances - -## Problem -Running two yolo containers simultaneously causes the first one to crash with: -``` -EACCES: permission denied, open '/home/austin/.claude/.claude.json' -``` - -## Root Cause -All volume mounts in yolo used `:Z` (uppercase) SELinux label, which means -"private unshared label". When a second container mounts the same `~/.claude` -directory with `:Z`, the container runtime relabels the files for container #2, -revoking access from container #1. Container #1 then gets EACCES. - -This explains why two native Claude Code sessions work fine (no SELinux -relabeling) but two yolo containers don't. - -## Fix Applied -Changed `CLAUDE_MOUNT` lines (417 and 423 in bin/yolo) from `:Z` to `:z` -(lowercase = shared label, allows multiple containers to access simultaneously). - -Only the `~/.claude` mount was changed. Other mounts (workspace, worktree) still -use `:Z` since those are typically not shared between concurrent containers. - -## Status -- Fix applied, needs testing: run two yolo sessions simultaneously and confirm - the first no longer crashes. -- If it works, commit and reference issue #51. diff --git a/notes/issues/issue-54.md b/notes/issues/issue-54.md deleted file mode 100644 index f56e0a8..0000000 --- a/notes/issues/issue-54.md +++ /dev/null @@ -1,18 +0,0 @@ -https://github.com/con/yolo/issues/54 - -# add support for openrouter users - -at the moment, yolo expects that I have a claude code subscription - sadly I don't. But I have a bunch of credits in an openrouter account - and I can use claude code with openrouter. - -I think this would be a relatively easy fix, afaict (and have tested locally), it just requires two extra ENV variables: - -```bash - -podman run --log-driver=none -it --rm \ - ... - -e ANTHROPIC_BASE_URL="https://openrouter.ai/api" \ - -e ANTHROPIC_AUTH_TOKEN="${OPENROUTER_API_KEY}" - ... -``` - -Would this go as an option into `setup.yolo`? diff --git a/notes/issues/issue-draft-config-environments.md b/notes/issues/issue-draft-config-environments.md deleted file mode 100644 index 8e10778..0000000 --- a/notes/issues/issue-draft-config-environments.md +++ /dev/null @@ -1,100 +0,0 @@ -# Config and Arbitrary Development Environments - -This isn't a firm proposal — just consolidating the discussions we've had across several issues and PRs, along with some of my thinking on where this could go. Opening this to get everyone's input in one place and encourage more! - -**Next steps:** -1. Discuss here — poke holes, raise concerns, add ideas -2. Generally agree on the shape of the approach -3. Write a design document (PR) for sharper, per-line discussion - -## Context - -yolo needs to create arbitrary, persistent development environments for each project. -Today, every time someone needs a tool in the image, we hit the same debate: add it to the Dockerfile? Make it an `--extras` flag? A separate image? - -This has come up repeatedly: -- PR #28: playwright added ~600MB, prompting "should be a separate image" -- PR #31: `--packages`/`--extras` added to setup-yolo.sh, with discussion of multiple images and runtime image selection -- PR #43: `--image` with derived Dockerfiles rejected for combinatorial explosion, landed as `--extras=datalad,jj` -- #39: newer git needed — another "what goes in the base image" question -- #33: singularity/apptainer — different container runtime entirely - -The `--extras` pattern was a good stopgap, but we can't encode install instructions for every tool every user might want. Meanwhile, yolo is fully capable of constructing environments ephemerally, but ephemeral environments aren't ideal for development — they need to be reconstructed every time. - -### Target audience - -Our primary users are scientists, not software engineers. -Most will never write a Dockerfile and shouldn't have to. -Whatever we design, the common case needs to be as simple as adding a package name to a config file. - -## Discussion: How should environment customization work? - -Some directions that have come up in prior discussions, consolidated here. - -### Pre-built base images - -Publish a base image to a registry so yolo works out of the box with no build step (#3). -What goes in the base? Just the minimum, or opinionated with group tools like datalad? - -### Config-driven packages - -Let users list packages in config files (apt, pip, etc.) without writing a Dockerfile: - -``` -# in .git/yolo/config or ~/.config/yolo/config -YOLO_APT_PACKAGES=(ffmpeg imagemagick) -YOLO_PIP_PACKAGES=(datalad) -``` - -This could be the primary customization path for most users — a scientist who needs `ffmpeg` just adds it to their project config. - -### Custom Dockerfiles for power users - -For anything that needs custom install steps, users could provide their own Dockerfile (using our base as `FROM` or not). -This would live outside our repo. - -### yolo as the single entrypoint - -Currently `setup-yolo.sh` handles building and `yolo` handles running. -Should yolo handle both — pulling/building images as needed? With a base image in a registry, this would mean yolo works immediately after install. - -### Config precedence - -Build-time config (image name, packages, Dockerfile path, registry) could follow the same precedence as existing runtime config: - -**CLI args > project config > user-wide config > defaults** - -### Build behavior - -Build on first run if image doesn't exist. -`--rebuild` to force. -Auto-detection of config changes could come later. - -## Alternative approaches - -### Two layers only: base image + custom Dockerfile - -This is what Gitpod and Codespaces do — provide a base image, let users write a Dockerfile for customization. Simpler to implement and reason about. However, the gap between "use the base" and "write a Dockerfile" is too wide for our audience. A scientist who just needs `ffmpeg` shouldn't have to learn Docker to get it. -We're leaning away from this toward a config-driven middle path because that's where most of our potential users would actually be comfortable. - -### Other prior art - -- **devcontainer features** — composable install scripts with metadata. Well-specified but heavyweight; requires authoring feature scripts with a specific structure. -- **Nix / devenv** — declarative, reproducible. Elegant but steep learning curve. -- **Docker official image variants** — tag-based (`python:3.12-slim`). No composition, just pick one. - -## Open questions - -- **CLI rewrite?** Bash is hitting its limits for config parsing, registry logic, and the complexity ahead. Python? How much rewrite vs. incremental? -- **Registry?** GHCR, Docker Hub, Quay, multiple? -- **Base image contents?** Minimal vs. opinionated? -- **Alternative runtimes** (#33) — Singularity/Apptainer is a related concern; good architecture now would make it easier later. - -## Related - -- #42 — Extract a SPEC.md -- #33 — Singularity/Apptainer support -- #3 — Does not work out of the box -- #39 — Need newer git in the environment -- #46 — HOME mismatch between host and container -- PR #28, #31, #43 — Prior discussions on image customization diff --git a/notes/issues/non-home-worktree-issue.md b/notes/issues/non-home-worktree-issue.md deleted file mode 100644 index 178cfea..0000000 --- a/notes/issues/non-home-worktree-issue.md +++ /dev/null @@ -1,36 +0,0 @@ -# Container name invalid when running outside $HOME - -## Problem - -When running `yolo` from a directory outside `$HOME` (e.g., `/tmp/duct-worktree`), the container fails to start: - -``` -Error: running container create option: names must match [a-zA-Z0-9][a-zA-Z0-9_.-]*: invalid argument -``` - -## Cause - -The container name is generated by this line in `bin/yolo`: - -```bash -name=$( echo "$PWD-$$" | sed -e "s,^$HOME/,,g" -e "s,[^a-zA-Z0-9_.-],_,g" ) -``` - -For `/tmp/duct-worktree`, this produces `_tmp_duct-worktree-12345` which starts with `_`. Container names must start with `[a-zA-Z0-9]`. - -## Reproduction - -```bash -git -C ~/devel/duct worktree add /tmp/duct-worktree HEAD -cd /tmp/duct-worktree -~/devel/yolo/bin/yolo --worktree=skip -``` - -## Possible fix - -Prefix with a letter or strip leading underscores: - -```bash -name=$( echo "$PWD-$$" | sed -e "s,^$HOME/,,g" -e "s,[^a-zA-Z0-9_.-],_,g" -e "s,^_*,," ) -name="yolo-${name:-default}" -``` diff --git a/notes/prs/pr-48.md b/notes/prs/pr-48.md deleted file mode 100644 index 3f09ba3..0000000 --- a/notes/prs/pr-48.md +++ /dev/null @@ -1,1013 +0,0 @@ -https://github.com/con/yolo/pull/48 - -# Add SPEC.md documenting all current features - -**Author:** Austin Macdonald (asmacdo) -**Branch:** `add-spec` - -## Full Diff - -This is the SPEC PR -- full diff included below. - -```patch -From 4ccd294b5f5d79e8f6e8ba88882a3ad8a54c8ddc Mon Sep 17 00:00:00 2001 -From: Austin Macdonald -Date: Thu, 12 Mar 2026 14:13:00 -0500 -Subject: [PATCH 1/2] Add SPEC.md documenting all current features - -Addresses #42. Extracts a comprehensive specification from the current -implementation covering CLI, configuration, volumes, path modes, worktree -support, NVIDIA GPU, container runtime, Dockerfile, setup script, security -boundaries, testing, and CI/CD. - -Co-Authored-By: Claude Opus 4.6 ---- - SPEC.md | 440 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++ - 1 file changed, 440 insertions(+) - create mode 100644 SPEC.md - -diff --git a/SPEC.md b/SPEC.md -new file mode 100644 -index 0000000..1211fb3 ---- /dev/null -+++ b/SPEC.md -@@ -0,0 +1,440 @@ -+# YOLO Specification -+ -+Extracted from the current implementation as of 2026-03-12. -+See [issue #42](https://github.com/con/yolo/issues/42). -+ -+## 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` or `enhs` branches. -+- 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. - -From af33b113d3c4a25829017007eceef10967b540dd Mon Sep 17 00:00:00 2001 -From: Austin Macdonald -Date: Thu, 12 Mar 2026 15:03:18 -0500 -Subject: [PATCH 2/2] Address PR #48 review: align tables, add CLAUDE.md, - remove enhs branch - -- Reformat all SPEC.md tables with proper column alignment for readability -- Remove dated "Extracted from..." header (not useful long-term) -- Add CLAUDE.md with directive to consult SPEC.md for yolo work -- Remove unused `enhs` branch from CI triggers and spec - -Co-Authored-By: Yaroslav Halchenko -Co-Authored-By: Austin Macdonald -Co-Authored-By: Claude Opus 4.6 ---- - .github/workflows/ci.yml | 2 +- - CLAUDE.md | 3 + - SPEC.md | 287 +++++++++++++++++++-------------------- - 3 files changed, 146 insertions(+), 146 deletions(-) - create mode 100644 CLAUDE.md - -diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml -index 78fc41e..64d85a8 100644 ---- a/.github/workflows/ci.yml -+++ b/.github/workflows/ci.yml -@@ -2,7 +2,7 @@ name: CI - - on: - push: -- branches: [ main, enhs ] -+ branches: [ main ] - pull_request: - branches: [ main ] - -diff --git a/CLAUDE.md b/CLAUDE.md -new file mode 100644 -index 0000000..4407598 ---- /dev/null -+++ b/CLAUDE.md -@@ -0,0 +1,3 @@ -+# YOLO Development -+ -+**IMPORTANT: Always consult `SPEC.md` before making any changes to yolo.** It documents all current features, CLI flags, configuration, container runtime behavior, and security boundaries. Any modifications must stay consistent with the spec, and the spec must be updated to reflect any changes. -diff --git a/SPEC.md b/SPEC.md -index 1211fb3..1b0e25c 100644 ---- a/SPEC.md -+++ b/SPEC.md -@@ -1,8 +1,5 @@ - # YOLO Specification - --Extracted from the current implementation as of 2026-03-12. --See [issue #42](https://github.com/con/yolo/issues/42). -- - ## Overview - - YOLO runs Claude Code inside a rootless Podman container with -@@ -11,12 +8,12 @@ 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 | -+| 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 | - - --- - -@@ -33,16 +30,16 @@ 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 | -+| 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 - -@@ -57,10 +54,10 @@ claude. If no `--` is present, all positional arguments go to claude. - - ### File Locations - --| Scope | Path | Precedence | --|-------|------|------------| --| User-wide | `${XDG_CONFIG_HOME:-~/.config}/yolo/config` | Lower | --| Per-project | `.git/yolo/config` | Higher | -+| 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. - -@@ -73,21 +70,21 @@ auto-created from the built-in template and a message is printed to stderr. - - #### 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 | -+| 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 | -+| 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 - -@@ -107,23 +104,23 @@ User-wide and project arrays are concatenated (user-wide first). - - ### 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 | -+| 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) | -+| 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. - -@@ -133,23 +130,23 @@ The `~/.claude` directory is auto-created if missing. - - ### Preserved Paths (default) - --| Variable | Value | --|----------|-------| --| `CLAUDE_DIR` | `$HOME/.claude` | --| `WORKSPACE_DIR` | `$(pwd)` | --| `CLAUDE_MOUNT` | `$HOME/.claude:$HOME/.claude:Z` | --| `WORKSPACE_MOUNT` | `$(pwd):$(pwd):Z` | -+| 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` | -+| 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. - -@@ -167,12 +164,12 @@ All projects appear at `/workspace`, enabling cross-project session context. - - ### 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 | -+| 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`. - -@@ -213,31 +210,31 @@ When `USE_NVIDIA=1`: - - ### 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 | -+| 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) | -+| 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`) | -+| Entrypoint | Command | -+|--------------------------|------------------------------------------------------------| -+| Default (`claude`) | `claude --dangerously-skip-permissions [CLAUDE_ARGS]` | -+| Custom (`--entrypoint=X`) | `X [CLAUDE_ARGS]` (no `--dangerously-skip-permissions`) | - - ### Image - -@@ -271,48 +268,48 @@ 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 | -+| 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 | -+| 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 | -+| 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` | -+| 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` | - - --- - -@@ -326,31 +323,31 @@ 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 | -+| 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 | -+| 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 | -+| 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. - -@@ -381,12 +378,12 @@ After install, checks if `~/.local/bin` is in `$PATH` and warns if not. - - ### 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 | -+| 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 - -@@ -425,16 +422,16 @@ BATS (Bash Automated Testing System) with `bats-assert` and `bats-support`. - - ### Triggers - --- Push to `main` or `enhs` branches. -+- 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` | -+| 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.``` - -## PR Description - -Addresses #42. Extracts a comprehensive specification from the current implementation covering CLI, configuration, volumes, path modes, worktree support, NVIDIA GPU, container runtime, Dockerfile, setup script, security boundaries, testing, and CI/CD. - -Produced by Claude-code - -## Comments - -### Inline: @yarikoptic on `SPEC.md` (CI/CD Triggers section) -> ``` -> +### Triggers -> + -> +- Push to `main` or `enhs` branches. -> ``` - -while at it - purge that outdated and unused `enhs` from branches, CI and here - -```suggestion -- Push to `main` branches. -``` - -*2026-03-12T19:24:33Z* - ---- - -### Inline: @yarikoptic on `SPEC.md` (Default Mounts table) -> ``` -> +### Default Mounts -> + -> +| Mount | Host Path | Container Path | Options | -> ``` - -ask claude to reformat all tables to be also for human consumption (properly indented) - -*2026-03-12T19:25:00Z* - ---- - -### Inline: @yarikoptic on `SPEC.md` (line 1) -> ``` -> +# YOLO Specification -> ``` - -add to CLAUDE.md a strong note to consult `SPEC.md` file for any work on `yolo` - -*2026-03-12T19:25:29Z* - ---- - -### Inline: @yarikoptic on `SPEC.md` (preamble) -> ``` -> +Extracted from the current implementation as of 2026-03-12. -> +See [issue #42](https://github.com/con/yolo/issues/42). -> ``` - -information not useful long run - -```suggestion -``` - -*2026-03-12T19:26:03Z* - ---- - -### Inline: @yarikoptic on `CLAUDE.md` (line 3) -> ``` -> +**IMPORTANT: Always consult `SPEC.md` before making any changes to yolo.** -> ``` - -well, if to have this, you should also instruct here to here to reflect any changes in YOLO behavior and interfaces etc mentioned in the file in that file happen they change. Otherwise this duplicated information will diverge soon! Eg. claude happily coded me #53 without changing anything in the spec, although likely he should have - -*2026-03-21T02:31:04Z* diff --git a/notes/prs/pr-51.md b/notes/prs/pr-51.md deleted file mode 100644 index 99094ef..0000000 --- a/notes/prs/pr-51.md +++ /dev/null @@ -1,17 +0,0 @@ -branch: fix/51-selinux-multi-instance -title: Fix EACCES crash when running multiple yolo instances (#51) -body: -## Summary - -- Change SELinux mount label from `:Z` (private unshared) to `:z` (shared) on all runtime-managed volume mounts -- Fixes concurrent yolo instances revoking each other's file access, causing `EACCES: permission denied` crashes -- `--extra-volume` mounts left as `:Z` since users control those directly - -## Test plan - -- [ ] Run `./setup-yolo.sh --install=yes` to reinstall -- [ ] Open two yolo sessions in the same directory simultaneously -- [ ] Type in the first session — confirm no EACCES crash -- [ ] Confirm bash/git commands work in both sessions concurrently - -Closes #51 diff --git a/notes/prs/pr-53.md b/notes/prs/pr-53.md deleted file mode 100644 index 5fc14c4..0000000 --- a/notes/prs/pr-53.md +++ /dev/null @@ -1,21 +0,0 @@ -https://github.com/con/yolo/pull/53 - -# Provide --name= into claude to match the one of the container - -**Author:** Yaroslav Halchenko (yarikoptic) -**Branch:** `enh-name` - -## Diff Summary - -A small refactor in `bin/yolo` (9 added, 5 removed lines). - -**bin/yolo:** -- Moves the container name generation block (`name=$(...)`) from before the worktree/config handling section to after path mode setup (after `WORKSPACE_MOUNT` is determined). This ensures `$PWD` reflects any worktree changes before the name is computed. -- Prepends `--name=$name` to `CLAUDE_ARGS` so the Claude Code session inside the container receives the same name as the podman container. This makes it easier to correlate a Claude session with its container. -- The name can still be overridden by the user via later CLI arguments. - -## PR Description - -So then it might be much easier to identify it. It would still be possible to rename it if needed or overload on CLI by providing one more --name. - -Note that it was added only recently to claude CLI so you might need to rebuild container diff --git a/notes/prs/pr-55.md b/notes/prs/pr-55.md deleted file mode 100644 index f4d94b4..0000000 --- a/notes/prs/pr-55.md +++ /dev/null @@ -1,37 +0,0 @@ -https://github.com/con/yolo/pull/55 - -# Fix HOME defaulting to CWD inside container (#36) - -**Author:** Yaroslav Halchenko (yarikoptic) -**Branch:** `bf-HOME` - -## Diff Summary - -A single-line change in `bin/yolo`. - -**bin/yolo:** -- Adds `-e HOME=/home/node` to the `podman run` invocation, explicitly setting the HOME environment variable inside the container. - -Without this, when using `--userns=keep-id`, the host UID has no `/etc/passwd` entry in the container, causing podman to default HOME to the working directory. This led to `~/.claude` resolving to `$CWD/.claude`, Node.js `os.homedir()` returning the workspace path, and workspace trust acceptance never persisting between sessions. - -## PR Description - -- Fixes #36 -- potentially relates/effects #46 (attn @just-meng) - -I tested on a few sessions - seems to work nice! Might have "unsandboxing" effect though since now ~/.claude is user's claude so changes do persist - -With --userns=keep-id, the host UID has no /etc/passwd entry in the container, so podman defaults HOME to the working directory. This causes several problems: - -- ~/.claude resolves to $CWD/.claude instead of the mounted config dir -- Node.js os.homedir() === process.cwd(), so Claude Code treats every workspace as "running from home directory" -- Workspace trust acceptance is only session-scoped (never persisted), causing the "trust this folder?" dialog on every launch - -Explicitly set HOME=/home/node (the Dockerfile's user home with shell configs) so that HOME is stable regardless of workspace. Claude Code's config is found via CLAUDE_CONFIG_DIR which is already set independently. - -## Comments - -### Comment: @just-meng -See my comment here: https://github.com/con/yolo/issues/46#issuecomment-4143892793 - -*2026-03-27T16:46:37Z* diff --git a/notes/prs/pr-56.md b/notes/prs/pr-56.md deleted file mode 100644 index 6b878e5..0000000 --- a/notes/prs/pr-56.md +++ /dev/null @@ -1,25 +0,0 @@ -https://github.com/con/yolo/pull/56 - -# feat: Add optional Deno runtime - -**Author:** Chris Markiewicz (effigies) -**Branch:** `deno` - -## Diff Summary - -This PR adds Deno as a new optional extra for the container image, across 2 commits. - -**images/Dockerfile:** -- Adds `EXTRA_DENO` and `DENO_VERSION` build args. -- When `EXTRA_DENO=1`, installs Deno via the official `deno.land/install.sh` script. If `DENO_VERSION` is set, pins to that version; otherwise installs latest. -- Adds Deno's bin directory to PATH in both `.zshrc` and `.bashrc`. -- Sets `ENV PATH="/home/node/.deno/bin:$PATH"` unconditionally so Deno is on the path even in non-interactive shells. - -**setup-yolo.sh:** -- Adds `deno` to the `--extras` help text. -- Adds `deno` to the `all` extras expansion list. -- Adds `deno` to the validation regex and error message for unknown extras. - -## PR Description - -Sometimes claude seems smart enough to install deno on its own, sometimes not. Just adding it to my Dockerfile and sharing upstream. diff --git a/notes/prs/pr-57.md b/notes/prs/pr-57.md deleted file mode 100644 index b1ddd60..0000000 --- a/notes/prs/pr-57.md +++ /dev/null @@ -1,37 +0,0 @@ -https://github.com/con/yolo/pull/57 - -# Add --worktree=create:\ mode - -**Author:** Austin Macdonald (asmacdo) -**Branch:** `create-worktree` - -## Diff Summary - -This PR adds a new `--worktree=create:` mode to `bin/yolo`. The changes span 3 commits across `bin/yolo`, `tests/yolo.bats`, `README.md`, `config.example`, and a new `CLAUDE.md`. - -**bin/yolo (core logic):** -- Extends the `--worktree` validation regex to accept `create` and `create:` in addition to the existing `ask|bind|skip|error` values. -- After config loading and `~/.claude` directory creation, adds a new block that handles the `create` mode: - - Bare `--worktree=create` (no branch name) prints an error and exits. - - `--worktree=create:` runs `git worktree add` to create a sibling directory with a new branch, `cd`s into it, regenerates the container name for the new directory, and then falls through with `WORKTREE_MODE="bind"`. -- Updates `--help` text to show the new mode. - -**tests/yolo.bats:** -- Adds two BATS tests: one verifying that bare `--worktree=create` fails with a "requires a branch name" error, and one verifying that `--worktree=create:test-branch` passes argument validation. - -**README.md:** -- Adds documentation and an example for the new `create:` worktree mode. - -**config.example:** -- Updates the `WORKTREE_MODE` comment to mention `create:`. - -**CLAUDE.md (new file):** -- Adds a project overview for Claude Code: architecture, testing instructions, CI description, and a checklist for completing tasks. - -## PR Description - -- Adds `--worktree=create:` mode that creates a git worktree as a sibling directory, checks out a new branch from HEAD, and launches yolo with bind mode -- Enables isolated agent workflows: `yolo --worktree=create:fix-issue-99` -- Updates README, config.example, and --help text -- Adds BATS tests for the new mode -- Adds CLAUDE.md with project overview and task checklist diff --git a/notes/sandbox-comparison.md b/notes/sandbox-comparison.md deleted file mode 100644 index 65aa9c0..0000000 --- a/notes/sandbox-comparison.md +++ /dev/null @@ -1,77 +0,0 @@ -# Claude Code Sandbox vs. YOLO: A Comparison - -Both solve the same fundamental problem — **how to let Claude Code run autonomously without it doing something dangerous** — but they take very different architectural approaches. - -## Core Philosophy - -| | Claude Code Sandbox | YOLO | -|---|---|---| -| **Approach** | Fine-grained OS-level policy enforcement | Coarse-grained container isolation | -| **Metaphor** | "You can run freely, but these specific walls are enforced by the kernel" | "You're in a separate room; do whatever you want in there" | -| **Permission model** | Whitelist domains + filesystem paths, deny everything else | Mount only what's needed, auto-approve everything inside | -| **Technology** | macOS Seatbelt / Linux Bubblewrap + network proxy | Podman rootless containers + user namespaces | - -## What Each Restricts - -| Resource | Claude Code Sandbox | YOLO | -|---|---|---| -| **Filesystem write** | CWD only (configurable allowlist) | Only mounted volumes (CWD, `~/.claude`, `.gitconfig`) | -| **Filesystem read** | Whole machine minus denylist | Only mounted volumes | -| **Network** | Proxy-based domain allowlist | **Unrestricted** (intentional) | -| **SSH keys** | Accessible (unless denied) | **Not mounted** (no git push) | -| **Subprocess isolation** | All children inherit sandbox | All children are in the container | -| **Credentials** | Configurable deny (e.g., `~/.aws/credentials`) | Only OAuth token passed via env var | - -## Key Trade-offs - -### Claude Code Sandbox strengths - -- **Network filtering** — the biggest differentiator. YOLO has no network restrictions, meaning a prompt injection attack could exfiltrate data. The sandbox's domain-level proxy blocks this. -- **No setup overhead** — no container build step, no image maintenance. Just `bubblewrap` + `socat` on Linux. -- **Full host read access by default** — Claude can read your whole machine (minus denylists), which is useful for cross-project work. -- **Granular configurability** — per-path, per-domain rules in `settings.json`. -- **Open-source runtime** — `@anthropic-ai/sandbox-runtime` is reusable in other agent projects. - -### YOLO strengths - -- **Stronger filesystem isolation** — sandbox allows reading the whole machine by default; YOLO only exposes what you mount. A read-based side-channel attack is harder. -- **Reproducible environment** — the Dockerfile gives you a known, consistent toolchain (git, gh, shellcheck, uv, etc.). No "works on my machine" issues. -- **Customizable environments** — NVIDIA GPU support, Playwright, DataLad, jj via `--extras`. The sandbox doesn't manage your tool environment. -- **Session portability** — preserved paths mean sessions work identically in and out of the container. -- **Config layering** — user-wide + per-project config with array merging for volumes/args. -- **No SSH key exposure** — a deliberate security boundary that forces push operations to happen on the host. - -## Where YOLO Has a Gap - -The **network** is the elephant in the room. YOLO's container has unrestricted network access. This means: - -- A prompt injection in a code comment could instruct Claude to `curl` data to an attacker's server -- Malicious dependencies could phone home -- There's no domain allowlisting - -The sandbox's proxy-based network filtering directly addresses this threat model. - -## Where Claude Code Sandbox Has a Gap - -- **No environment management** — you're running on your host. If Claude needs `playwright` or `cuda`, it's installing on your machine. -- **Read access is broad by default** — `~/.ssh`, `~/.gnupg`, env files, etc. are readable unless you explicitly deny them. YOLO's mount-only approach is deny-by-default for the filesystem. -- **Platform limitations** — no native Windows support, weaker in nested Docker environments on Linux. - -## Could They Be Combined? - -Yes, and it would be the strongest posture: - -1. **YOLO for environment isolation** — reproducible toolchain, mount-only filesystem, no SSH keys -2. **Claude Code sandbox inside the container** — add network filtering and filesystem write restrictions within the already-restricted container - -This would give you defense-in-depth: container boundaries for the coarse isolation + sandbox policies for fine-grained network and filesystem control inside. - -## Summary - -If your primary threat model is **"Claude modifies the wrong files or installs something I don't want"**, both approaches work well. - -If your threat model includes **"data exfiltration via network"**, the Claude Code sandbox is strictly better today — YOLO would need network restrictions (e.g., podman `--network=none` plus an allowlist proxy, or just enabling sandbox mode inside the container). - -If your concern is **reproducible, portable environments with known toolchains**, YOLO provides something the sandbox doesn't attempt. - -They're complementary rather than competing. diff --git a/security-reports/audit-20260328-125651.md b/security-reports/audit-20260328-125651.md deleted file mode 100644 index d48968e..0000000 --- a/security-reports/audit-20260328-125651.md +++ /dev/null @@ -1,43 +0,0 @@ -# Container Security Audit — 2026-03-28 - -Performed from inside a running yolo container (rootless podman, `--userns=keep-id`). - -## What's locked down (good) - -- **Namespace isolation works.** `nsenter` into PID 1 namespaces denied — rootless podman maps container root to host UID 1000, no path to real host root. -- **Capabilities are minimal.** Standard rootless set (CHOWN, DAC_OVERRIDE, FOWNER, SETUID/GID, NET_BIND_SERVICE, SYS_CHROOT). Missing the dangerous ones (SYS_ADMIN, SYS_PTRACE, NET_RAW). -- **No block devices** visible, `/sys` is read-only. -- **Host filesystem not exposed** beyond explicit mounts — `/home/austin/devel/duct/`, `~/.ssh`, `~/.gnupg`, `~/.aws` all inaccessible. -- **`.gitconfig` mounted read-only** at `/tmp/.gitconfig`. - -## Bind mounts (attack surface) - -| Mount | Mode | Risk | -|-------|------|------| -| `/home/austin/devel/yolo` | rw | Expected — workspace | -| `/home/austin/.claude` | rw | **See below** | -| `/tmp/.gitconfig` | ro | Low — leaks name/email, not writable | - -## CRITICAL: `~/.claude` mounted rw enables cross-session prompt injection - -The entire `~/.claude` directory is bind-mounted read-write. A compromised agent inside the container can: - -1. **Modify `~/.claude/CLAUDE.md`** — injects instructions into the *global* system prompt for *all* future Claude Code sessions, across *all* projects, including sessions running directly on the host without container isolation. -2. **Modify `~/.claude/settings.json`** — alter permissions, allowlisted commands. -3. **Modify any project config** in `~/.claude/projects/` — poison per-project instructions for *other* repos the container has no business touching. -4. **Read `.credentials.json`** — exfiltrate auth tokens. - -This is a between-session escape: the agent can't break out of the container *now*, but it can plant instructions that a future uncontained session will execute. - -### Recommended mitigation - -Mount `~/.claude` selectively instead of wholesale: - -- `.credentials.json` — **ro** -- `settings.json` — **ro** -- `CLAUDE.md` — **ro** -- `projects//` — rw (session state, memory) - -Exclude everything else (other project configs, history, sessions). - -The redesign's `volumes` hook is the natural place to implement this. From 1810a9542853f85572ba60275307936484d7d546 Mon Sep 17 00:00:00 2001 From: Austin Macdonald Date: Sat, 28 Mar 2026 14:27:21 -0500 Subject: [PATCH 13/55] Add pre-commit (ruff, yamllint, shellcheck), CI, CONTRIBUTING.md - pre-commit config with ruff lint+format, yamllint, shellcheck - New CI workflow with lint and test jobs (replaces legacy CI) - Move legacy ci.yml to design/legacy/ - CONTRIBUTING.md with setup, testing, and container-extras guide - Fix unused imports, shellcheck warnings, YAML document starts - yamllint config to allow bare 'on' key in GitHub Actions Co-Authored-By: Claude Opus 4.6 (1M context) --- .github/workflows/ci.yml | 130 ++++------------------------------ .pre-commit-config.yaml | 21 ++++++ .yamllint.yml | 6 ++ CONTRIBUTING.md | 54 ++++++++++++++ container-extras/apt.sh | 1 + design/legacy/.github-ci.yml | 129 +++++++++++++++++++++++++++++++++ pyproject.toml | 1 + src/yolo/builder.py | 9 ++- src/yolo/config.py | 7 +- src/yolo/defaults/config.yaml | 1 + tests/test-extras-build.sh | 4 +- tests/test_builder.py | 15 ++-- tests/test_config.py | 65 ++++++++++------- 13 files changed, 289 insertions(+), 154 deletions(-) create mode 100644 .pre-commit-config.yaml create mode 100644 .yamllint.yml create mode 100644 CONTRIBUTING.md create mode 100644 design/legacy/.github-ci.yml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 78fc41e..9ea1547 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,129 +1,25 @@ +--- name: CI on: push: - branches: [ main, enhs ] + branches: [main] pull_request: - branches: [ main ] + branches: [main] jobs: - shellcheck: - name: ShellCheck + lint: runs-on: ubuntu-latest steps: - - name: Checkout code - uses: actions/checkout@v4 + - 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 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 + test: 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" + - uses: actions/checkout@v4 + - uses: astral-sh/setup-uv@v5 + - run: uv venv && uv pip install -e ".[dev]" + - run: .venv/bin/pytest 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/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..387c9aa --- /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 container-extras scripts + +Scripts live in `container-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 +container-extras: + - name: apt + packages: [zsh, fzf] +``` + +See `design/HACK_DECISIONS.md` for the full contract. diff --git a/container-extras/apt.sh b/container-extras/apt.sh index b5330ad..562cfb5 100644 --- a/container-extras/apt.sh +++ b/container-extras/apt.sh @@ -3,4 +3,5 @@ # Env: YOLO_APT_PACKAGES (space-separated, required) set -eu [ -z "${YOLO_APT_PACKAGES:-}" ] && { echo "apt.sh: YOLO_APT_PACKAGES required"; exit 1; } +# shellcheck disable=SC2086 # intentional word splitting sudo apt-get install -y --no-install-recommends $YOLO_APT_PACKAGES 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/pyproject.toml b/pyproject.toml index 60991f6..54f55af 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -15,6 +15,7 @@ dependencies = [ [project.optional-dependencies] dev = [ "pytest", + "pre-commit", ] [project.scripts] diff --git a/src/yolo/builder.py b/src/yolo/builder.py index 778f426..84da902 100644 --- a/src/yolo/builder.py +++ b/src/yolo/builder.py @@ -123,9 +123,12 @@ def build(extras_config: list) -> None: build_dir = assemble_build_context(extras_config) try: cmd = [ - "podman", "build", - "-f", str(CONTAINERFILE_EXTRAS), - "-t", CUSTOM_IMAGE, + "podman", + "build", + "-f", + str(CONTAINERFILE_EXTRAS), + "-t", + CUSTOM_IMAGE, str(build_dir), ] print(f"Building {CUSTOM_IMAGE}...") diff --git a/src/yolo/config.py b/src/yolo/config.py index 4de6cfb..c0bd48c 100644 --- a/src/yolo/config.py +++ b/src/yolo/config.py @@ -2,7 +2,6 @@ from pathlib import Path import os -import subprocess from ruamel.yaml import YAML @@ -31,7 +30,7 @@ def _find_git_dir() -> Path | None: # Worktree — parse gitdir: line text = dot_git.read_text().strip() if text.startswith("gitdir: "): - gitdir = Path(text[len("gitdir: "):]) + gitdir = Path(text[len("gitdir: ") :]) if not gitdir.is_absolute(): gitdir = current / gitdir gitdir = gitdir.resolve() @@ -78,7 +77,9 @@ def _merge(base: dict, override: dict) -> dict: for key, value in override.items(): if 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): + elif ( + key in merged and isinstance(merged[key], dict) and isinstance(value, dict) + ): merged[key] = _merge(merged[key], value) else: merged[key] = value diff --git a/src/yolo/defaults/config.yaml b/src/yolo/defaults/config.yaml index f870aae..aabe65d 100644 --- a/src/yolo/defaults/config.yaml +++ b/src/yolo/defaults/config.yaml @@ -1,3 +1,4 @@ +--- # Default yolo configuration # Override any of these in your user or project config diff --git a/tests/test-extras-build.sh b/tests/test-extras-build.sh index d5e7b61..5ba8b74 100755 --- a/tests/test-extras-build.sh +++ b/tests/test-extras-build.sh @@ -5,8 +5,8 @@ set -x set -eu REPO_ROOT="$(cd "$(dirname "$0")/.." && pwd)" -BUILD_CONTEXT="$(mktemp -d ${TMPDIR:-/tmp}/yolo-test-XXXXXXX)" -trap "rm -rf $BUILD_CONTEXT" EXIT +BUILD_CONTEXT="$(mktemp -d "${TMPDIR:-/tmp}"/yolo-test-XXXXXXX)" +trap 'rm -rf $BUILD_CONTEXT' EXIT # Assemble build context mkdir -p "$BUILD_CONTEXT/build/scripts" diff --git a/tests/test_builder.py b/tests/test_builder.py index 56108c2..bc04ff5 100644 --- a/tests/test_builder.py +++ b/tests/test_builder.py @@ -80,7 +80,9 @@ def test_creates_run_sh_with_env_vars(self, tmp_path, monkeypatch): extras_dir = self._make_extras_dir(tmp_path, {"apt": "#!/bin/bash"}) monkeypatch.setattr("yolo.builder._extras_search_path", lambda: [extras_dir]) - build_dir = assemble_build_context([{"name": "apt", "packages": ["zsh", "fzf"]}]) + build_dir = assemble_build_context( + [{"name": "apt", "packages": ["zsh", "fzf"]}] + ) try: content = (build_dir / "build" / "run.sh").read_text() assert 'YOLO_APT_PACKAGES="zsh fzf"' in content @@ -101,10 +103,13 @@ def test_no_env_vars_for_bare_name(self, tmp_path, monkeypatch): shutil.rmtree(build_dir) def test_copies_scripts(self, tmp_path, monkeypatch): - extras_dir = self._make_extras_dir(tmp_path, { - "apt": "#!/bin/bash", - "python": "#!/bin/bash", - }) + extras_dir = self._make_extras_dir( + tmp_path, + { + "apt": "#!/bin/bash", + "python": "#!/bin/bash", + }, + ) monkeypatch.setattr("yolo.builder._extras_search_path", lambda: [extras_dir]) config = [ diff --git a/tests/test_config.py b/tests/test_config.py index 923b8b2..d37af8f 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -1,12 +1,11 @@ """Tests for yolo config loading and merging.""" -import os from pathlib import Path import pytest from ruamel.yaml import YAML -from yolo.config import _merge, _config_paths, _find_git_dir, load_config +from yolo.config import _merge, _config_paths, load_config def _write_yaml(path: Path, data: dict): @@ -95,10 +94,13 @@ def test_empty_when_no_files(self, tmp_path, monkeypatch): def test_loads_single_file(self, tmp_path, monkeypatch): monkeypatch.chdir(tmp_path) monkeypatch.setenv("XDG_CONFIG_HOME", str(tmp_path / "xdg")) - _write_yaml(tmp_path / ".yolo" / "config.yaml", { - "nvidia": True, - "container-extras": ["zsh"], - }) + _write_yaml( + tmp_path / ".yolo" / "config.yaml", + { + "nvidia": True, + "container-extras": ["zsh"], + }, + ) config = load_config() assert config["nvidia"] is True assert config["container-extras"] == ["zsh"] @@ -108,15 +110,21 @@ def test_merges_layers(self, tmp_path, monkeypatch): xdg = tmp_path / "xdg" monkeypatch.setenv("XDG_CONFIG_HOME", str(xdg)) - _write_yaml(xdg / "yolo" / "config.yaml", { - "nvidia": False, - "container-extras": ["zsh"], - }) - - _write_yaml(tmp_path / ".yolo" / "config.yaml", { - "nvidia": True, - "container-extras": ["python"], - }) + _write_yaml( + xdg / "yolo" / "config.yaml", + { + "nvidia": False, + "container-extras": ["zsh"], + }, + ) + + _write_yaml( + tmp_path / ".yolo" / "config.yaml", + { + "nvidia": True, + "container-extras": ["python"], + }, + ) config = load_config() assert config["nvidia"] is True @@ -127,9 +135,12 @@ def test_git_yolo_config(self, tmp_path, monkeypatch): monkeypatch.setenv("XDG_CONFIG_HOME", str(tmp_path / "no-xdg")) (tmp_path / ".git").mkdir() - _write_yaml(tmp_path / ".git" / "yolo" / "config.yaml", { - "worktree": "bind", - }) + _write_yaml( + tmp_path / ".git" / "yolo" / "config.yaml", + { + "worktree": "bind", + }, + ) config = load_config() assert config["worktree"] == "bind" @@ -138,14 +149,20 @@ def test_git_overrides_project(self, tmp_path, monkeypatch): monkeypatch.chdir(tmp_path) monkeypatch.setenv("XDG_CONFIG_HOME", str(tmp_path / "no-xdg")) - _write_yaml(tmp_path / ".yolo" / "config.yaml", { - "worktree": "ask", - }) + _write_yaml( + tmp_path / ".yolo" / "config.yaml", + { + "worktree": "ask", + }, + ) (tmp_path / ".git").mkdir() - _write_yaml(tmp_path / ".git" / "yolo" / "config.yaml", { - "worktree": "skip", - }) + _write_yaml( + tmp_path / ".git" / "yolo" / "config.yaml", + { + "worktree": "skip", + }, + ) config = load_config() assert config["worktree"] == "skip" From 97c696d561da1e6848f4264da58625ba0a15c3b5 Mon Sep 17 00:00:00 2001 From: Austin Macdonald Date: Sat, 28 Mar 2026 14:31:50 -0500 Subject: [PATCH 14/55] CI: add stubs for codespell, shellcheck, unit/integration test jobs - Separate unit-tests and integration-tests jobs - Integration depends on lint + unit passing - Commented-out matrix for python 3.11/3.12/3.13 x linux/mac/windows - codespell and shellcheck as standalone jobs - Integration stubs fail with TODO messages Co-Authored-By: Claude Opus 4.6 (1M context) --- .github/workflows/ci.yml | 45 ++++++++++++++++++++++++++++++++++++++-- 1 file changed, 43 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9ea1547..1c06480 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -16,10 +16,51 @@ jobs: - run: uv venv && uv pip install -e ".[dev]" - uses: pre-commit/action@v3.0.1 - test: + codespell: runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: codespell-project/actions-codespell@v2 + + shellcheck: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: ludeeus/action-shellcheck@master + with: + scandir: container-extras + + unit-tests: + runs-on: ubuntu-latest + # strategy: + # matrix: + # os: [ubuntu-latest, macos-latest, windows-latest] + # python-version: ["3.11", "3.12", "3.13"] + # runs-on: ${{ matrix.os }} + steps: + - uses: actions/checkout@v4 + - uses: astral-sh/setup-uv@v5 + # - run: >- + # uv venv --python ${{ matrix.python-version }} + # && uv pip install -e ".[dev]" + - run: uv venv && uv pip install -e ".[dev]" + - run: .venv/bin/pytest tests/test_config.py tests/test_builder.py + + integration-tests: + runs-on: ubuntu-latest + # strategy: + # matrix: + # os: [ubuntu-latest, macos-latest, windows-latest] + # python-version: ["3.11", "3.12", "3.13"] + # runs-on: ${{ matrix.os }} + needs: [lint, unit-tests] steps: - uses: actions/checkout@v4 - uses: astral-sh/setup-uv@v5 - run: uv venv && uv pip install -e ".[dev]" - - run: .venv/bin/pytest + - name: Build base image + run: echo "TODO - podman build base image" && exit 1 + - name: Build extras image + run: echo "TODO - yo build" && exit 1 + - name: Verify container + run: echo "TODO - run container and check tools" && exit 1 From fb0d6271f5a25ea9447d374ea4825a3d79c34752 Mon Sep 17 00:00:00 2001 From: Austin Macdonald Date: Sat, 28 Mar 2026 14:41:55 -0500 Subject: [PATCH 15/55] CI: run on redesign-python branch (temporary) Co-Authored-By: Claude Opus 4.6 (1M context) --- .github/workflows/ci.yml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1c06480..3b81a9b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -3,7 +3,8 @@ name: CI on: push: - branches: [main] + # TODO: remove redesign-python after merge + branches: [main, redesign-python] pull_request: branches: [main] From 592b4570838869c6be3ebf221dd5495854c3c04d Mon Sep 17 00:00:00 2001 From: Austin Macdonald Date: Sat, 28 Mar 2026 16:44:17 -0500 Subject: [PATCH 16/55] Add hardcoded launcher: yo run launches Claude in container MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Hardcoded v1 — mounts claude config, gitconfig, workspace, sets up env vars, container naming, runs claude with --dangerously-skip-permissions. No config integration yet. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/yolo/cli.py | 5 ++--- src/yolo/launcher.py | 48 ++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 50 insertions(+), 3 deletions(-) create mode 100644 src/yolo/launcher.py diff --git a/src/yolo/cli.py b/src/yolo/cli.py index 0bc2c08..2e46f3e 100644 --- a/src/yolo/cli.py +++ b/src/yolo/cli.py @@ -4,6 +4,7 @@ from yolo.config import load_config from yolo.builder import build as builder_build +from yolo.launcher import run as launcher_run @click.group() @@ -22,6 +23,4 @@ def build(): @main.command() def run(): """Launch Claude Code in a container.""" - config = load_config() - click.echo(f"Config: {config}") - click.echo("TODO: run") + launcher_run() diff --git a/src/yolo/launcher.py b/src/yolo/launcher.py new file mode 100644 index 0000000..678ba57 --- /dev/null +++ b/src/yolo/launcher.py @@ -0,0 +1,48 @@ +"""Launch Claude Code in a container.""" + +import os +import subprocess +from pathlib import Path + + +def run() -> None: + """Launch Claude Code in a podman container.""" + home = Path.home() + cwd = Path.cwd() + claude_dir = home / ".claude" + claude_dir.mkdir(exist_ok=True) + + name = f"{cwd}-{os.getpid()}" + name = name.replace(str(home) + "/", "") + name = "".join(c if c.isalnum() or c in "._-" else "_" for c in name) + name = name.lstrip("._") + + cmd = [ + "podman", + "run", + "--log-driver=none", + "-it", + "--rm", + f"--user={os.getuid()}:{os.getgid()}", + "--userns=keep-id", + f"--name={name}", + "-v", + f"{claude_dir}:{claude_dir}:z", + "-v", + f"{home}/.gitconfig:/tmp/.gitconfig:ro,z", + "-v", + f"{cwd}:{cwd}:z", + "-w", + str(cwd), + "-e", + f"CLAUDE_CONFIG_DIR={claude_dir}", + "-e", + "GIT_CONFIG_GLOBAL=/tmp/.gitconfig", + "-e", + "CLAUDE_CODE_OAUTH_TOKEN", + "yolo-custom", + "claude", + "--dangerously-skip-permissions", + ] + + subprocess.run(cmd) From 557df3f1beb51f8b5227ec206545581f31fd641d Mon Sep 17 00:00:00 2001 From: Austin Macdonald Date: Sun, 29 Mar 2026 16:29:15 -0500 Subject: [PATCH 17/55] Pass-through args to claude: yo run --resume, yo run "prompt" - click UNPROCESSED args forwarded to claude command - yo run --help shows [CLAUDE_ARGS]... Co-Authored-By: Claude Opus 4.6 (1M context) --- context-injection-notes.md | 109 +++++++++++++++++++++++++++++++++++++ src/yolo/cli.py | 7 ++- src/yolo/launcher.py | 3 +- 3 files changed, 115 insertions(+), 4 deletions(-) create mode 100644 context-injection-notes.md 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/src/yolo/cli.py b/src/yolo/cli.py index 2e46f3e..8e9d7d3 100644 --- a/src/yolo/cli.py +++ b/src/yolo/cli.py @@ -20,7 +20,8 @@ def build(): builder_build(extras) -@main.command() -def run(): +@main.command(context_settings={"ignore_unknown_options": True}) +@click.argument("claude_args", nargs=-1, type=click.UNPROCESSED) +def run(claude_args): """Launch Claude Code in a container.""" - launcher_run() + launcher_run(list(claude_args)) diff --git a/src/yolo/launcher.py b/src/yolo/launcher.py index 678ba57..211a747 100644 --- a/src/yolo/launcher.py +++ b/src/yolo/launcher.py @@ -5,7 +5,7 @@ from pathlib import Path -def run() -> None: +def run(claude_args: list[str] | None = None) -> None: """Launch Claude Code in a podman container.""" home = Path.home() cwd = Path.cwd() @@ -43,6 +43,7 @@ def run() -> None: "yolo-custom", "claude", "--dangerously-skip-permissions", + *(claude_args or []), ] subprocess.run(cmd) From bff257be3b8ab8a99fb5c8ccc040d91085a09fc1 Mon Sep 17 00:00:00 2001 From: Austin Macdonald Date: Sun, 29 Mar 2026 16:36:40 -0500 Subject: [PATCH 18/55] Wire volumes: config + CLI -v flag, with launcher tests - Launcher reads volumes from config and CLI -v flags - Config volumes and CLI volumes both added as bind mounts - 8 new launcher tests (command assembly, args, volumes) - Updated CLAUDE.md: imports at top convention Co-Authored-By: Claude Opus 4.6 (1M context) --- CLAUDE.md | 4 +++ src/yolo/cli.py | 7 +++-- src/yolo/launcher.py | 22 +++++++++++++- tests/test_launcher.py | 68 ++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 98 insertions(+), 3 deletions(-) create mode 100644 tests/test_launcher.py diff --git a/CLAUDE.md b/CLAUDE.md index ab5c6e4..e6154e8 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,5 +1,9 @@ **Before writing files, check if CLAUDE.md, design docs, or tests need updating to reflect your changes.** +## 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. diff --git a/src/yolo/cli.py b/src/yolo/cli.py index 8e9d7d3..e3002cd 100644 --- a/src/yolo/cli.py +++ b/src/yolo/cli.py @@ -21,7 +21,10 @@ def build(): @main.command(context_settings={"ignore_unknown_options": True}) +@click.option( + "-v", "--volume", multiple=True, help="Extra bind mount (host:container[:opts])" +) @click.argument("claude_args", nargs=-1, type=click.UNPROCESSED) -def run(claude_args): +def run(volume, claude_args): """Launch Claude Code in a container.""" - launcher_run(list(claude_args)) + launcher_run(list(claude_args), extra_volumes=list(volume)) diff --git a/src/yolo/launcher.py b/src/yolo/launcher.py index 211a747..d5cf6b8 100644 --- a/src/yolo/launcher.py +++ b/src/yolo/launcher.py @@ -4,9 +4,25 @@ import subprocess from pathlib import Path +from yolo.config import load_config -def run(claude_args: list[str] | None = None) -> None: + +def _build_volume_args(volumes: list[str]) -> list[str]: + """Turn a list of volume specs into podman -v args.""" + # TODO: rename to mounts? these are bind mounts, not docker volumes + args = [] + for vol in volumes: + args.extend(["-v", vol]) + return args + + +def run( + claude_args: list[str] | None = None, + extra_volumes: list[str] | None = None, +) -> None: """Launch Claude Code in a podman container.""" + config = load_config() + home = Path.home() cwd = Path.cwd() claude_dir = home / ".claude" @@ -17,6 +33,8 @@ def run(claude_args: list[str] | None = None) -> None: name = "".join(c if c.isalnum() or c in "._-" else "_" for c in name) name = name.lstrip("._") + config_volumes = config.get("volumes", []) + cmd = [ "podman", "run", @@ -32,6 +50,8 @@ def run(claude_args: list[str] | None = None) -> None: f"{home}/.gitconfig:/tmp/.gitconfig:ro,z", "-v", f"{cwd}:{cwd}:z", + *_build_volume_args(config_volumes), + *_build_volume_args(extra_volumes or []), "-w", str(cwd), "-e", diff --git a/tests/test_launcher.py b/tests/test_launcher.py new file mode 100644 index 0000000..1809f02 --- /dev/null +++ b/tests/test_launcher.py @@ -0,0 +1,68 @@ +"""Tests for yolo launcher: command assembly.""" + +from unittest.mock import patch + +from yolo.launcher import _build_volume_args, run + + +class TestBuildVolumeArgs: + def test_empty(self): + assert _build_volume_args([]) == [] + + def test_single(self): + assert _build_volume_args(["/a:/b:z"]) == ["-v", "/a:/b:z"] + + def test_multiple(self): + result = _build_volume_args(["/a:/b:z", "/c:/d:ro,z"]) + assert result == ["-v", "/a:/b:z", "-v", "/c:/d:ro,z"] + + +class TestRun: + @patch("yolo.launcher.subprocess.run") + @patch("yolo.launcher.load_config", return_value={}) + def test_basic_command(self, mock_config, mock_run): + run() + cmd = mock_run.call_args[0][0] + assert "podman" == cmd[0] + assert "run" == cmd[1] + assert "--userns=keep-id" in cmd + assert "claude" in cmd + assert "--dangerously-skip-permissions" in cmd + + @patch("yolo.launcher.subprocess.run") + @patch("yolo.launcher.load_config", return_value={}) + def test_claude_args_passed(self, mock_config, mock_run): + run(claude_args=["--resume"]) + cmd = mock_run.call_args[0][0] + assert "--resume" in cmd + idx = cmd.index("--dangerously-skip-permissions") + assert cmd[idx + 1] == "--resume" + + @patch("yolo.launcher.subprocess.run") + @patch("yolo.launcher.load_config", return_value={}) + def test_extra_volumes(self, mock_config, mock_run): + run(extra_volumes=["/data:/data:z"]) + cmd = mock_run.call_args[0][0] + assert "-v" in cmd + assert "/data:/data:z" in cmd + + @patch("yolo.launcher.subprocess.run") + @patch( + "yolo.launcher.load_config", + return_value={"volumes": ["/cfg:/cfg:ro,z"]}, + ) + def test_config_volumes(self, mock_config, mock_run): + run() + cmd = mock_run.call_args[0][0] + assert "/cfg:/cfg:ro,z" in cmd + + @patch("yolo.launcher.subprocess.run") + @patch( + "yolo.launcher.load_config", + return_value={"volumes": ["/cfg:/cfg:z"]}, + ) + def test_config_and_extra_volumes(self, mock_config, mock_run): + run(extra_volumes=["/cli:/cli:z"]) + cmd = mock_run.call_args[0][0] + assert "/cfg:/cfg:z" in cmd + assert "/cli:/cli:z" in cmd From 3e013e18c3f791ab8e5c8a3c572860316de4353c Mon Sep 17 00:00:00 2001 From: Austin Macdonald Date: Sun, 29 Mar 2026 16:42:42 -0500 Subject: [PATCH 19/55] Add --entrypoint override to launcher Custom entrypoint skips --dangerously-skip-permissions (legacy behavior). TODO: make skip_permissions a separate config value. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/yolo/cli.py | 5 +++-- src/yolo/launcher.py | 11 ++++++++--- tests/test_launcher.py | 17 +++++++++++++++++ 3 files changed, 28 insertions(+), 5 deletions(-) diff --git a/src/yolo/cli.py b/src/yolo/cli.py index e3002cd..1a51955 100644 --- a/src/yolo/cli.py +++ b/src/yolo/cli.py @@ -24,7 +24,8 @@ def build(): @click.option( "-v", "--volume", multiple=True, help="Extra bind mount (host:container[:opts])" ) +@click.option("--entrypoint", default=None, help="Override container entrypoint") @click.argument("claude_args", nargs=-1, type=click.UNPROCESSED) -def run(volume, claude_args): +def run(volume, entrypoint, claude_args): """Launch Claude Code in a container.""" - launcher_run(list(claude_args), extra_volumes=list(volume)) + launcher_run(list(claude_args), extra_volumes=list(volume), entrypoint=entrypoint) diff --git a/src/yolo/launcher.py b/src/yolo/launcher.py index d5cf6b8..4c4b25f 100644 --- a/src/yolo/launcher.py +++ b/src/yolo/launcher.py @@ -19,6 +19,7 @@ def _build_volume_args(volumes: list[str]) -> list[str]: def run( claude_args: list[str] | None = None, extra_volumes: list[str] | None = None, + entrypoint: str | None = None, ) -> None: """Launch Claude Code in a podman container.""" config = load_config() @@ -61,9 +62,13 @@ def run( "-e", "CLAUDE_CODE_OAUTH_TOKEN", "yolo-custom", - "claude", - "--dangerously-skip-permissions", - *(claude_args or []), ] + # TODO: make dangerously_skip_permissions a separate config value + # so --entrypoint claude doesn't automatically get it + if entrypoint: + cmd += [entrypoint, *(claude_args or [])] + else: + cmd += ["claude", "--dangerously-skip-permissions", *(claude_args or [])] + subprocess.run(cmd) diff --git a/tests/test_launcher.py b/tests/test_launcher.py index 1809f02..47ef7c4 100644 --- a/tests/test_launcher.py +++ b/tests/test_launcher.py @@ -66,3 +66,20 @@ def test_config_and_extra_volumes(self, mock_config, mock_run): cmd = mock_run.call_args[0][0] assert "/cfg:/cfg:z" in cmd assert "/cli:/cli:z" in cmd + + @patch("yolo.launcher.subprocess.run") + @patch("yolo.launcher.load_config", return_value={}) + def test_custom_entrypoint(self, mock_config, mock_run): + run(entrypoint="bash") + cmd = mock_run.call_args[0][0] + assert "bash" in cmd + assert "claude" not in cmd + assert "--dangerously-skip-permissions" not in cmd + + @patch("yolo.launcher.subprocess.run") + @patch("yolo.launcher.load_config", return_value={}) + def test_custom_entrypoint_with_args(self, mock_config, mock_run): + run(entrypoint="bash", claude_args=["-c", "echo hi"]) + cmd = mock_run.call_args[0][0] + idx = cmd.index("bash") + assert cmd[idx + 1 : idx + 3] == ["-c", "echo hi"] From 65e7381c6b4c4a3c25a89aa021ff4a4befeec0c3 Mon Sep 17 00:00:00 2001 From: Austin Macdonald Date: Sun, 29 Mar 2026 17:32:18 -0500 Subject: [PATCH 20/55] Named images with FROM support, project-derived tags - Config key 'container-extras' becomes 'images' list, each with name + extras - Image tags derived from project dirname: yolo-- - 'from' key overrides BASE_IMAGE build arg (podman handles composition) - Containerfile.extras uses ARG BASE_IMAGE=yolo-base - CLI: yo build --image, yo run --image - Remove TODO about renaming volumes Co-Authored-By: Claude Opus 4.6 (1M context) --- images/Containerfile.extras | 3 +- src/yolo/builder.py | 73 +++++++++++++++++++++++++++-------- src/yolo/cli.py | 19 ++++++--- src/yolo/defaults/config.yaml | 28 +++++++------- src/yolo/launcher.py | 4 +- tests/test_builder.py | 28 +++++++++----- tests/test_launcher.py | 19 +++++++++ 7 files changed, 127 insertions(+), 47 deletions(-) diff --git a/images/Containerfile.extras b/images/Containerfile.extras index 9b0574d..df35d29 100644 --- a/images/Containerfile.extras +++ b/images/Containerfile.extras @@ -1,6 +1,7 @@ # Layer container-extras on top of yolo-base # yolo assembles a build context with scripts and a run.sh manifest -FROM yolo-base +ARG BASE_IMAGE=yolo-base +FROM ${BASE_IMAGE} USER root RUN apt-get update diff --git a/src/yolo/builder.py b/src/yolo/builder.py index 84da902..1557ac8 100644 --- a/src/yolo/builder.py +++ b/src/yolo/builder.py @@ -1,23 +1,41 @@ """Build container images with container-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 / "container-extras" BASE_IMAGE = "yolo-base" -CUSTOM_IMAGE = "yolo-custom" + + +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 container-extras directories in precedence order (lowest first).""" - import os - paths = [BUILTIN_EXTRAS] xdg = os.environ.get("XDG_CONFIG_HOME", "") @@ -51,9 +69,9 @@ def _parse_extra(entry) -> tuple[str, dict[str, str]]: """Parse a single container-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", {}) + {"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, {}) @@ -114,25 +132,48 @@ def assemble_build_context(extras_config: list) -> Path: return build_dir -def build(extras_config: list) -> None: - """Build the custom image with container-extras.""" - if not extras_config: - print("No container-extras configured, nothing to build.") - return +def build_image(image_entry: dict) -> 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) - build_dir = assemble_build_context(extras_config) + if not extras: + print(f"No extras for image '{name}', skipping.") + return tag + + base = image_entry.get("from", BASE_IMAGE) + + build_dir = assemble_build_context(extras) try: cmd = [ "podman", "build", + "--build-arg", + f"BASE_IMAGE={base}", "-f", str(CONTAINERFILE_EXTRAS), "-t", - CUSTOM_IMAGE, + tag, str(build_dir), ] - print(f"Building {CUSTOM_IMAGE}...") + print(f"Building {tag}...") subprocess.run(cmd, check=True) - print(f"Built {CUSTOM_IMAGE}") + print(f"Built {tag}") finally: shutil.rmtree(build_dir) + + return tag + + +def build(images_config: list, only: str | None = None) -> 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) diff --git a/src/yolo/cli.py b/src/yolo/cli.py index 1a51955..f728b8b 100644 --- a/src/yolo/cli.py +++ b/src/yolo/cli.py @@ -2,8 +2,8 @@ import click -from yolo.config import load_config from yolo.builder import build as builder_build +from yolo.config import load_config from yolo.launcher import run as launcher_run @@ -13,11 +13,12 @@ def main(): @main.command() -def build(): +@click.option("--image", default=None, help="Build only this named image") +def build(image): """Build the container image with configured extras.""" config = load_config() - extras = config.get("container-extras", []) - builder_build(extras) + images = config.get("images", []) + builder_build(images, only=image) @main.command(context_settings={"ignore_unknown_options": True}) @@ -25,7 +26,13 @@ def build(): "-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.argument("claude_args", nargs=-1, type=click.UNPROCESSED) -def run(volume, entrypoint, claude_args): +def run(volume, entrypoint, image, claude_args): """Launch Claude Code in a container.""" - launcher_run(list(claude_args), extra_volumes=list(volume), entrypoint=entrypoint) + launcher_run( + list(claude_args), + extra_volumes=list(volume), + entrypoint=entrypoint, + image_name=image, + ) diff --git a/src/yolo/defaults/config.yaml b/src/yolo/defaults/config.yaml index aabe65d..fce8907 100644 --- a/src/yolo/defaults/config.yaml +++ b/src/yolo/defaults/config.yaml @@ -2,16 +2,18 @@ # Default yolo configuration # Override any of these in your user or project config -container-extras: - - name: apt - packages: - - fzf - - gh - - less - - man-db - - shellcheck - - zsh - - name: git-delta - version: "0.18.2" - - name: python - version: "3.12" +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/launcher.py b/src/yolo/launcher.py index 4c4b25f..3717682 100644 --- a/src/yolo/launcher.py +++ b/src/yolo/launcher.py @@ -4,6 +4,7 @@ import subprocess from pathlib import Path +from yolo.builder import image_tag from yolo.config import load_config @@ -20,6 +21,7 @@ def run( claude_args: list[str] | None = None, extra_volumes: list[str] | None = None, entrypoint: str | None = None, + image_name: str | None = None, ) -> None: """Launch Claude Code in a podman container.""" config = load_config() @@ -61,7 +63,7 @@ def run( "GIT_CONFIG_GLOBAL=/tmp/.gitconfig", "-e", "CLAUDE_CODE_OAUTH_TOKEN", - "yolo-custom", + image_tag(image_name or "default"), ] # TODO: make dangerously_skip_permissions a separate config value diff --git a/tests/test_builder.py b/tests/test_builder.py index bc04ff5..cddad0e 100644 --- a/tests/test_builder.py +++ b/tests/test_builder.py @@ -5,7 +5,12 @@ import pytest -from yolo.builder import _parse_extra, _resolve_script, assemble_build_context +from yolo.builder import ( + _parse_extra, + _resolve_script, + assemble_build_context, + image_tag, +) # ── _parse_extra ─────────────────────────────────────────────── @@ -130,13 +135,16 @@ def test_raises_on_missing_script(self, tmp_path, monkeypatch): with pytest.raises(FileNotFoundError, match="nope"): assemble_build_context([{"name": "nope"}]) - def test_string_entry(self, tmp_path, monkeypatch): - extras_dir = self._make_extras_dir(tmp_path, {"datalad": "#!/bin/bash"}) - monkeypatch.setattr("yolo.builder._extras_search_path", lambda: [extras_dir]) - build_dir = assemble_build_context(["datalad"]) - try: - content = (build_dir / "build" / "run.sh").read_text() - assert "datalad.sh" in content - finally: - shutil.rmtree(build_dir) +class TestImageTag: + def test_default_name(self, monkeypatch): + monkeypatch.setattr("yolo.builder._project_dirname", lambda: "myproject") + assert image_tag("default") == "yolo-myproject-default" + + def test_custom_name(self, monkeypatch): + monkeypatch.setattr("yolo.builder._project_dirname", lambda: "myproject") + assert image_tag("heavy") == "yolo-myproject-heavy" + + def test_sanitizes_dirname(self, monkeypatch): + monkeypatch.setattr("yolo.builder._project_dirname", lambda: "my project!") + assert image_tag("default") == "yolo-my-project--default" diff --git a/tests/test_launcher.py b/tests/test_launcher.py index 47ef7c4..6e3feb6 100644 --- a/tests/test_launcher.py +++ b/tests/test_launcher.py @@ -2,9 +2,17 @@ from unittest.mock import patch +import pytest + from yolo.launcher import _build_volume_args, run +@pytest.fixture(autouse=True) +def _mock_image_tag(): + with patch("yolo.launcher.image_tag", return_value="yolo-test-default"): + yield + + class TestBuildVolumeArgs: def test_empty(self): assert _build_volume_args([]) == [] @@ -28,6 +36,7 @@ def test_basic_command(self, mock_config, mock_run): assert "--userns=keep-id" in cmd assert "claude" in cmd assert "--dangerously-skip-permissions" in cmd + assert "yolo-test-default" in cmd @patch("yolo.launcher.subprocess.run") @patch("yolo.launcher.load_config", return_value={}) @@ -83,3 +92,13 @@ def test_custom_entrypoint_with_args(self, mock_config, mock_run): cmd = mock_run.call_args[0][0] idx = cmd.index("bash") assert cmd[idx + 1 : idx + 3] == ["-c", "echo hi"] + + @patch("yolo.launcher.image_tag") + @patch("yolo.launcher.subprocess.run") + @patch("yolo.launcher.load_config", return_value={}) + def test_image_name_passed(self, mock_config, mock_run, mock_tag): + mock_tag.return_value = "yolo-myproject-heavy" + run(image_name="heavy") + mock_tag.assert_called_with("heavy") + cmd = mock_run.call_args[0][0] + assert "yolo-myproject-heavy" in cmd From ce5b8bae6a4aefb9845fcc3e72df9df76d1b8e90 Mon Sep 17 00:00:00 2001 From: Austin Macdonald Date: Sun, 29 Mar 2026 17:33:34 -0500 Subject: [PATCH 21/55] Move REDESIGN_HACKIN.md to SPEC.md at root for rewrite Co-Authored-By: Claude Opus 4.6 (1M context) --- design/REDESIGN_HACKIN.md => SPEC.md | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename design/REDESIGN_HACKIN.md => SPEC.md (100%) diff --git a/design/REDESIGN_HACKIN.md b/SPEC.md similarity index 100% rename from design/REDESIGN_HACKIN.md rename to SPEC.md From 53fef84dd9d2abf188a300dc505d238d2a8f6cf2 Mon Sep 17 00:00:00 2001 From: Austin Macdonald Date: Sun, 29 Mar 2026 17:36:34 -0500 Subject: [PATCH 22/55] Rewrite SPEC.md to reflect current implementation Replaces the brainstorming hackpad with an accurate spec covering: config format/locations/merging, named images with FROM support, container-extras contract (env vars), script resolution, launcher behavior, and security posture. Co-Authored-By: Claude Opus 4.6 (1M context) --- CLAUDE.md | 3 +- SPEC.md | 323 ++++++++++++++++++++++-------------------------------- 2 files changed, 134 insertions(+), 192 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index e6154e8..b1583a7 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -12,8 +12,8 @@ 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/REDESIGN_HACKIN.md` — working design notes - `design/LEGACY_SPEC.md` — spec of the legacy bash implementation - `tests/features_to_test.md` — checklist of things that need tests @@ -32,6 +32,7 @@ Entry point is `yo` (temporary, becomes `yolo` at cutover). - `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) +- `src/yolo/launcher.py` — assembles podman run command - `src/yolo/defaults/config.yaml` — default container-extras shipped with package - `images/Containerfile.base` — minimal debian base image - `images/Containerfile.extras` — layers container-extras on top diff --git a/SPEC.md b/SPEC.md index 49ba2d5..5b1c805 100644 --- a/SPEC.md +++ b/SPEC.md @@ -1,236 +1,176 @@ -# YOLO Redesign Hackpad +# YOLO Specification -Working design notes from brainstorming session 2026-03-28. -This is a living document — ideas, not commitments. -Locked decisions live in `HACK_DECISIONS.md`. +Working spec for the Python rewrite. Locked decisions in `design/HACK_DECISIONS.md`. -## Core problem +## Overview -The current architecture has no extension points. Every new capability — -a tool in the image, a worktree strategy, a container runtime — requires -modifying the core. This is unsustainable. +yolo runs Claude Code inside a rootless Podman container with +`--dangerously-skip-permissions`. The container is the sandbox — no +permission prompts needed. -The `--extras` pattern is the proof: every new tool is a PR to the -Dockerfile, a flag in `setup-yolo.sh`, a debate about what belongs in the -base image. The architecture forces everything through the center. +## Components -**The goal is an architecture where the core is small and stable, and -growth happens at the edges.** Adding datalad support, a new worktree -strategy, or singularity as a runtime should not require touching the core. +| 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 container-extras on base | +| Scripts | `container-extras/` | Composable install scripts | +| Defaults | `src/yolo/defaults/config.yaml` | Default image config | -## What is yolo? +--- -Two core components, plus an installer: +## Config -1. **Launcher** — assemble mounts/env/command, invoke the container runtime -2. **Environment builder** — resolve features, build a derived image -3. **Installer** (`setup-yolo.sh` successor) — deferred for now +### Format -These are decoupled. The launcher doesn't know how the image was built. -The builder doesn't know how the image will be run. +YAML. Not sourced bash — declarative only to prevent prompt injection +across sessions. -## User stories +### Locations (later overrides earlier) -**"I just want to run it"** -Scientist clones a repo, types `yolo`, it works. No build step, no config -editing, no container knowledge required. +| # | 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) | -**"I need datalad too"** -Scientist adds `datalad` to a list in project config. A pre-written install -script runs behind the scenes. They didn't need to know what that script does. +CLI args override everything. -**"I do worktrees differently"** -Yarik has opinions about worktree layout. He drops a script into a known -location that overrides the default worktree behavior. No fork needed. +### Merge rules -**"Here's a repo, it just works"** -A PI commits yolo config to a repo. Collaborators clone it, run `yolo`, and -the environment is ready — right features, right mounts, right setup. +- **Lists**: append +- **Dicts**: recurse +- **Scalars**: replace +- **`!replace` tag**: TBD — per-key override to replace instead of append --- -## Features (environment builder) - -Composable install units. Users compose by name, yolo resolves and runs them. +## Images -### Syntax (YAML config) +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 -features: - - datalad # finds install_datalad.sh in feature path → runs it - - ffmpeg # no script found → falls back to apt - - apt:imagemagick # explicit: apt-get install - - uv:con-duct # explicit: uv tool install - - pip:numpy # explicit: pip install +images: + - name: default + extras: + - name: apt + packages: [zsh, fzf, shellcheck] + - name: python + version: "3.12" + + - name: myproject:heavy + from: myproject + extras: + - name: cuda ``` -### Resolution for bare names +### Image naming -1. Search the feature path for `install_.sh` → run it if found -2. No script? → `apt-get install -y ` +Image tags are derived from the project dirname + image name: +`yolo--`. Project dirname comes from git toplevel or cwd. -Prefixed names (`apt:`, `uv:`, `pip:`) skip the search, go straight to the -package manager script with args. +### `from` key -Curated scripts only need to exist where the install is non-trivial (datalad -needs extra `--with` flags, CUDA needs apt sources modified, playwright needs -both system deps and npm). +Overrides the `BASE_IMAGE` build arg in `Containerfile.extras`. Podman +handles composition natively via `FROM` — no inheritance system needed. +Default is `yolo-base`. -### Feature path (resolution order) - -``` -/features/ ← ships with yolo (builtins) -~/.config/yolo/features/ ← user local -.yolo/features/ ← project (committed) -.git/yolo/features/ ← project (local/untracked) -``` +### No image inheritance in config -Later wins if names collide. +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. -### Build mechanism +--- -Static Dockerfiles, dynamic build context. No Dockerfile generation. +## Container-extras -- `Dockerfile.base` — the base image (Claude Code + core tools) -- `Dockerfile.custom` — layers features on top of base +### Contract -`Dockerfile.custom` is dumb — it copies in a build context and runs -a manifest script. yolo assembles the build context: +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 ``` -build/ - scripts/ - install_datalad.sh ← resolved from feature path - apt.sh ← builtin package manager script - uv.sh ← builtin package manager script - run.sh ← generated manifest -``` - -`run.sh` is just: -```bash -bash /tmp/yolo-build/scripts/install_datalad.sh -bash /tmp/yolo-build/scripts/apt.sh imagemagick visidata -bash /tmp/yolo-build/scripts/uv.sh con-duct -``` - -Package manager scripts are simple. `apt.sh` is literally: -```bash -apt-get install -y "$@" -``` - -`apt-get update` happens once in the Dockerfile before running scripts, -not in each script. - -### Build-time vs run-time - -- **Primary: build-time.** Features baked into the derived image. -- **Secondary: run-time.** For things like `pip install -e .` that are - inherently per-session. Configured separately (e.g. `startup` key). -- **Explicit rebuild.** No auto-detection of staleness. User runs - `yolo --rebuild` when they change features. - -### OPEN: Sharp edges - -- What if a bare name matches a script AND is a valid apt package? - (Script wins — is that always right?) -- Is the prefix syntax extensible enough? (`npm:`, `cargo:`, etc.) -- Error messages when an install fails — which script broke? ---- +Becomes: +- `YOLO_APT_PACKAGES="zsh fzf" bash apt.sh` +- `YOLO_PYTHON_VERSION=3.12 bash python.sh` +- `bash datalad.sh` -## Hooks / extensible launch behavior +### Script resolution -Same resolution pattern as features, same override mechanism. -A hook is just a script in a known location that runs at a specific phase. +Search path (later wins): -### Unified with features via phases +1. `/container-extras/` — builtins +2. `~/.config/yolo/container-extras/` — user +3. `.yolo/container-extras/` — project (committed) +4. `.git/yolo/container-extras/` — project (local) -``` -.yolo/ - build/ ← feature install scripts (build phase) - launch/ ← runtime behavior scripts (launch phase) - config.yaml ← the thing users actually edit -``` +### Script requirements -No separate "hook framework." If a script with a known name exists, -it runs at the right phase. The phase is implied by location. +- Self-contained bash scripts +- Validate own env vars, fail with clear message if missing +- No cross-script dependencies (duplicate is OK, idempotent is better) -### Hook points (launch phase) +### Build mechanism -| Hook | What it does by default | Why someone might override | -|-----------------|--------------------------------|-----------------------------------| -| `worktree` | detect, prompt/bind/skip/error | custom worktree layout/naming | -| `volumes` | assemble the mount list | SSH keys, conditional mounts | -| `container-name`| `$PWD-$$` sanitized | org naming conventions | -| `pre-launch` | nothing | env setup, credential injection | -| `post-exit` | nothing | cleanup, sync, notifications | -| `entrypoint` | `claude --dangerously-skip-permissions` | different agent, wrapper | +Static Containerfiles, dynamic build context. No Dockerfile generation. -### OPEN: Hook contract +`Containerfile.base`: minimal debian + Claude Code + tini + essential tools. -- Override vs wrap? Does a user hook replace the default or run around it? -- Data return: how does a hook communicate back (e.g. "add these mounts")? -- We want to stay on the simple side of: `exit code → stdout → JSON → plugin API` +`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 -The launcher speaks in **intent**, not container runtime flags. +### Default behavior + +Mounts claude config (rw), gitconfig (ro), workspace (rw). Sets up +env vars. Runs `claude --dangerously-skip-permissions`. -### Vocabulary (YAML config keys) +### Config keys ```yaml volumes: - - ~/projects - - ~/data::ro - -nvidia: true -worktree: ask + - /host/path:/container/path:opts ``` -NOT raw podman flags. Intent is portable across runtimes. - -The launcher's job: -1. **Mounts** — default secure set + user additions from config -2. **Env vars** — default set + user additions -3. **Command** — claude with skip-permissions, or custom entrypoint -4. **Translate** — turn intent into `podman run` invocation +### CLI flags -Raw passthrough (`--`) exists as an escape hatch, not the normal path. +| Flag | Description | +|------|-------------| +| `-v, --volume` | Extra bind mount (repeatable) | +| `--entrypoint` | Override container command | +| `--image` | Run a specific named image | +| `[CLAUDE_ARGS]` | Passed through to claude | ---- - -## Config +### Entrypoint override -See `HACK_DECISIONS.md` for locked decisions on format and locations. +Custom entrypoint skips `--dangerously-skip-permissions`. +TODO: make skip_permissions a separate config value. -### Layering (later overrides earlier) +### Container naming -1. `/etc/yolo/config.yaml` — system-wide (org defaults) -2. `~/.config/yolo/config.yaml` — user preferences -3. `.yolo/config.yaml` — project, committed (shareable) -4. `.git/yolo/config.yaml` — project, local (personal) -5. CLI args - -### OPEN: Array merging vs replacement - -With 4 config layers, does `volumes: [~/data]` in project config -replace or append to `volumes: [~/tools]` in user config? - ---- - -## Context injection - -yolo generates a context file bound into the container at launch, -telling Claude about its environment: - -- "You're in a yolo container" -- "Mounted volumes: X, Y, Z" -- "You cannot: access SSH keys, write outside mounted volumes" -- "Installed features: datalad, ffmpeg" - -Helps Claude work effectively AND respects boundaries. +`-` with `$HOME/` stripped and non-alphanumeric chars +replaced with `_`. --- @@ -240,28 +180,29 @@ Helps Claude work effectively AND respects boundaries. Secure by default. Flexible enough to weaken deliberately. -### Escape vector: config as attack surface +- No SSH keys mounted +- No cloud credentials accessible +- Workspace mounted read-write +- Network unrestricted (known gap) + +### Config as attack surface -Claude has write access to `.yolo/config.yaml` (in the workspace). -Could modify config to mount sensitive directories on next launch. -Between-session escape, not within-session. +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): -Mitigations under consideration: - Mount `.yolo/` read-only inside container -- Diff-on-launch: show config changes, ask to confirm -- Exit warning if `.yolo/` was modified during session -- Trust prompt for committed config in unfamiliar repos -- Document honestly, don't pretend it's fully solved +- Diff-on-launch warning +- Exit warning if config modified during session --- ## Still open -- Image naming/tagging strategy for derived images -- Singularity/apptainer runtime abstraction (#33) -- Registry story (GHCR for base image?) — deferred, build locally for now -- How extensions repo works (if at all) -- Installer redesign (setup-yolo.sh successor) — deferred -- Organize security-reports/ (generated by yolo session test run) -- `!replace` YAML tag for per-key merge vs replace semantics -- Default container-extras config (ships with package) +- 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) +- Installer redesign (setup-yolo.sh successor) +- `dangerously_skip_permissions` as separate config value From b11b56ce2c7a62d4e9efb8950cd047ae4c92c9f0 Mon Sep 17 00:00:00 2001 From: Austin Macdonald Date: Sun, 29 Mar 2026 17:41:21 -0500 Subject: [PATCH 23/55] Auto-build base image if missing, error on unknown base - Check if base image exists before building extras - yolo-base: auto-build from Containerfile.base - Config-defined image: build that first - Unknown image: error with clear message Co-Authored-By: Claude Opus 4.6 (1M context) --- src/yolo/builder.py | 52 +++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 50 insertions(+), 2 deletions(-) diff --git a/src/yolo/builder.py b/src/yolo/builder.py index 1557ac8..4f9dea3 100644 --- a/src/yolo/builder.py +++ b/src/yolo/builder.py @@ -132,7 +132,54 @@ def assemble_build_context(extras_config: list) -> Path: return build_dir -def build_image(image_entry: dict) -> str: +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) -> str: """Build a single image from an images list entry. Returns the tag.""" name = image_entry.get("name", "default") extras = image_entry.get("extras", []) @@ -143,6 +190,7 @@ def build_image(image_entry: dict) -> str: return tag base = image_entry.get("from", BASE_IMAGE) + _ensure_base(base, images_config or []) build_dir = assemble_build_context(extras) try: @@ -176,4 +224,4 @@ def build(images_config: list, only: str | None = None) -> None: name = entry.get("name", "default") if only and name != only: continue - build_image(entry) + build_image(entry, images_config) From 315c5da038083701803fc8a372a048e93425082d Mon Sep 17 00:00:00 2001 From: Austin Macdonald Date: Sun, 29 Mar 2026 17:46:00 -0500 Subject: [PATCH 24/55] Add worktree detection and handling (ask/bind/skip/error) - _detect_worktree: parse .git file/symlink, find original repo - _worktree_volume: handle modes (ask prompts, bind mounts, skip ignores, error exits) - Config key 'worktree' with CLI --worktree override - 7 new tests for detection and volume behavior Co-Authored-By: Claude Opus 4.6 (1M context) --- SPEC.md | 1 + src/yolo/cli.py | 9 +++++- src/yolo/launcher.py | 67 +++++++++++++++++++++++++++++++++++++++++- tests/test_launcher.py | 59 ++++++++++++++++++++++++++++++++++++- 4 files changed, 133 insertions(+), 3 deletions(-) diff --git a/SPEC.md b/SPEC.md index 5b1c805..ef69275 100644 --- a/SPEC.md +++ b/SPEC.md @@ -199,6 +199,7 @@ vector. Mitigations under consideration (none implemented): ## 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 diff --git a/src/yolo/cli.py b/src/yolo/cli.py index f728b8b..5bc7c46 100644 --- a/src/yolo/cli.py +++ b/src/yolo/cli.py @@ -27,12 +27,19 @@ def build(image): ) @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.argument("claude_args", nargs=-1, type=click.UNPROCESSED) -def run(volume, entrypoint, image, claude_args): +def run(volume, entrypoint, image, worktree, claude_args): """Launch Claude Code in a container.""" launcher_run( list(claude_args), extra_volumes=list(volume), entrypoint=entrypoint, image_name=image, + worktree=worktree, ) diff --git a/src/yolo/launcher.py b/src/yolo/launcher.py index 3717682..7767034 100644 --- a/src/yolo/launcher.py +++ b/src/yolo/launcher.py @@ -1,7 +1,9 @@ """Launch Claude Code in a container.""" import os +import re import subprocess +import sys from pathlib import Path from yolo.builder import image_tag @@ -10,21 +12,83 @@ def _build_volume_args(volumes: list[str]) -> list[str]: """Turn a list of volume specs into podman -v args.""" - # TODO: rename to mounts? these are bind mounts, not docker volumes args = [] for vol in volumes: args.extend(["-v", vol]) return args +def _detect_worktree() -> Path | None: + """If cwd is a git worktree, return the original repo dir.""" + dot_git = Path.cwd() / ".git" + + if dot_git.is_symlink(): + gitdir_path = dot_git.resolve() + elif dot_git.is_file(): + text = dot_git.read_text().strip() + if not text.startswith("gitdir: "): + return None + gitdir_path = Path(text[len("gitdir: ") :]) + if not gitdir_path.is_absolute(): + gitdir_path = Path.cwd() / gitdir_path + gitdir_path = gitdir_path.resolve() + else: + return None + + match = re.match(r"^(.+/\.git)/worktrees/", str(gitdir_path)) + if not match: + return None + + original_repo = Path(match.group(1)).parent + if original_repo == Path.cwd(): + return None + + return original_repo + + +def _worktree_volume(mode: str) -> list[str]: + """Handle worktree detection and return extra volume args.""" + original = _detect_worktree() + if original is None: + return [] + + if mode == "error": + print( + f"Error: Running in a git worktree is not allowed (original: {original})", + file=sys.stderr, + ) + sys.exit(1) + elif mode == "skip": + return [] + elif mode == "bind": + return ["-v", f"{original}:{original}:z"] + elif mode == "ask": + print( + f"Detected git worktree. Original repository: {original}", file=sys.stderr + ) + print( + "Bind mounting the original repo allows git operations but may expose unintended files.", + file=sys.stderr, + ) + reply = input("Bind mount original repository? [y/N] ") + if reply.strip().lower() == "y": + return ["-v", f"{original}:{original}:z"] + return [] + else: + print(f"Warning: unknown worktree mode '{mode}', skipping", file=sys.stderr) + return [] + + def run( claude_args: list[str] | None = None, extra_volumes: list[str] | None = None, entrypoint: str | None = None, image_name: str | None = None, + worktree: str | None = None, ) -> None: """Launch Claude Code in a podman container.""" config = load_config() + worktree_mode = worktree or config.get("worktree", "ask") home = Path.home() cwd = Path.cwd() @@ -55,6 +119,7 @@ def run( f"{cwd}:{cwd}:z", *_build_volume_args(config_volumes), *_build_volume_args(extra_volumes or []), + *_worktree_volume(worktree_mode), "-w", str(cwd), "-e", diff --git a/tests/test_launcher.py b/tests/test_launcher.py index 6e3feb6..23e7ddc 100644 --- a/tests/test_launcher.py +++ b/tests/test_launcher.py @@ -1,10 +1,11 @@ """Tests for yolo launcher: command assembly.""" +from pathlib import Path from unittest.mock import patch import pytest -from yolo.launcher import _build_volume_args, run +from yolo.launcher import _build_volume_args, _detect_worktree, _worktree_volume, run @pytest.fixture(autouse=True) @@ -102,3 +103,59 @@ def test_image_name_passed(self, mock_config, mock_run, mock_tag): mock_tag.assert_called_with("heavy") cmd = mock_run.call_args[0][0] assert "yolo-myproject-heavy" in cmd + + +class TestDetectWorktree: + def test_not_a_worktree(self, tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + (tmp_path / ".git").mkdir() + assert _detect_worktree() is None + + def test_no_git_at_all(self, tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + assert _detect_worktree() is None + + def test_detects_worktree(self, tmp_path, monkeypatch): + # Set up fake worktree structure + original = tmp_path / "original" + original.mkdir() + git_dir = original / ".git" + git_dir.mkdir() + worktrees = git_dir / "worktrees" / "wt1" + worktrees.mkdir(parents=True) + + wt = tmp_path / "worktree1" + wt.mkdir() + (wt / ".git").write_text(f"gitdir: {worktrees}") + monkeypatch.chdir(wt) + + assert _detect_worktree() == original + + +class TestWorktreeVolume: + def test_skip_no_worktree(self): + with patch("yolo.launcher._detect_worktree", return_value=None): + assert _worktree_volume("ask") == [] + + def test_bind_mounts(self): + with patch( + "yolo.launcher._detect_worktree", + return_value=Path("/repo"), + ): + result = _worktree_volume("bind") + assert result == ["-v", "/repo:/repo:z"] + + def test_skip_mode(self): + with patch( + "yolo.launcher._detect_worktree", + return_value=Path("/repo"), + ): + assert _worktree_volume("skip") == [] + + def test_error_mode(self): + with patch( + "yolo.launcher._detect_worktree", + return_value=Path("/repo"), + ): + with pytest.raises(SystemExit): + _worktree_volume("error") From 754b26d6ab9fa94d1e2f8005482bb5f737ab45ff Mon Sep 17 00:00:00 2001 From: Austin Macdonald Date: Sun, 29 Mar 2026 17:47:24 -0500 Subject: [PATCH 25/55] Add shorthand volume expansion MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ~/projects → 1-to-1 mount with :z ~/data::ro → 1-to-1 with custom options /host:/container → partial, :z appended /host:/cont:opts → full form, unchanged Co-Authored-By: Claude Opus 4.6 (1M context) --- src/yolo/launcher.py | 24 +++++++++++++++++++++++- tests/test_launcher.py | 29 ++++++++++++++++++++++++++++- 2 files changed, 51 insertions(+), 2 deletions(-) diff --git a/src/yolo/launcher.py b/src/yolo/launcher.py index 7767034..91964fd 100644 --- a/src/yolo/launcher.py +++ b/src/yolo/launcher.py @@ -10,11 +10,33 @@ from yolo.config import load_config +def _expand_volume(vol: str) -> str: + """Expand volume shorthand to full podman -v syntax. + + ~/projects → $HOME/projects:$HOME/projects:z + ~/data::ro → $HOME/data:$HOME/data:ro + /host:/container → /host:/container:z + /host:/cont:opts → /host:/cont:opts (unchanged) + """ + home = str(Path.home()) + if "::" in vol: + path, _, opts = vol.partition("::") + path = path.replace("~", home, 1) + return f"{path}:{path}:{opts}" + elif vol.count(":") >= 2: + return vol + elif ":" in vol: + return f"{vol}:z" + else: + path = vol.replace("~", home, 1) + return f"{path}:{path}:z" + + def _build_volume_args(volumes: list[str]) -> list[str]: """Turn a list of volume specs into podman -v args.""" args = [] for vol in volumes: - args.extend(["-v", vol]) + args.extend(["-v", _expand_volume(vol)]) return args diff --git a/tests/test_launcher.py b/tests/test_launcher.py index 23e7ddc..e02d2cc 100644 --- a/tests/test_launcher.py +++ b/tests/test_launcher.py @@ -5,7 +5,13 @@ import pytest -from yolo.launcher import _build_volume_args, _detect_worktree, _worktree_volume, run +from yolo.launcher import ( + _build_volume_args, + _detect_worktree, + _expand_volume, + _worktree_volume, + run, +) @pytest.fixture(autouse=True) @@ -14,6 +20,27 @@ def _mock_image_tag(): yield +class TestExpandVolume: + def test_shorthand(self): + result = _expand_volume("~/projects") + home = str(Path.home()) + assert result == f"{home}/projects:{home}/projects:z" + + def test_shorthand_with_options(self): + result = _expand_volume("~/data::ro") + home = str(Path.home()) + assert result == f"{home}/data:{home}/data:ro" + + def test_partial(self): + assert _expand_volume("/host:/container") == "/host:/container:z" + + def test_full_passthrough(self): + assert _expand_volume("/host:/container:ro,z") == "/host:/container:ro,z" + + def test_absolute_shorthand(self): + assert _expand_volume("/data") == "/data:/data:z" + + class TestBuildVolumeArgs: def test_empty(self): assert _build_volume_args([]) == [] From 5464398a8b88c78570b1e1b5655603e2f149445f Mon Sep 17 00:00:00 2001 From: Austin Macdonald Date: Sun, 29 Mar 2026 17:51:50 -0500 Subject: [PATCH 26/55] Add --podman-arg escape hatch for raw podman flags Repeatable flag passes args directly to podman run. Explicit opt-in, not blanket passthrough. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/yolo/cli.py | 8 +++++++- src/yolo/launcher.py | 2 ++ 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/src/yolo/cli.py b/src/yolo/cli.py index 5bc7c46..62328d8 100644 --- a/src/yolo/cli.py +++ b/src/yolo/cli.py @@ -33,8 +33,13 @@ def build(image): default=None, help="Git worktree handling mode", ) +@click.option( + "--podman-arg", + multiple=True, + help="Pass raw arg to podman run (repeatable)", +) @click.argument("claude_args", nargs=-1, type=click.UNPROCESSED) -def run(volume, entrypoint, image, worktree, claude_args): +def run(volume, entrypoint, image, worktree, podman_arg, claude_args): """Launch Claude Code in a container.""" launcher_run( list(claude_args), @@ -42,4 +47,5 @@ def run(volume, entrypoint, image, worktree, claude_args): entrypoint=entrypoint, image_name=image, worktree=worktree, + podman_args=list(podman_arg), ) diff --git a/src/yolo/launcher.py b/src/yolo/launcher.py index 91964fd..88c3cb4 100644 --- a/src/yolo/launcher.py +++ b/src/yolo/launcher.py @@ -107,6 +107,7 @@ def run( entrypoint: str | None = None, image_name: str | None = None, worktree: str | None = None, + podman_args: list[str] | None = None, ) -> None: """Launch Claude Code in a podman container.""" config = load_config() @@ -142,6 +143,7 @@ def run( *_build_volume_args(config_volumes), *_build_volume_args(extra_volumes or []), *_worktree_volume(worktree_mode), + *(podman_args or []), "-w", str(cwd), "-e", From 8d33db8ca225c0ac9530bb2e9bfad785ee8d4b35 Mon Sep 17 00:00:00 2001 From: Austin Macdonald Date: Sun, 29 Mar 2026 17:58:17 -0500 Subject: [PATCH 27/55] Add --nvidia flag and --container-arg escape hatch - --nvidia: CDI GPU passthrough with CDI spec warning - --container-arg: repeatable raw arg to container engine - nvidia also configurable via config key - Renamed from --podman-arg to --container-arg for runtime agnosticism Co-Authored-By: Claude Opus 4.6 (1M context) --- src/yolo/cli.py | 15 +++++++++++---- src/yolo/launcher.py | 21 +++++++++++++++++++-- tests/test_launcher.py | 13 +++++++++++++ 3 files changed, 43 insertions(+), 6 deletions(-) diff --git a/src/yolo/cli.py b/src/yolo/cli.py index 62328d8..247ad06 100644 --- a/src/yolo/cli.py +++ b/src/yolo/cli.py @@ -34,12 +34,18 @@ def build(image): help="Git worktree handling mode", ) @click.option( - "--podman-arg", + "--nvidia", + is_flag=True, + default=False, + help="Enable NVIDIA GPU passthrough via CDI", +) +@click.option( + "--container-arg", multiple=True, - help="Pass raw arg to podman run (repeatable)", + help="Pass raw arg to container engine (repeatable)", ) @click.argument("claude_args", nargs=-1, type=click.UNPROCESSED) -def run(volume, entrypoint, image, worktree, podman_arg, claude_args): +def run(volume, entrypoint, image, worktree, nvidia, container_arg, claude_args): """Launch Claude Code in a container.""" launcher_run( list(claude_args), @@ -47,5 +53,6 @@ def run(volume, entrypoint, image, worktree, podman_arg, claude_args): entrypoint=entrypoint, image_name=image, worktree=worktree, - podman_args=list(podman_arg), + nvidia=nvidia, + container_args=list(container_arg), ) diff --git a/src/yolo/launcher.py b/src/yolo/launcher.py index 88c3cb4..3bf33ed 100644 --- a/src/yolo/launcher.py +++ b/src/yolo/launcher.py @@ -40,6 +40,20 @@ def _build_volume_args(volumes: list[str]) -> list[str]: return args +def _nvidia_args(enabled: bool) -> list[str]: + """Return podman args for NVIDIA GPU passthrough.""" + if not enabled: + return [] + cdi_paths = [Path("/etc/cdi/nvidia.yaml"), Path("/var/run/cdi/nvidia.yaml")] + if not any(p.exists() for p in cdi_paths): + print( + "Warning: NVIDIA CDI spec not found. GPU passthrough may not work.\n" + " sudo nvidia-ctk cdi generate --output=/etc/cdi/nvidia.yaml", + file=sys.stderr, + ) + return ["--device", "nvidia.com/gpu=all", "--security-opt", "label=disable"] + + def _detect_worktree() -> Path | None: """If cwd is a git worktree, return the original repo dir.""" dot_git = Path.cwd() / ".git" @@ -107,11 +121,13 @@ def run( entrypoint: str | None = None, image_name: str | None = None, worktree: str | None = None, - podman_args: list[str] | None = None, + nvidia: bool = False, + container_args: list[str] | None = None, ) -> None: """Launch Claude Code in a podman container.""" config = load_config() worktree_mode = worktree or config.get("worktree", "ask") + use_nvidia = nvidia or config.get("nvidia", False) home = Path.home() cwd = Path.cwd() @@ -143,7 +159,8 @@ def run( *_build_volume_args(config_volumes), *_build_volume_args(extra_volumes or []), *_worktree_volume(worktree_mode), - *(podman_args or []), + *_nvidia_args(use_nvidia), + *(container_args or []), "-w", str(cwd), "-e", diff --git a/tests/test_launcher.py b/tests/test_launcher.py index e02d2cc..5a56f2e 100644 --- a/tests/test_launcher.py +++ b/tests/test_launcher.py @@ -9,6 +9,7 @@ _build_volume_args, _detect_worktree, _expand_volume, + _nvidia_args, _worktree_volume, run, ) @@ -132,6 +133,18 @@ def test_image_name_passed(self, mock_config, mock_run, mock_tag): assert "yolo-myproject-heavy" in cmd +class TestNvidiaArgs: + def test_disabled(self): + assert _nvidia_args(False) == [] + + def test_enabled(self): + result = _nvidia_args(True) + assert "--device" in result + assert "nvidia.com/gpu=all" in result + assert "--security-opt" in result + assert "label=disable" in result + + class TestDetectWorktree: def test_not_a_worktree(self, tmp_path, monkeypatch): monkeypatch.chdir(tmp_path) From 61408742fe0ef30a2a4b44947394f0681d400c87 Mon Sep 17 00:00:00 2001 From: Austin Macdonald Date: Sun, 29 Mar 2026 18:04:41 -0500 Subject: [PATCH 28/55] Add !replace YAML tag for per-key merge override Values tagged !replace in YAML replace instead of appending: extras: !replace - datalad Allows overriding default lists without repeating everything. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/yolo/config.py | 27 +++++++++++++++++++++++++-- tests/test_config.py | 28 +++++++++++++++++++++++++++- 2 files changed, 52 insertions(+), 3 deletions(-) diff --git a/src/yolo/config.py b/src/yolo/config.py index c0bd48c..aa3f583 100644 --- a/src/yolo/config.py +++ b/src/yolo/config.py @@ -8,8 +8,23 @@ 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) @@ -72,10 +87,18 @@ def _load_yaml(path: Path) -> dict: def _merge(base: dict, override: dict) -> dict: - """Merge override into base. Lists append, dicts recurse, scalars replace.""" + """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 key in merged and isinstance(merged[key], list) and isinstance(value, list): + 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) diff --git a/tests/test_config.py b/tests/test_config.py index d37af8f..d3d9977 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -5,7 +5,7 @@ import pytest from ruamel.yaml import YAML -from yolo.config import _merge, _config_paths, load_config +from yolo.config import _Replace, _merge, _config_paths, load_config def _write_yaml(path: Path, data: dict): @@ -47,6 +47,14 @@ def test_empty_base(self): def test_empty_override(self): assert _merge({"a": 1}, {}) == {"a": 1} + def test_replace_tag_overrides_list(self): + base = {"x": [1, 2, 3]} + override = {"x": _Replace([4, 5])} + assert _merge(base, override) == {"x": [4, 5]} + + def test_replace_on_new_key(self): + assert _merge({}, {"x": _Replace([1])}) == {"x": [1]} + # ── _config_paths ────────────────────────────────────────────── @@ -166,3 +174,21 @@ def test_git_overrides_project(self, tmp_path, monkeypatch): config = load_config() assert config["worktree"] == "skip" + + def test_replace_tag_in_yaml(self, tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + + xdg = tmp_path / "xdg" + monkeypatch.setenv("XDG_CONFIG_HOME", str(xdg)) + _write_yaml( + xdg / "yolo" / "config.yaml", + {"extras": ["zsh", "fzf", "python"]}, + ) + + # Write raw YAML with !replace tag (can't use _write_yaml for tags) + project_cfg = tmp_path / ".yolo" / "config.yaml" + project_cfg.parent.mkdir(parents=True, exist_ok=True) + project_cfg.write_text("extras: !replace\n - datalad\n") + + config = load_config() + assert config["extras"] == ["datalad"] From 6296972aae6a7a90f56b93e228d70876840d6846 Mon Sep 17 00:00:00 2001 From: Austin Macdonald Date: Sun, 29 Mar 2026 18:10:33 -0500 Subject: [PATCH 29/55] Add xfail tests for recursive named-list merge Two failing test cases documenting the desired behavior: - Deep named-list merge (images/extras merged by name) - Deep !replace inside named lists TODO: implement recursive merge for lists of named dicts Co-Authored-By: Claude Opus 4.6 (1M context) --- tests/test_config.py | 61 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 61 insertions(+) diff --git a/tests/test_config.py b/tests/test_config.py index d3d9977..026243e 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -55,6 +55,67 @@ def test_replace_tag_overrides_list(self): def test_replace_on_new_key(self): assert _merge({}, {"x": _Replace([1])}) == {"x": [1]} + @pytest.mark.xfail(reason="TODO: implement recursive named-list merge") + def test_deep_named_list_merge(self): + base = { + "images": [ + { + "name": "default", + "extras": [ + {"name": "apt", "packages": ["zsh", "fzf"]}, + {"name": "python", "version": "3.12"}, + ], + }, + ], + } + override = { + "images": [ + { + "name": "default", + "extras": [ + {"name": "apt", "packages": ["shellcheck"]}, + ], + }, + ], + } + result = _merge(base, override) + # Should merge the "default" image, and merge "apt" extras + assert len(result["images"]) == 1 + apt_entry = next(e for e in result["images"][0]["extras"] if e["name"] == "apt") + assert apt_entry["packages"] == ["zsh", "fzf", "shellcheck"] + # python should still be there + assert any(e["name"] == "python" for e in result["images"][0]["extras"]) + + @pytest.mark.xfail( + reason="TODO: implement recursive named-list merge with !replace" + ) + def test_deep_replace_in_named_list(self): + base = { + "images": [ + { + "name": "default", + "extras": [ + {"name": "apt", "packages": ["zsh", "fzf"]}, + ], + }, + ], + } + override = { + "images": [ + { + "name": "default", + "extras": _Replace( + [ + {"name": "datalad"}, + ] + ), + }, + ], + } + result = _merge(base, override) + assert len(result["images"]) == 1 + assert result["images"][0]["extras"] == [{"name": "datalad"}] + # ── _config_paths ────────────────────────────────────────────── From 1634bbca953c5bf767e149d02451c361e64586ee Mon Sep 17 00:00:00 2001 From: Austin Macdonald Date: Sun, 29 Mar 2026 18:14:21 -0500 Subject: [PATCH 30/55] Config-driven env var passthrough env key in config: bare names pass through from host, KEY=VALUE sets explicitly. Defaults pass through CLAUDE_CODE_OAUTH_TOKEN and CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/yolo/defaults/config.yaml | 4 ++++ src/yolo/launcher.py | 14 ++++++++++++-- 2 files changed, 16 insertions(+), 2 deletions(-) diff --git a/src/yolo/defaults/config.yaml b/src/yolo/defaults/config.yaml index fce8907..f5e312f 100644 --- a/src/yolo/defaults/config.yaml +++ b/src/yolo/defaults/config.yaml @@ -2,6 +2,10 @@ # Default yolo configuration # Override any of these in your user or project config +env: + - CLAUDE_CODE_OAUTH_TOKEN + - CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS + images: - name: default extras: diff --git a/src/yolo/launcher.py b/src/yolo/launcher.py index 3bf33ed..d48e516 100644 --- a/src/yolo/launcher.py +++ b/src/yolo/launcher.py @@ -54,6 +54,17 @@ def _nvidia_args(enabled: bool) -> list[str]: return ["--device", "nvidia.com/gpu=all", "--security-opt", "label=disable"] +def _build_env_args(env_config: list[str]) -> list[str]: + """Build -e args from env config. + + Bare name = passthrough from host. KEY=VALUE = set explicitly. + """ + args = [] + for entry in env_config: + args.extend(["-e", entry]) + return args + + def _detect_worktree() -> Path | None: """If cwd is a git worktree, return the original repo dir.""" dot_git = Path.cwd() / ".git" @@ -167,8 +178,7 @@ def run( f"CLAUDE_CONFIG_DIR={claude_dir}", "-e", "GIT_CONFIG_GLOBAL=/tmp/.gitconfig", - "-e", - "CLAUDE_CODE_OAUTH_TOKEN", + *_build_env_args(config.get("env", [])), image_tag(image_name or "default"), ] From 9b0d7bd9ee2239db7cbe2787d7e07dc0c28084e8 Mon Sep 17 00:00:00 2001 From: Austin Macdonald Date: Sun, 29 Mar 2026 18:16:18 -0500 Subject: [PATCH 31/55] Add --no-config flag to skip all user/project config files Global flag on yo: yo --no-config run, yo --no-config build. Still loads package defaults, skips /etc, user, and project configs. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/yolo/cli.py | 17 +++++++++++++---- src/yolo/config.py | 4 +++- src/yolo/launcher.py | 3 ++- 3 files changed, 18 insertions(+), 6 deletions(-) diff --git a/src/yolo/cli.py b/src/yolo/cli.py index 247ad06..a4afee8 100644 --- a/src/yolo/cli.py +++ b/src/yolo/cli.py @@ -8,15 +8,22 @@ @click.group() -def main(): +@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") -def build(image): +@click.pass_context +def build(ctx, image): """Build the container image with configured extras.""" - config = load_config() + config = load_config(no_config=ctx.obj["no_config"]) images = config.get("images", []) builder_build(images, only=image) @@ -45,10 +52,12 @@ def build(image): help="Pass raw arg to container engine (repeatable)", ) @click.argument("claude_args", nargs=-1, type=click.UNPROCESSED) -def run(volume, entrypoint, image, worktree, nvidia, container_arg, claude_args): +@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, diff --git a/src/yolo/config.py b/src/yolo/config.py index aa3f583..1d31dc3 100644 --- a/src/yolo/config.py +++ b/src/yolo/config.py @@ -109,9 +109,11 @@ def _merge(base: dict, override: dict) -> dict: return merged -def load_config() -> dict: +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: diff --git a/src/yolo/launcher.py b/src/yolo/launcher.py index d48e516..e8a9344 100644 --- a/src/yolo/launcher.py +++ b/src/yolo/launcher.py @@ -134,9 +134,10 @@ def run( worktree: str | None = None, nvidia: bool = False, container_args: list[str] | None = None, + no_config: bool = False, ) -> None: """Launch Claude Code in a podman container.""" - config = load_config() + config = load_config(no_config=no_config) worktree_mode = worktree or config.get("worktree", "ask") use_nvidia = nvidia or config.get("nvidia", False) From 8987ff709b79e1956fcbb84b4698797ca380517d Mon Sep 17 00:00:00 2001 From: Austin Macdonald Date: Sun, 29 Mar 2026 18:21:32 -0500 Subject: [PATCH 32/55] Add yo init: generate config from default template MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit yo init → .yolo/config.yaml (committed, default) yo init --local → .git/yolo/config.yaml (personal) yo init --user → ~/.config/yolo/config.yaml yo init --path /etc/yolo → custom location Template is the default config with commented examples for all keys. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/yolo/cli.py | 45 ++++++++++++++++++++++++++++++++++- src/yolo/defaults/config.yaml | 21 ++++++++++++++-- 2 files changed, 63 insertions(+), 3 deletions(-) diff --git a/src/yolo/cli.py b/src/yolo/cli.py index a4afee8..b06422c 100644 --- a/src/yolo/cli.py +++ b/src/yolo/cli.py @@ -1,9 +1,12 @@ """CLI entry point for yolo.""" +import shutil +from pathlib import Path + import click from yolo.builder import build as builder_build -from yolo.config import load_config +from yolo.config import DEFAULTS_CONFIG, load_config from yolo.launcher import run as launcher_run @@ -28,6 +31,46 @@ def build(ctx, image): builder_build(images, only=image) +@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(DEFAULTS_CONFIG, dest) + click.echo(f"Created {dest}") + + @main.command(context_settings={"ignore_unknown_options": True}) @click.option( "-v", "--volume", multiple=True, help="Extra bind mount (host:container[:opts])" diff --git a/src/yolo/defaults/config.yaml b/src/yolo/defaults/config.yaml index f5e312f..0c1e391 100644 --- a/src/yolo/defaults/config.yaml +++ b/src/yolo/defaults/config.yaml @@ -1,13 +1,30 @@ --- -# Default yolo configuration -# Override any of these in your user or project config +# yolo configuration +# Docs: SPEC.md | Locations: /etc/yolo, ~/.config/yolo, .yolo/, .git/yolo/ +# Later files override earlier. Lists append. Use !replace to override a list. +# Env vars passed into the container +# Bare name = passthrough from host. KEY=VALUE = set explicitly. env: - CLAUDE_CODE_OAUTH_TOKEN - CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS +# Extra bind mounts (shorthand: ~/path, ~/path::ro, or full podman syntax) +# volumes: +# - ~/data +# - ~/projects::ro + +# Git worktree handling: ask, bind, skip, error +# worktree: ask + +# NVIDIA GPU passthrough via CDI +# nvidia: false + +# Container images to build images: - name: default + # from: yolo-base + # or any image: ghcr.io/org/image:tag, docker.io/lib/python:3.12 extras: - name: apt packages: From 1f118fc631536b8aed1696219e08b9d0a43a90ed Mon Sep 17 00:00:00 2001 From: Austin Macdonald Date: Sun, 29 Mar 2026 18:24:27 -0500 Subject: [PATCH 33/55] Expand default config with docs for all config features Explains config layering, merge rules, !replace, volume shorthand, extras script resolution path, and env var contract. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/yolo/defaults/config.yaml | 51 ++++++++++++++++++++++++++++++----- 1 file changed, 45 insertions(+), 6 deletions(-) diff --git a/src/yolo/defaults/config.yaml b/src/yolo/defaults/config.yaml index 0c1e391..f2a99a2 100644 --- a/src/yolo/defaults/config.yaml +++ b/src/yolo/defaults/config.yaml @@ -1,30 +1,69 @@ --- # yolo configuration -# Docs: SPEC.md | Locations: /etc/yolo, ~/.config/yolo, .yolo/, .git/yolo/ -# Later files override earlier. Lists append. Use !replace to override a list. +# +# Config is loaded from these locations (later overrides earlier): +# 0. Package defaults (this file) +# 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 +# +# Run `yo init` to generate a copy of this file in your project. -# Env vars passed into the container +# ── Env vars ──────────────────────────────────────────────── +# Passed into the container. # Bare name = passthrough from host. KEY=VALUE = set explicitly. env: - CLAUDE_CODE_OAUTH_TOKEN - CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS -# Extra bind mounts (shorthand: ~/path, ~/path::ro, or full podman syntax) +# ── 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, bind, skip, error # worktree: ask # NVIDIA GPU passthrough via CDI # nvidia: false -# Container images to build +# ── 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 for the script. +# +# Script search path (later wins): +# /container-extras/ — builtins shipped with yolo +# ~/.config/yolo/container-extras/ — user scripts +# .yolo/container-extras/ — project scripts (committed) +# .git/yolo/container-extras/ — project scripts (local) +# +# Scripts must be self-contained bash. They validate their own +# env vars and fail with a clear message if something is missing. + images: - name: default # from: yolo-base - # or any image: ghcr.io/org/image:tag, docker.io/lib/python:3.12 extras: - name: apt packages: From 2bd34e20b37337432e8ee5663e9d2a53287e1dc6 Mon Sep 17 00:00:00 2001 From: Austin Macdonald Date: Sun, 29 Mar 2026 18:26:00 -0500 Subject: [PATCH 34/55] Rename container-extras to image-extras Co-Authored-By: Claude Opus 4.6 (1M context) --- CLAUDE.md | 6 +++--- CONTRIBUTING.md | 6 +++--- SPEC.md | 12 ++++++------ design/HACK_DECISIONS.md | 2 +- {container-extras => image-extras}/apt.sh | 0 {container-extras => image-extras}/git-delta.sh | 0 {container-extras => image-extras}/python.sh | 0 src/yolo/builder.py | 16 ++++++++-------- src/yolo/defaults/config.yaml | 8 ++++---- tests/test-extras-build.sh | 6 +++--- 10 files changed, 28 insertions(+), 28 deletions(-) rename {container-extras => image-extras}/apt.sh (100%) rename {container-extras => image-extras}/git-delta.sh (100%) rename {container-extras => image-extras}/python.sh (100%) diff --git a/CLAUDE.md b/CLAUDE.md index b1583a7..95d6fe6 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -33,8 +33,8 @@ Entry point is `yo` (temporary, becomes `yolo` at cutover). - `src/yolo/builder.py` — resolves extras, assembles build context, invokes podman - `src/yolo/cli.py` — click CLI (yo build, yo run) - `src/yolo/launcher.py` — assembles podman run command -- `src/yolo/defaults/config.yaml` — default container-extras shipped with package +- `src/yolo/defaults/config.yaml` — default image-extras shipped with package - `images/Containerfile.base` — minimal debian base image -- `images/Containerfile.extras` — layers container-extras on top -- `container-extras/` — composable install scripts (apt.sh, python.sh, etc.) +- `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 index 387c9aa..3084ca2 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -29,9 +29,9 @@ Run manually against all files: pre-commit run --all-files ``` -## Writing container-extras scripts +## Writing image-extras scripts -Scripts live in `container-extras/`. Each script is a self-contained +Scripts live in `image-extras/`. Each script is a self-contained bash installer. Parameters are passed as env vars prefixed with `YOLO_{SCRIPTNAME}_{KEY}`: @@ -46,7 +46,7 @@ sudo apt-get install -y $YOLO_APT_PACKAGES Config references scripts by name: ```yaml -container-extras: +image-extras: - name: apt packages: [zsh, fzf] ``` diff --git a/SPEC.md b/SPEC.md index ef69275..ac1df13 100644 --- a/SPEC.md +++ b/SPEC.md @@ -17,8 +17,8 @@ permission prompts needed. | 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 container-extras on base | -| Scripts | `container-extras/` | Composable install scripts | +| 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 | --- @@ -116,10 +116,10 @@ Becomes: Search path (later wins): -1. `/container-extras/` — builtins -2. `~/.config/yolo/container-extras/` — user -3. `.yolo/container-extras/` — project (committed) -4. `.git/yolo/container-extras/` — project (local) +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 diff --git a/design/HACK_DECISIONS.md b/design/HACK_DECISIONS.md index e40acf7..980a787 100644 --- a/design/HACK_DECISIONS.md +++ b/design/HACK_DECISIONS.md @@ -47,7 +47,7 @@ Every entry uses the `name:` form. All params become env vars prefixed with `YOLO_{NAME}_{KEY}` uppercased: ```yaml -container-extras: +image-extras: - name: apt packages: [zsh, fzf] - name: python diff --git a/container-extras/apt.sh b/image-extras/apt.sh similarity index 100% rename from container-extras/apt.sh rename to image-extras/apt.sh diff --git a/container-extras/git-delta.sh b/image-extras/git-delta.sh similarity index 100% rename from container-extras/git-delta.sh rename to image-extras/git-delta.sh diff --git a/container-extras/python.sh b/image-extras/python.sh similarity index 100% rename from container-extras/python.sh rename to image-extras/python.sh diff --git a/src/yolo/builder.py b/src/yolo/builder.py index 4f9dea3..599f99d 100644 --- a/src/yolo/builder.py +++ b/src/yolo/builder.py @@ -1,4 +1,4 @@ -"""Build container images with container-extras.""" +"""Build container images with image-extras.""" import os import shutil @@ -8,7 +8,7 @@ REPO_ROOT = Path(__file__).resolve().parent.parent.parent CONTAINERFILE_EXTRAS = REPO_ROOT / "images" / "Containerfile.extras" -BUILTIN_EXTRAS = REPO_ROOT / "container-extras" +BUILTIN_EXTRAS = REPO_ROOT / "image-extras" BASE_IMAGE = "yolo-base" @@ -35,22 +35,22 @@ def image_tag(image_name: str) -> str: def _extras_search_path() -> list[Path]: - """Return container-extras directories in precedence order (lowest first).""" + """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" / "container-extras") + paths.append(Path(xdg) / "yolo" / "image-extras") else: - paths.append(Path.home() / ".config" / "yolo" / "container-extras") + paths.append(Path.home() / ".config" / "yolo" / "image-extras") - paths.append(Path.cwd() / ".yolo" / "container-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" / "container-extras") + paths.append(git_dir / "yolo" / "image-extras") return paths @@ -66,7 +66,7 @@ def _resolve_script(name: str, search_path: list[Path]) -> Path | None: def _parse_extra(entry) -> tuple[str, dict[str, str]]: - """Parse a single container-extras entry into (name, env_vars). + """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"}) diff --git a/src/yolo/defaults/config.yaml b/src/yolo/defaults/config.yaml index f2a99a2..a129c75 100644 --- a/src/yolo/defaults/config.yaml +++ b/src/yolo/defaults/config.yaml @@ -53,10 +53,10 @@ env: # Additional keys become YOLO_{NAME}_{KEY} env vars for the script. # # Script search path (later wins): -# /container-extras/ — builtins shipped with yolo -# ~/.config/yolo/container-extras/ — user scripts -# .yolo/container-extras/ — project scripts (committed) -# .git/yolo/container-extras/ — project scripts (local) +# /image-extras/ — builtins shipped with yolo +# ~/.config/yolo/image-extras/ — user scripts +# .yolo/image-extras/ — project scripts (committed) +# .git/yolo/image-extras/ — project scripts (local) # # Scripts must be self-contained bash. They validate their own # env vars and fail with a clear message if something is missing. diff --git a/tests/test-extras-build.sh b/tests/test-extras-build.sh index 5ba8b74..39da58a 100755 --- a/tests/test-extras-build.sh +++ b/tests/test-extras-build.sh @@ -1,5 +1,5 @@ #!/bin/bash -# Test that container-extras build correctly and are idempotent +# Test that image-extras build correctly and are idempotent export PS4='> ' set -x set -eu @@ -10,8 +10,8 @@ trap 'rm -rf $BUILD_CONTEXT' EXIT # Assemble build context mkdir -p "$BUILD_CONTEXT/build/scripts" -cp "$REPO_ROOT/container-extras/apt.sh" "$BUILD_CONTEXT/build/scripts/" -cp "$REPO_ROOT/container-extras/python.sh" "$BUILD_CONTEXT/build/scripts/" +cp "$REPO_ROOT/image-extras/apt.sh" "$BUILD_CONTEXT/build/scripts/" +cp "$REPO_ROOT/image-extras/python.sh" "$BUILD_CONTEXT/build/scripts/" cat > "$BUILD_CONTEXT/build/run.sh" << 'EOF' #!/bin/bash set -eu From 36eceba9126bafcece8730751eed75633a6d2f76 Mon Sep 17 00:00:00 2001 From: Austin Macdonald Date: Sun, 29 Mar 2026 18:27:51 -0500 Subject: [PATCH 35/55] Separate integration tests: marker + directory - tests/integration/ for slow tests needing podman - pytest marker 'integration' auto-applied via conftest - Default: pytest -m 'not integration' skips them - Move test-extras-build.sh to integration/ Co-Authored-By: Claude Opus 4.6 (1M context) --- pyproject.toml | 3 +++ tests/integration/__init__.py | 0 tests/integration/conftest.py | 9 +++++++++ tests/{ => integration}/test-extras-build.sh | 0 4 files changed, 12 insertions(+) create mode 100644 tests/integration/__init__.py create mode 100644 tests/integration/conftest.py rename tests/{ => integration}/test-extras-build.sh (100%) diff --git a/pyproject.toml b/pyproject.toml index 54f55af..2fa521f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -26,3 +26,6 @@ packages = ["src/yolo"] [tool.pytest.ini_options] testpaths = ["tests"] +markers = [ + "integration: slow tests that need podman (deselect with -m 'not integration')", +] diff --git a/tests/integration/__init__.py b/tests/integration/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/integration/conftest.py b/tests/integration/conftest.py new file mode 100644 index 0000000..31e40ab --- /dev/null +++ b/tests/integration/conftest.py @@ -0,0 +1,9 @@ +"""Mark all tests in this directory as integration tests.""" + +import pytest + + +def pytest_collection_modifyitems(items): + for item in items: + if "integration" in str(item.fspath): + item.add_marker(pytest.mark.integration) diff --git a/tests/test-extras-build.sh b/tests/integration/test-extras-build.sh similarity index 100% rename from tests/test-extras-build.sh rename to tests/integration/test-extras-build.sh From a31a20bc116ee672d030981f60ac23f4581c314c Mon Sep 17 00:00:00 2001 From: Austin Macdonald Date: Sun, 29 Mar 2026 19:06:52 -0500 Subject: [PATCH 36/55] Add YOLO_VERIFY mode to extras scripts + integration tests Scripts support YOLO_VERIFY=1: set test defaults, install, verify. Integration tests build through Containerfile.extras (real path), test both verify and idempotency for all scripts. - apt.sh: installs figlet, verifies command exists - python.sh: installs 3.12, verifies python/python3 symlinks - git-delta.sh: installs 0.18.2, verifies delta command Co-Authored-By: Claude Opus 4.6 (1M context) --- image-extras/apt.sh | 12 ++- image-extras/git-delta.sh | 12 ++- image-extras/python.sh | 23 +++-- tests/integration/test_image_extras.py | 135 +++++++++++++++++++++++++ 4 files changed, 172 insertions(+), 10 deletions(-) create mode 100644 tests/integration/test_image_extras.py diff --git a/image-extras/apt.sh b/image-extras/apt.sh index 562cfb5..dec972b 100644 --- a/image-extras/apt.sh +++ b/image-extras/apt.sh @@ -2,6 +2,16 @@ # Install apt packages # Env: YOLO_APT_PACKAGES (space-separated, required) set -eu -[ -z "${YOLO_APT_PACKAGES:-}" ] && { echo "apt.sh: YOLO_APT_PACKAGES required"; exit 1; } + +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/git-delta.sh b/image-extras/git-delta.sh index 9a8a235..326e075 100644 --- a/image-extras/git-delta.sh +++ b/image-extras/git-delta.sh @@ -1,11 +1,19 @@ #!/bin/bash # Install git-delta from GitHub releases # Env: YOLO_GIT_DELTA_VERSION (required) -# TODO: determine latest from GitHub API if version not provided set -eu -[ -z "${YOLO_GIT_DELTA_VERSION:-}" ] && { echo "git-delta.sh: YOLO_GIT_DELTA_VERSION required"; exit 1; } + +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/python.sh b/image-extras/python.sh index c1f7de7..2a5c8bc 100644 --- a/image-extras/python.sh +++ b/image-extras/python.sh @@ -3,22 +3,31 @@ # 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" + 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" + uv python install "$YOLO_PYTHON_VERSION" else - uv python install + 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" + 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/tests/integration/test_image_extras.py b/tests/integration/test_image_extras.py new file mode 100644 index 0000000..cb2442c --- /dev/null +++ b/tests/integration/test_image_extras.py @@ -0,0 +1,135 @@ +"""Integration tests for image-extras scripts. + +Each script in image-extras/ must support YOLO_VERIFY=1 mode. +Tests build an image through the normal Containerfile.extras path, +then verify inside the built image. +""" + +import shutil +import subprocess +from pathlib import Path + +import pytest + +from yolo.builder import assemble_build_context + +REPO_ROOT = Path(__file__).resolve().parent.parent.parent +IMAGE_EXTRAS_DIR = REPO_ROOT / "image-extras" +CONTAINERFILE_EXTRAS = REPO_ROOT / "images" / "Containerfile.extras" +BASE_IMAGE = "yolo-base" + + +def _get_extra_scripts(): + return sorted(IMAGE_EXTRAS_DIR.glob("*.sh")) + + +def _script_ids(): + return [s.stem for s in _get_extra_scripts()] + + +def _verify_config(script): + """Build a config entry that runs the script in verify mode.""" + return [{"name": script.stem}] + + +@pytest.fixture(scope="module", autouse=True) +def ensure_base_image(): + result = subprocess.run( + ["podman", "image", "exists", BASE_IMAGE], + capture_output=True, + ) + if result.returncode != 0: + subprocess.run( + [ + "podman", + "build", + "-f", + str(REPO_ROOT / "images" / "Containerfile.base"), + "-t", + BASE_IMAGE, + str(REPO_ROOT / "images"), + ], + check=True, + ) + + +def _build_verify_image(script, tag): + """Build an image with a single script via Containerfile.extras.""" + build_dir = assemble_build_context(_verify_config(script)) + try: + # Inject YOLO_VERIFY into run.sh + run_sh = build_dir / "build" / "run.sh" + content = run_sh.read_text() + content = content.replace("set -eu", "set -eu\nexport YOLO_VERIFY=1") + run_sh.write_text(content) + + subprocess.run( + [ + "podman", + "build", + "-f", + str(CONTAINERFILE_EXTRAS), + "-t", + tag, + str(build_dir), + ], + check=True, + capture_output=True, + text=True, + ) + finally: + shutil.rmtree(build_dir) + + +@pytest.mark.integration +@pytest.mark.parametrize("script", _get_extra_scripts(), ids=_script_ids()) +def test_verify_mode(script): + """Build image with script in verify mode via Containerfile.extras.""" + tag = f"yolo-verify-{script.stem}" + try: + _build_verify_image(script, tag) + except subprocess.CalledProcessError as e: + pytest.fail(f"{script.name} verify failed:\n{e.stdout}\n{e.stderr}") + finally: + subprocess.run(["podman", "rmi", tag], capture_output=True) + + +@pytest.mark.integration +@pytest.mark.parametrize("script", _get_extra_scripts(), ids=_script_ids()) +def test_idempotent(script): + """Build image with script, then build again on top — should succeed.""" + tag1 = f"yolo-idem1-{script.stem}" + tag2 = f"yolo-idem2-{script.stem}" + try: + _build_verify_image(script, tag1) + + # Build again on top of the first image + build_dir = assemble_build_context(_verify_config(script)) + try: + run_sh = build_dir / "build" / "run.sh" + content = run_sh.read_text() + content = content.replace("set -eu", "set -eu\nexport YOLO_VERIFY=1") + run_sh.write_text(content) + + result = subprocess.run( + [ + "podman", + "build", + "--build-arg", + f"BASE_IMAGE={tag1}", + "-f", + str(CONTAINERFILE_EXTRAS), + "-t", + tag2, + str(build_dir), + ], + capture_output=True, + text=True, + ) + assert result.returncode == 0, ( + f"{script.name} not idempotent:\n{result.stdout}\n{result.stderr}" + ) + finally: + shutil.rmtree(build_dir) + finally: + subprocess.run(["podman", "rmi", tag1, tag2], capture_output=True) From 8ff8a2b228a668ef98ddf0910c2bce61f3477839 Mon Sep 17 00:00:00 2001 From: Austin Macdonald Date: Sun, 29 Mar 2026 19:17:12 -0500 Subject: [PATCH 37/55] Update features_to_test.md: mark done, add integration TODOs Co-Authored-By: Claude Opus 4.6 (1M context) --- tests/features_to_test.md | 29 ++++++++++++++++------------- 1 file changed, 16 insertions(+), 13 deletions(-) diff --git a/tests/features_to_test.md b/tests/features_to_test.md index 6ed34bf..c538adb 100644 --- a/tests/features_to_test.md +++ b/tests/features_to_test.md @@ -1,17 +1,20 @@ # Features to test -## container-extras -- [ ] extras-build to pytest not bash -- [ ] apt.sh: installs packages, idempotent -- [ ] python.sh: installs python, creates python/python3 symlinks, idempotent -- [ ] python.sh: works with version arg and without -- [ ] python.sh: symlinks point to correct version when specified +## container-extras (done) +- [x] apt.sh: installs packages, verify mode +- [x] python.sh: installs python, creates symlinks, verify mode +- [x] git-delta.sh: installs delta, verify mode +- [x] all scripts: idempotent -## Containerfile.base -- [ ] builds clean from scratch -- [ ] claude is on PATH and runnable +## Containerfile.base (done) +- [x] builds clean from scratch +- [x] claude is on PATH and runnable -## Containerfile.extras -- [ ] builds with empty run.sh (vanilla) -- [ ] builds with apt + python extras -- [ ] idempotent rebuild produces working image +## Integration TODO +- [ ] yo build → yo run --entrypoint bash -c "python --version" +- [ ] volumes actually mount (write on host, read in container) +- [ ] env vars arrive inside container +- [ ] image tagged correctly in podman images +- [ ] claude --help exits 0 inside image +- [ ] --no-config changes build behavior +- [ ] worktree detection in real git worktree From 1ef25d63024b9c18b575f2169f98256c162a6366 Mon Sep 17 00:00:00 2001 From: Austin Macdonald Date: Sun, 29 Mar 2026 19:26:40 -0500 Subject: [PATCH 38/55] Improve build output: summary before build, PS4 tracing - Print image name, base, and extras with source paths before building - run.sh uses PS4='+ [yolo] ' and set -eux for clear trace output - Echo ==> script_name before each extras script Co-Authored-By: Claude Opus 4.6 (1M context) --- src/yolo/builder.py | 17 ++++++++++++++--- 1 file changed, 14 insertions(+), 3 deletions(-) diff --git a/src/yolo/builder.py b/src/yolo/builder.py index 599f99d..3a34372 100644 --- a/src/yolo/builder.py +++ b/src/yolo/builder.py @@ -104,7 +104,7 @@ def assemble_build_context(extras_config: list) -> Path: scripts_dir = build_dir / "build" / "scripts" scripts_dir.mkdir(parents=True) - run_lines = ["#!/bin/bash", "set -eu"] + run_lines = ["#!/bin/bash", "export PS4='+ [yolo] '", "set -eux"] for entry in extras_config: name, env_vars = _parse_extra(entry) @@ -120,6 +120,7 @@ def assemble_build_context(extras_config: list) -> Path: 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") @@ -190,6 +191,17 @@ def build_image(image_entry: dict, images_config: list | None = None) -> str: 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) @@ -205,9 +217,8 @@ def build_image(image_entry: dict, images_config: list | None = None) -> str: tag, str(build_dir), ] - print(f"Building {tag}...") subprocess.run(cmd, check=True) - print(f"Built {tag}") + print(f"\n Built {tag}\n") finally: shutil.rmtree(build_dir) From aaf67b7cd06c6f3babd3694193158fd61c160bcf Mon Sep 17 00:00:00 2001 From: Austin Macdonald Date: Sun, 29 Mar 2026 19:49:55 -0500 Subject: [PATCH 39/55] Split defaults from template: yo init copies commented template - config.yaml: real defaults loaded by code (no comments) - config.template.yaml: all commented, copied by yo init - Template is documentation, defaults are functional Co-Authored-By: Claude Opus 4.6 (1M context) --- .yolo/config.yaml | 79 +++++++++ docs/tutorial.md | 234 +++++++++++++++++++++++++ src/yolo/cli.py | 6 +- src/yolo/defaults/config.template.yaml | 67 +++++++ src/yolo/defaults/config.yaml | 59 ------- 5 files changed, 384 insertions(+), 61 deletions(-) create mode 100644 .yolo/config.yaml create mode 100644 docs/tutorial.md create mode 100644 src/yolo/defaults/config.template.yaml diff --git a/.yolo/config.yaml b/.yolo/config.yaml new file mode 100644 index 0000000..a129c75 --- /dev/null +++ b/.yolo/config.yaml @@ -0,0 +1,79 @@ +--- +# yolo configuration +# +# Config is loaded from these locations (later overrides earlier): +# 0. Package defaults (this file) +# 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 +# +# Run `yo init` to generate a copy of this file in your project. + +# ── Env vars ──────────────────────────────────────────────── +# Passed into the container. +# Bare name = passthrough from host. KEY=VALUE = set explicitly. +env: + - CLAUDE_CODE_OAUTH_TOKEN + - CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS + +# ── 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, 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 for the script. +# +# Script search path (later wins): +# /image-extras/ — builtins shipped with yolo +# ~/.config/yolo/image-extras/ — user scripts +# .yolo/image-extras/ — project scripts (committed) +# .git/yolo/image-extras/ — project scripts (local) +# +# Scripts must be self-contained bash. They validate their own +# env vars and fail with a clear message if something is missing. + +images: + - name: default + # from: yolo-base + 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/docs/tutorial.md b/docs/tutorial.md new file mode 100644 index 0000000..89c86da --- /dev/null +++ b/docs/tutorial.md @@ -0,0 +1,234 @@ +# yolo tutorial — Python rewrite + +You know the bash yolo. This is the same idea — Claude Code in a rootless +Podman container with `--dangerously-skip-permissions` — rebuilt as a +Python package with YAML config instead of sourced shell scripts. + +## 1. Install + +```bash +cd /path/to/yolo +uv venv .venv && source .venv/bin/activate +uv pip install -e ".[dev]" +``` + +The entry point is `yo` (becomes `yolo` at cutover). + +## 2. yo init — generate a config + +CLAUDE WE JUST CHANGED INIT REGENERATE THIS SECTION + +```bash +cd ~/my-project +yo init +``` + + +Creates `.yolo/config.yaml` from the built-in template: + +CLAUDE BUT SHOWING THE DEFAULT YAML IS STILL COOL + +```yaml +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" +``` + +Other targets: `yo init --user` (`~/.config/yolo/`), +`yo init --local` (`.git/yolo/`, untracked). + +## 3. yo build — build the image + +```bash +yo build +``` + +Output shows what's about to happen before the build starts: + +``` + Image: yolo-my-project-default + Base: yolo-base + Extras: + - apt (/path/to/yolo/image-extras) + - git-delta (/path/to/yolo/image-extras) + - python (/path/to/yolo/image-extras) +``` + +First build also builds `yolo-base` (debian + Claude Code + tini). + +## 4. yo run — launch Claude + +```bash +yo run +``` + +That's it. You're in a container running +`claude --dangerously-skip-permissions`. Workspace is bind-mounted rw. + +Pass args through to Claude: + +```bash +yo run -- --model sonnet +``` +CLAUDE WE HAVE A BUILTINS SHOW HOW TO USE THOSE FIRST AND INTRODUCE CUSTOM SCRIPTS LATER +WHEN YOU DO YOU CAN ALSO CASUALLY MENTION THAT IF GENERALLY USEFUL CONSIDER ADDING IT TO YOLO + +## 5. Customize: add an extras script + +Extras are self-contained bash scripts. Add one to your project: + +```bash +mkdir -p .yolo/image-extras +``` +CLAUDE WE HAVE AN APT BUILTIN FOR CUSTOM SHOW SOMETHING NOT APT, MAYBE EVEN NOT AN INSTALL IDK + +```bash +cat > .yolo/image-extras/datalad.sh << 'EOF' +#!/bin/bash +# Install datalad +set -eu +sudo apt-get install -y --no-install-recommends datalad +EOF +``` + +Add it to your config: + +```yaml +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" + - name: datalad +``` + +## 6. Rebuild and verify + +```bash +yo build +yo run --entrypoint bash +``` + +`--entrypoint` overrides the container command (skips +`--dangerously-skip-permissions`). Poke around: + +```bash +which datalad +datalad --version +exit +``` + +CLAUDE LETS MAKE A SHORT SECTION THATS LIKE HEY ITS AT PARITY THESE OTHER FEATURES STILL WORK + +## 7. Volume shorthand + +Add extra bind mounts in config: + +```yaml +volumes: + - ~/data # → ~/data:~/data:z + - ~/datasets::ro # → ~/datasets:~/datasets:ro + - /scratch:/scratch # → /scratch:/scratch:z +``` + +Or on the command line: + +```bash +yo run -v ~/data -v /scratch:/scratch +``` + +## 8. NVIDIA GPU passthrough + +In config: + +```yaml +nvidia: true +``` + +Or per-run: + +```bash +yo run --nvidia +``` + +Requires NVIDIA CDI spec (`sudo nvidia-ctk cdi generate --output=/etc/cdi/nvidia.yaml`). + +## 9. Git worktree handling + +If you run from a git worktree, yolo can bind-mount the original repo +so git operations work inside the container. + +```yaml +worktree: bind # always mount original repo +# worktree: ask # prompt (default) +# worktree: skip # ignore worktree +# worktree: error # refuse to run +``` + +Or per-run: + +```bash +yo run --worktree bind +``` + +## 10. Key differences from legacy + +| Legacy (bash) | Rewrite (Python) | +|---|---| +| Sourced shell config | YAML config — declarative, no injection risk | +| Single hardcoded image | Named images with `from:` composition | +| Extras baked into Containerfile | Composable scripts, 4-level search path | +| Flags in env vars | Proper CLI (`yo build`, `yo run`) | +| One config location | 5-layer merge (package → system → user → project → local) | + +### Config layering + +Later layers override earlier ones. Lists append, dicts merge, scalars replace. + +``` +0. Package defaults (built-in) +1. /etc/yolo/config.yaml — org-wide +2. ~/.config/yolo/config.yaml — your preferences +3. .yolo/config.yaml — project (committed) +4. .git/yolo/config.yaml — project (local, untracked) +``` + +### Image composition with `from:` + +Stack images using Podman's native `FROM`: + +```yaml +images: + - name: default + extras: + - name: apt + packages: [zsh, fzf] + - name: python + version: "3.12" + + - name: heavy + from: yolo-my-project-default + extras: + - name: cuda +``` + +Build a specific image: + +```bash +yo build --image heavy +yo run --image heavy +``` diff --git a/src/yolo/cli.py b/src/yolo/cli.py index b06422c..c4d56b5 100644 --- a/src/yolo/cli.py +++ b/src/yolo/cli.py @@ -6,9 +6,11 @@ import click from yolo.builder import build as builder_build -from yolo.config import DEFAULTS_CONFIG, load_config +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( @@ -67,7 +69,7 @@ def init(target, custom_path): return dest.parent.mkdir(parents=True, exist_ok=True) - shutil.copy2(DEFAULTS_CONFIG, dest) + shutil.copy2(CONFIG_TEMPLATE, dest) click.echo(f"Created {dest}") 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 index a129c75..9c1a30b 100644 --- a/src/yolo/defaults/config.yaml +++ b/src/yolo/defaults/config.yaml @@ -1,69 +1,10 @@ --- -# yolo configuration -# -# Config is loaded from these locations (later overrides earlier): -# 0. Package defaults (this file) -# 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 -# -# Run `yo init` to generate a copy of this file in your project. - -# ── Env vars ──────────────────────────────────────────────── -# Passed into the container. -# Bare name = passthrough from host. KEY=VALUE = set explicitly. env: - CLAUDE_CODE_OAUTH_TOKEN - CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS -# ── 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, 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 for the script. -# -# Script search path (later wins): -# /image-extras/ — builtins shipped with yolo -# ~/.config/yolo/image-extras/ — user scripts -# .yolo/image-extras/ — project scripts (committed) -# .git/yolo/image-extras/ — project scripts (local) -# -# Scripts must be self-contained bash. They validate their own -# env vars and fail with a clear message if something is missing. - images: - name: default - # from: yolo-base extras: - name: apt packages: From 6977e843b17414b64168a7e879c07fd4084341e4 Mon Sep 17 00:00:00 2001 From: Austin Macdonald Date: Sun, 29 Mar 2026 19:50:31 -0500 Subject: [PATCH 40/55] Remove accidentally committed .yolo/ and docs/tutorial.md MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Gitignore .yolo/ — it's per-project config, not repo content. Co-Authored-By: Claude Opus 4.6 (1M context) --- .gitignore | 1 + .yolo/config.yaml | 79 ---------------- docs/tutorial.md | 234 ---------------------------------------------- 3 files changed, 1 insertion(+), 313 deletions(-) delete mode 100644 .yolo/config.yaml delete mode 100644 docs/tutorial.md diff --git a/.gitignore b/.gitignore index e91d481..6673fe4 100644 --- a/.gitignore +++ b/.gitignore @@ -2,3 +2,4 @@ __pycache__/ *.egg-info/ .local-notes/ +.yolo/ diff --git a/.yolo/config.yaml b/.yolo/config.yaml deleted file mode 100644 index a129c75..0000000 --- a/.yolo/config.yaml +++ /dev/null @@ -1,79 +0,0 @@ ---- -# yolo configuration -# -# Config is loaded from these locations (later overrides earlier): -# 0. Package defaults (this file) -# 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 -# -# Run `yo init` to generate a copy of this file in your project. - -# ── Env vars ──────────────────────────────────────────────── -# Passed into the container. -# Bare name = passthrough from host. KEY=VALUE = set explicitly. -env: - - CLAUDE_CODE_OAUTH_TOKEN - - CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS - -# ── 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, 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 for the script. -# -# Script search path (later wins): -# /image-extras/ — builtins shipped with yolo -# ~/.config/yolo/image-extras/ — user scripts -# .yolo/image-extras/ — project scripts (committed) -# .git/yolo/image-extras/ — project scripts (local) -# -# Scripts must be self-contained bash. They validate their own -# env vars and fail with a clear message if something is missing. - -images: - - name: default - # from: yolo-base - 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/docs/tutorial.md b/docs/tutorial.md deleted file mode 100644 index 89c86da..0000000 --- a/docs/tutorial.md +++ /dev/null @@ -1,234 +0,0 @@ -# yolo tutorial — Python rewrite - -You know the bash yolo. This is the same idea — Claude Code in a rootless -Podman container with `--dangerously-skip-permissions` — rebuilt as a -Python package with YAML config instead of sourced shell scripts. - -## 1. Install - -```bash -cd /path/to/yolo -uv venv .venv && source .venv/bin/activate -uv pip install -e ".[dev]" -``` - -The entry point is `yo` (becomes `yolo` at cutover). - -## 2. yo init — generate a config - -CLAUDE WE JUST CHANGED INIT REGENERATE THIS SECTION - -```bash -cd ~/my-project -yo init -``` - - -Creates `.yolo/config.yaml` from the built-in template: - -CLAUDE BUT SHOWING THE DEFAULT YAML IS STILL COOL - -```yaml -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" -``` - -Other targets: `yo init --user` (`~/.config/yolo/`), -`yo init --local` (`.git/yolo/`, untracked). - -## 3. yo build — build the image - -```bash -yo build -``` - -Output shows what's about to happen before the build starts: - -``` - Image: yolo-my-project-default - Base: yolo-base - Extras: - - apt (/path/to/yolo/image-extras) - - git-delta (/path/to/yolo/image-extras) - - python (/path/to/yolo/image-extras) -``` - -First build also builds `yolo-base` (debian + Claude Code + tini). - -## 4. yo run — launch Claude - -```bash -yo run -``` - -That's it. You're in a container running -`claude --dangerously-skip-permissions`. Workspace is bind-mounted rw. - -Pass args through to Claude: - -```bash -yo run -- --model sonnet -``` -CLAUDE WE HAVE A BUILTINS SHOW HOW TO USE THOSE FIRST AND INTRODUCE CUSTOM SCRIPTS LATER -WHEN YOU DO YOU CAN ALSO CASUALLY MENTION THAT IF GENERALLY USEFUL CONSIDER ADDING IT TO YOLO - -## 5. Customize: add an extras script - -Extras are self-contained bash scripts. Add one to your project: - -```bash -mkdir -p .yolo/image-extras -``` -CLAUDE WE HAVE AN APT BUILTIN FOR CUSTOM SHOW SOMETHING NOT APT, MAYBE EVEN NOT AN INSTALL IDK - -```bash -cat > .yolo/image-extras/datalad.sh << 'EOF' -#!/bin/bash -# Install datalad -set -eu -sudo apt-get install -y --no-install-recommends datalad -EOF -``` - -Add it to your config: - -```yaml -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" - - name: datalad -``` - -## 6. Rebuild and verify - -```bash -yo build -yo run --entrypoint bash -``` - -`--entrypoint` overrides the container command (skips -`--dangerously-skip-permissions`). Poke around: - -```bash -which datalad -datalad --version -exit -``` - -CLAUDE LETS MAKE A SHORT SECTION THATS LIKE HEY ITS AT PARITY THESE OTHER FEATURES STILL WORK - -## 7. Volume shorthand - -Add extra bind mounts in config: - -```yaml -volumes: - - ~/data # → ~/data:~/data:z - - ~/datasets::ro # → ~/datasets:~/datasets:ro - - /scratch:/scratch # → /scratch:/scratch:z -``` - -Or on the command line: - -```bash -yo run -v ~/data -v /scratch:/scratch -``` - -## 8. NVIDIA GPU passthrough - -In config: - -```yaml -nvidia: true -``` - -Or per-run: - -```bash -yo run --nvidia -``` - -Requires NVIDIA CDI spec (`sudo nvidia-ctk cdi generate --output=/etc/cdi/nvidia.yaml`). - -## 9. Git worktree handling - -If you run from a git worktree, yolo can bind-mount the original repo -so git operations work inside the container. - -```yaml -worktree: bind # always mount original repo -# worktree: ask # prompt (default) -# worktree: skip # ignore worktree -# worktree: error # refuse to run -``` - -Or per-run: - -```bash -yo run --worktree bind -``` - -## 10. Key differences from legacy - -| Legacy (bash) | Rewrite (Python) | -|---|---| -| Sourced shell config | YAML config — declarative, no injection risk | -| Single hardcoded image | Named images with `from:` composition | -| Extras baked into Containerfile | Composable scripts, 4-level search path | -| Flags in env vars | Proper CLI (`yo build`, `yo run`) | -| One config location | 5-layer merge (package → system → user → project → local) | - -### Config layering - -Later layers override earlier ones. Lists append, dicts merge, scalars replace. - -``` -0. Package defaults (built-in) -1. /etc/yolo/config.yaml — org-wide -2. ~/.config/yolo/config.yaml — your preferences -3. .yolo/config.yaml — project (committed) -4. .git/yolo/config.yaml — project (local, untracked) -``` - -### Image composition with `from:` - -Stack images using Podman's native `FROM`: - -```yaml -images: - - name: default - extras: - - name: apt - packages: [zsh, fzf] - - name: python - version: "3.12" - - - name: heavy - from: yolo-my-project-default - extras: - - name: cuda -``` - -Build a specific image: - -```bash -yo build --image heavy -yo run --image heavy -``` From b574109407db1ee46859c58a99e950ba3e21a2ef Mon Sep 17 00:00:00 2001 From: Austin Macdonald Date: Sun, 29 Mar 2026 20:08:35 -0500 Subject: [PATCH 41/55] Replace yo verify with yo build --verify MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Build with YOLO_VERIFY=1 injected into run.sh — scripts install AND verify in the same build pass. No separate command needed. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/yolo/builder.py | 14 +++++++++----- src/yolo/cli.py | 5 +++-- 2 files changed, 12 insertions(+), 7 deletions(-) diff --git a/src/yolo/builder.py b/src/yolo/builder.py index 3a34372..dfe7cb6 100644 --- a/src/yolo/builder.py +++ b/src/yolo/builder.py @@ -93,7 +93,7 @@ def _parse_extra(entry) -> tuple[str, dict[str, str]]: return (name, env_vars) -def assemble_build_context(extras_config: list) -> Path: +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. @@ -105,6 +105,8 @@ def assemble_build_context(extras_config: list) -> Path: 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) @@ -180,7 +182,9 @@ def _ensure_base(base: str, images_config: list) -> None: raise RuntimeError(f"Base image '{base}' not found and not defined in config") -def build_image(image_entry: dict, images_config: list | None = None) -> str: +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", []) @@ -204,7 +208,7 @@ def build_image(image_entry: dict, images_config: list | None = None) -> str: _ensure_base(base, images_config or []) - build_dir = assemble_build_context(extras) + build_dir = assemble_build_context(extras, verify=verify) try: cmd = [ "podman", @@ -225,7 +229,7 @@ def build_image(image_entry: dict, images_config: list | None = None) -> str: return tag -def build(images_config: list, only: str | None = None) -> None: +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.") @@ -235,4 +239,4 @@ def build(images_config: list, only: str | None = None) -> None: name = entry.get("name", "default") if only and name != only: continue - build_image(entry, images_config) + build_image(entry, images_config, verify=verify) diff --git a/src/yolo/cli.py b/src/yolo/cli.py index c4d56b5..6d4efba 100644 --- a/src/yolo/cli.py +++ b/src/yolo/cli.py @@ -25,12 +25,13 @@ def main(ctx, 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): +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) + builder_build(images, only=image, verify=verify) @main.command() From 0500612255d9af74958b681cf4db7cead7a9e721 Mon Sep 17 00:00:00 2001 From: Austin Macdonald Date: Sun, 29 Mar 2026 21:59:49 -0500 Subject: [PATCH 42/55] Auto-build image on yo run if it doesn't exist Checks `podman image exists` before launching. If the image is missing, runs build() automatically instead of falling through to podman's registry search. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/yolo/launcher.py | 10 ++++++++-- tests/test_launcher.py | 37 +++++++++++++++++++++++++++++-------- 2 files changed, 37 insertions(+), 10 deletions(-) diff --git a/src/yolo/launcher.py b/src/yolo/launcher.py index e8a9344..f8511ad 100644 --- a/src/yolo/launcher.py +++ b/src/yolo/launcher.py @@ -6,7 +6,7 @@ import sys from pathlib import Path -from yolo.builder import image_tag +from yolo.builder import build, image_tag from yolo.config import load_config @@ -151,6 +151,12 @@ def run( name = "".join(c if c.isalnum() or c in "._-" else "_" for c in name) name = name.lstrip("._") + tag = image_tag(image_name or "default") + result = subprocess.run(["podman", "image", "exists", tag], capture_output=True) + if result.returncode != 0: + print(f"Image {tag} not found, building...", file=sys.stderr) + build(config.get("images", []), only=image_name) + config_volumes = config.get("volumes", []) cmd = [ @@ -180,7 +186,7 @@ def run( "-e", "GIT_CONFIG_GLOBAL=/tmp/.gitconfig", *_build_env_args(config.get("env", [])), - image_tag(image_name or "default"), + tag, ] # TODO: make dangerously_skip_permissions a separate config value diff --git a/tests/test_launcher.py b/tests/test_launcher.py index 5a56f2e..c201040 100644 --- a/tests/test_launcher.py +++ b/tests/test_launcher.py @@ -54,8 +54,14 @@ def test_multiple(self): assert result == ["-v", "/a:/b:z", "-v", "/c:/d:ro,z"] +def _sub_run_image_exists(cmd, **kw): + """Mock subprocess.run: return success for 'podman image exists'.""" + if cmd[:2] == ["podman", "image"]: + return type("R", (), {"returncode": 0})() + + class TestRun: - @patch("yolo.launcher.subprocess.run") + @patch("yolo.launcher.subprocess.run", side_effect=_sub_run_image_exists) @patch("yolo.launcher.load_config", return_value={}) def test_basic_command(self, mock_config, mock_run): run() @@ -67,7 +73,7 @@ def test_basic_command(self, mock_config, mock_run): assert "--dangerously-skip-permissions" in cmd assert "yolo-test-default" in cmd - @patch("yolo.launcher.subprocess.run") + @patch("yolo.launcher.subprocess.run", side_effect=_sub_run_image_exists) @patch("yolo.launcher.load_config", return_value={}) def test_claude_args_passed(self, mock_config, mock_run): run(claude_args=["--resume"]) @@ -76,7 +82,7 @@ def test_claude_args_passed(self, mock_config, mock_run): idx = cmd.index("--dangerously-skip-permissions") assert cmd[idx + 1] == "--resume" - @patch("yolo.launcher.subprocess.run") + @patch("yolo.launcher.subprocess.run", side_effect=_sub_run_image_exists) @patch("yolo.launcher.load_config", return_value={}) def test_extra_volumes(self, mock_config, mock_run): run(extra_volumes=["/data:/data:z"]) @@ -84,7 +90,7 @@ def test_extra_volumes(self, mock_config, mock_run): assert "-v" in cmd assert "/data:/data:z" in cmd - @patch("yolo.launcher.subprocess.run") + @patch("yolo.launcher.subprocess.run", side_effect=_sub_run_image_exists) @patch( "yolo.launcher.load_config", return_value={"volumes": ["/cfg:/cfg:ro,z"]}, @@ -94,7 +100,7 @@ def test_config_volumes(self, mock_config, mock_run): cmd = mock_run.call_args[0][0] assert "/cfg:/cfg:ro,z" in cmd - @patch("yolo.launcher.subprocess.run") + @patch("yolo.launcher.subprocess.run", side_effect=_sub_run_image_exists) @patch( "yolo.launcher.load_config", return_value={"volumes": ["/cfg:/cfg:z"]}, @@ -105,7 +111,7 @@ def test_config_and_extra_volumes(self, mock_config, mock_run): assert "/cfg:/cfg:z" in cmd assert "/cli:/cli:z" in cmd - @patch("yolo.launcher.subprocess.run") + @patch("yolo.launcher.subprocess.run", side_effect=_sub_run_image_exists) @patch("yolo.launcher.load_config", return_value={}) def test_custom_entrypoint(self, mock_config, mock_run): run(entrypoint="bash") @@ -114,7 +120,7 @@ def test_custom_entrypoint(self, mock_config, mock_run): assert "claude" not in cmd assert "--dangerously-skip-permissions" not in cmd - @patch("yolo.launcher.subprocess.run") + @patch("yolo.launcher.subprocess.run", side_effect=_sub_run_image_exists) @patch("yolo.launcher.load_config", return_value={}) def test_custom_entrypoint_with_args(self, mock_config, mock_run): run(entrypoint="bash", claude_args=["-c", "echo hi"]) @@ -123,7 +129,7 @@ def test_custom_entrypoint_with_args(self, mock_config, mock_run): assert cmd[idx + 1 : idx + 3] == ["-c", "echo hi"] @patch("yolo.launcher.image_tag") - @patch("yolo.launcher.subprocess.run") + @patch("yolo.launcher.subprocess.run", side_effect=_sub_run_image_exists) @patch("yolo.launcher.load_config", return_value={}) def test_image_name_passed(self, mock_config, mock_run, mock_tag): mock_tag.return_value = "yolo-myproject-heavy" @@ -132,6 +138,21 @@ def test_image_name_passed(self, mock_config, mock_run, mock_tag): cmd = mock_run.call_args[0][0] assert "yolo-myproject-heavy" in cmd + @patch("yolo.launcher.build") + @patch("yolo.launcher.subprocess.run") + @patch("yolo.launcher.load_config", return_value={"images": [{"name": "default"}]}) + def test_auto_build_when_image_missing(self, mock_config, mock_run, mock_build): + mock_run.return_value = type("R", (), {"returncode": 1})() + run() + mock_build.assert_called_once_with([{"name": "default"}], only=None) + + @patch("yolo.launcher.build") + @patch("yolo.launcher.subprocess.run", side_effect=_sub_run_image_exists) + @patch("yolo.launcher.load_config", return_value={}) + def test_no_build_when_image_exists(self, mock_config, mock_run, mock_build): + run() + mock_build.assert_not_called() + class TestNvidiaArgs: def test_disabled(self): From 86a21db2ef5c1c66c0e9b726e0c15eda4ba26f23 Mon Sep 17 00:00:00 2001 From: Austin Macdonald Date: Sun, 29 Mar 2026 22:01:03 -0500 Subject: [PATCH 43/55] Add context injection via --append-system-prompt Composes `context` list from config and passes it to Claude as a single --append-system-prompt arg. Config layers append as usual, so defaults, user, and project context all combine. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/yolo/launcher.py | 12 +++++++++++- tests/test_launcher.py | 30 ++++++++++++++++++++++++++++++ 2 files changed, 41 insertions(+), 1 deletion(-) diff --git a/src/yolo/launcher.py b/src/yolo/launcher.py index f8511ad..d53c298 100644 --- a/src/yolo/launcher.py +++ b/src/yolo/launcher.py @@ -189,11 +189,21 @@ def run( tag, ] + context_lines = config.get("context", []) + context_args = [] + if context_lines: + context_args = ["--append-system-prompt", "\n".join(context_lines)] + # TODO: make dangerously_skip_permissions a separate config value # so --entrypoint claude doesn't automatically get it if entrypoint: cmd += [entrypoint, *(claude_args or [])] else: - cmd += ["claude", "--dangerously-skip-permissions", *(claude_args or [])] + cmd += [ + "claude", + "--dangerously-skip-permissions", + *context_args, + *(claude_args or []), + ] subprocess.run(cmd) diff --git a/tests/test_launcher.py b/tests/test_launcher.py index c201040..a41b4d8 100644 --- a/tests/test_launcher.py +++ b/tests/test_launcher.py @@ -153,6 +153,36 @@ def test_no_build_when_image_exists(self, mock_config, mock_run, mock_build): run() mock_build.assert_not_called() + @patch("yolo.launcher.subprocess.run", side_effect=_sub_run_image_exists) + @patch( + "yolo.launcher.load_config", + return_value={"context": ["You are in a container."]}, + ) + def test_context_injection(self, mock_config, mock_run): + run() + cmd = mock_run.call_args[0][0] + assert "--append-system-prompt" in cmd + idx = cmd.index("--append-system-prompt") + assert cmd[idx + 1] == "You are in a container." + + @patch("yolo.launcher.subprocess.run", side_effect=_sub_run_image_exists) + @patch( + "yolo.launcher.load_config", + return_value={"context": ["Line one.", "Line two."]}, + ) + def test_context_multiple_lines(self, mock_config, mock_run): + run() + cmd = mock_run.call_args[0][0] + idx = cmd.index("--append-system-prompt") + assert cmd[idx + 1] == "Line one.\nLine two." + + @patch("yolo.launcher.subprocess.run", side_effect=_sub_run_image_exists) + @patch("yolo.launcher.load_config", return_value={}) + def test_no_context_no_flag(self, mock_config, mock_run): + run() + cmd = mock_run.call_args[0][0] + assert "--append-system-prompt" not in cmd + class TestNvidiaArgs: def test_disabled(self): From 21c0a4ff1e6b6374d756ac53c9ec315f3b5413eb Mon Sep 17 00:00:00 2001 From: Austin Macdonald Date: Sun, 29 Mar 2026 22:03:58 -0500 Subject: [PATCH 44/55] Add clipboard bridge: yo clip + /tmp/yolo-clip mount Container Claude writes to /tmp/yolo-clip/content, user runs `yo clip` on the host to pipe it to their clipboard. Configurable via `host_clipboard_command` (default: xclip -selection clipboard). Default context line tells Claude about the mechanism. Co-Authored-By: Claude Opus 4.6 (1M context) --- SPEC.md | 19 +++++++++++++ src/yolo/cli.py | 15 +++++++++++ src/yolo/defaults/config.yaml | 8 ++++++ src/yolo/launcher.py | 4 +++ tests/test_cli.py | 51 +++++++++++++++++++++++++++++++++++ tests/test_launcher.py | 7 +++++ 6 files changed, 104 insertions(+) create mode 100644 tests/test_cli.py diff --git a/SPEC.md b/SPEC.md index ac1df13..a85340e 100644 --- a/SPEC.md +++ b/SPEC.md @@ -167,11 +167,30 @@ volumes: 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 diff --git a/src/yolo/cli.py b/src/yolo/cli.py index 6d4efba..1e8275d 100644 --- a/src/yolo/cli.py +++ b/src/yolo/cli.py @@ -1,6 +1,7 @@ """CLI entry point for yolo.""" import shutil +import subprocess from pathlib import Path import click @@ -74,6 +75,20 @@ def init(target, custom_path): click.echo(f"Created {dest}") +@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(context_settings={"ignore_unknown_options": True}) @click.option( "-v", "--volume", multiple=True, help="Extra bind mount (host:container[:opts])" diff --git a/src/yolo/defaults/config.yaml b/src/yolo/defaults/config.yaml index 9c1a30b..aa6942b 100644 --- a/src/yolo/defaults/config.yaml +++ b/src/yolo/defaults/config.yaml @@ -1,4 +1,12 @@ --- +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 diff --git a/src/yolo/launcher.py b/src/yolo/launcher.py index d53c298..a4f6ce6 100644 --- a/src/yolo/launcher.py +++ b/src/yolo/launcher.py @@ -145,6 +145,8 @@ def run( cwd = Path.cwd() claude_dir = home / ".claude" claude_dir.mkdir(exist_ok=True) + clip_dir = home / ".local" / "share" / "yolo" / "clip" + clip_dir.mkdir(parents=True, exist_ok=True) name = f"{cwd}-{os.getpid()}" name = name.replace(str(home) + "/", "") @@ -174,6 +176,8 @@ def run( f"{home}/.gitconfig:/tmp/.gitconfig:ro,z", "-v", f"{cwd}:{cwd}:z", + "-v", + f"{clip_dir}:/tmp/yolo-clip:z", *_build_volume_args(config_volumes), *_build_volume_args(extra_volumes or []), *_worktree_volume(worktree_mode), diff --git a/tests/test_cli.py b/tests/test_cli.py new file mode 100644 index 0000000..911ddd7 --- /dev/null +++ b/tests/test_cli.py @@ -0,0 +1,51 @@ +"""Tests for yolo CLI commands.""" + +from unittest.mock import patch + +from click.testing import CliRunner + +from yolo.cli import main + + +class TestClip: + def test_clip_no_content(self, tmp_path): + with patch("yolo.cli.Path.home", return_value=tmp_path): + runner = CliRunner() + result = runner.invoke(main, ["clip"]) + assert result.exit_code != 0 + assert "Nothing to clip" in result.output + + def test_clip_copies_content(self, tmp_path): + clip_dir = tmp_path / ".local" / "share" / "yolo" / "clip" + clip_dir.mkdir(parents=True) + (clip_dir / "content").write_text("hello world") + + with ( + patch("yolo.cli.Path.home", return_value=tmp_path), + patch("yolo.cli.subprocess.run") as mock_run, + patch("yolo.cli.load_config", return_value={}), + ): + runner = CliRunner() + result = runner.invoke(main, ["clip"]) + assert result.exit_code == 0 + assert "11 chars" in result.output + mock_run.assert_called_once() + assert mock_run.call_args.kwargs["input"] == "hello world" + + def test_clip_custom_command(self, tmp_path): + clip_dir = tmp_path / ".local" / "share" / "yolo" / "clip" + clip_dir.mkdir(parents=True) + (clip_dir / "content").write_text("test") + + with ( + patch("yolo.cli.Path.home", return_value=tmp_path), + patch("yolo.cli.subprocess.run") as mock_run, + patch( + "yolo.cli.load_config", + return_value={"host_clipboard_command": "wl-copy"}, + ), + ): + runner = CliRunner() + result = runner.invoke(main, ["clip"]) + assert result.exit_code == 0 + assert mock_run.call_args[0][0] == ["wl-copy"] diff --git a/tests/test_launcher.py b/tests/test_launcher.py index a41b4d8..2c45b51 100644 --- a/tests/test_launcher.py +++ b/tests/test_launcher.py @@ -183,6 +183,13 @@ def test_no_context_no_flag(self, mock_config, mock_run): cmd = mock_run.call_args[0][0] assert "--append-system-prompt" not in cmd + @patch("yolo.launcher.subprocess.run", side_effect=_sub_run_image_exists) + @patch("yolo.launcher.load_config", return_value={}) + def test_clip_dir_mounted(self, mock_config, mock_run): + run() + cmd = mock_run.call_args[0][0] + assert "/tmp/yolo-clip" in " ".join(cmd) + class TestNvidiaArgs: def test_disabled(self): From 73f94fec46fa4155dfed9ebe12b040a8d980799b Mon Sep 17 00:00:00 2001 From: Austin Macdonald Date: Sun, 29 Mar 2026 22:04:02 -0500 Subject: [PATCH 45/55] Add git workflow guidelines to CLAUDE.md Co-Authored-By: Claude Opus 4.6 (1M context) --- CLAUDE.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/CLAUDE.md b/CLAUDE.md index 95d6fe6..3d9521f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,5 +1,10 @@ **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. From 828544f6f34db022b95b737de23e8693cafff13a Mon Sep 17 00:00:00 2001 From: Austin Macdonald Date: Sun, 29 Mar 2026 22:06:24 -0500 Subject: [PATCH 46/55] Add yo demo command and move demo script into repo `yo demo` chdirs to the bundled demo/ directory and launches a container with the demo script as the initial prompt. Removes the need for a separate yo-demo project directory. Co-Authored-By: Claude Opus 4.6 (1M context) --- demo/demo.md | 93 +++++++++++++++++++++++++++++++++++++++++++++++++ src/yolo/cli.py | 12 +++++++ 2 files changed, 105 insertions(+) create mode 100644 demo/demo.md diff --git a/demo/demo.md b/demo/demo.md new file mode 100644 index 0000000..2af15a9 --- /dev/null +++ b/demo/demo.md @@ -0,0 +1,93 @@ +# YOLO Demo Script + +You are Claude Code running inside a yolo container. You're doing a live demo +for developers who might use yolo. Be yourself — casual, fun, a little cheeky. +you can even use **some** emojis,. +Show, don't tell. Run real commands, show real output. + +## Pacing + +The presenter is doing voiceover, so pause between steps. +After each numbered step, use AskUserQuestion with "Next" / "Stop" options. +The presenter hits enter to advance (first option is pre-selected). +Keep each step self-contained so pauses feel natural. + +## Part 1: Introduction + +Introduce yourself briefly. You're regular Claude Code, but you're running +inside a container. The cool part: you have full autonomy. No permission +prompts. The container IS the sandbox. + +Demonstrate this: + +1. **Write a file** without asking permission. Something fun. Show it worked. + +2. **Install a package** with sudo. Pick something small and amusing (cowsay? + figlet? fortune?). Use it immediately to prove it worked. + +3. **Try to escape** — try to read ~/Downloads or ~/.ssh on the host. + It won't work. React to this. The point: you can do anything inside + the box, but you can't get out. That's the security model. + +Keep this section short and punchy. Under 2 minutes of output. + +## Part 2: What's new in the rewrite + +Transition: "Same yolo you know, but rebuilt in Python. Let me show you +what changed." + +The yolo source is mounted read-only in this workspace. Use it to show +real code and config. + +Show these quickly (run real commands, keep it brief): + +1. **Config is YAML now** — show the default config from the source. + Point out it's declarative, not sourced bash. Mention why (security — + old config was arbitrary code execution). + +2. **Extras are composable scripts** — show what's in image-extras/. + `ls` the directory, `cat` one script (apt.sh is simple). Explain: + these run at build time, you list them in config. Focus on the + config interface, not the script internals. + +3. **Volume shorthand** — show the config syntax: `~/data`, `~/data::ro`. + Easier than raw podman mount syntax. + +4. **The big one: named images with FROM** — explain briefly: you can have + multiple images per project, and they can build on each other using + podman's native layering. Show the correct config syntax (list of + dicts with `name:` keys): + + ```yaml + images: + - name: base + extras: + - name: apt + packages: [git, curl] + - name: ml + from: base + extras: + - name: python + version: "3.12" + ``` + +## Part 2.5: Clipboard bridge + +Show the yolo clipboard bridge — this is how containers talk to the +host clipboard without X11/Wayland access. + +1. **Write something to the clip file**: + `printf '%s' 'yo demo' > /tmp/yolo-clip/content` + +2. **Tell the user to run `yo clip` on the host** — that reads the file + and pipes it to their clipboard. No display socket needed. + +3. Explain briefly: this is configurable via `host_clipboard_command` + in config (defaults to `xclip -selection clipboard`, swap for + `wl-copy`, `pbcopy`, whatever). + +## Part 3: Sign off + +Sign off: "That's yolo. Same autonomy, better architecture. +`pip install con-yolo` when it's on PyPI. For now: clone and +`uv pip install -e .`" diff --git a/src/yolo/cli.py b/src/yolo/cli.py index 1e8275d..35ed64a 100644 --- a/src/yolo/cli.py +++ b/src/yolo/cli.py @@ -89,6 +89,18 @@ def clip(ctx): click.echo(f"Copied {len(content)} chars to clipboard") +@main.command() +def demo(): + """Run the interactive yolo demo.""" + import os + + demo_dir = Path(__file__).resolve().parent.parent.parent / "demo" + if not (demo_dir / "demo.md").exists(): + raise click.ClickException(f"Demo not found at {demo_dir}") + os.chdir(demo_dir) + launcher_run(["Read demo.md and follow it."]) + + @main.command(context_settings={"ignore_unknown_options": True}) @click.option( "-v", "--volume", multiple=True, help="Extra bind mount (host:container[:opts])" From 8e0bd6a3ed2431191762951fde08daf3e3efeef8 Mon Sep 17 00:00:00 2001 From: Austin Macdonald Date: Sun, 29 Mar 2026 22:08:57 -0500 Subject: [PATCH 47/55] Add yo demo command and move demo script into repo `yo demo` copies the bundled demo script to a tmpdir and launches a container there. Demo files live in src/yolo/demo/ so they ship with the package via importlib.resources. Early clipboard step writes the tmpdir path so the presenter can cd there on the host. Co-Authored-By: Claude Opus 4.6 (1M context) --- .gitignore | 1 + CLAUDE.md | 2 +- pyproject.toml | 1 + src/yolo/cli.py | 18 +++-- src/yolo/demo/.yolo/config.yaml | 8 ++ src/yolo/demo/demo.md | 102 +++++++++++++++++++++++++ tests/integration/test_image_extras.py | 15 +--- 7 files changed, 127 insertions(+), 20 deletions(-) create mode 100644 src/yolo/demo/.yolo/config.yaml create mode 100644 src/yolo/demo/demo.md diff --git a/.gitignore b/.gitignore index 6673fe4..9fa9483 100644 --- a/.gitignore +++ b/.gitignore @@ -3,3 +3,4 @@ __pycache__/ *.egg-info/ .local-notes/ .yolo/ +!src/yolo/demo/.yolo/ diff --git a/CLAUDE.md b/CLAUDE.md index 3d9521f..a405ba1 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -36,7 +36,7 @@ Entry point is `yo` (temporary, becomes `yolo` at cutover). - `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) +- `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 diff --git a/pyproject.toml b/pyproject.toml index 2fa521f..0c73454 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -24,6 +24,7 @@ yo = "yolo.cli:main" [tool.hatch.build.targets.wheel] packages = ["src/yolo"] + [tool.pytest.ini_options] testpaths = ["tests"] markers = [ diff --git a/src/yolo/cli.py b/src/yolo/cli.py index 35ed64a..36ecd60 100644 --- a/src/yolo/cli.py +++ b/src/yolo/cli.py @@ -93,12 +93,18 @@ def clip(ctx): def demo(): """Run the interactive yolo demo.""" import os - - demo_dir = Path(__file__).resolve().parent.parent.parent / "demo" - if not (demo_dir / "demo.md").exists(): - raise click.ClickException(f"Demo not found at {demo_dir}") - os.chdir(demo_dir) - launcher_run(["Read demo.md and follow it."]) + 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) + launcher_run(["Read demo.md and follow it."]) @main.command(context_settings={"ignore_unknown_options": True}) diff --git a/src/yolo/demo/.yolo/config.yaml b/src/yolo/demo/.yolo/config.yaml new file mode 100644 index 0000000..f408c0c --- /dev/null +++ b/src/yolo/demo/.yolo/config.yaml @@ -0,0 +1,8 @@ +--- +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. diff --git a/src/yolo/demo/demo.md b/src/yolo/demo/demo.md new file mode 100644 index 0000000..d9d93dc --- /dev/null +++ b/src/yolo/demo/demo.md @@ -0,0 +1,102 @@ +# YOLO Demo Script + +You are Claude Code running inside a yolo container. You're doing a live demo +for developers who might use yolo. Be yourself — casual, fun, a little cheeky. +you can even use **some** emojis,. +Show, don't tell. Run real commands, show real output. + +CRITICAL: Tool output is hidden from the audience. After every Bash +call, you MUST paste the output into your response as a fenced code +block. Never assume the audience can see tool results. + +## Pacing + +The presenter is doing voiceover, so pause between steps. +After each numbered step, use AskUserQuestion with "Next" / "Stop" options. +The presenter hits enter to advance (first option is pre-selected). +Keep each step self-contained so pauses feel natural. + +## Part 1: Introduction + +Introduce yourself briefly. You're regular Claude Code, but you're running +inside a container. The cool part: you have full autonomy. No permission +prompts. The container IS the sandbox. + +First, write a cd command to the clipboard so the presenter can find +this workspace: +`printf '%s' "cd $(pwd)" > /tmp/yolo-clip/content` +Tell them to run `yo clip` on the host, then paste to cd there. + +Demonstrate this: + +1. **Write a file** without asking permission. Something fun. Show it worked. + +2. **Install a package** with sudo. Pick something small and amusing (cowsay? + figlet? fortune?). Use it immediately to prove it worked. + +3. **Try to escape** — try to read ~/Downloads or ~/.ssh on the host. + It won't work. React to this. The point: you can do anything inside + the box, but you can't get out. That's the security model. + +Keep this section short and punchy. Under 2 minutes of output. + +## Part 2: What's new in the rewrite + +Transition: "Same yolo you know, but rebuilt in Python. Let me show you +what changed." + +The yolo source is mounted read-only in this workspace. Use it to show +real code and config. + +Show these quickly (run real commands, keep it brief): + +1. **Config is YAML now** — show the default config from the source. + Point out it's declarative, not sourced bash. Mention why (security — + old config was arbitrary code execution). + +2. **Extras are composable scripts** — show what's in image-extras/. + `ls` the directory, `cat` one script (apt.sh is simple). Explain: + these run at build time, you list them in config. Focus on the + config interface, not the script internals. + +3. **Volume shorthand** — show the config syntax: `~/data`, `~/data::ro`. + Easier than raw podman mount syntax. + +4. **The big one: named images with FROM** — explain briefly: you can have + multiple images per project, and they can build on each other using + podman's native layering. Show the correct config syntax (list of + dicts with `name:` keys): + + ```yaml + images: + - name: base + extras: + - name: apt + packages: [git, curl] + - name: ml + from: base + extras: + - name: python + version: "3.12" + ``` + +## Part 2.5: Clipboard bridge + +Show the yolo clipboard bridge — this is how containers talk to the +host clipboard without X11/Wayland access. + +1. **Write something to the clip file**: + `printf '%s' 'yo demo' > /tmp/yolo-clip/content` + +2. **Tell the user to run `yo clip` on the host** — that reads the file + and pipes it to their clipboard. No display socket needed. + +3. Explain briefly: this is configurable via `host_clipboard_command` + in config (defaults to `xclip -selection clipboard`, swap for + `wl-copy`, `pbcopy`, whatever). + +## Part 3: Sign off + +Sign off: "That's yolo. Same autonomy, better architecture. +`pip install con-yolo` when it's on PyPI. For now: clone and +`uv pip install -e .`" diff --git a/tests/integration/test_image_extras.py b/tests/integration/test_image_extras.py index cb2442c..5b7ab48 100644 --- a/tests/integration/test_image_extras.py +++ b/tests/integration/test_image_extras.py @@ -55,14 +55,8 @@ def ensure_base_image(): def _build_verify_image(script, tag): """Build an image with a single script via Containerfile.extras.""" - build_dir = assemble_build_context(_verify_config(script)) + build_dir = assemble_build_context(_verify_config(script), verify=True) try: - # Inject YOLO_VERIFY into run.sh - run_sh = build_dir / "build" / "run.sh" - content = run_sh.read_text() - content = content.replace("set -eu", "set -eu\nexport YOLO_VERIFY=1") - run_sh.write_text(content) - subprocess.run( [ "podman", @@ -104,13 +98,8 @@ def test_idempotent(script): _build_verify_image(script, tag1) # Build again on top of the first image - build_dir = assemble_build_context(_verify_config(script)) + build_dir = assemble_build_context(_verify_config(script), verify=True) try: - run_sh = build_dir / "build" / "run.sh" - content = run_sh.read_text() - content = content.replace("set -eu", "set -eu\nexport YOLO_VERIFY=1") - run_sh.write_text(content) - result = subprocess.run( [ "podman", From 385dad6606c7dc0f81aadbdffd5ce22b8186fd70 Mon Sep 17 00:00:00 2001 From: Austin Macdonald Date: Sun, 29 Mar 2026 23:52:53 -0500 Subject: [PATCH 48/55] Add datalad, jj, and playwright image extras Ports the remaining extras from the legacy bash implementation: - datalad: installs via uv tool with datalad-container and datalad-next - jj: downloads musl binary from GitHub releases, adds zsh completions - playwright: installs nodejs/npm, system deps, and chromium Co-Authored-By: Claude Opus 4.6 (1M context) --- image-extras/datalad.sh | 16 ++++++++++++++++ image-extras/jj.sh | 26 ++++++++++++++++++++++++++ image-extras/playwright.sh | 16 ++++++++++++++++ 3 files changed, 58 insertions(+) create mode 100644 image-extras/datalad.sh create mode 100644 image-extras/jj.sh create mode 100644 image-extras/playwright.sh 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/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/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 From 8100fbd607574db65a73d31afd6711f05dfd8155 Mon Sep 17 00:00:00 2001 From: Austin Macdonald Date: Sun, 29 Mar 2026 23:55:14 -0500 Subject: [PATCH 49/55] Add yo images command to list configured images Shows each image name, its podman tag, and whether it's been built. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/yolo/cli.py | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/src/yolo/cli.py b/src/yolo/cli.py index 36ecd60..33ef86b 100644 --- a/src/yolo/cli.py +++ b/src/yolo/cli.py @@ -6,7 +6,7 @@ import click -from yolo.builder import build as builder_build +from yolo.builder import build as builder_build, image_tag from yolo.config import load_config from yolo.launcher import run as launcher_run @@ -75,6 +75,19 @@ def init(target, custom_path): 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): From c438951b8fb3864de504349fe322501da123a7c6 Mon Sep 17 00:00:00 2001 From: Austin Macdonald Date: Sun, 29 Mar 2026 23:55:48 -0500 Subject: [PATCH 50/55] Revamp demo: conversational flow, source mount, context fixes - Demo asks about user's experience and tailors accordingly - Offers feature topics as user scenarios instead of scripted walkthrough - Mounts yolo source read-only at /opt/yolo for showing real code - Context config tells Claude to quote tool output and abort gracefully - Removes old demo/demo.md (now in src/yolo/demo/) - SPEC.md: mark context injection done, add claude-to-claude TODO Co-Authored-By: Claude Opus 4.6 (1M context) --- SPEC.md | 3 +- demo/demo.md | 93 ---------------------- src/yolo/cli.py | 12 ++- src/yolo/demo/.yolo/config.yaml | 12 +++ src/yolo/demo/demo.md | 135 +++++++++++++------------------- 5 files changed, 81 insertions(+), 174 deletions(-) delete mode 100644 demo/demo.md diff --git a/SPEC.md b/SPEC.md index a85340e..8938669 100644 --- a/SPEC.md +++ b/SPEC.md @@ -223,6 +223,7 @@ vector. Mitigations under consideration (none implemented): - 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) +- 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/demo/demo.md b/demo/demo.md deleted file mode 100644 index 2af15a9..0000000 --- a/demo/demo.md +++ /dev/null @@ -1,93 +0,0 @@ -# YOLO Demo Script - -You are Claude Code running inside a yolo container. You're doing a live demo -for developers who might use yolo. Be yourself — casual, fun, a little cheeky. -you can even use **some** emojis,. -Show, don't tell. Run real commands, show real output. - -## Pacing - -The presenter is doing voiceover, so pause between steps. -After each numbered step, use AskUserQuestion with "Next" / "Stop" options. -The presenter hits enter to advance (first option is pre-selected). -Keep each step self-contained so pauses feel natural. - -## Part 1: Introduction - -Introduce yourself briefly. You're regular Claude Code, but you're running -inside a container. The cool part: you have full autonomy. No permission -prompts. The container IS the sandbox. - -Demonstrate this: - -1. **Write a file** without asking permission. Something fun. Show it worked. - -2. **Install a package** with sudo. Pick something small and amusing (cowsay? - figlet? fortune?). Use it immediately to prove it worked. - -3. **Try to escape** — try to read ~/Downloads or ~/.ssh on the host. - It won't work. React to this. The point: you can do anything inside - the box, but you can't get out. That's the security model. - -Keep this section short and punchy. Under 2 minutes of output. - -## Part 2: What's new in the rewrite - -Transition: "Same yolo you know, but rebuilt in Python. Let me show you -what changed." - -The yolo source is mounted read-only in this workspace. Use it to show -real code and config. - -Show these quickly (run real commands, keep it brief): - -1. **Config is YAML now** — show the default config from the source. - Point out it's declarative, not sourced bash. Mention why (security — - old config was arbitrary code execution). - -2. **Extras are composable scripts** — show what's in image-extras/. - `ls` the directory, `cat` one script (apt.sh is simple). Explain: - these run at build time, you list them in config. Focus on the - config interface, not the script internals. - -3. **Volume shorthand** — show the config syntax: `~/data`, `~/data::ro`. - Easier than raw podman mount syntax. - -4. **The big one: named images with FROM** — explain briefly: you can have - multiple images per project, and they can build on each other using - podman's native layering. Show the correct config syntax (list of - dicts with `name:` keys): - - ```yaml - images: - - name: base - extras: - - name: apt - packages: [git, curl] - - name: ml - from: base - extras: - - name: python - version: "3.12" - ``` - -## Part 2.5: Clipboard bridge - -Show the yolo clipboard bridge — this is how containers talk to the -host clipboard without X11/Wayland access. - -1. **Write something to the clip file**: - `printf '%s' 'yo demo' > /tmp/yolo-clip/content` - -2. **Tell the user to run `yo clip` on the host** — that reads the file - and pipes it to their clipboard. No display socket needed. - -3. Explain briefly: this is configurable via `host_clipboard_command` - in config (defaults to `xclip -selection clipboard`, swap for - `wl-copy`, `pbcopy`, whatever). - -## Part 3: Sign off - -Sign off: "That's yolo. Same autonomy, better architecture. -`pip install con-yolo` when it's on PyPI. For now: clone and -`uv pip install -e .`" diff --git a/src/yolo/cli.py b/src/yolo/cli.py index 33ef86b..26b4325 100644 --- a/src/yolo/cli.py +++ b/src/yolo/cli.py @@ -117,7 +117,17 @@ def demo(): tmp_path = Path(tmp) / "demo" shutil.copytree(str(demo_src), str(tmp_path)) os.chdir(tmp_path) - launcher_run(["Read demo.md and follow it."]) + # 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}) diff --git a/src/yolo/demo/.yolo/config.yaml b/src/yolo/demo/.yolo/config.yaml index f408c0c..94dc1cd 100644 --- a/src/yolo/demo/.yolo/config.yaml +++ b/src/yolo/demo/.yolo/config.yaml @@ -6,3 +6,15 @@ context: 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 index d9d93dc..3d397f3 100644 --- a/src/yolo/demo/demo.md +++ b/src/yolo/demo/demo.md @@ -1,102 +1,79 @@ -# YOLO Demo Script +# YOLO Demo -You are Claude Code running inside a yolo container. You're doing a live demo -for developers who might use yolo. Be yourself — casual, fun, a little cheeky. -you can even use **some** emojis,. -Show, don't tell. Run real commands, show real output. +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 audience. After every Bash -call, you MUST paste the output into your response as a fenced code -block. Never assume the audience can see tool results. +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 -The presenter is doing voiceover, so pause between steps. -After each numbered step, use AskUserQuestion with "Next" / "Stop" options. -The presenter hits enter to advance (first option is pre-selected). -Keep each step self-contained so pauses feel natural. +Short steps. Pause between steps. After each step, stop and wait for the user to +type something before continuing. -## Part 1: Introduction +## Getting started -Introduce yourself briefly. You're regular Claude Code, but you're running -inside a container. The cool part: you have full autonomy. No permission -prompts. The container IS the sandbox. +Ask if they're familiar with Claude Code. If not, briefly explain. -First, write a cd command to the clipboard so the presenter can find -this workspace: -`printf '%s' "cd $(pwd)" > /tmp/yolo-clip/content` -Tell them to run `yo clip` on the host, then paste to cd there. +Then ask if they've used yolo before. -Demonstrate this: +- 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. -1. **Write a file** without asking permission. Something fun. Show it worked. +Let the conversation flow naturally from here. -2. **Install a package** with sudo. Pick something small and amusing (cowsay? - figlet? fortune?). Use it immediately to prove it worked. +## Things to demonstrate -3. **Try to escape** — try to read ~/Downloads or ~/.ssh on the host. - It won't work. React to this. The point: you can do anything inside - the box, but you can't get out. That's the security model. +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: -Keep this section short and punchy. Under 2 minutes of output. +**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