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>
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
bunfor 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>:
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 - Memory limit: configurable via
sandlot config memory(default 16G) - Mounts:
~/devread-only at/host,~/.sandlotread-write at/sandlot - Host symlinks: creates
~/dev→/hostand~/.sandlot→/sandlotinside the container so host-absolute worktree paths resolve correctly containerPath()invm.tstranslates 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) — subsequentvm createuses cached copies, skipping downloads - 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: trueandeffortCalloutDismissed: 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
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.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--continuewas used, retries without--continuevm.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 openalways passescontinue: truetovm.claude()to resume the previous conversationsandlot saveusesvm.claudePipe()to generate commit messages from the staged diffsandlot merge,sandlot squash, andsandlot rebaseusevm.claudePipe()to resolve merge conflicts automaticallysandlot squashgenerates an AI commit message for the squash commit viaclaudePipe(); falls back to"squash <branch>"sandlot mergeandsandlot squashboth delegate tomergeAndClose()inhelpers.ts, which merges, resolves conflicts, commits, and then callscloseAction()to clean upsandlot listdiscovers missing session prompts by parsing Claude'shistory.jsonlfrom inside the containersandlot listshows five status icons: idle (dim◯), active (cyan◎), dirty/unsaved (yellow◐), saved (green●), review (magenta⦿)sandlot reviewsetsin_reviewon the session during the review and clears it in afinallyblock on exit;listdetects stalein_reviewflags (Claude not active) and clears them from statesandlot newandsandlot openauto-save changes when Claude exits (disable with--no-save)sandlot closehas a hiddenrmalias- Default behavior (no subcommand): always runs
list(which prints "No active sessions." if empty) .sandlot/should be in the repo's.gitignore