# 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 `), 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) 13.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 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 `:** 1. `git.createWorktree()` → creates worktree at `~/.sandlot//` 2. Creates symlink `/.sandlot/` → 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///` (outside the repo) **Symlink in repo**: `/.sandlot/` → worktree **State file**: `/.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: ```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.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`