diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..e0b94c2 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +node_modules/ +.sandlot/ diff --git a/README.md b/README.md new file mode 100644 index 0000000..d357e44 --- /dev/null +++ b/README.md @@ -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 # re-enter a session's VM +sandlot stop # stop a VM without destroying it +sandlot rm # tear down without merging +``` diff --git a/SPEC.md b/SPEC.md new file mode 100644 index 0000000..e0d1459 --- /dev/null +++ b/SPEC.md @@ -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 ` 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 ` + +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//` (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 ` + +Push the current session's branch into ``, then tear everything down. + +1. Checks out `` in the main working tree +2. Merges the session branch (fast-forward if possible, merge commit otherwise) +3. Pushes `` 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 ` 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 ` + +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 ` + +Stop a session's VM without destroying it. The worktree and branch remain. + +### `sandlot rm ` + +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//` 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. diff --git a/bun.lock b/bun.lock new file mode 100644 index 0000000..5596a7a --- /dev/null +++ b/bun.lock @@ -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=="], + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..8f1a125 --- /dev/null +++ b/package.json @@ -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" + } +} diff --git a/src/ai.ts b/src/ai.ts new file mode 100644 index 0000000..0b1494d --- /dev/null +++ b/src/ai.ts @@ -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 { + 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: +RESOLVED: + + +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", + }; +} diff --git a/src/cli.ts b/src/cli.ts new file mode 100755 index 0000000..9c82f89 --- /dev/null +++ b/src/cli.ts @@ -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 ────────────────────────────────────────────── + +program + .command("new") + .argument("", "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 ───────────────────────────────────────────── + +program + .command("push") + .argument("", "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 ───────────────────────────────────────────── + +program + .command("open") + .argument("", "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 ───────────────────────────────────────────── + +program + .command("stop") + .argument("", "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 ─────────────────────────────────────────────── + +program + .command("rm") + .argument("", "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 { + // 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 { + 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(); diff --git a/src/config.ts b/src/config.ts new file mode 100644 index 0000000..fd243da --- /dev/null +++ b/src/config.ts @@ -0,0 +1,35 @@ +import { join } from "path"; + +export interface VmConfig { + cpus?: number; + memory?: string; + image?: string; + mounts?: Record; +} + +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 { + 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; +} diff --git a/src/git.ts b/src/git.ts new file mode 100644 index 0000000..b27694f --- /dev/null +++ b/src/git.ts @@ -0,0 +1,121 @@ +import { $ } from "bun"; + +/** Get the repo root from a working directory. */ +export async function repoRoot(cwd?: string): Promise { + 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 { + 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 { + 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 { + await $`git worktree remove ${worktreePath} --force`.cwd(cwd); +} + +/** Delete a local branch. */ +export async function deleteLocalBranch(branch: string, cwd: string): Promise { + await $`git branch -D ${branch}`.cwd(cwd).nothrow(); +} + +/** Delete a remote branch. */ +export async function deleteRemoteBranch(branch: string, cwd: string): Promise { + 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 { + 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 { + 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 { + return await $`git diff --cached`.cwd(cwd).text(); +} + +/** Commit with a message. */ +export async function commit(message: string, cwd: string): Promise { + await $`git commit -m ${message}`.cwd(cwd); +} + +/** Push a branch to origin. */ +export async function push(branch: string, cwd: string): Promise { + await $`git push -u origin ${branch}`.cwd(cwd); +} + +/** Checkout a branch in a working tree. */ +export async function checkout(branch: string, cwd: string): Promise { + 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 { + 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 { + 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 { + return await Bun.file(`${cwd}/${filePath}`).text(); +} + +/** Stage a resolved file. */ +export async function stageFile(filePath: string, cwd: string): Promise { + await $`git add ${filePath}`.cwd(cwd); +} + +/** Commit a merge (no message needed, uses default merge message). */ +export async function commitMerge(cwd: string): Promise { + await $`git commit --no-edit`.cwd(cwd); +} + +/** Abort a merge. */ +export async function abortMerge(cwd: string): Promise { + await $`git merge --abort`.cwd(cwd); +} diff --git a/src/state.ts b/src/state.ts new file mode 100644 index 0000000..2fe919d --- /dev/null +++ b/src/state.ts @@ -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; +} + +function statePath(repoRoot: string): string { + return join(repoRoot, ".sandlot", "state.json"); +} + +export async function load(repoRoot: string): Promise { + 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 { + 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 { + const state = await load(repoRoot); + return state.sessions[branch]; +} + +export async function setSession(repoRoot: string, session: Session): Promise { + const state = await load(repoRoot); + state.sessions[session.branch] = session; + await save(repoRoot, state); +} + +export async function removeSession(repoRoot: string, branch: string): Promise { + const state = await load(repoRoot); + delete state.sessions[branch]; + await save(repoRoot, state); +} diff --git a/src/vm.ts b/src/vm.ts new file mode 100644 index 0000000..ff7c3da --- /dev/null +++ b/src/vm.ts @@ -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 { + 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 { + await $`container stop ${vmId}`.nothrow().quiet(); +} + +/** Remove a container. */ +export async function rm(vmId: string): Promise { + await $`container rm ${vmId}`.nothrow().quiet(); +} + +/** Stop and remove a container. */ +export async function destroy(vmId: string): Promise { + 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 { + 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> { + 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 }; + }); +} diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..5f06b7b --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,13 @@ +{ + "compilerOptions": { + "target": "ESNext", + "module": "ESNext", + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "types": ["bun-types"], + "strict": true, + "skipLibCheck": true, + "noEmit": true + }, + "include": ["src"] +}