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>
161 lines
8.6 KiB
Markdown
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`
|