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

161 lines
8.6 KiB
Markdown

# CLAUDE.md
## Project Overview
**Sandlot** is a CLI tool for branch-based development using git worktrees and [Apple Container](https://github.com/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](https://bun.sh) (not Node.js — use `bun` for everything)
- **Language**: TypeScript (strict, ESNext, bundler module resolution)
- **CLI parsing**: [Commander.js](https://github.com/tj/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
```bash
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:
```typescript
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):
```json
{
"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`