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
bunfor 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>:
git.createWorktree()→ creates worktree at~/.sandlot/<repo>/<branch>- Creates symlink
<repo-root>/.sandlot/<branch>→ worktree path vm.ensure()→ start/create/provision the containerstate.setSession()→ write to.sandlot/state.jsonvm.claude()→ launch Claude Code in container at worktree pathsaveChanges()→ 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:
~/devand~/.sandlotfrom 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
~/.envon host (ANTHROPIC_API_KEY=...), written to a temp file and copied as~/.claude/api-key-helper.shin the container (never passed as a process argument) - Claude settings:
skipDangerousModePermissionPrompt: true, activity tracking hooks (UserPromptSubmit/Stop) in container - Also writes
~/.claude.jsonwithhasCompletedOnboarding: trueto 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
Errorwith descriptive messages from git/vm modules - Command handlers use
die()fromfmt.tsfor user-facing errors (writes to stderr, exits 1) - On
newfailure, roll back: remove worktree, delete branch, unlink symlink - Non-fatal cleanup steps use
.catch(() => {})to continue past failures
Key Implementation Notes
vm.exec()prependsexport PATH=$HOME/.local/bin:$PATHsoclaudebinary is foundvm.claude()usesBun.spawnwithstdin/stdout/stderr: "inherit"for interactive TTY; in print mode (-p), captures stdout via pipe and returns the outputvm.claudePipe()writes input to a temp file in~/.sandlot/, pipes it toclaude -pinside the container, and returns the result — used for commit message generation and conflict resolutionvm.isClaudeActive()reads activity marker files written by the in-containersandlot-activityhook script- Branch creation in
createWorktree()handles three cases: local branch, remote branch (tracks origin), new branch from HEAD sandlot newaccepts a prompt instead of a branch name — derives a 2-word branch name via the Anthropic API (Haiku), falling back to simple text mungingsandlot saveusesvm.claudePipe()to generate commit messages from the staged diffsandlot mergeandsandlot rebaseusevm.claudePipe()to resolve merge conflicts automaticallysandlot newandsandlot openauto-save changes when Claude exits (disable with--no-save)- Default behavior (no subcommand): shows
listif sessions exist, otherwise shows help .sandlot/should be in the repo's.gitignore