Add sandlot CLI for branch-based dev with worktrees and Apple containers

Implements all 7 commands: new, save, push, list, open, stop, rm.
Uses Commander for CLI parsing, Anthropic SDK for AI commit messages
and merge conflict resolution, and Apple container for VMs.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Chris Wanstrath 2026-02-16 20:15:29 -08:00
parent ad51746aa1
commit 5d7bf302f2
12 changed files with 1129 additions and 0 deletions

2
.gitignore vendored Normal file
View File

@ -0,0 +1,2 @@
node_modules/
.sandlot/

79
README.md Normal file
View File

@ -0,0 +1,79 @@
# sandlot
A CLI for branch-based development using git worktrees and [Apple containers](https://github.com/apple/container). Each branch gets its own worktree and isolated VM. Commits are auto-summarized by Claude. Merging cleans everything up.
## Prerequisites
- macOS on Apple Silicon
- [Bun](https://bun.sh)
- [container](https://github.com/apple/container) installed and on PATH
- Git
## Install
```bash
git clone https://github.com/your/sandlot
cd sandlot
bun install
bun link
```
## Configure
Set your Anthropic API key:
```bash
export ANTHROPIC_API_KEY=sk-ant-...
```
Optionally add a `sandlot.json` to your repo root:
```json
{
"vm": {
"cpus": 4,
"memory": "8GB",
"image": "ubuntu:24.04",
"mounts": { "/path/to/deps": "/deps" }
},
"ai": {
"model": "claude-sonnet-4-20250514"
}
}
```
## Usage
### Start a session
```bash
sandlot new fix-POST
```
Creates a worktree at `.sandlot/fix-POST/`, boots a VM mapped to it, and drops you into a shell.
### Save your work
```bash
sandlot save # AI-generated commit message
sandlot save "wip: rough validation" # manual message
```
Stages all changes, commits, and pushes to origin.
### Merge and tear down
```bash
sandlot push main
```
Merges the session branch into the target, pushes, then tears down the VM, worktree, and branch. If there are merge conflicts, Claude resolves them and asks for confirmation.
### Other commands
```bash
sandlot list # show all sessions
sandlot open <branch> # re-enter a session's VM
sandlot stop <branch> # stop a VM without destroying it
sandlot rm <branch> # tear down without merging
```

226
SPEC.md Normal file
View File

@ -0,0 +1,226 @@
# sandlot
A CLI for branch-based development using git worktrees and Apple containers. Each branch gets its own worktree and isolated VM. Commits are auto-summarized by Claude. Merging cleans everything up.
## Concepts
**Sandlot** is a thin workflow layer over three things: git worktrees, [Apple containers](https://github.com/apple/container), and the Claude API. The idea is that spinning up a branch should give you a fully isolated environment — filesystem and runtime — with zero setup. When you're done, you merge and everything tears down.
A sandlot **session** is a (worktree, VM) pair tied to a branch. Sessions are created with `sandlot <branch>` and destroyed on merge.
## Tech Stack
- [Bun](https://bun.sh) runtime
- [Commander](https://github.com/tj/commander.js) for CLI parsing
- [Apple container](https://github.com/apple/container) for VMs
## Setup
### Prerequisites
- macOS on Apple Silicon
- [Bun](https://bun.sh) installed
- Git installed
- [container](https://github.com/apple/container) installed and available on PATH
### Install
```bash
git clone https://github.com/your/sandlot
cd sandlot
bun install
bun link
```
This makes `sandlot` available globally.
### Configure
Set your Anthropic API key:
```bash
export ANTHROPIC_API_KEY=sk-ant-...
```
Add to your shell profile (`.zshrc`, `.bashrc`, etc.) to persist it.
## CLI
### `sandlot new <branch>`
Create a new session. This:
1. Checks out the branch if it exists (local or remote), or creates a new branch from current HEAD
2. Creates a git worktree at `.sandlot/<branch>/` (relative to the repo root)
3. Boots an Apple container VM mapped to that worktree
4. Drops you into the VM shell
```
$ sandlot new fix-POST
Creating worktree at .sandlot/fix-POST/
Booting VM...
root@fix-POST:~#
```
### `sandlot save [message]`
Stage and commit all changes. If a message is provided, use it. If not, generate one with Claude.
1. Runs `git add .` in the worktree
2. If no message provided: diffs staged changes against the last commit, sends the diff to Claude API (claude-sonnet-4-20250514) to generate a commit message
3. Commits with the message
4. Pushes the branch to origin
If there are no changes, does nothing.
The AI-generated commit message is a single-line summary (≤72 chars) followed by an optional body with more detail if the diff is substantial.
```
$ sandlot save
Staged 3 files
Commit: Fix POST handler to validate request body before processing
Pushed fix-POST → origin/fix-POST
$ sandlot save "wip: rough cut of validation"
Staged 3 files
Commit: wip: rough cut of validation
Pushed fix-POST → origin/fix-POST
```
### `sandlot push <target>`
Push the current session's branch into `<target>`, then tear everything down.
1. Checks out `<target>` in the main working tree
2. Merges the session branch (fast-forward if possible, merge commit otherwise)
3. Pushes `<target>` to origin
4. Stops and removes the VM
5. Removes the worktree
6. Deletes the local branch
7. Deletes the remote branch
If there are uncommitted changes in the worktree, prompts to `sandlot save` first.
If the merge has conflicts:
1. Collects all conflicted files
2. For each file, sends the full conflict diff (ours, theirs, and base) to the Claude API
3. Claude resolves the conflict and returns the merged file
4. Writes the resolved file and stages it
5. Shows a summary of what Claude chose and why
6. Prompts for confirmation before committing the merge
If you reject Claude's resolution, drops you into the main working tree to resolve manually. Run `sandlot push <target>` again to complete cleanup.
```
$ sandlot push main
Pushing fix-POST → main...
Pushed main → origin/main
Stopped VM fix-POST
Removed worktree .sandlot/fix-POST/
Deleted branch fix-POST (local + remote)
```
With conflicts:
```
$ sandlot push main
Pushing fix-POST → main...
2 conflicts found. Resolving with Claude...
src/handlers/post.ts
✓ Kept the new validation logic from fix-POST,
preserved the logging added in main
src/routes.ts
✓ Combined route definitions from both branches
Accept Claude's resolutions? [Y/n] y
Committed merge
Pushed main → origin/main
Stopped VM fix-POST
Removed worktree .sandlot/fix-POST/
Deleted branch fix-POST (local + remote)
```
### `sandlot list`
Show all active sessions.
```
$ sandlot list
BRANCH VM STATUS WORKTREE
fix-POST running .sandlot/fix-POST/
refactor-auth stopped .sandlot/refactor-auth/
```
### `sandlot open <branch>`
Re-enter an existing session's VM. If the VM is stopped, boots it first.
```
$ sandlot open fix-POST
Booting VM...
root@fix-POST:~#
```
### `sandlot stop <branch>`
Stop a session's VM without destroying it. The worktree and branch remain.
### `sandlot rm <branch>`
Tear down a session without merging. Stops the VM, removes the worktree, deletes the local branch. Does not touch the remote branch.
## Configuration
Optional `sandlot.json` at the repo root:
```json
{
"vm": {
"cpus": 4,
"memory": "8GB",
"image": "ubuntu:24.04",
"mounts": {
"/path/to/shared/deps": "/deps"
}
},
"ai": {
"model": "claude-sonnet-4-20250514"
}
}
```
## State
Sandlot tracks sessions in `.sandlot/state.json` at the repo root:
```json
{
"sessions": {
"fix-POST": {
"branch": "fix-POST",
"worktree": ".sandlot/fix-POST",
"vm_id": "container-abc123",
"created_at": "2026-02-16T10:30:00Z",
"status": "running"
}
}
}
```
`.sandlot/` should be added to `.gitignore`.
## Edge Cases
- **Nested sandlot calls**: `sandlot save` and `sandlot push` detect the current session from the working directory or a `SANDLOT_BRANCH` env var set when entering the VM.
- **Stale VMs**: If a VM crashes, `sandlot open` should detect the dead VM and reboot it.
- **Multiple repos**: State is per-repo. No global daemon.
- **Branch name conflicts**: If `.sandlot/<branch>/` already exists as a directory but the session state is missing, prompt to clean up or recover.
## Non-Goals
- Not a CI/CD tool. No pipelines, no test runners.
- Not a replacement for git. All git state lives in the real repo. Sandlot is sugar on top.
- No multi-user collaboration features. This is a single-developer workflow tool.

97
bun.lock Normal file
View File

@ -0,0 +1,97 @@
{
"lockfileVersion": 1,
"configVersion": 1,
"workspaces": {
"": {
"name": "sandlot",
"dependencies": {
"@anthropic-ai/sdk": "^0.39.0",
"commander": "^13.1.0",
},
"devDependencies": {
"@types/bun": "^1.3.9",
},
},
},
"packages": {
"@anthropic-ai/sdk": ["@anthropic-ai/sdk@0.39.0", "https://npm.nose.space/@anthropic-ai/sdk/-/sdk-0.39.0.tgz", { "dependencies": { "@types/node": "^18.11.18", "@types/node-fetch": "^2.6.4", "abort-controller": "^3.0.0", "agentkeepalive": "^4.2.1", "form-data-encoder": "1.7.2", "formdata-node": "^4.3.2", "node-fetch": "^2.6.7" } }, "sha512-eMyDIPRZbt1CCLErRCi3exlAvNkBtRe+kW5vvJyef93PmNr/clstYgHhtvmkxN82nlKgzyGPCyGxrm0JQ1ZIdg=="],
"@types/bun": ["@types/bun@1.3.9", "https://npm.nose.space/@types/bun/-/bun-1.3.9.tgz", { "dependencies": { "bun-types": "1.3.9" } }, "sha512-KQ571yULOdWJiMH+RIWIOZ7B2RXQGpL1YQrBtLIV3FqDcCu6FsbFUBwhdKUlCKUpS3PJDsHlJ1QKlpxoVR+xtw=="],
"@types/node": ["@types/node@18.19.130", "https://npm.nose.space/@types/node/-/node-18.19.130.tgz", { "dependencies": { "undici-types": "~5.26.4" } }, "sha512-GRaXQx6jGfL8sKfaIDD6OupbIHBr9jv7Jnaml9tB7l4v068PAOXqfcujMMo5PhbIs6ggR1XODELqahT2R8v0fg=="],
"@types/node-fetch": ["@types/node-fetch@2.6.13", "https://npm.nose.space/@types/node-fetch/-/node-fetch-2.6.13.tgz", { "dependencies": { "@types/node": "*", "form-data": "^4.0.4" } }, "sha512-QGpRVpzSaUs30JBSGPjOg4Uveu384erbHBoT1zeONvyCfwQxIkUshLAOqN/k9EjGviPRmWTTe6aH2qySWKTVSw=="],
"abort-controller": ["abort-controller@3.0.0", "https://npm.nose.space/abort-controller/-/abort-controller-3.0.0.tgz", { "dependencies": { "event-target-shim": "^5.0.0" } }, "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg=="],
"agentkeepalive": ["agentkeepalive@4.6.0", "https://npm.nose.space/agentkeepalive/-/agentkeepalive-4.6.0.tgz", { "dependencies": { "humanize-ms": "^1.2.1" } }, "sha512-kja8j7PjmncONqaTsB8fQ+wE2mSU2DJ9D4XKoJ5PFWIdRMa6SLSN1ff4mOr4jCbfRSsxR4keIiySJU0N9T5hIQ=="],
"asynckit": ["asynckit@0.4.0", "https://npm.nose.space/asynckit/-/asynckit-0.4.0.tgz", {}, "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q=="],
"bun-types": ["bun-types@1.3.9", "https://npm.nose.space/bun-types/-/bun-types-1.3.9.tgz", { "dependencies": { "@types/node": "*" } }, "sha512-+UBWWOakIP4Tswh0Bt0QD0alpTY8cb5hvgiYeWCMet9YukHbzuruIEeXC2D7nMJPB12kbh8C7XJykSexEqGKJg=="],
"call-bind-apply-helpers": ["call-bind-apply-helpers@1.0.2", "https://npm.nose.space/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", { "dependencies": { "es-errors": "^1.3.0", "function-bind": "^1.1.2" } }, "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ=="],
"combined-stream": ["combined-stream@1.0.8", "https://npm.nose.space/combined-stream/-/combined-stream-1.0.8.tgz", { "dependencies": { "delayed-stream": "~1.0.0" } }, "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg=="],
"commander": ["commander@13.1.0", "https://npm.nose.space/commander/-/commander-13.1.0.tgz", {}, "sha512-/rFeCpNJQbhSZjGVwO9RFV3xPqbnERS8MmIQzCtD/zl6gpJuV/bMLuN92oG3F7d8oDEHHRrujSXNUr8fpjntKw=="],
"delayed-stream": ["delayed-stream@1.0.0", "https://npm.nose.space/delayed-stream/-/delayed-stream-1.0.0.tgz", {}, "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ=="],
"dunder-proto": ["dunder-proto@1.0.1", "https://npm.nose.space/dunder-proto/-/dunder-proto-1.0.1.tgz", { "dependencies": { "call-bind-apply-helpers": "^1.0.1", "es-errors": "^1.3.0", "gopd": "^1.2.0" } }, "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A=="],
"es-define-property": ["es-define-property@1.0.1", "https://npm.nose.space/es-define-property/-/es-define-property-1.0.1.tgz", {}, "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g=="],
"es-errors": ["es-errors@1.3.0", "https://npm.nose.space/es-errors/-/es-errors-1.3.0.tgz", {}, "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw=="],
"es-object-atoms": ["es-object-atoms@1.1.1", "https://npm.nose.space/es-object-atoms/-/es-object-atoms-1.1.1.tgz", { "dependencies": { "es-errors": "^1.3.0" } }, "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA=="],
"es-set-tostringtag": ["es-set-tostringtag@2.1.0", "https://npm.nose.space/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", { "dependencies": { "es-errors": "^1.3.0", "get-intrinsic": "^1.2.6", "has-tostringtag": "^1.0.2", "hasown": "^2.0.2" } }, "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA=="],
"event-target-shim": ["event-target-shim@5.0.1", "https://npm.nose.space/event-target-shim/-/event-target-shim-5.0.1.tgz", {}, "sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ=="],
"form-data": ["form-data@4.0.5", "https://npm.nose.space/form-data/-/form-data-4.0.5.tgz", { "dependencies": { "asynckit": "^0.4.0", "combined-stream": "^1.0.8", "es-set-tostringtag": "^2.1.0", "hasown": "^2.0.2", "mime-types": "^2.1.12" } }, "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w=="],
"form-data-encoder": ["form-data-encoder@1.7.2", "https://npm.nose.space/form-data-encoder/-/form-data-encoder-1.7.2.tgz", {}, "sha512-qfqtYan3rxrnCk1VYaA4H+Ms9xdpPqvLZa6xmMgFvhO32x7/3J/ExcTd6qpxM0vH2GdMI+poehyBZvqfMTto8A=="],
"formdata-node": ["formdata-node@4.4.1", "https://npm.nose.space/formdata-node/-/formdata-node-4.4.1.tgz", { "dependencies": { "node-domexception": "1.0.0", "web-streams-polyfill": "4.0.0-beta.3" } }, "sha512-0iirZp3uVDjVGt9p49aTaqjk84TrglENEDuqfdlZQ1roC9CWlPk6Avf8EEnZNcAqPonwkG35x4n3ww/1THYAeQ=="],
"function-bind": ["function-bind@1.1.2", "https://npm.nose.space/function-bind/-/function-bind-1.1.2.tgz", {}, "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA=="],
"get-intrinsic": ["get-intrinsic@1.3.0", "https://npm.nose.space/get-intrinsic/-/get-intrinsic-1.3.0.tgz", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "es-define-property": "^1.0.1", "es-errors": "^1.3.0", "es-object-atoms": "^1.1.1", "function-bind": "^1.1.2", "get-proto": "^1.0.1", "gopd": "^1.2.0", "has-symbols": "^1.1.0", "hasown": "^2.0.2", "math-intrinsics": "^1.1.0" } }, "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ=="],
"get-proto": ["get-proto@1.0.1", "https://npm.nose.space/get-proto/-/get-proto-1.0.1.tgz", { "dependencies": { "dunder-proto": "^1.0.1", "es-object-atoms": "^1.0.0" } }, "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g=="],
"gopd": ["gopd@1.2.0", "https://npm.nose.space/gopd/-/gopd-1.2.0.tgz", {}, "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg=="],
"has-symbols": ["has-symbols@1.1.0", "https://npm.nose.space/has-symbols/-/has-symbols-1.1.0.tgz", {}, "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ=="],
"has-tostringtag": ["has-tostringtag@1.0.2", "https://npm.nose.space/has-tostringtag/-/has-tostringtag-1.0.2.tgz", { "dependencies": { "has-symbols": "^1.0.3" } }, "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw=="],
"hasown": ["hasown@2.0.2", "https://npm.nose.space/hasown/-/hasown-2.0.2.tgz", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ=="],
"humanize-ms": ["humanize-ms@1.2.1", "https://npm.nose.space/humanize-ms/-/humanize-ms-1.2.1.tgz", { "dependencies": { "ms": "^2.0.0" } }, "sha512-Fl70vYtsAFb/C06PTS9dZBo7ihau+Tu/DNCk/OyHhea07S+aeMWpFFkUaXRa8fI+ScZbEI8dfSxwY7gxZ9SAVQ=="],
"math-intrinsics": ["math-intrinsics@1.1.0", "https://npm.nose.space/math-intrinsics/-/math-intrinsics-1.1.0.tgz", {}, "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g=="],
"mime-db": ["mime-db@1.52.0", "https://npm.nose.space/mime-db/-/mime-db-1.52.0.tgz", {}, "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg=="],
"mime-types": ["mime-types@2.1.35", "https://npm.nose.space/mime-types/-/mime-types-2.1.35.tgz", { "dependencies": { "mime-db": "1.52.0" } }, "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw=="],
"ms": ["ms@2.1.3", "https://npm.nose.space/ms/-/ms-2.1.3.tgz", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
"node-domexception": ["node-domexception@1.0.0", "https://npm.nose.space/node-domexception/-/node-domexception-1.0.0.tgz", {}, "sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ=="],
"node-fetch": ["node-fetch@2.7.0", "https://npm.nose.space/node-fetch/-/node-fetch-2.7.0.tgz", { "dependencies": { "whatwg-url": "^5.0.0" }, "peerDependencies": { "encoding": "^0.1.0" }, "optionalPeers": ["encoding"] }, "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A=="],
"tr46": ["tr46@0.0.3", "https://npm.nose.space/tr46/-/tr46-0.0.3.tgz", {}, "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw=="],
"undici-types": ["undici-types@5.26.5", "https://npm.nose.space/undici-types/-/undici-types-5.26.5.tgz", {}, "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA=="],
"web-streams-polyfill": ["web-streams-polyfill@4.0.0-beta.3", "https://npm.nose.space/web-streams-polyfill/-/web-streams-polyfill-4.0.0-beta.3.tgz", {}, "sha512-QW95TCTaHmsYfHDybGMwO5IJIM93I/6vTRk+daHTWFPhwh+C8Cg7j7XyKrwrj8Ib6vYXe0ocYNrmzY4xAAN6ug=="],
"webidl-conversions": ["webidl-conversions@3.0.1", "https://npm.nose.space/webidl-conversions/-/webidl-conversions-3.0.1.tgz", {}, "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ=="],
"whatwg-url": ["whatwg-url@5.0.0", "https://npm.nose.space/whatwg-url/-/whatwg-url-5.0.0.tgz", { "dependencies": { "tr46": "~0.0.3", "webidl-conversions": "^3.0.0" } }, "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw=="],
}
}

15
package.json Normal file
View File

@ -0,0 +1,15 @@
{
"name": "sandlot",
"version": "0.1.0",
"type": "module",
"bin": {
"sandlot": "./src/cli.ts"
},
"dependencies": {
"@anthropic-ai/sdk": "^0.39.0",
"commander": "^13.1.0"
},
"devDependencies": {
"@types/bun": "^1.3.9"
}
}

74
src/ai.ts Normal file
View File

@ -0,0 +1,74 @@
import Anthropic from "@anthropic-ai/sdk";
let client: Anthropic | null = null;
function getClient(): Anthropic {
if (!client) {
client = new Anthropic();
}
return client;
}
/** Generate a commit message from a diff. */
export async function generateCommitMessage(diff: string, model: string): Promise<string> {
const anthropic = getClient();
const response = await anthropic.messages.create({
model,
max_tokens: 256,
messages: [
{
role: "user",
content: `Generate a git commit message for this diff. The first line must be a single-line summary of 72 characters or less. If the diff is substantial, add a blank line followed by a body with more detail. Return ONLY the commit message, nothing else.\n\n${diff}`,
},
],
});
const block = response.content[0];
if (block.type === "text") return block.text.trim();
throw new Error("Unexpected response from Claude");
}
/** Resolve a merge conflict in a file. Returns the resolved content and an explanation. */
export async function resolveConflict(
filePath: string,
conflictContent: string,
model: string
): Promise<{ resolved: string; explanation: string }> {
const anthropic = getClient();
const response = await anthropic.messages.create({
model,
max_tokens: 4096,
messages: [
{
role: "user",
content: `You are resolving a git merge conflict in the file "${filePath}". The file contains conflict markers (<<<<<<< HEAD, =======, >>>>>>> branch). Resolve the conflict by intelligently combining changes from both sides.
Return your response in this exact format:
EXPLANATION: <one line explaining what you chose and why>
RESOLVED:
<the full resolved file content with no conflict markers>
File with conflicts:
${conflictContent}`,
},
],
});
const block = response.content[0];
if (block.type !== "text") throw new Error("Unexpected response from Claude");
const text = block.text;
const explMatch = text.match(/EXPLANATION:\s*(.+)/);
const resolvedMatch = text.match(/RESOLVED:\n([\s\S]+)/);
if (!explMatch || !resolvedMatch) {
throw new Error("Could not parse Claude's conflict resolution response");
}
return {
explanation: explMatch[1].trim(),
resolved: resolvedMatch[1].trimEnd() + "\n",
};
}

331
src/cli.ts Executable file
View File

@ -0,0 +1,331 @@
#!/usr/bin/env bun
import { Command } from "commander";
import { join } from "path";
import * as git from "./git.ts";
import * as vm from "./vm.ts";
import * as state from "./state.ts";
import { loadConfig } from "./config.ts";
import { generateCommitMessage, resolveConflict } from "./ai.ts";
const program = new Command();
program.name("sandlot").description("Branch-based development with git worktrees and Apple containers").version("0.1.0");
// ── sandlot new <branch> ──────────────────────────────────────────────
program
.command("new")
.argument("<branch>", "branch name")
.description("Create a new session with a worktree and VM")
.action(async (branch: string) => {
const root = await git.repoRoot();
const config = await loadConfig(root);
const worktreeRel = `.sandlot/${branch}`;
const worktreeAbs = join(root, worktreeRel);
// Check for stale directory
const existing = await state.getSession(root, branch);
if (existing) {
console.error(`Session "${branch}" already exists. Use "sandlot open ${branch}" to re-enter it.`);
process.exit(1);
}
console.log(`Creating worktree at ${worktreeRel}/`);
await git.createWorktree(branch, worktreeAbs, root);
console.log("Booting VM...");
const vmId = await vm.boot(`sandlot-${branch}`, worktreeAbs, config.vm);
await state.setSession(root, {
branch,
worktree: worktreeRel,
vm_id: vmId,
created_at: new Date().toISOString(),
status: "running",
});
await vm.shell(vmId);
});
// ── sandlot save [message] ────────────────────────────────────────────
program
.command("save")
.argument("[message]", "commit message (auto-generated if omitted)")
.description("Stage and commit all changes, push to origin")
.action(async (message?: string) => {
const root = await git.repoRoot();
const config = await loadConfig(root);
const branch = await detectBranch(root);
const session = await state.getSession(root, branch);
const cwd = session ? join(root, session.worktree) : root;
if (!(await git.hasChanges(cwd))) {
console.log("No changes to commit.");
return;
}
const staged = await git.stageAll(cwd);
console.log(`Staged ${staged} files`);
let commitMsg: string;
if (message) {
commitMsg = message;
} else {
const diff = await git.stagedDiff(cwd);
commitMsg = await generateCommitMessage(diff, config.ai?.model ?? "claude-sonnet-4-20250514");
}
await git.commit(commitMsg, cwd);
const firstLine = commitMsg.split("\n")[0];
console.log(`Commit: ${firstLine}`);
await git.push(branch, cwd);
console.log(`Pushed ${branch} → origin/${branch}`);
});
// ── sandlot push <target> ─────────────────────────────────────────────
program
.command("push")
.argument("<target>", "target branch to merge into")
.description("Merge session branch into target, then tear down")
.action(async (target: string) => {
const root = await git.repoRoot();
const config = await loadConfig(root);
const branch = await detectBranch(root);
const session = await state.getSession(root, branch);
if (!session) {
console.error(`No session found for branch "${branch}".`);
process.exit(1);
}
const worktreeCwd = join(root, session.worktree);
// Check for uncommitted changes
if (await git.hasChanges(worktreeCwd)) {
console.error(`Uncommitted changes in worktree. Run "sandlot save" first.`);
process.exit(1);
}
console.log(`Pushing ${branch}${target}...`);
// Checkout target in main working tree
await git.checkout(target, root);
// Merge
const merged = await git.merge(branch, root);
if (!merged) {
// Handle conflicts
const conflicts = await git.conflictedFiles(root);
console.log(`${conflicts.length} conflicts found. Resolving with Claude...\n`);
const model = config.ai?.model ?? "claude-sonnet-4-20250514";
const resolutions: Array<{ file: string; explanation: string }> = [];
for (const file of conflicts) {
const content = await git.conflictContent(file, root);
const { resolved, explanation } = await resolveConflict(file, content, model);
await Bun.write(join(root, file), resolved);
await git.stageFile(file, root);
resolutions.push({ file, explanation });
}
for (const { file, explanation } of resolutions) {
console.log(` ${file}`);
console.log(`${explanation}\n`);
}
// Prompt for confirmation
process.stdout.write("Accept Claude's resolutions? [Y/n] ");
const answer = await readLine();
if (answer.toLowerCase() === "n") {
await git.abortMerge(root);
console.log("Merge aborted. Resolve conflicts manually, then run sandlot push again.");
return;
}
await git.commitMerge(root);
console.log("Committed merge");
}
// Push target
await git.push(target, root);
console.log(`Pushed ${target} → origin/${target}`);
// Tear down
await vm.destroy(session.vm_id);
console.log(`Stopped VM ${branch}`);
await git.removeWorktree(join(root, session.worktree), root);
console.log(`Removed worktree ${session.worktree}/`);
await git.deleteLocalBranch(branch, root);
await git.deleteRemoteBranch(branch, root);
console.log(`Deleted branch ${branch} (local + remote)`);
await state.removeSession(root, branch);
});
// ── sandlot list ──────────────────────────────────────────────────────
program
.command("list")
.description("Show all active sessions")
.action(async () => {
const root = await git.repoRoot();
const st = await state.load(root);
const sessions = Object.values(st.sessions);
if (sessions.length === 0) {
console.log("No active sessions.");
return;
}
// Check actual VM statuses
const rows: Array<{ branch: string; vmStatus: string; worktree: string }> = [];
for (const s of sessions) {
const vmStatus = await vm.status(s.vm_id);
rows.push({ branch: s.branch, vmStatus, worktree: s.worktree });
}
// Print table
const branchWidth = Math.max(6, ...rows.map((r) => r.branch.length));
const statusWidth = Math.max(9, ...rows.map((r) => r.vmStatus.length));
console.log(
`${"BRANCH".padEnd(branchWidth)} ${"VM STATUS".padEnd(statusWidth)} WORKTREE`
);
for (const row of rows) {
console.log(
`${row.branch.padEnd(branchWidth)} ${row.vmStatus.padEnd(statusWidth)} ${row.worktree}/`
);
}
});
// ── sandlot open <branch> ─────────────────────────────────────────────
program
.command("open")
.argument("<branch>", "branch name")
.description("Re-enter an existing session's VM")
.action(async (branch: string) => {
const root = await git.repoRoot();
const config = await loadConfig(root);
const session = await state.getSession(root, branch);
if (!session) {
console.error(`No session found for branch "${branch}".`);
process.exit(1);
}
const vmStatus = await vm.status(session.vm_id);
if (vmStatus === "missing") {
// Stale VM, reboot
console.log("VM is gone. Rebooting...");
const worktreeAbs = join(root, session.worktree);
const vmId = await vm.boot(`sandlot-${branch}`, worktreeAbs, config.vm);
await state.setSession(root, { ...session, vm_id: vmId, status: "running" });
await vm.shell(vmId);
} else if (vmStatus === "stopped") {
console.log("Booting VM...");
// Need to start the existing container
const proc = Bun.spawn(["container", "start", session.vm_id], {
stdout: "inherit",
stderr: "inherit",
});
await proc.exited;
await state.setSession(root, { ...session, status: "running" });
await vm.shell(session.vm_id);
} else {
await vm.shell(session.vm_id);
}
});
// ── sandlot stop <branch> ─────────────────────────────────────────────
program
.command("stop")
.argument("<branch>", "branch name")
.description("Stop a session's VM without destroying it")
.action(async (branch: string) => {
const root = await git.repoRoot();
const session = await state.getSession(root, branch);
if (!session) {
console.error(`No session found for branch "${branch}".`);
process.exit(1);
}
await vm.stop(session.vm_id);
await state.setSession(root, { ...session, status: "stopped" });
console.log(`Stopped VM for ${branch}`);
});
// ── sandlot rm <branch> ───────────────────────────────────────────────
program
.command("rm")
.argument("<branch>", "branch name")
.description("Tear down a session without merging")
.action(async (branch: string) => {
const root = await git.repoRoot();
const session = await state.getSession(root, branch);
if (!session) {
console.error(`No session found for branch "${branch}".`);
process.exit(1);
}
await vm.destroy(session.vm_id);
console.log(`Stopped VM ${branch}`);
await git.removeWorktree(join(root, session.worktree), root);
console.log(`Removed worktree ${session.worktree}/`);
await git.deleteLocalBranch(branch, root);
console.log(`Deleted local branch ${branch}`);
await state.removeSession(root, branch);
});
// ── Helpers ───────────────────────────────────────────────────────────
/** Detect the current session branch from env or working directory. */
async function detectBranch(root: string): Promise<string> {
// Check env var first (set when inside a VM)
if (process.env.SANDLOT_BRANCH) {
return process.env.SANDLOT_BRANCH;
}
// Check if cwd is inside a worktree
const cwd = process.cwd();
const sandlotDir = join(root, ".sandlot");
if (cwd.startsWith(sandlotDir)) {
const rel = cwd.slice(sandlotDir.length + 1);
const branch = rel.split("/")[0];
if (branch) return branch;
}
// Fall back to current git branch
return await git.currentBranch();
}
/** Read a line from stdin. */
function readLine(): Promise<string> {
return new Promise((resolve) => {
process.stdin.setRawMode?.(false);
process.stdin.resume();
process.stdin.once("data", (chunk) => {
process.stdin.pause();
resolve(chunk.toString().trim());
});
});
}
program.parse();

35
src/config.ts Normal file
View File

@ -0,0 +1,35 @@
import { join } from "path";
export interface VmConfig {
cpus?: number;
memory?: string;
image?: string;
mounts?: Record<string, string>;
}
export interface AiConfig {
model?: string;
}
export interface SandlotConfig {
vm?: VmConfig;
ai?: AiConfig;
}
const DEFAULT_CONFIG: SandlotConfig = {
vm: { image: "ubuntu:24.04" },
ai: { model: "claude-sonnet-4-20250514" },
};
export async function loadConfig(repoRoot: string): Promise<SandlotConfig> {
const path = join(repoRoot, "sandlot.json");
const file = Bun.file(path);
if (await file.exists()) {
const userConfig = await file.json();
return {
vm: { ...DEFAULT_CONFIG.vm, ...userConfig.vm },
ai: { ...DEFAULT_CONFIG.ai, ...userConfig.ai },
};
}
return DEFAULT_CONFIG;
}

121
src/git.ts Normal file
View File

@ -0,0 +1,121 @@
import { $ } from "bun";
/** Get the repo root from a working directory. */
export async function repoRoot(cwd?: string): Promise<string> {
const result = await $`git rev-parse --show-toplevel`.cwd(cwd ?? ".").text();
return result.trim();
}
/** Get the current branch name. */
export async function currentBranch(cwd?: string): Promise<string> {
const result = await $`git rev-parse --abbrev-ref HEAD`.cwd(cwd ?? ".").text();
return result.trim();
}
/** Check if a branch exists locally or remotely. Returns "local", "remote", or null. */
export async function branchExists(branch: string, cwd?: string): Promise<"local" | "remote" | null> {
const dir = cwd ?? ".";
const local = await $`git show-ref --verify --quiet refs/heads/${branch}`.cwd(dir).nothrow().quiet();
if (local.exitCode === 0) return "local";
await $`git fetch origin`.cwd(dir).nothrow().quiet();
const remote = await $`git show-ref --verify --quiet refs/remotes/origin/${branch}`.cwd(dir).nothrow().quiet();
if (remote.exitCode === 0) return "remote";
return null;
}
/** Create a worktree for the given branch. */
export async function createWorktree(branch: string, worktreePath: string, cwd: string): Promise<void> {
const exists = await branchExists(branch, cwd);
if (exists === "local") {
await $`git worktree add ${worktreePath} ${branch}`.cwd(cwd);
} else if (exists === "remote") {
await $`git worktree add ${worktreePath} -b ${branch} origin/${branch}`.cwd(cwd);
} else {
// New branch from current HEAD
await $`git worktree add -b ${branch} ${worktreePath}`.cwd(cwd);
}
}
/** Remove a worktree. */
export async function removeWorktree(worktreePath: string, cwd: string): Promise<void> {
await $`git worktree remove ${worktreePath} --force`.cwd(cwd);
}
/** Delete a local branch. */
export async function deleteLocalBranch(branch: string, cwd: string): Promise<void> {
await $`git branch -D ${branch}`.cwd(cwd).nothrow();
}
/** Delete a remote branch. */
export async function deleteRemoteBranch(branch: string, cwd: string): Promise<void> {
await $`git push origin --delete ${branch}`.cwd(cwd).nothrow().quiet();
}
/** Stage all changes and return the number of staged files. */
export async function stageAll(cwd: string): Promise<number> {
await $`git add .`.cwd(cwd);
const status = await $`git diff --cached --name-only`.cwd(cwd).text();
const files = status.trim().split("\n").filter(Boolean);
return files.length;
}
/** Check if there are any uncommitted changes (staged or unstaged). */
export async function hasChanges(cwd: string): Promise<boolean> {
const result = await $`git status --porcelain`.cwd(cwd).text();
return result.trim().length > 0;
}
/** Get the diff of staged changes. */
export async function stagedDiff(cwd: string): Promise<string> {
return await $`git diff --cached`.cwd(cwd).text();
}
/** Commit with a message. */
export async function commit(message: string, cwd: string): Promise<void> {
await $`git commit -m ${message}`.cwd(cwd);
}
/** Push a branch to origin. */
export async function push(branch: string, cwd: string): Promise<void> {
await $`git push -u origin ${branch}`.cwd(cwd);
}
/** Checkout a branch in a working tree. */
export async function checkout(branch: string, cwd: string): Promise<void> {
await $`git checkout ${branch}`.cwd(cwd);
}
/** Merge a branch into the current branch. Returns true if successful, false if conflicts. */
export async function merge(branch: string, cwd: string): Promise<boolean> {
const result = await $`git merge ${branch}`.cwd(cwd).nothrow();
return result.exitCode === 0;
}
/** Get list of conflicted files. */
export async function conflictedFiles(cwd: string): Promise<string[]> {
const result = await $`git diff --name-only --diff-filter=U`.cwd(cwd).text();
return result.trim().split("\n").filter(Boolean);
}
/** Get the conflict content of a file (with markers). */
export async function conflictContent(filePath: string, cwd: string): Promise<string> {
return await Bun.file(`${cwd}/${filePath}`).text();
}
/** Stage a resolved file. */
export async function stageFile(filePath: string, cwd: string): Promise<void> {
await $`git add ${filePath}`.cwd(cwd);
}
/** Commit a merge (no message needed, uses default merge message). */
export async function commitMerge(cwd: string): Promise<void> {
await $`git commit --no-edit`.cwd(cwd);
}
/** Abort a merge. */
export async function abortMerge(cwd: string): Promise<void> {
await $`git merge --abort`.cwd(cwd);
}

50
src/state.ts Normal file
View File

@ -0,0 +1,50 @@
import { join } from "path";
export interface Session {
branch: string;
worktree: string;
vm_id: string;
created_at: string;
status: "running" | "stopped";
}
export interface State {
sessions: Record<string, Session>;
}
function statePath(repoRoot: string): string {
return join(repoRoot, ".sandlot", "state.json");
}
export async function load(repoRoot: string): Promise<State> {
const path = statePath(repoRoot);
const file = Bun.file(path);
if (await file.exists()) {
return await file.json();
}
return { sessions: {} };
}
export async function save(repoRoot: string, state: State): Promise<void> {
const path = statePath(repoRoot);
const dir = join(repoRoot, ".sandlot");
await Bun.write(join(dir, ".gitkeep"), ""); // ensure dir exists
await Bun.write(path, JSON.stringify(state, null, 2) + "\n");
}
export async function getSession(repoRoot: string, branch: string): Promise<Session | undefined> {
const state = await load(repoRoot);
return state.sessions[branch];
}
export async function setSession(repoRoot: string, session: Session): Promise<void> {
const state = await load(repoRoot);
state.sessions[session.branch] = session;
await save(repoRoot, state);
}
export async function removeSession(repoRoot: string, branch: string): Promise<void> {
const state = await load(repoRoot);
delete state.sessions[branch];
await save(repoRoot, state);
}

86
src/vm.ts Normal file
View File

@ -0,0 +1,86 @@
import { $ } from "bun";
import type { VmConfig } from "./config.ts";
/** Boot a container VM mapped to a worktree directory. Returns the container ID. */
export async function boot(
name: string,
worktreePath: string,
config?: VmConfig
): Promise<string> {
const args: string[] = ["container", "run", "--name", name];
if (config?.cpus) args.push("--cpus", String(config.cpus));
if (config?.memory) args.push("--memory", config.memory);
// Mount worktree as /root/work
args.push("--mount", `type=virtiofs,source=${worktreePath},target=/root/work`);
// Additional mounts from config
if (config?.mounts) {
for (const [source, target] of Object.entries(config.mounts)) {
args.push("--mount", `type=virtiofs,source=${source},target=${target}`);
}
}
const image = config?.image ?? "ubuntu:24.04";
args.push("-d", image);
const result = await $`${args}`.text();
return result.trim();
}
/** Stop a running container. */
export async function stop(vmId: string): Promise<void> {
await $`container stop ${vmId}`.nothrow().quiet();
}
/** Remove a container. */
export async function rm(vmId: string): Promise<void> {
await $`container rm ${vmId}`.nothrow().quiet();
}
/** Stop and remove a container. */
export async function destroy(vmId: string): Promise<void> {
await stop(vmId);
await rm(vmId);
}
/** Check if a container is running. Returns "running", "stopped", or "missing". */
export async function status(vmId: string): Promise<"running" | "stopped" | "missing"> {
const result = await $`container inspect ${vmId} --format '{{.State.Status}}'`
.nothrow()
.quiet()
.text();
const state = result.trim().replace(/'/g, "");
if (state.includes("running")) return "running";
if (state.includes("exited") || state.includes("stopped") || state.includes("created")) return "stopped";
return "missing";
}
/** Exec into a container shell interactively. */
export async function shell(vmId: string): Promise<void> {
const proc = Bun.spawn(["container", "exec", "-it", vmId, "/bin/bash"], {
stdin: "inherit",
stdout: "inherit",
stderr: "inherit",
});
await proc.exited;
}
/** List all containers with their names and statuses. */
export async function list(): Promise<Array<{ id: string; name: string; status: string }>> {
const result = await $`container ps -a --format '{{.ID}}\t{{.Names}}\t{{.Status}}'`
.nothrow()
.quiet()
.text();
return result
.trim()
.split("\n")
.filter(Boolean)
.map((line) => {
const [id, name, status] = line.replace(/'/g, "").split("\t");
return { id, name, status };
});
}

13
tsconfig.json Normal file
View File

@ -0,0 +1,13 @@
{
"compilerOptions": {
"target": "ESNext",
"module": "ESNext",
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"types": ["bun-types"],
"strict": true,
"skipLibCheck": true,
"noEmit": true
},
"include": ["src"]
}