sandlot/CLAUDE.md
Chris Wanstrath fa077ff8f5 Move validateMemory to config module and harden against bad values
The validator is now reusable by both the CLI config command and
the VM startup path, which falls back to the default if the stored
value is invalid. Also lowers the default memory limit to 16G and
makes config.load() resilient to malformed JSON.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-20 10:36:48 -07:00

8.6 KiB

CLAUDE.md

Project Overview

Sandlot is a CLI tool for branch-based development using git worktrees and Apple Container. Each branch gets its own isolated worktree and container. The primary workflow is: create a session (sandlot new <branch>), do work with Claude Code inside the container, then merge and clean up.

Platform requirement: macOS on Apple Silicon only.

Tech Stack

  • Runtime: Bun (not Node.js — use bun for everything)
  • Language: TypeScript (strict, ESNext, bundler module resolution)
  • CLI parsing: Commander.js 14.x
  • Containers: Apple Container (brew install container)
  • Entry point: src/cli.ts (shebang: #!/usr/bin/env bun, runs directly without compilation)

Commands

bun install          # install dependencies
bun link             # make `sandlot` available globally
sandlot --help       # list all commands

No build step. TypeScript runs directly via Bun. There are no tests.

Source Structure

src/
  cli.ts        # CLI entry point, command registration (Commander.js)
  git.ts        # Git operations: worktrees, branches, merge, rebase
  vm.ts         # Container lifecycle: create, provision, exec, shell, claude
  state.ts      # Per-repo session persistence (.sandlot/state.json)
  env.ts        # Read ANTHROPIC_API_KEY from ~/.env
  fmt.ts        # ANSI escape codes, die/success/info helpers, pager
  markdown.ts   # Terminal markdown renderer (headings, bold, links, code blocks)
  spinner.ts    # CLI progress spinner (braille frames)
  config.ts     # Per-user config (~/.config/sandlot/config.json): memory, etc.
  commands/
    config.ts   # Get/set config values (e.g. `sandlot config memory 16G`)
    new.ts      # Create session, derive branch name from prompt
    open.ts     # Re-enter existing session (always uses --continue)
    close.ts    # Remove worktree and clean up session
    list.ts     # Show all active sessions with status
    save.ts     # Stage all changes and commit
    merge.ts    # Merge branch into main with conflict resolution
    squash.ts   # Squash-merge branch into main (AI commit message) and close
    rebase.ts   # Rebase branch onto main with conflict resolution
    review.ts   # Launch grumpy code review with Claude
    diff.ts     # Show uncommitted changes or full branch diff
    show.ts     # Show prompt and full diff for a branch
    log.ts      # Show commits on branch not on main
    dir.ts      # Print worktree path for a session
    edit.ts     # Open a session file in $EDITOR (with path escape check)
    shell.ts    # Open fish shell in container
    cleanup.ts  # Remove stale sessions
    vm.ts       # VM subcommands (create, start, stop, destroy, status, info, uncache)
    completions.ts  # Fish shell completions generator
    helpers.ts  # Shared: requireSession, resolveConflicts, mergeAndClose, saveChanges

Architecture

Each module has a single responsibility. No classes — only exported async functions.

Session flow for sandlot new <branch>:

  1. git.createWorktree() → creates worktree at ~/.sandlot/<repo>/<branch>
  2. Creates symlink <repo-root>/.sandlot/<branch> → worktree path
  3. vm.ensure() → start/create/provision the container
  4. state.setSession() → write to .sandlot/state.json
  5. vm.claude() → launch Claude Code in container at worktree path
  6. saveChanges() → auto-save on exit (stage all, AI-generated commit message) unless --no-save

Worktree location: ~/.sandlot/<repo-name>/<branch>/ (outside the repo) Symlink in repo: <repo-root>/.sandlot/<branch> → worktree State file: <repo-root>/.sandlot/state.json Container name: always "sandlot" (single shared container per machine)

Container Details

  • Image: ubuntu:24.04
  • User: ubuntu
  • Memory limit: configurable via sandlot config memory (default 16G)
  • Mounts: ~/dev read-only at /host, ~/.sandlot read-write at /sandlot
  • Host symlinks: creates ~/dev/host and ~/.sandlot/sandlot inside the container so host-absolute worktree paths resolve correctly
  • containerPath() in vm.ts translates host paths to container paths (~/.sandlot/…/sandlot/…, ~/dev/…/host/…)
  • Provisioned once on first use: apt installs curl git fish unzip, then installs Bun, Claude Code, neofetch, and Neovim
  • Binary caching: after first install, binaries are cached in ~/.sandlot/.cache/ (bun, claude, neofetch, nvim.tar.gz) — subsequent vm create uses cached copies, skipping downloads
  • API key: read from ~/.env on host (ANTHROPIC_API_KEY=...), written to a temp file and copied as ~/.claude/api-key-helper.sh in the container (never passed as a process argument)
  • Claude settings: skipDangerousModePermissionPrompt: true, activity tracking hooks (UserPromptSubmit / Stop) in container
  • Also writes ~/.claude.json with hasCompletedOnboarding: true and effortCalloutDismissed: true

Shell Command Pattern

Uses Bun's $ template literal for shell execution:

import { $ } from "bun"

// Capture output
const text = await $`git rev-parse --show-toplevel`.cwd(dir).nothrow().quiet().text()

// Suppress output, ignore failures
await $`git worktree prune`.cwd(cwd).nothrow().quiet()

// Check exit code
const result = await $`git diff --staged --quiet`.nothrow().quiet()
if (result.exitCode === 0) { ... }

Always use .nothrow() for commands that may fail non-fatally. Use .quiet() to suppress stdout/stderr.

State Schema

.sandlot/state.json (per repo, gitignored):

{
  "sessions": {
    "branch-name": {
      "branch": "branch-name",
      "worktree": "/Users/you/.sandlot/repo/branch-name",
      "created_at": "2026-02-16T10:30:00Z",
      "prompt": "optional initial prompt text",
      "in_review": false
    }
  }
}

Error Handling Conventions

  • Throw Error with descriptive messages from git/vm modules
  • Command handlers use die() from fmt.ts for user-facing errors (writes to stderr, exits 1)
  • On new failure, roll back: remove worktree, delete branch, unlink symlink
  • Non-fatal cleanup steps use .catch(() => {}) to continue past failures

Key Implementation Notes

  • vm.exec() prepends export PATH=$HOME/.local/bin:$PATH so claude binary is found
  • vm.claude() uses Bun.spawn with stdin/stdout/stderr: "inherit" for interactive TTY; in print mode (-p), captures stdout via pipe and returns the output
  • vm.claude() runs Claude with --dangerously-skip-permissions, --model claude-opus-4-6, and --append-system-prompt (system prompt describes the container environment)
  • vm.claude() retry logic: if exit code is non-zero and --continue was used, retries without --continue
  • vm.claudePipe() writes input to a temp file in ~/.sandlot/, pipes it to claude -p inside the container, and returns the result — used for commit message generation and conflict resolution
  • vm.isClaudeActive() reads activity marker files written by the in-container sandlot-activity hook script
  • Branch creation in createWorktree() handles three cases: local branch, remote branch (tracks origin), new branch from HEAD
  • sandlot new accepts a prompt instead of a branch name — derives a 2-word branch name via the Anthropic API (Haiku), falling back to simple text munging
  • sandlot open always passes continue: true to vm.claude() to resume the previous conversation
  • sandlot save uses vm.claudePipe() to generate commit messages from the staged diff
  • sandlot merge, sandlot squash, and sandlot rebase use vm.claudePipe() to resolve merge conflicts automatically
  • sandlot squash generates an AI commit message for the squash commit via claudePipe(); falls back to "squash <branch>"
  • sandlot merge and sandlot squash both delegate to mergeAndClose() in helpers.ts, which merges, resolves conflicts, commits, and then calls closeAction() to clean up
  • sandlot list discovers missing session prompts by parsing Claude's history.jsonl from inside the container
  • sandlot list shows five status icons: idle (dim ), active (cyan ), dirty/unsaved (yellow ), saved (green ), review (magenta ⦿)
  • sandlot review sets in_review on the session during the review and clears it in a finally block on exit; list detects stale in_review flags (Claude not active) and clears them from state
  • sandlot new and sandlot open auto-save changes when Claude exits (disable with --no-save)
  • sandlot close has a hidden rm alias
  • Default behavior (no subcommand): always runs list (which prints "No active sessions." if empty)
  • .sandlot/ should be in the repo's .gitignore