sandlot/CLAUDE.md

6.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, 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)
  commands/
    new.ts      # Create session, derive branch name from prompt
    open.ts     # Re-enter existing session
    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
    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
    shell.ts    # Open fish shell in container
    cleanup.ts  # Remove stale sessions
    vm.ts       # VM subcommands (create, start, stop, destroy, status, info)
    completions.ts  # Fish shell completions generator
    helpers.ts  # Shared: requireSession, resolveConflicts, 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
  • Mounts: ~/dev and ~/.sandlot from host
  • Provisioned once on first use: installs curl git neofetch fish unzip, Bun, Claude Code, git identity, API key helper, activity tracking hook
  • 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 to skip first-run prompts

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

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.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 save uses vm.claudePipe() to generate commit messages from the staged diff
  • sandlot merge and sandlot rebase use vm.claudePipe() to resolve merge conflicts automatically
  • sandlot new and sandlot open auto-save changes when Claude exits (disable with --no-save)
  • Default behavior (no subcommand): shows list if sessions exist, otherwise shows help
  • .sandlot/ should be in the repo's .gitignore