sandlot/CLAUDE.md

4.5 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 13.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, all command handlers (Commander.js)
  git.ts      # Git operations: worktrees, branches, merge
  vm.ts       # Container lifecycle: create, provision, exec, shell, claude
  state.ts    # Per-repo session persistence (.sandlot/state.json)
  config.ts   # Load optional sandlot.json config from repo root
  spinner.ts  # CLI progress spinner (braille frames)

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

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
  • Mounts: ~/dev and ~/.sandlot from host
  • Provisioned once on first use: installs curl git neofetch fish, Claude Code, git identity, API key helper
  • API key: read from ~/.env on host (ANTHROPIC_API_KEY=...), written as ~/.claude/api-key-helper.sh in the container (never passed as a process argument)
  • Claude settings: skipDangerousModePermissionPrompt: true in container

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.

Configuration

Optional sandlot.json at repo root (loaded by config.ts):

{
  "vm": {
    "cpus": 4,
    "memory": "8GB",
    "image": "ubuntu:24.04",
    "mounts": { "/host/path": "/container/path" }
  }
}

Note: config.ts loads this but vm.ts does not yet use it — the container is hardcoded with -m 4G and ubuntu:24.04.

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"
    }
  }
}

Error Handling Conventions

  • Throw Error with descriptive messages from git/vm modules
  • CLI commands catch and display errors, then process.exit(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
  • Branch creation in createWorktree() handles three cases: local branch, remote branch (tracks origin), new branch from HEAD
  • sandlot save uses Claude (claude -p "...") inside the container to generate commit messages
  • .sandlot/ should be in the repo's .gitignore