sandlot/CLAUDE.md

157 lines
8.2 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)
commands/
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: 4G
- 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"
}
}
}
```
## 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 four status icons: idle (dim `◌`), active (cyan `◯`), dirty/unsaved (yellow `◎`), saved (green `●`)
- `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`