Compare commits

..

1 Commits

Author SHA1 Message Date
92b64fcf3c rust rewrite 2026-04-10 11:13:00 -07:00
55 changed files with 7777 additions and 185 deletions

View File

@ -69,7 +69,7 @@ Each module has a single responsibility. No classes — only exported functions
2. Creates symlink `<repo-root>/.sandlot/<branch>` → worktree path 2. Creates symlink `<repo-root>/.sandlot/<branch>` → worktree path
3. `vm.ensure()` → start/create/provision the container 3. `vm.ensure()` → start/create/provision the container
4. `state.setSession()` → write to `.sandlot/state.json` 4. `state.setSession()` → write to `.sandlot/state.json`
5. `vm.pi()` → launch Pi in container at worktree path 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` 6. `saveChanges()` → auto-save on exit (stage all, AI-generated commit message) unless `--no-save`
**Worktree location**: `~/.sandlot/<repo-name>/<branch>/` (outside the repo) **Worktree location**: `~/.sandlot/<repo-name>/<branch>/` (outside the repo)
@ -85,13 +85,11 @@ Each module has a single responsibility. No classes — only exported functions
- Mounts: `~/dev` **read-only** at `/host`, `~/.sandlot` read-write at `/sandlot` - 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 - 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/…`) - `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, neofetch, and Neovim - Provisioned once on first use: apt installs `curl git fish unzip`, then installs Bun, Claude Code, neofetch, and Neovim
- Binary caching: after first install, bun/neofetch/nvim are cached in `~/.sandlot/.cache/` — subsequent `vm create` uses cached copies, skipping downloads - 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
- Persistent tooling: Pi, Rust, Go, and sccache are installed to `/sandlot/` paths that survive container recreates - 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)
- Pi is installed as a standalone binary from GitHub releases (`pi-linux-arm64.tar.gz`) to `/sandlot/.pi-bin/` - Claude settings: `skipDangerousModePermissionPrompt: true`, activity tracking hooks (`UserPromptSubmit` / `Stop`) in container
- API key: read from `~/.env` on host (`ANTHROPIC_API_KEY=...`), written to `~/.pi/agent/auth.json` in the container via temp file (never passed as a process argument) - Also writes `~/.claude.json` with `hasCompletedOnboarding: true` and `effortCalloutDismissed: true`
- Pi settings: `~/.pi/agent/settings.json` with `defaultProvider: anthropic`, `defaultModel: claude-opus-4-6`, `defaultThinkingLevel: high`, `quietStartup: true`
- Activity tracking: a Pi extension at `~/.pi/agent/extensions/sandlot-activity.ts` writes activity markers on `before_agent_start` and `tool_call` events
## Shell Command Pattern ## Shell Command Pattern
@ -140,21 +138,23 @@ Always use `.nothrow()` for commands that may fail non-fatally. Use `.quiet()` t
## Key Implementation Notes ## Key Implementation Notes
- `vm.pi()` uses `Bun.spawn` with `stdin/stdout/stderr: "inherit"` for interactive TTY; in print mode (`-p`), captures stdout via pipe and returns the output - `vm.exec()` prepends `export PATH=$HOME/.local/bin:$PATH` so `claude` binary is found
- `vm.pi()` runs Pi with `--append-system-prompt` (system prompt describes the container environment); model and thinking level are configured via `~/.pi/agent/settings.json` - `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.pi()` retry logic: if exit code is non-zero and `--continue` was used, retries without `--continue` - `vm.claude()` runs Claude with `--dangerously-skip-permissions`, `--model claude-opus-4-6`, and `--append-system-prompt` (system prompt describes the container environment)
- `vm.piPipe()` writes input to a temp file in `~/.sandlot/`, pipes it to `pi -p` inside the container, and returns the result — used for commit message generation and conflict resolution - `vm.claude()` retry logic: if exit code is non-zero and `--continue` was used, retries without `--continue`
- `vm.isPiActive()` reads activity marker files written by the in-container `sandlot-activity.ts` Pi extension - `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 - 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 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.pi()` to resume the previous conversation - `sandlot open` always passes `continue: true` to `vm.claude()` to resume the previous conversation
- `sandlot save` uses `vm.piPipe()` to generate commit messages from the staged diff - `sandlot save` uses `vm.claudePipe()` to generate commit messages from the staged diff
- `sandlot merge` and `sandlot rebase` use `vm.piPipe()` to resolve merge conflicts automatically - `sandlot merge` and `sandlot rebase` use `vm.claudePipe()` to resolve merge conflicts automatically
- `sandlot squash` collapses all branch commits into a single commit in-place via `git reset --soft` to the merge base, then generates an AI commit message via `piPipe()`; falls back to `"squash <branch>"`. Rolls back to the original HEAD on failure. - `sandlot squash` collapses all branch commits into a single commit in-place via `git reset --soft` to the merge base, then generates an AI commit message via `claudePipe()`; falls back to `"squash <branch>"`. Rolls back to the original HEAD on failure.
- `sandlot merge` delegates to `mergeAndClose()` in `helpers.ts`, which merges, resolves conflicts, commits, and then calls `closeAction()` to clean up - `sandlot merge` delegates 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 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 (Pi not active) and clears them from state - `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 Pi exits (disable with `--no-save`) - `sandlot new` and `sandlot open` auto-save changes when Claude exits (disable with `--no-save`)
- `sandlot close` has a hidden `rm` alias - `sandlot close` has a hidden `rm` alias
- Default behavior (no subcommand): always runs `list` (which prints "No active sessions." if empty) - Default behavior (no subcommand): always runs `list` (which prints "No active sessions." if empty)
- `.sandlot/` should be in the repo's `.gitignore` - `.sandlot/` should be in the repo's `.gitignore`

View File

@ -59,17 +59,3 @@ Config is stored in `~/.config/sandlot/config.json`.
| Key | Default | Description | | Key | Default | Description |
|-----|---------|-------------| |-----|---------|-------------|
| `memory` | `16G` | Container memory limit (e.g. 4G, 16G, 32G, 64G) | | `memory` | `16G` | Container memory limit (e.g. 4G, 16G, 32G, 64G) |
| `hosts` | `[]` | Hostnames to resolve on the host and inject into the container |
#### Local network hosts
The container can't resolve `.local` (mDNS) hostnames natively. To make local network hosts reachable from inside the container, add them to the `hosts` config:
```bash
sandlot config hosts add claude.toes.local
sandlot config hosts add myserver.local
sandlot config hosts rm myserver.local
sandlot config hosts # list configured hosts
```
Hostnames are resolved on the Mac via mDNS and written to the container's `/etc/hosts` every time the VM starts.

View File

@ -1,6 +1,6 @@
{ {
"name": "@because/sandlot", "name": "@because/sandlot",
"version": "0.0.54", "version": "0.0.50",
"description": "Sandboxed, branch-based development with Claude", "description": "Sandboxed, branch-based development with Claude",
"type": "module", "type": "module",
"bin": { "bin": {

1
rust-sandlot/.gitignore vendored Normal file
View File

@ -0,0 +1 @@
target/

1872
rust-sandlot/Cargo.lock generated Normal file

File diff suppressed because it is too large Load Diff

24
rust-sandlot/Cargo.toml Normal file
View File

@ -0,0 +1,24 @@
[package]
name = "sandlot"
version = "0.0.50"
edition = "2024"
description = "Sandboxed, branch-based development with Claude"
license = "MIT"
[[bin]]
name = "sandlot"
path = "src/main.rs"
[dependencies]
clap = { version = "4", features = ["derive", "string"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
tokio = { version = "1", features = ["full"] }
reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] }
uuid = { version = "1", features = ["v4"] }
rand = "0.9"
dirs = "6"
regex = "1"
which = "7"
libc = "0.2"
anyhow = "1"

895
rust-sandlot/TESTING.md Normal file
View File

@ -0,0 +1,895 @@
# Sandlot Rust Rewrite: VM Integration Testing
This document describes how to test the Rust rewrite of sandlot against the TypeScript original. The goal is to verify **identical behavior** for every command that interacts with the VM/container, git worktrees, or session state.
## Prerequisites
- macOS on Apple Silicon
- Apple Container installed (`brew install container`)
- Rust toolchain (`rustup`)
- Bun installed (`brew install oven-sh/bun/bun`)
- An `ANTHROPIC_API_KEY` in `~/.env` (format: `ANTHROPIC_API_KEY=sk-ant-...`)
- A git repo to use as a test bed (create a throwaway one)
## Setup
### 1. Build the Rust binary
```bash
cd rust-sandlot
cargo build --release
```
The binary is at `./rust-sandlot/target/release/sandlot`.
### 2. Set up aliases
Use two distinct aliases so you can run either implementation:
```bash
alias sandlot-ts='bun run /path/to/rust-rewrite/src/cli.ts'
alias sandlot-rs='/path/to/rust-rewrite/rust-sandlot/target/release/sandlot'
```
### 3. Destroy any existing VM
Start from a clean slate. Both implementations share the same container name (`sandlot`), so only one can be tested at a time:
```bash
sandlot-ts vm destroy 2>/dev/null
```
### 4. Create a test repo
```bash
mkdir /tmp/sandlot-test-repo && cd /tmp/sandlot-test-repo
git init
echo "hello" > README.md
git add . && git commit -m "initial commit"
```
All tests below assume you run commands from inside this repo.
---
## Testing methodology
For each test:
1. Run the command with `sandlot-ts` first, observe the result
2. Clean up / reset state
3. Run the same command with `sandlot-rs`, observe the result
4. Compare: stdout content, stderr content, exit code, and side effects (files created, git state, container state)
Some commands produce animated spinner output on stderr. The final line of spinner output is what matters (the success/failure message). Intermediate spinner frames are cosmetic and may differ in timing.
When comparing output, strip ANSI codes for semantic comparison:
```bash
sandlot-rs list 2>&1 | sed 's/\x1b\[[0-9;]*m//g'
sandlot-ts list 2>&1 | sed 's/\x1b\[[0-9;]*m//g'
```
---
## Phase 1: VM Lifecycle
These tests verify container management. Run them in order.
### Test 1.1: `vm create`
```bash
sandlot-ts vm destroy 2>/dev/null # clean slate
sandlot-rs vm create
```
**Expect:**
- Spinner output on stderr progressing through: "Creating VM" -> "Pulling image & creating container" -> "Installing packages" -> "Installing Bun" -> "Installing Claude Code" -> "Installing neofetch" -> "Installing Neovim" -> "Configuring environment"
- Final line: `✔ VM created`
- Exit code: 0
**Verify side effects:**
```bash
container list --format json --all # should show "sandlot" container running
container exec sandlot which claude # should print /home/ubuntu/.local/bin/claude
container exec sandlot which bun # should print /home/ubuntu/.local/bin/bun
container exec sandlot which fish # should print /usr/bin/fish
container exec sandlot test -f /home/ubuntu/.claude/settings.json && echo ok
container exec sandlot test -f /home/ubuntu/.claude/api-key-helper.sh && echo ok
container exec sandlot cat /home/ubuntu/.claude.json # should have hasCompletedOnboarding: true
```
Now destroy and repeat with TS:
```bash
sandlot-rs vm destroy
sandlot-ts vm create
```
Verify the same side effects exist.
### Test 1.2: `vm status`
```bash
# With VM running:
sandlot-rs vm status
sandlot-ts vm status
```
**Expect (no sessions):**
```
VM: running (in green)
No active sessions. (in dim)
```
```bash
# JSON mode:
sandlot-rs vm status --json
sandlot-ts vm status --json
```
**Expect:** JSON with `"vm": "running"` and `"sessions": []`.
### Test 1.3: `vm stop`
```bash
sandlot-rs vm stop
```
**Expect:** Spinner, then `✔ VM stopped`. Exit code 0.
```bash
sandlot-rs vm status
```
**Expect:** `VM: stopped` (in yellow).
### Test 1.4: `vm start`
```bash
sandlot-rs vm start
```
**Expect:** `✔ VM started` on stdout. Exit code 0.
### Test 1.5: `vm info`
```bash
sandlot-rs vm info
sandlot-ts vm info
```
**Expect:** neofetch output (system info). Both should show identical container specs.
### Test 1.6: `vm shell`
```bash
sandlot-rs vm shell
```
**Expect:** Drops into an interactive fish shell inside the container. Type `exit` to leave. Verify the prompt works and `echo $PATH` includes the expected paths.
### Test 1.7: `vm destroy`
```bash
sandlot-rs vm destroy
```
**Expect:** Spinner, then `✔ VM destroyed`. Exit code 0.
```bash
sandlot-rs vm status
```
**Expect:** `VM: missing` (in red).
### Test 1.8: `vm create` (duplicate)
```bash
sandlot-rs vm create
# Then try again:
sandlot-rs vm create
```
**Expect second call:** Error: `Container already exists. Use 'sandlot vm destroy' first to recreate it.` Exit code 1.
### Test 1.9: `vm uncache`
```bash
sandlot-rs vm uncache
```
**Expect:** `✔ Package cache cleared` if cache existed, or `No cache to clear`.
### Test 1.10: `vm start` when missing
```bash
sandlot-rs vm destroy
sandlot-rs vm start
```
**Expect:** Error: `Container does not exist. Use 'sandlot vm create' first.` Exit code 1.
---
## Phase 2: Session Lifecycle
Ensure a VM is running before starting: `sandlot-rs vm create` (or `ensure` will auto-create).
### Test 2.1: `new` with explicit branch name
```bash
sandlot-rs new test-branch-1
# Claude launches interactively. Press Ctrl+C or /exit to quit.
```
**Expect:**
- Spinner: "Creating worktree" -> "Starting container" -> `✔ [test-branch-1] Session ready`
- Claude Code launches in the container
- After exit, auto-save runs (spinner: "Staging changes" -> either "No changes to commit" or "Saved: ...")
**Verify side effects:**
```bash
ls -la ~/.sandlot/sandlot-test-repo/test-branch-1/ # worktree exists
ls -la .sandlot/test-branch-1 # symlink exists
cat .sandlot/state.json # session entry exists
git worktree list # shows the worktree
```
### Test 2.2: `new` with no branch (random name)
```bash
sandlot-rs new
```
**Expect:** A random `adjective-noun` branch name is generated (e.g., `calm-fern`). The rest of the flow is identical to 2.1.
### Test 2.3: `new` with prompt (spaces in "branch")
```bash
sandlot-rs new "fix the login bug on the settings page"
```
**Expect:** The text is treated as a prompt. A branch name is derived via Claude Haiku API (e.g., `login-fix`). If the API call fails, falls back to first two words (`fix-the`). The prompt is stored in `state.json`.
### Test 2.4: `new` with `-p` (print mode)
```bash
sandlot-rs new -p "what is 2+2"
```
**Expect:**
- Branch name derived from the prompt
- Spinner: "Creating worktree" -> "Starting container" -> "Running prompt..."
- Claude's response printed to stdout (rendered as markdown)
- No interactive session
- Auto-save runs after
### Test 2.5: `new` duplicate session
```bash
sandlot-rs new test-branch-1
```
**Expect:** `✖ Session "test-branch-1" already exists. Use "sandlot open test-branch-1" to re-enter it.` Exit code 1.
### Test 2.6: `list` with sessions
```bash
sandlot-rs list
```
**Expect:**
```
BRANCH PROMPT
◯ test-branch-1
◯ other-branch fix the login bug...
◯ idle · ◎ active · ◐ unsaved · ● saved · ⦿ review
```
Status icons use ANSI colors (dim for idle, cyan for active, yellow for dirty, green for saved, magenta for review).
```bash
sandlot-rs list --json
```
**Expect:** JSON array with each session having `branch`, `worktree`, `created_at`, `prompt`, `in_review`, `status`, `repoRoot` fields.
### Test 2.7: `open` existing session
```bash
sandlot-rs open test-branch-1
```
**Expect:**
- Spinner: "Starting container" -> `✔ [test-branch-1] Session ready`
- Claude launches with `--continue` (resumes prior conversation)
- After exit, auto-save runs
### Test 2.8: `open` with `--no-save`
```bash
sandlot-rs open test-branch-1 --no-save
```
**Expect:** Same as 2.7 but no auto-save after Claude exits.
### Test 2.9: `open` nonexistent session but existing branch
If you manually create a branch and remove the session from state.json, `open` should recreate the session:
```bash
# Remove from state but keep the branch
cat .sandlot/state.json # note the session
# Manually edit state.json to remove the session entry
sandlot-rs open test-branch-1
```
**Expect:** Worktree is recreated, session is re-added to state, Claude launches.
### Test 2.10: `open` nonexistent branch
```bash
sandlot-rs open nonexistent-branch-xyz
```
**Expect:** `✖ No session or branch found for "nonexistent-branch-xyz".` Exit code 1.
---
## Phase 3: Branch Operations (read-only)
These commands read git state without modifying it. Create a session with some commits first:
```bash
sandlot-rs new branch-ops-test
# Inside Claude, make some changes and commit, then exit
# Or manually:
cd ~/.sandlot/sandlot-test-repo/branch-ops-test
echo "new file" > test.txt
git add . && git commit -m "add test file"
cd /tmp/sandlot-test-repo
```
### Test 3.1: `diff`
```bash
sandlot-rs diff branch-ops-test
```
**Expect:**
- If uncommitted changes in worktree: shows `git diff HEAD`
- If clean: shows `git diff main...branch-ops-test`
- Output piped through git's native diff display (with colors if terminal supports)
Compare with:
```bash
sandlot-ts diff branch-ops-test
```
### Test 3.2: `log`
```bash
sandlot-rs log branch-ops-test
```
**Expect:**
- If the session has a prompt, prints `PROMPT: <text>` to stderr first
- Shows `git log main..HEAD` output with commit hashes highlighted in yellow
- Piped through pager if output exceeds terminal height
### Test 3.3: `show`
```bash
sandlot-rs show branch-ops-test
```
**Expect:**
- Prints prompt to stderr (if stored)
- Shows full `git diff main...branch` output on stdout
### Test 3.4: `web`
```bash
sandlot-rs web branch-ops-test
```
**Expect:**
- Generates `/tmp/sandlot-branch-ops-test.html`
- Opens it in the default browser
- HTML contains: branch name, prompt, commit log, diff stats, syntax-highlighted diff
**Verify:** Open the generated HTML file and compare it with the one generated by `sandlot-ts web branch-ops-test`.
### Test 3.5: `dir`
```bash
sandlot-rs dir branch-ops-test
```
**Expect:** Prints the absolute worktree path to stdout, e.g., `/Users/you/.sandlot/sandlot-test-repo/branch-ops-test`.
### Test 3.6: `dir` nonexistent session
```bash
sandlot-rs dir nonexistent
```
**Expect:** `✖ No session found for branch "nonexistent".` Exit code 1.
---
## Phase 4: Save, Merge, Squash, Rebase
### Test 4.1: `save` with auto-generated message
```bash
# Make changes in the worktree first:
echo "change" >> ~/.sandlot/sandlot-test-repo/branch-ops-test/test.txt
sandlot-rs save branch-ops-test
```
**Expect:**
- Spinner: `[branch-ops-test] Staging changes` -> `Starting container` -> `Generating commit message` -> `Committing` -> `✔ [branch-ops-test] Saved: <commit message>`
- The commit message is AI-generated from the diff
### Test 4.2: `save` with explicit message
```bash
echo "another change" >> ~/.sandlot/sandlot-test-repo/branch-ops-test/test.txt
sandlot-rs save branch-ops-test "manual commit message"
```
**Expect:**
- Spinner: `Staging changes` -> `Committing` -> `✔ [branch-ops-test] Saved: manual commit message`
- No AI generation (no container startup needed for the message)
### Test 4.3: `save` with no changes
```bash
sandlot-rs save branch-ops-test
```
**Expect:** `✖ [branch-ops-test] No changes to commit`. Exit code 1.
### Test 4.4: `squash`
```bash
# Ensure branch has multiple commits beyond main
sandlot-rs squash branch-ops-test
```
**Expect:**
- Spinner: `[branch-ops-test] Squashing` -> `Starting container` -> `Generating commit message` -> `✔ [branch-ops-test] Squashed branch-ops-test into a single commit`
- `git log main..HEAD` in the worktree should show exactly 1 commit
### Test 4.5: `squash` with no commits
```bash
sandlot-rs new fresh-branch
# Exit Claude immediately without making changes
sandlot-rs squash fresh-branch
```
**Expect:** `✖ Branch "fresh-branch" has no commits beyond main.` Exit code 1.
### Test 4.6: `squash` with dirty worktree
```bash
echo "dirty" >> ~/.sandlot/sandlot-test-repo/branch-ops-test/test.txt
sandlot-rs squash branch-ops-test
```
**Expect:** `✖ Branch "branch-ops-test" has unsaved changes. Run "sandlot save branch-ops-test" first.` Exit code 1.
### Test 4.7: `rebase`
Set up a scenario where main has advanced:
```bash
# In the main repo, add a commit to main
cd /tmp/sandlot-test-repo
echo "main change" > main-file.txt
git add . && git commit -m "advance main"
sandlot-rs rebase branch-ops-test
```
**Expect (clean rebase):**
- Spinner: `[branch-ops-test] Fetching origin` -> `Rebasing onto origin/main` -> `✔ [branch-ops-test] Rebased branch-ops-test onto main`
**Expect (with conflicts):**
- `◆ Rebase conflicts in N file(s). Resolving with Claude...`
- Spinner: `[branch-ops-test] Starting container` -> `(1/N) Resolving <file> (round 1)` -> `✔ [branch-ops-test] Rebased branch-ops-test onto main (resolved N conflict round(s))`
### Test 4.8: `rebase` with dirty worktree
```bash
echo "dirty" >> ~/.sandlot/sandlot-test-repo/branch-ops-test/test.txt
sandlot-rs rebase branch-ops-test
```
**Expect:** `✖ Branch "branch-ops-test" has unsaved changes. Run "sandlot save branch-ops-test" first.` Exit code 1.
### Test 4.9: `merge`
```bash
cd /tmp/sandlot-test-repo
git checkout main
sandlot-rs merge branch-ops-test
```
**Expect (clean merge):**
- Spinner: `Merging branch-ops-test` -> `✔ Merged branch-ops-test into main`
- Session is torn down (worktree removed, symlink removed, state cleared)
- Local branch is deleted
**Expect (with conflicts):**
- Spinner: `Resolving N conflict(s)` -> `Starting container` -> `(1/N) Resolving <file>` -> `✔ Resolved N conflict(s) and merged branch-ops-test`
- Same cleanup as clean merge
### Test 4.10: `merge` not on main
```bash
git checkout -b other-branch
sandlot-rs merge some-branch
```
**Expect:** `✖ You must be on "main" to merge. Currently on "other-branch". Use --force to merge into "other-branch" anyway.` Exit code 1.
### Test 4.11: `merge --force` on non-main
```bash
sandlot-rs merge some-branch --force
```
**Expect:** Merge proceeds into `other-branch` instead of `main`.
### Test 4.12: `merge` with dirty session
```bash
echo "dirty" >> ~/.sandlot/sandlot-test-repo/some-branch/file.txt
sandlot-rs merge some-branch
```
**Expect:** `✖ Branch "some-branch" has unsaved changes. Run "sandlot save some-branch" first.` Exit code 1.
---
## Phase 5: Review
### Test 5.1: `review` interactive
```bash
sandlot-rs review branch-ops-test
```
**Expect:**
- Spinner: `[branch-ops-test] Starting container` -> `✔ [branch-ops-test] Session ready`
- Claude launches with the review prompt (4-agent grumpy senior engineer review)
- `state.json` shows `in_review: true` during the review
- After exit: `in_review` is cleared, auto-save runs
### Test 5.2: `review --print`
```bash
sandlot-rs review branch-ops-test --print
```
**Expect:**
- Spinner: `[branch-ops-test] Starting container` -> `Running review...`
- Review output printed to stdout (not interactive)
- No auto-save after
### Test 5.3: `review` with extra prompt
```bash
sandlot-rs review branch-ops-test "also check for SQL injection"
```
**Expect:** The extra text is appended to the review prompt. Claude receives both the standard review instructions and the additional context.
---
## Phase 6: Shell and Edit
### Test 6.1: `shell` with branch
```bash
sandlot-rs shell branch-ops-test
```
**Expect:** Interactive fish shell opens in the worktree directory inside the container. `pwd` should show the container-translated worktree path.
### Test 6.2: `shell` without branch
```bash
sandlot-rs shell
```
**Expect:** Interactive fish shell opens at a default location (no `--workdir` flag).
### Test 6.3: `edit`
```bash
export EDITOR=vim
sandlot-rs edit branch-ops-test test.txt
```
**Expect:** vim opens the file at the worktree path. After closing, exits cleanly.
### Test 6.4: `edit` with missing EDITOR
```bash
unset EDITOR
sandlot-rs edit branch-ops-test test.txt
```
**Expect:** `✖ $EDITOR is not set.` Exit code 1.
### Test 6.5: `edit` with missing file
```bash
export EDITOR=vim
sandlot-rs edit branch-ops-test nonexistent.txt
```
**Expect:** `✖ File not found: nonexistent.txt` Exit code 1.
### Test 6.6: `edit` path escape attempt
```bash
sandlot-rs edit branch-ops-test ../../etc/passwd
```
**Expect:** Error (path escapes the worktree). The exact message may vary but should prevent access.
---
## Phase 7: Close and Checkout
### Test 7.1: `close` clean session
```bash
sandlot-rs close test-branch-1
```
**Expect:**
- `✔ Closed session test-branch-1` on stdout
- Worktree removed from `~/.sandlot/...`
- Symlink removed from `.sandlot/test-branch-1`
- Session removed from `state.json`
- Local branch deleted
- Exit code 0
### Test 7.2: `close` dirty session
```bash
# Set up a dirty session first
sandlot-rs new dirty-test
echo "uncommitted" > ~/.sandlot/sandlot-test-repo/dirty-test/uncommitted.txt
sandlot-rs close dirty-test
```
**Expect:** `✖ Branch "dirty-test" has unsaved changes. Run "sandlot save dirty-test" first, or use -f to force.` Exit code 1.
### Test 7.3: `close --force` dirty session
```bash
sandlot-rs close dirty-test --force
```
**Expect:** `✔ Closed session dirty-test`. Session is torn down despite uncommitted changes.
### Test 7.4: `rm` alias
```bash
sandlot-rs rm some-branch
```
**Expect:** Identical to `close`. The `rm` command is a hidden alias.
### Test 7.5: `close` nonexistent session
```bash
sandlot-rs close nonexistent-xyz
```
**Expect:** `✖ No session found for branch "nonexistent-xyz".` Exit code 1.
### Test 7.6: `checkout`
```bash
sandlot-rs new checkout-test
# Make a commit
echo "data" > ~/.sandlot/sandlot-test-repo/checkout-test/data.txt
cd ~/.sandlot/sandlot-test-repo/checkout-test && git add . && git commit -m "data"
cd /tmp/sandlot-test-repo
sandlot-rs checkout checkout-test
```
**Expect:**
- `✔ Checked out checkout-test`
- Session torn down (worktree, symlink, state removed)
- `git branch` in main repo shows you're now on `checkout-test`
- Branch is NOT deleted (unlike `close` and `merge`)
### Test 7.7: `checkout` with dirty main worktree
```bash
echo "dirty" > /tmp/sandlot-test-repo/dirty.txt
sandlot-rs checkout some-branch
```
**Expect:** `✖ Working tree has uncommitted changes that may conflict with checkout. Commit or stash them first, or use -f to force.` Exit code 1.
### Test 7.8: `checkout --force` with dirty main worktree
```bash
sandlot-rs checkout some-branch --force
```
**Expect:** Proceeds despite dirty working tree.
---
## Phase 8: Cleanup and Upgrade
### Test 8.1: `cleanup` with stale sessions
```bash
# Create a session, then manually delete the worktree
sandlot-rs new stale-test
rm -rf ~/.sandlot/sandlot-test-repo/stale-test
sandlot-rs cleanup
```
**Expect:** `✔ Removed stale session: stale-test`. Session removed from state.json.
### Test 8.2: `cleanup` with no stale sessions
```bash
sandlot-rs cleanup
```
**Expect:** `No stale sessions found.` (or `No sessions to clean up.` if no sessions at all).
### Test 8.3: `upgrade`
```bash
sandlot-rs upgrade
```
**Expect:** Attempts to upgrade sandlot. Compare behavior with `sandlot-ts upgrade`. Both should attempt the same upgrade mechanism.
---
## Phase 9: List Status Resolution
This tests that `list` correctly resolves session status.
### Test 9.1: Idle session
```bash
sandlot-rs new idle-test
# Exit Claude immediately, no changes
sandlot-rs list
```
**Expect:** `idle-test` shows `◯` (dim circle) = idle.
### Test 9.2: Dirty session
```bash
echo "dirty" > ~/.sandlot/sandlot-test-repo/idle-test/dirty.txt
sandlot-rs list
```
**Expect:** `idle-test` shows `◐` (yellow half-circle) = unsaved.
### Test 9.3: Saved session
```bash
cd ~/.sandlot/sandlot-test-repo/idle-test
git add . && git commit -m "save"
cd /tmp/sandlot-test-repo
sandlot-rs list
```
**Expect:** `idle-test` shows `●` (green circle) = saved.
### Test 9.4: `list --all`
```bash
sandlot-rs list --all
```
**Expect:** Sessions grouped by repo name with headers:
```
── repo-name ──
BRANCH PROMPT
◯ branch prompt text
```
### Test 9.5: `list` with no sessions
```bash
# Close all sessions first
sandlot-rs list
```
**Expect:** `◆ No active sessions.`
### Test 9.6: `list` with VM down
```bash
sandlot-rs vm stop
sandlot-rs list
```
**Expect:** Normal session list (all show as idle since VM can't check status), plus:
```
VM is not running. (in red)
```
---
## Phase 10: End-to-End Comparison
For each command tested above, run the same scenario with both `sandlot-ts` and `sandlot-rs` and compare:
1. **Exit codes** must be identical
2. **Stdout content** must be semantically identical (exact match after stripping ANSI if formatting differs)
3. **Stderr content** must match (error messages, spinner final lines)
4. **Side effects** must match:
- Same files created/deleted
- Same git state (branches, worktrees, commits)
- Same state.json content (modulo timestamps)
- Same container state
### Comparison script
```bash
#!/bin/bash
# Compare a command between TS and Rust
CMD="$@"
echo "=== TypeScript ==="
sandlot-ts $CMD 2>/tmp/ts-stderr; TS_EXIT=$?
echo "EXIT: $TS_EXIT"
cat /tmp/ts-stderr
echo ""
echo "=== Rust ==="
sandlot-rs $CMD 2>/tmp/rs-stderr; RS_EXIT=$?
echo "EXIT: $RS_EXIT"
cat /tmp/rs-stderr
echo ""
if [ "$TS_EXIT" = "$RS_EXIT" ]; then
echo "EXIT CODES: MATCH ($TS_EXIT)"
else
echo "EXIT CODES: MISMATCH (ts=$TS_EXIT rs=$RS_EXIT)"
fi
```
---
## Known Differences to Accept
- **Timestamps** in `state.json` will differ between runs (different `created_at` values). Compare structure and non-timestamp fields only.
- **Spinner frame timing** may differ slightly. Only compare the final spinner message.
- **AI-generated content** (branch names from prompts, commit messages, conflict resolutions, reviews) will differ between runs since they involve LLM calls. Verify the format is correct, not the exact text.
- **Random branch names** from `sandlot new` (no args) will differ. Verify the format is `adjective-noun` from the same word lists.
- **Order of JSON object keys** may differ between serde_json (Rust) and JSON.stringify (TS). Compare semantically.
## What Must Be Identical
- All error messages (exact wording, Unicode markers)
- Exit codes for all error and success paths
- File paths (worktree locations, symlink targets, state file location)
- Git operations (same branches created/deleted, same merge behavior)
- Container commands (same `container exec` invocations, same environment variables)
- Flag parsing (`-f`, `--force`, `-p`, `--print`, `-n`, `--no-save`, `--json`, `-a`, `--all`)
- Default behavior (no args = `list`)
- Shell init output (`init fish`, `init bash`, `init zsh`) -- these were already verified byte-for-byte identical
- Fish/bash/zsh completions -- already verified byte-for-byte identical

View File

@ -0,0 +1,7 @@
use anyhow::Result;
pub fn action(_branch: &str) -> Result<()> {
crate::fmt::die(
"\"sandlot cd\" requires shell integration.\n\nAdd one of these to your shell config:\n\n Fish (~/.config/fish/config.fish):\n sandlot init fish | source\n\n Bash (~/.bashrc):\n eval \"$(sandlot init bash)\"\n\n Zsh (~/.zshrc):\n eval \"$(sandlot init zsh)\""
)
}

View File

@ -0,0 +1,28 @@
use anyhow::Result;
use crate::git;
use super::helpers::{require_session, teardown_session};
pub async fn action(branch: &str, force: bool) -> Result<()> {
let (root, session) = require_session(branch).await;
if git::is_dirty(&session.worktree).await {
crate::fmt::die(&format!(
"Branch \"{branch}\" has unsaved changes. Run \"sandlot save {branch}\" first."
));
}
if !force && git::is_dirty(&root).await {
crate::fmt::die(
"Working tree has uncommitted changes that may conflict with checkout. Commit or stash them first, or use -f to force.",
);
}
teardown_session(&root, branch, &session.worktree).await;
git::checkout(branch, &root).await?;
println!("\u{2714} Checked out {branch}");
Ok(())
}

View File

@ -0,0 +1,38 @@
use anyhow::Result;
use std::path::Path;
use crate::git;
use crate::state;
use super::helpers::unlink_session_symlink;
pub async fn action() -> Result<()> {
let root = git::repo_root(None).await.unwrap_or_else(|e| {
crate::fmt::die(&e.to_string());
});
let st = state::load(&root).await;
let sessions: Vec<_> = st.sessions.values().collect();
if sessions.is_empty() {
println!("No sessions to clean up.");
return Ok(());
}
let stale: Vec<_> = sessions
.iter()
.filter(|s| !Path::new(&s.worktree).exists())
.collect();
if stale.is_empty() {
println!("No stale sessions found.");
return Ok(());
}
for s in &stale {
state::remove_session(&root, &s.branch).await.ok();
unlink_session_symlink(&root, &s.branch).await;
println!("\u{2714} Removed stale session: {}", s.branch);
}
Ok(())
}

View File

@ -0,0 +1,22 @@
use anyhow::Result;
use crate::git;
use super::helpers::{require_session, teardown_session};
pub async fn action(branch: &str, force: bool) -> Result<()> {
let (root, session) = require_session(branch).await;
if !force && git::is_dirty(&session.worktree).await {
crate::fmt::die(&format!(
"Branch \"{branch}\" has unsaved changes. Run \"sandlot save {branch}\" first, or use -f to force."
));
}
teardown_session(&root, branch, &session.worktree).await;
git::delete_local_branch(branch, &root).await;
println!("\u{2714} Closed session {branch}");
Ok(())
}

View File

@ -0,0 +1,217 @@
use anyhow::Result;
use crate::config::VALID_KEYS;
/// Commands that accept a branch argument
pub const BRANCH_COMMANDS: &[&str] = &[
"new", "open", "close", "rm", "checkout", "diff", "log", "show", "web",
"save", "merge", "squash", "rebase", "review", "shell", "edit", "dir", "cd",
];
/// All visible subcommands
pub const SUBCOMMANDS: &[(&str, &str)] = &[
("list", "Show all active sessions"),
("new", "Create a new session and launch Claude"),
("open", "Open an existing Claude session"),
("close", "Remove a worktree and clean up the session"),
("checkout", "Close the session and check out the branch locally"),
("diff", "Show uncommitted changes, or full branch diff vs main"),
("log", "Show commits on a branch that are not on main"),
("show", "Show the prompt and full diff for a branch"),
("web", "Open the branch diff in a web browser"),
("save", "Stage all changes and commit"),
("merge", "Merge a branch into main and close the session"),
("squash", "Squash all commits on a branch into a single commit"),
("rebase", "Rebase a branch onto the latest main"),
("review", "Launch an interactive grumpy code review for a branch"),
("shell", "Open a shell in the VM"),
("edit", "Open a file from a session in $EDITOR"),
("dir", "Print the worktree path for a session"),
("cd", "Change to a branch's worktree directory"),
("config", "Get or set configuration (e.g. sandlot config memory 16G)"),
("cleanup", "Remove stale sessions whose worktrees no longer exist"),
("vm", "Manage the sandlot VM"),
("upgrade", "Upgrade sandlot to the latest version"),
("version", "Print the version number"),
("completions", "Output fish shell completions"),
("init", "Print shell init script (eval in your shell config)"),
];
const VM_SUBCOMMANDS: &[(&str, &str)] = &[
("create", "Create and provision the VM"),
("start", "Start the VM"),
("shell", "Open a shell in the VM"),
("status", "Show VM status and all sessions across repos"),
("info", "Show VM system info (via neofetch)"),
("stop", "Stop the VM"),
("destroy", "Stop and delete the VM"),
("uncache", "Clear the package cache (next create will re-download)"),
];
fn esc(s: &str) -> String {
format!("\"{}\"", s.replace('"', "\\\""))
}
pub fn generate_fish_completions() -> Vec<String> {
let mut lines = vec![
"# Fish completions for sandlot (auto-generated)".to_string(),
String::new(),
"complete -c sandlot -f".to_string(),
String::new(),
"function __sandlot_sessions".to_string(),
" command sandlot list --json 2>/dev/null | string match -r '\"branch\":\\s*\"[^\"]+\"' | string replace -r '.*\"branch\":\\s*\"([^\"]+)\".*' '$1'".to_string(),
"end".to_string(),
String::new(),
];
// Commands with their options interleaved (matching TS traversal order)
// Each entry: (name, desc, options)
// Options: (short, long, desc, required)
let commands_with_opts: Vec<(&str, &str, Vec<(Option<&str>, Option<&str>, &str, bool)>)> = vec![
("list", "Show all active sessions", vec![
(None, Some("json"), "Output as JSON", false),
(Some("a"), Some("all"), "Show sessions across all projects", false),
]),
("new", "Create a new session and launch Claude", vec![
(Some("p"), Some("print"), "run Claude in non-interactive mode with -p", true),
(Some("n"), Some("no-save"), "skip auto-save after Claude exits", false),
]),
("open", "Open an existing Claude session", vec![
(Some("p"), Some("print"), "run Claude in non-interactive mode with -p", true),
(Some("n"), Some("no-save"), "skip auto-save after Claude exits", false),
]),
("close", "Remove a worktree and clean up the session", vec![
(Some("f"), Some("force"), "close even if there are unsaved changes", false),
]),
("rm", "Remove a session (alias for close)", vec![
(Some("f"), Some("force"), "close even if there are unsaved changes", false),
]),
("checkout", "Close the session and check out the branch locally", vec![
(Some("f"), Some("force"), "checkout even if there are unsaved changes", false),
]),
("diff", "Show uncommitted changes, or full branch diff vs main", vec![]),
("log", "Show commits on a branch that are not on main", vec![]),
("show", "Show the prompt and full diff for a branch", vec![]),
("web", "Open the branch diff in a web browser", vec![]),
("save", "Stage all changes and commit", vec![]),
("merge", "Merge a branch into main and close the session", vec![
(Some("f"), Some("force"), "allow merging into a non-main branch", false),
]),
("squash", "Squash all commits on a branch into a single commit", vec![]),
("rebase", "Rebase a branch onto the latest main", vec![]),
("review", "Launch an interactive grumpy code review for a branch", vec![
(Some("p"), Some("print"), "print the review to stdout instead of launching interactive mode", false),
]),
("shell", "Open a shell in the VM", vec![]),
("edit", "Open a file from a session in $EDITOR", vec![]),
("dir", "Print the worktree path for a session", vec![]),
("cd", "Change to a branch's worktree directory", vec![]),
("config", "Get or set configuration (e.g. sandlot config memory 16G)", vec![]),
("cleanup", "Remove stale sessions whose worktrees no longer exist", vec![]),
];
for (name, desc, opts) in &commands_with_opts {
lines.push(format!(
"complete -c sandlot -n __fish_use_subcommand -a {name} -d {}",
esc(desc)
));
for (short, long, opt_desc, required) in opts {
let mut parts = vec![format!("complete -c sandlot -n \"__fish_seen_subcommand_from {name}\"")];
if let Some(s) = short {
parts.push(format!("-s {s}"));
}
if let Some(l) = long {
parts.push(format!("-l {l}"));
}
parts.push(format!("-d {}", esc(opt_desc)));
if *required {
parts.push("-r".to_string());
}
lines.push(parts.join(" "));
}
}
// VM parent command with subcommands
lines.push(format!(
"complete -c sandlot -n __fish_use_subcommand -a vm -d {}",
esc("Manage the sandlot VM")
));
let sub_names: Vec<&str> = VM_SUBCOMMANDS.iter().map(|(n, _)| *n).collect();
let guard = format!(
"\"__fish_seen_subcommand_from vm; and not __fish_seen_subcommand_from {}\"",
sub_names.join(" ")
);
for (sub, sub_desc) in VM_SUBCOMMANDS {
lines.push(format!(
"complete -c sandlot -n {guard} -a {sub} -d {}",
esc(sub_desc)
));
}
// VM subcommand options
lines.push(format!(
"complete -c sandlot -n \"__fish_seen_subcommand_from vm status\" -l json -d {}",
esc("Output as JSON")
));
// Remaining top-level commands without options
for (name, desc) in &[
("upgrade", "Upgrade sandlot to the latest version"),
("version", "Print the version number"),
] {
lines.push(format!(
"complete -c sandlot -n __fish_use_subcommand -a {name} -d {}",
esc(desc)
));
}
lines.push(format!(
"complete -c sandlot -n __fish_use_subcommand -a completions -d {}",
esc("Output fish shell completions")
));
lines.push(format!(
"complete -c sandlot -n \"__fish_seen_subcommand_from completions\" -l install -d {}",
esc("Output a shell script that installs the completions file")
));
lines.push(format!(
"complete -c sandlot -n __fish_use_subcommand -a init -d {}",
esc("Print shell init script (eval in your shell config)")
));
// Session completions for branch-taking commands
lines.push(String::new());
lines.push(format!(
"complete -c sandlot -n \"__fish_seen_subcommand_from {}\" -xa \"(__sandlot_sessions)\"",
BRANCH_COMMANDS.join(" ")
));
// Config key completions
lines.push(String::new());
lines.push(format!(
"complete -c sandlot -n \"__fish_seen_subcommand_from config\" -xa \"{}\"",
VALID_KEYS.join(" ")
));
lines.push(String::new());
lines
}
pub fn action(install: bool) -> Result<()> {
if install {
let dest = "~/.config/fish/completions/sandlot.fish";
println!("#!/bin/sh");
println!("mkdir -p ~/.config/fish/completions");
println!("sandlot completions > {dest}");
println!("echo \"Installed fish completions to {dest}\"");
return Ok(());
}
let mut lines = generate_fish_completions();
lines.insert(
1,
"# Install: sandlot completions > ~/.config/fish/completions/sandlot.fish".to_string(),
);
for line in &lines {
println!("{line}");
}
Ok(())
}

View File

@ -0,0 +1,60 @@
use anyhow::Result;
use crate::config::{self, DEFAULTS_MEMORY, VALID_KEYS};
pub async fn action(args: &[String]) -> Result<()> {
if args.is_empty() {
let cfg = config::load().await;
for key in VALID_KEYS {
let display = match *key {
"memory" => match &cfg.memory {
Some(v) => v.to_string(),
None => format!("{DEFAULTS_MEMORY} (default)"),
},
_ => "(unknown)".to_string(),
};
println!("{key} = {display}");
}
return Ok(());
}
let key = &args[0];
if !VALID_KEYS.contains(&key.as_str()) {
crate::fmt::die(&format!(
"Unknown config key: {key}\nAvailable keys: {}",
VALID_KEYS.join(", ")
));
}
if args.len() == 1 {
let val = match key.as_str() {
"memory" => config::get_memory().await,
_ => None,
};
let default = match key.as_str() {
"memory" => DEFAULTS_MEMORY,
_ => "",
};
match val {
Some(v) => println!("{v}"),
None => println!("{default} (default)"),
}
return Ok(());
}
if args.len() > 2 {
crate::fmt::die(&format!(
"Too many arguments. Usage: sandlot config {key} <value>"
));
}
let value = &args[1];
let normalized = match config::validate_memory(value) {
Ok(v) => v,
Err(_) => crate::fmt::die("Must be a number followed by G or M, minimum 512M (e.g. 16G)"),
};
config::set_memory(normalized.clone()).await?;
println!("{key} = {normalized}");
Ok(())
}

View File

@ -0,0 +1,116 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>{{BRANCH}} — sandlot diff</title>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/diff2html/bundles/css/diff2html.min.css">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/gh/highlightjs/cdn-release/build/styles/github-dark.min.css" media="(prefers-color-scheme: dark)">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/gh/highlightjs/cdn-release/build/styles/github.min.css" media="(prefers-color-scheme: light)">
<style>
:root {
--bg: #0d1117; --fg: #e6edf3; --fg-secondary: #8b949e; --fg-code: #c9d1d9;
--code-bg: #1f2937; --border: #30363d;
--add: #3fb950; --remove: #f85149;
--color-scheme: dark;
}
@media (prefers-color-scheme: light) {
:root {
--bg: #ffffff; --fg: #1f2328; --fg-secondary: #656d76; --fg-code: #1f2328;
--code-bg: #f6f8fa; --border: #d0d7de;
--add: #1a7f37; --remove: #cf222e;
--color-scheme: light;
}
}
body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, sans-serif; margin: 0; padding: 0; background: var(--bg); color: var(--fg); color-scheme: light dark; }
.header { padding: 24px 32px; border-bottom: 1px solid var(--border); }
.header h1 { margin: 0 0 8px; font-size: 24px; font-weight: 600; }
.header h1 code { background: var(--code-bg); padding: 2px 8px; border-radius: 6px; font-size: 22px; }
.prompt { color: var(--fg-secondary); margin: 0 0 16px; font-style: italic; }
.meta { display: flex; gap: 32px; }
.meta-section h3 { margin: 0 0 6px; font-size: 13px; text-transform: uppercase; color: var(--fg-secondary); letter-spacing: 0.05em; }
.meta-section pre { margin: 0; font-size: 13px; line-height: 1.5; color: var(--fg-code); white-space: pre-wrap; }
.diff-container { padding: 16px 32px; }
.d2h-file-wrapper { margin-bottom: 16px; border-radius: 6px; overflow: hidden; }
.d2h-code-line-ctn { background: transparent; }
</style>
</head>
<body>
<div class="header">
<h1><code>{{BRANCH}}</code></h1>
{{PROMPT_SECTION}}
<div style="margin: 12px 0;">
<label style="cursor:pointer; user-select:none; font-size:14px; color:var(--fg-secondary);">
<input type="checkbox" id="unified-toggle" style="cursor:pointer; vertical-align:middle; margin-right:6px;">
Unified
</label>
</div>
<div class="meta">
{{LOG_SECTION}}
{{STAT_SECTION}}
</div>
</div>
<div class="diff-container" id="diff"></div>
<script src="https://cdn.jsdelivr.net/gh/highlightjs/cdn-release/build/highlight.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/diff2html/bundles/js/diff2html-ui.min.js"></script>
<script>
const diffString = {{DIFF_JSON}};
const targetElement = document.getElementById("diff");
const toggle = document.getElementById("unified-toggle");
const schemeMq = window.matchMedia("(prefers-color-scheme: dark)");
function getColorScheme() {
return schemeMq.matches ? "dark" : "light";
}
// Split raw diff into per-file chunks and classify each
function splitDiff(raw) {
const files = [];
const parts = raw.split(/^(?=diff --git )/m);
for (const part of parts) {
if (!part.trim()) continue;
const isNew = /^new file mode/m.test(part);
const isDeleted = /^deleted file mode/m.test(part);
files.push({ raw: part, isNew, isDeleted });
}
return files;
}
const files = splitDiff(diffString);
function renderAll(modifiedFormat) {
targetElement.innerHTML = "";
for (const file of files) {
const div = document.createElement("div");
targetElement.appendChild(div);
const format = (file.isNew || file.isDeleted) ? "line-by-line" : modifiedFormat;
const ui = new Diff2HtmlUI(div, file.raw, {
drawFileList: false,
matching: "lines",
outputFormat: format,
highlight: true,
colorScheme: getColorScheme(),
}, hljs);
ui.draw();
ui.highlightCode();
}
}
function currentFormat() {
return toggle.checked ? "line-by-line" : "side-by-side";
}
const saved = localStorage.getItem("sandlot-unified") === "1";
toggle.checked = saved;
renderAll(currentFormat());
toggle.addEventListener("change", function () {
localStorage.setItem("sandlot-unified", this.checked ? "1" : "0");
renderAll(currentFormat());
});
schemeMq.addEventListener("change", function () {
renderAll(currentFormat());
});
</script>
</body>
</html>

View File

@ -0,0 +1,63 @@
use anyhow::Result;
use crate::git;
use super::helpers::require_session;
pub async fn action(branch: &str) -> Result<()> {
let (_, session) = require_session(branch).await;
// Check for uncommitted changes (staged + unstaged)
let status = tokio::process::Command::new("git")
.args(["-C", &session.worktree, "status", "--porcelain"])
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::piped())
.output()
.await;
let status = match status {
Ok(o) if o.status.success() => String::from_utf8_lossy(&o.stdout).to_string(),
_ => {
eprintln!("\u{2716} git status failed");
std::process::exit(1);
}
};
let args: Vec<String> = if !status.trim().is_empty() {
// Show uncommitted changes
let has_head = tokio::process::Command::new("git")
.args(["-C", &session.worktree, "rev-parse", "--verify", "HEAD"])
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.status()
.await;
if has_head.is_ok_and(|s| s.success()) {
vec!["diff".into(), "HEAD".into()]
} else {
vec!["diff".into()]
}
} else {
// No uncommitted changes — show full branch diff vs main
let main = git::main_branch(Some(&session.worktree)).await?;
vec!["diff".into(), format!("{main}...{branch}")]
};
// Run git diff with inherited stdio
let status = std::process::Command::new("git")
.args(
std::iter::once("-C".to_string())
.chain(std::iter::once(session.worktree.clone()))
.chain(args),
)
.stdin(std::process::Stdio::inherit())
.stdout(std::process::Stdio::inherit())
.stderr(std::process::Stdio::inherit())
.spawn()?
.wait()?;
if !status.success() {
std::process::exit(status.code().unwrap_or(1));
}
Ok(())
}

View File

@ -0,0 +1,9 @@
use anyhow::Result;
use super::helpers::require_session;
pub async fn action(branch: &str) -> Result<()> {
let (_, session) = require_session(branch).await;
println!("{}", session.worktree);
Ok(())
}

View File

@ -0,0 +1,50 @@
use anyhow::Result;
use std::path::Path;
use super::helpers::require_session;
pub async fn action(branch: &str, file: &str) -> Result<()> {
let editor = match std::env::var("EDITOR") {
Ok(e) if !e.is_empty() => e,
_ => crate::fmt::die("$EDITOR is not set."),
};
let (_, session) = require_session(branch).await;
let worktree = std::fs::canonicalize(&session.worktree)
.unwrap_or_else(|_| Path::new(&session.worktree).to_path_buf());
let worktree_str = worktree.to_string_lossy().to_string();
let path = std::fs::canonicalize(worktree.join(file))
.unwrap_or_else(|_| worktree.join(file));
let path_str = path.to_string_lossy().to_string();
if !path_str.starts_with(&format!("{worktree_str}/")) && path_str != worktree_str {
crate::fmt::die("File path escapes the worktree.");
}
if !path.exists() {
crate::fmt::die(&format!("File not found: {file}"));
}
let parts: Vec<&str> = editor.split_whitespace().collect();
let (cmd, args) = parts.split_first().unwrap();
let mut command_args: Vec<&str> = args.to_vec();
command_args.push(&path_str);
let status = std::process::Command::new(cmd)
.args(&command_args)
.stdin(std::process::Stdio::inherit())
.stdout(std::process::Stdio::inherit())
.stderr(std::process::Stdio::inherit())
.spawn()?
.wait()?;
if !status.success() {
crate::fmt::die(&format!(
"Editor exited with code {}.",
status.code().unwrap_or(1)
));
}
Ok(())
}

View File

@ -0,0 +1,414 @@
use anyhow::Result;
use std::collections::HashSet;
use std::path::Path;
use crate::git;
use crate::spinner::Spinner;
use crate::state::{self, Session};
use crate::vm;
/// Generated files to skip AI resolution — accept theirs and move on.
fn skip_resolve_set() -> HashSet<&'static str> {
[
"bun.lock",
"bun.lockb",
"Cargo.lock",
"composer.lock",
"Gemfile.lock",
"go.sum",
"mix.lock",
"package-lock.json",
"Pipfile.lock",
"pnpm-lock.yaml",
"Podfile.lock",
"poetry.lock",
"pubspec.lock",
"flake.lock",
"gradle.lockfile",
"npm-shrinkwrap.json",
"Package.resolved",
"uv.lock",
"yarn.lock",
]
.into_iter()
.collect()
}
/// Remove a .sandlot/<branch> symlink and prune empty parent dirs up to .sandlot/.
pub async fn unlink_session_symlink(root: &str, branch: &str) {
let sandlot_dir = Path::new(root).join(".sandlot");
let symlink_path = sandlot_dir.join(branch);
tokio::fs::remove_file(&symlink_path).await.ok();
// Walk up from the symlink's parent, removing empty dirs, stopping at .sandlot/ itself
let mut dir = symlink_path.parent().map(|p| p.to_path_buf());
while let Some(d) = &dir {
if d == &sandlot_dir {
break;
}
if tokio::fs::remove_dir(d).await.is_err() {
break;
}
dir = d.parent().map(|p| p.to_path_buf());
}
}
/// Look up a session by branch, dying if it doesn't exist.
pub async fn require_session(branch: &str) -> (String, Session) {
let root = git::repo_root(None).await.unwrap_or_else(|e| {
crate::fmt::die(&e.to_string());
});
let session = state::get_session(&root, branch).await;
match session {
Some(s) => (root, s),
None => crate::fmt::die(&format!("No session found for branch \"{branch}\".")),
}
}
/// Look up a session by branch, recreating worktree/session if the branch exists but the session doesn't.
pub async fn ensure_session(branch: &str) -> (String, Session) {
let root = git::repo_root(None).await.unwrap_or_else(|e| {
crate::fmt::die(&e.to_string());
});
if let Some(existing) = state::get_session(&root, branch).await {
return (root, existing);
}
// No session — check if the branch exists
let exists = git::branch_exists(branch, Some(&root), false).await;
if exists.is_none() {
crate::fmt::die(&format!("No session or branch found for \"{branch}\"."));
}
// Recreate worktree and session
let home = dirs::home_dir().expect("cannot find home directory");
let repo_name = Path::new(&root)
.file_name()
.unwrap_or_default()
.to_string_lossy()
.to_string();
let worktree_abs = home
.join(".sandlot")
.join(&repo_name)
.join(branch)
.to_string_lossy()
.to_string();
match git::create_worktree(branch, &worktree_abs, &root).await {
Ok(_) => {
let symlink_path = Path::new(&root).join(".sandlot").join(branch);
if let Some(parent) = symlink_path.parent() {
tokio::fs::create_dir_all(parent).await.ok();
}
#[cfg(unix)]
{
tokio::fs::symlink(&worktree_abs, &symlink_path).await.ok();
}
}
Err(err) => {
git::remove_worktree(&worktree_abs, &root).await.ok();
unlink_session_symlink(&root, branch).await;
crate::fmt::die(&format!("Failed to recreate session: {err}"));
}
}
let session = Session {
branch: branch.to_string(),
worktree: worktree_abs,
created_at: chrono_now(),
prompt: None,
in_review: None,
};
state::set_session(&root, session.clone()).await.ok();
(root, session)
}
/// Tear down a session: clear activity, remove worktree, unlink symlink, remove state.
pub async fn teardown_session(root: &str, branch: &str, worktree: &str) {
vm::clear_activity(worktree, branch).await;
if let Err(e) = git::remove_worktree(worktree, root).await {
eprintln!("\u{26A0} Failed to remove worktree: {e}");
}
unlink_session_symlink(root, branch).await;
state::remove_session(root, branch).await.ok();
}
/// Resolve conflict markers in files using Claude, then stage them.
pub async fn resolve_conflicts(
files: &[String],
cwd: &str,
on_file: &dyn Fn(&str, usize, usize),
) -> Result<()> {
let skip = skip_resolve_set();
for (i, file) in files.iter().enumerate() {
on_file(file, i + 1, files.len());
let basename = Path::new(file)
.file_name()
.unwrap_or_default()
.to_string_lossy()
.to_string();
if skip.contains(basename.as_str()) {
git::checkout_theirs(file, cwd).await?;
git::stage_file(file, cwd).await?;
continue;
}
let file_path = Path::new(cwd).join(file);
let content = tokio::fs::read_to_string(&file_path).await.map_err(|_| {
anyhow::anyhow!("Failed to read conflicted file: {file}")
})?;
let (exit_code, stdout, stderr) = vm::claude_pipe(
&content,
"resolve this merge conflict. output ONLY the resolved file content with no markdown fences, no explanation, no surrounding text.",
)
.await;
if exit_code != 0 || stdout.trim().is_empty() {
let detail = if stderr.trim().is_empty() {
"(no output)"
} else {
stderr.trim()
};
anyhow::bail!("Claude failed to resolve {file}: {detail}");
}
let resolved = format!("{}\n", stdout.trim_end());
tokio::fs::write(&file_path, &resolved).await?;
git::stage_file(file, cwd).await?;
}
Ok(())
}
/// Merge a branch into main, resolve conflicts if needed, and close the session.
pub async fn merge_and_close(branch: &str, force: bool) -> Result<()> {
let root = git::repo_root(None).await?;
let main = git::main_branch(Some(&root)).await?;
let current = git::current_branch(Some(&root)).await?;
if current != main && !force {
crate::fmt::die(&format!(
"You must be on \"{main}\" to merge. Currently on \"{current}\". Use --force to merge into \"{current}\" anyway."
));
}
let session = state::get_session(&root, branch).await;
if let Some(ref s) = session {
if git::is_dirty(&s.worktree).await {
crate::fmt::die(&format!(
"Branch \"{branch}\" has unsaved changes. Run \"sandlot save {branch}\" first."
));
}
}
let spin = Spinner::new("Merging", Some(branch));
let conflicts = git::merge(branch, &root).await?;
if conflicts.is_empty() {
spin.succeed(&format!("Merged {branch} into {current}"));
if let Some(ref s) = session {
teardown_session(&root, branch, &s.worktree).await;
}
git::delete_local_branch(branch, &root).await;
return Ok(());
}
// Resolve conflicts with Claude
spin.set_text(&format!("Resolving {} conflict(s)", conflicts.len()));
let result: Result<()> = async {
vm::ensure(&|msg| spin.set_text(msg)).await?;
if let Some(ref s) = session {
vm::set_activity(&s.worktree, branch).await;
}
resolve_conflicts(&conflicts, &root, &|file, i, total| {
if total > 1 {
spin.set_text(&format!("({i}/{total}) Resolving {file}"));
} else {
spin.set_text(&format!("Resolving {file}"));
}
})
.await?;
git::commit_merge(&root).await?;
spin.succeed(&format!(
"Resolved {} conflict(s) and merged {branch}",
conflicts.len()
));
Ok(())
}
.await;
if let Err(e) = result {
spin.fail(&e.to_string());
if let Some(ref s) = session {
vm::clear_activity(&s.worktree, branch).await;
}
git::abort_merge(&root).await;
std::process::exit(1);
}
if let Some(ref s) = session {
vm::clear_activity(&s.worktree, branch).await;
teardown_session(&root, branch, &s.worktree).await;
}
git::delete_local_branch(branch, &root).await;
Ok(())
}
/// Stage all changes, generate a commit message, and commit. Returns true on success.
pub async fn save_changes(worktree: &str, branch: &str, message: Option<&str>) -> bool {
let spin = Spinner::new("Staging changes", Some(branch));
// git add .
let _ = tokio::process::Command::new("git")
.args(["-C", worktree, "add", "."])
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.output()
.await;
// Check for staged changes
let check = tokio::process::Command::new("git")
.args(["-C", worktree, "diff", "--staged", "--quiet"])
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.status()
.await;
if check.is_ok_and(|s| s.success()) {
spin.fail("No changes to commit");
return false;
}
let msg = if let Some(m) = message {
m.to_string()
} else {
spin.set_text("Starting container");
if let Err(e) = vm::ensure(&|m| spin.set_text(m)).await {
spin.fail(&format!("Failed to start container: {e}"));
return false;
}
spin.set_text("Generating commit message");
let diff_output = tokio::process::Command::new("git")
.args(["-C", worktree, "diff", "--staged"])
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::null())
.output()
.await;
let diff = match diff_output {
Ok(o) => String::from_utf8_lossy(&o.stdout).to_string(),
Err(_) => String::new(),
};
let (exit_code, stdout, stderr) = vm::claude_pipe(
&diff,
"Write a commit message for this diff. Subject line: imperative mood, max 72 characters, no period. If the changes warrant it, add a blank line then a brief body explaining why, not what. Output only the raw commit message, no quotes or markdown.",
)
.await;
if exit_code != 0 {
spin.fail("Failed to generate commit message");
if !stderr.is_empty() {
eprintln!("{stderr}");
}
return false;
}
stdout
};
spin.set_text("Committing");
let commit = tokio::process::Command::new("git")
.args(["-C", worktree, "commit", "-m", &msg])
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::piped())
.output()
.await;
match commit {
Ok(o) if o.status.success() => {
let first_line = msg.lines().next().unwrap_or(&msg);
spin.succeed(&format!("Saved: {first_line}"));
true
}
Ok(o) => {
spin.fail("Commit failed");
let stderr = String::from_utf8_lossy(&o.stderr);
if !stderr.trim().is_empty() {
eprintln!("{}", stderr.trim());
}
false
}
Err(_) => {
spin.fail("Commit failed");
false
}
}
}
pub fn chrono_now() -> String {
// Simple ISO 8601 timestamp without chrono dependency
use std::time::SystemTime;
let duration = SystemTime::now()
.duration_since(SystemTime::UNIX_EPOCH)
.unwrap_or_default();
let secs = duration.as_secs();
// Basic UTC timestamp
let days = secs / 86400;
let time_secs = secs % 86400;
let hours = time_secs / 3600;
let minutes = (time_secs % 3600) / 60;
let seconds = time_secs % 60;
// Calculate date from days since epoch (1970-01-01)
let mut y = 1970i64;
let mut remaining_days = days as i64;
loop {
let days_in_year = if is_leap_year(y) { 366 } else { 365 };
if remaining_days < days_in_year {
break;
}
remaining_days -= days_in_year;
y += 1;
}
let month_days = if is_leap_year(y) {
[31, 29, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31]
} else {
[31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31]
};
let mut m = 0;
for (i, &md) in month_days.iter().enumerate() {
if remaining_days < md as i64 {
m = i;
break;
}
remaining_days -= md as i64;
}
format!(
"{:04}-{:02}-{:02}T{:02}:{:02}:{:02}Z",
y,
m + 1,
remaining_days + 1,
hours,
minutes,
seconds
)
}
fn is_leap_year(y: i64) -> bool {
(y % 4 == 0 && y % 100 != 0) || (y % 400 == 0)
}

View File

@ -0,0 +1,97 @@
use anyhow::Result;
use super::completions::{generate_fish_completions, BRANCH_COMMANDS, SUBCOMMANDS};
pub fn action(shell: &str) -> Result<()> {
match shell {
"fish" => emit_fish(),
"bash" => emit_bash(),
"zsh" => emit_zsh(),
_ => crate::fmt::die(&format!(
"Unsupported shell: {shell}. Supported shells: fish, bash, zsh"
)),
}
}
fn emit_fish() -> Result<()> {
let lines = vec![
"function sandlot --wraps sandlot --description 'Sandlot CLI wrapper'",
" if test (count $argv) -ge 1; and test \"$argv[1]\" = cd",
" set -l dir (command sandlot dir $argv[2..])",
" and cd $dir",
" else",
" command sandlot $argv",
" end",
"end",
"",
];
for line in &lines {
println!("{line}");
}
for line in &generate_fish_completions() {
println!("{line}");
}
Ok(())
}
/// Hidden commands excluded from bash/zsh completions (fish includes them via the full generator).
const HIDDEN_COMMANDS: &[&str] = &["rm"];
fn emit_bash() -> Result<()> {
let subcommands: Vec<&str> = SUBCOMMANDS
.iter()
.map(|(name, _)| *name)
.filter(|name| !HIDDEN_COMMANDS.contains(name))
.collect();
let lines = vec![
"sandlot() {",
" if [ \"$#\" -ge 1 ] && [ \"$1\" = \"cd\" ]; then",
" local dir",
" dir=\"$(command sandlot dir \"${@:2}\")\" && cd \"$dir\"",
" else",
" command sandlot \"$@\"",
" fi",
"}",
"",
"_sandlot_completions() {",
" local cur prev",
" cur=\"${COMP_WORDS[COMP_CWORD]}\"",
" prev=\"${COMP_WORDS[COMP_CWORD-1]}\"",
"",
" if [ \"$COMP_CWORD\" -eq 1 ]; then",
];
for line in &lines {
println!("{line}");
}
println!(
" COMPREPLY=( $(compgen -W \"{}\" -- \"$cur\") )",
subcommands.join(" ")
);
println!(" return");
println!(" fi");
println!();
let bash_branch_cmds: Vec<&str> = BRANCH_COMMANDS
.iter()
.filter(|name| !HIDDEN_COMMANDS.contains(name))
.copied()
.collect();
println!(" case \"$prev\" in");
println!(" {})", bash_branch_cmds.join("|"));
println!(" local branches");
println!(" branches=\"$(command sandlot list --json 2>/dev/null | grep -o '\"branch\": *\"[^\"]*\"' | sed 's/.*\"\\([^\"]*\\)\"$/\\1/')\"");
println!(" COMPREPLY=( $(compgen -W \"$branches\" -- \"$cur\") )");
println!(" return");
println!(" ;;");
println!(" esac");
println!("}}");
println!("complete -F _sandlot_completions sandlot");
Ok(())
}
fn emit_zsh() -> Result<()> {
println!("autoload -Uz bashcompinit && bashcompinit");
emit_bash()
}

View File

@ -0,0 +1,313 @@
use anyhow::Result;
use std::collections::HashMap;
use crate::fmt::{self, CYAN, DIM, GREEN, MAGENTA, RED, RESET, YELLOW};
use crate::git;
use crate::state::{self, GlobalSession};
use crate::vm;
struct StyleDef {
icon: String,
color: &'static str,
}
fn styles() -> HashMap<&'static str, StyleDef> {
let mut m = HashMap::new();
m.insert(
"idle",
StyleDef {
icon: format!("{DIM}\u{25EF}{RESET}"),
color: DIM,
},
);
m.insert(
"active",
StyleDef {
icon: format!("{CYAN}\u{25CE}{RESET}"),
color: CYAN,
},
);
m.insert(
"dirty",
StyleDef {
icon: format!("{YELLOW}\u{25D0}{RESET}"),
color: YELLOW,
},
);
m.insert(
"saved",
StyleDef {
icon: format!("{GREEN}\u{25CF}{RESET}"),
color: GREEN,
},
);
m.insert(
"review",
StyleDef {
icon: format!("{MAGENTA}\u{29BF}{RESET}"),
color: MAGENTA,
},
);
m
}
fn render_sessions(
sessions: &[&GlobalSession],
status_map: &HashMap<usize, String>,
indices: &[usize],
) {
let styles = styles();
let branch_width = sessions
.iter()
.map(|s| s.session.branch.len())
.max()
.unwrap_or(6)
.max(6);
let cols = fmt::terminal_width();
let prefix_width = branch_width + 4;
println!(
" {DIM}{:branch_width$} PROMPT{RESET}",
"BRANCH"
);
for (i, gs) in sessions.iter().enumerate() {
let idx = indices[i];
let prompt = gs
.session
.prompt
.as_deref()
.unwrap_or("")
.lines()
.next()
.unwrap_or("");
let status = status_map
.get(&idx)
.map(|s| s.as_str())
.unwrap_or("idle");
let style = styles.get(status).unwrap_or(styles.get("idle").unwrap());
let max_prompt = if cols > prefix_width {
cols - prefix_width
} else {
0
};
let truncated = if max_prompt <= 3 {
String::new()
} else if prompt.len() <= max_prompt {
prompt.to_string()
} else {
format!("{}...", &prompt[..max_prompt - 3])
};
println!(
"{} {}{:branch_width$}{RESET} {DIM}{truncated}{RESET}",
style.icon, style.color, gs.session.branch,
);
}
}
async fn resolve_status(session: &state::Session, vm_running: bool) -> String {
if !std::path::Path::new(&session.worktree).exists() {
return "idle".to_string();
}
if vm_running {
let active = vm::is_claude_active(&session.worktree, &session.branch).await;
if active && session.in_review == Some(true) {
return "review".to_string();
}
if active {
return "active".to_string();
}
}
if git::is_dirty(&session.worktree).await {
return "dirty".to_string();
}
if git::has_new_commits(&session.worktree).await {
return "saved".to_string();
}
"idle".to_string()
}
async fn clear_stale_reviews(sessions: &[GlobalSession], status_map: &HashMap<usize, String>) {
let mut stale_by_repo: HashMap<String, Vec<String>> = HashMap::new();
for (i, s) in sessions.iter().enumerate() {
if s.session.in_review == Some(true) {
let status = status_map.get(&i).map(|s| s.as_str()).unwrap_or("idle");
if status != "review" {
stale_by_repo
.entry(s.repo_root.clone())
.or_default()
.push(s.session.branch.clone());
}
}
}
for (repo_root, branches) in &stale_by_repo {
let mut fresh = state::load(repo_root).await;
for branch in branches {
if let Some(s) = fresh.sessions.get_mut(branch) {
s.in_review = Some(false);
}
}
state::save(repo_root, &fresh).await.ok();
}
}
async fn backfill_prompts(sessions: &mut [GlobalSession], vm_running: bool) {
if !vm_running {
return;
}
let needs_prompt: Vec<usize> = sessions
.iter()
.enumerate()
.filter(|(_, s)| s.session.prompt.is_none())
.map(|(i, _)| i)
.collect();
if needs_prompt.is_empty() {
return;
}
let home = dirs::home_dir().unwrap_or_default();
let sandlot_dir = home.join(".sandlot").to_string_lossy().to_string();
let (code, stdout, _) =
vm::exec(&sandlot_dir, "cat /home/ubuntu/.claude/history.jsonl 2>/dev/null").await;
if code != 0 || stdout.is_empty() {
return;
}
let mut by_project: HashMap<String, String> = HashMap::new();
for line in stdout.lines() {
if line.is_empty() {
continue;
}
if let Ok(e) = serde_json::from_str::<serde_json::Value>(line) {
if let (Some(project), Some(display)) =
(e.get("project").and_then(|p| p.as_str()), e.get("display").and_then(|d| d.as_str()))
{
by_project.insert(project.to_string(), display.to_string());
}
}
}
for i in needs_prompt {
let container_wt = vm::container_path(&sessions[i].session.worktree);
if let Some(display) = by_project.get(&container_wt) {
sessions[i].session.prompt = Some(display.clone());
}
}
}
pub async fn action(json: bool, all: bool) -> Result<()> {
let mut sessions: Vec<GlobalSession> = if all {
state::load_all().await
} else {
let root = git::repo_root(None).await.unwrap_or_else(|e| {
crate::fmt::die(&e.to_string());
});
let st = state::load(&root).await;
st.sessions
.into_values()
.map(|s| GlobalSession {
session: s,
repo_root: root.clone(),
})
.collect()
};
let vm_running = vm::status().await == "running";
if sessions.is_empty() && !json {
if all {
println!("\u{25C6} No active sessions across any project.");
} else {
println!("\u{25C6} No active sessions.");
}
if !all && !vm_running {
println!("\n{RED}VM is not running.{RESET}");
}
return Ok(());
}
backfill_prompts(&mut sessions, vm_running).await;
// Resolve statuses
let mut status_map: HashMap<usize, String> = HashMap::new();
for (i, gs) in sessions.iter().enumerate() {
let status = resolve_status(&gs.session, vm_running).await;
status_map.insert(i, status);
}
clear_stale_reviews(&sessions, &status_map).await;
if json {
let with_status: Vec<serde_json::Value> = sessions
.iter()
.enumerate()
.map(|(i, gs)| {
let mut val = serde_json::to_value(&gs.session).unwrap_or_default();
if let Some(obj) = val.as_object_mut() {
obj.insert(
"status".to_string(),
serde_json::Value::String(
status_map
.get(&i)
.cloned()
.unwrap_or("idle".to_string()),
),
);
obj.insert(
"repoRoot".to_string(),
serde_json::Value::String(gs.repo_root.clone()),
);
}
val
})
.collect();
println!("{}", serde_json::to_string_pretty(&with_status)?);
return Ok(());
}
if all {
// Group by repo
let mut by_repo: HashMap<String, Vec<usize>> = HashMap::new();
for (i, gs) in sessions.iter().enumerate() {
by_repo
.entry(gs.repo_root.clone())
.or_default()
.push(i);
}
let mut repos: Vec<_> = by_repo.keys().cloned().collect();
repos.sort_by(|a, b| {
let a_name = std::path::Path::new(a)
.file_name()
.unwrap_or_default()
.to_string_lossy();
let b_name = std::path::Path::new(b)
.file_name()
.unwrap_or_default()
.to_string_lossy();
a_name.cmp(&b_name)
});
for repo_root in &repos {
let repo_name = std::path::Path::new(repo_root)
.file_name()
.unwrap_or_default()
.to_string_lossy();
println!("\n{DIM}\u{2500}\u{2500} {RESET}{repo_name}{DIM} \u{2500}\u{2500}{RESET}");
let indices = by_repo.get(repo_root).unwrap();
let repo_sessions: Vec<&GlobalSession> =
indices.iter().map(|&i| &sessions[i]).collect();
render_sessions(&repo_sessions, &status_map, indices);
}
} else {
let indices: Vec<usize> = (0..sessions.len()).collect();
let refs: Vec<&GlobalSession> = sessions.iter().collect();
render_sessions(&refs, &status_map, &indices);
}
println!("\n{DIM}\u{25EF} idle{RESET} \u{00B7} {CYAN}\u{25CE} active{RESET} \u{00B7} {YELLOW}\u{25D0} unsaved{RESET} \u{00B7} {GREEN}\u{25CF} saved{RESET} \u{00B7} {MAGENTA}\u{29BF} review{RESET}");
if !vm_running {
println!("\n{RED}VM is not running.{RESET}");
}
Ok(())
}

View File

@ -0,0 +1,32 @@
use anyhow::Result;
use regex::Regex;
use crate::fmt::{self, RESET, YELLOW};
use super::helpers::require_session;
pub async fn action(branch: &str) -> Result<()> {
let (_, session) = require_session(branch).await;
let output = tokio::process::Command::new("git")
.args(["-C", &session.worktree, "log", "--no-color", "main..HEAD"])
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::piped())
.output()
.await;
let output = match output {
Ok(o) if o.status.success() => String::from_utf8_lossy(&o.stdout).to_string(),
_ => {
crate::fmt::die("git log failed");
}
};
// Highlight commit hashes in yellow
let re = Regex::new(r"(?m)^(commit [0-9a-f]+)").unwrap();
let colored = re.replace_all(&output, format!("{YELLOW}$1{RESET}"));
fmt::pager(&colored).await;
Ok(())
}

View File

@ -0,0 +1,7 @@
use anyhow::Result;
use super::helpers::merge_and_close;
pub async fn action(branch: &str, force: bool) -> Result<()> {
merge_and_close(branch, force).await
}

View File

@ -0,0 +1,25 @@
pub mod cd;
pub mod checkout;
pub mod cleanup;
pub mod close;
pub mod completions;
pub mod config;
pub mod diff;
pub mod dir;
pub mod edit;
pub mod helpers;
pub mod init;
pub mod list;
pub mod log;
pub mod merge;
pub mod new;
pub mod open;
pub mod rebase;
pub mod review;
pub mod save;
pub mod shell;
pub mod show;
pub mod squash;
pub mod upgrade;
pub mod vm_cmd;
pub mod web;

View File

@ -0,0 +1,230 @@
use anyhow::Result;
use rand::Rng;
use std::path::Path;
use crate::git;
use crate::markdown::render_markdown;
use crate::spinner::Spinner;
use crate::state::{self, Session};
use crate::vm;
use super::helpers::{save_changes, unlink_session_symlink};
const ADJECTIVES: &[&str] = &[
"calm", "bold", "warm", "cool", "keen", "soft", "fast", "wild", "fair", "rare",
"deep", "dark", "pale", "wide", "slim", "tall", "glad", "pure", "safe", "free",
"hazy", "lazy", "cozy", "tiny", "vast", "busy", "easy", "gray", "gold", "blue",
"rosy", "wavy", "mild", "loud", "firm", "flat", "crisp", "dry", "raw", "odd",
];
const NOUNS: &[&str] = &[
"fern", "dune", "cove", "pine", "reef", "hawk", "pond", "mesa", "vale", "glen",
"haze", "moss", "peak", "tide", "dawn", "lynx", "wren", "sage", "crag", "flint",
"leaf", "reed", "cave", "star", "gust", "surf", "opal", "lark", "vale", "plum",
"birch", "clay", "jade", "ivy", "fox", "elk", "bay", "ash", "dew", "oak",
];
fn random_branch_name() -> String {
let mut rng = rand::rng();
let adj = ADJECTIVES[rng.random_range(0..ADJECTIVES.len())];
let noun = NOUNS[rng.random_range(0..NOUNS.len())];
format!("{adj}-{noun}")
}
fn fallback_branch_name(text: &str) -> String {
let lower = text.to_lowercase();
let cleaned: String = lower
.chars()
.map(|c| {
if c.is_ascii_alphanumeric() || c == ' ' || c == '-' {
c
} else {
' '
}
})
.collect();
let trimmed = cleaned.trim();
trimmed
.split_whitespace()
.take(2)
.collect::<Vec<_>>()
.join("-")
}
async fn branch_from_prompt(text: &str) -> String {
let api_key = match crate::env::get_api_key().await {
Some(k) => k,
None => return fallback_branch_name(text),
};
let body = serde_json::json!({
"model": "claude-haiku-4-5-20251001",
"max_tokens": 15,
"temperature": 0,
"messages": [{"role": "user", "content": format!("Generate a 2-word git branch name (lowercase, hyphen-separated) for this task:\n\n{text}\n\nOutput ONLY the branch name, nothing else.")}],
});
let client = match reqwest::Client::new()
.post("https://api.anthropic.com/v1/messages")
.header("content-type", "application/json")
.header("x-api-key", &api_key)
.header("anthropic-version", "2023-06-01")
.json(&body)
.send()
.await
{
Ok(res) if res.status().is_success() => res,
_ => return fallback_branch_name(text),
};
let json: serde_json::Value = match client.json().await {
Ok(j) => j,
Err(_) => return fallback_branch_name(text),
};
let raw = json
.get("content")
.and_then(|c| c.as_array())
.and_then(|a| a.first())
.and_then(|t| t.get("text"))
.and_then(|t| t.as_str())
.unwrap_or("");
let name: String = raw
.trim()
.to_lowercase()
.chars()
.map(|c| if c.is_ascii_alphanumeric() || c == '-' { c } else { '-' })
.collect();
// Collapse multiple hyphens, strip leading/trailing
let re = regex::Regex::new(r"-+").unwrap();
let name = re.replace_all(&name, "-").to_string();
let name = name.trim_matches('-');
if !name.is_empty() && name.len() <= 50 {
name.to_string()
} else {
fallback_branch_name(text)
}
}
pub async fn action(
branch: Option<String>,
prompt: Option<String>,
print: Option<String>,
save: bool,
) -> Result<()> {
let mut branch = branch;
let mut prompt = prompt;
// No branch given — derive from -p prompt
if branch.is_none() && print.is_some() {
branch = Some(branch_from_prompt(print.as_ref().unwrap()).await);
} else if branch.is_none() {
branch = Some(random_branch_name());
} else if let Some(ref b) = branch {
if b.contains(' ') {
// If the "branch" contains spaces, it's actually a prompt
prompt = Some(b.clone());
branch = Some(branch_from_prompt(b).await);
}
}
let branch = branch.unwrap();
let root = git::repo_root(None).await.unwrap_or_else(|e| {
crate::fmt::die(&e.to_string());
});
let home = dirs::home_dir().expect("cannot find home directory");
let repo_name = Path::new(&root)
.file_name()
.unwrap_or_default()
.to_string_lossy()
.to_string();
let worktree_abs = home
.join(".sandlot")
.join(&repo_name)
.join(&branch)
.to_string_lossy()
.to_string();
let existing = state::get_session(&root, &branch).await;
if existing.is_some() {
crate::fmt::die(&format!(
"Session \"{branch}\" already exists. Use \"sandlot open {branch}\" to re-enter it."
));
}
let spin = Spinner::new("Creating worktree", Some(&branch));
let mut branch_created = false;
match git::create_worktree(&branch, &worktree_abs, &root).await {
Ok(created) => {
branch_created = created;
let symlink_path = Path::new(&root).join(".sandlot").join(&branch);
if let Some(parent) = symlink_path.parent() {
tokio::fs::create_dir_all(parent).await.ok();
}
#[cfg(unix)]
{
tokio::fs::symlink(&worktree_abs, &symlink_path).await.ok();
}
spin.set_text("Starting container");
if let Err(e) = vm::ensure(&|msg| spin.set_text(msg)).await {
spin.fail(&e.to_string());
git::remove_worktree(&worktree_abs, &root).await.ok();
if branch_created {
git::delete_local_branch(&branch, &root).await;
}
unlink_session_symlink(&root, &branch).await;
std::process::exit(1);
}
if print.is_none() {
spin.succeed("Session ready");
}
}
Err(e) => {
spin.fail(&e.to_string());
git::remove_worktree(&worktree_abs, &root).await.ok();
if branch_created {
git::delete_local_branch(&branch, &root).await;
}
unlink_session_symlink(&root, &branch).await;
std::process::exit(1);
}
}
let effective_prompt = print.as_ref().or(prompt.as_ref()).cloned();
let session = Session {
branch: branch.clone(),
worktree: worktree_abs.clone(),
created_at: super::helpers::chrono_now(),
prompt: effective_prompt,
in_review: None,
};
state::set_session(&root, session).await.ok();
if let Some(ref p) = print {
spin.set_text("Running prompt\u{2026}");
let (_, output) =
vm::claude(&worktree_abs, prompt.as_deref(), Some(p), false).await?;
if let Some(ref out) = output {
spin.stop();
print!("{}\n", render_markdown(out));
} else {
spin.succeed("Done");
}
} else {
vm::claude(&worktree_abs, prompt.as_deref(), None, false).await?;
}
vm::clear_activity(&worktree_abs, &branch).await;
if save {
save_changes(&worktree_abs, &branch, None).await;
}
Ok(())
}

View File

@ -0,0 +1,53 @@
use anyhow::Result;
use crate::markdown::render_markdown;
use crate::spinner::Spinner;
use crate::state;
use crate::vm;
use super::helpers::{ensure_session, save_changes};
pub async fn action(
branch: String,
prompt: Option<String>,
print: Option<String>,
save: bool,
) -> Result<()> {
let (root, session) = ensure_session(&branch).await;
let effective_prompt = print.as_ref().or(prompt.as_ref()).cloned();
if let Some(ref p) = effective_prompt {
let mut updated = session.clone();
updated.prompt = Some(p.clone());
state::set_session(&root, updated).await.ok();
}
let spin = Spinner::new("Starting container", Some(&branch));
if let Err(e) = vm::ensure(&|msg| spin.set_text(msg)).await {
spin.fail(&e.to_string());
std::process::exit(1);
}
if let Some(ref p) = print {
spin.set_text("Running prompt\u{2026}");
let (_, output) =
vm::claude(&session.worktree, prompt.as_deref(), Some(p), true).await?;
if let Some(ref out) = output {
spin.stop();
print!("{}\n", render_markdown(out));
} else {
spin.succeed("Done");
}
} else {
spin.succeed("Session ready");
vm::claude(&session.worktree, prompt.as_deref(), None, true).await?;
}
vm::clear_activity(&session.worktree, &branch).await;
if save {
save_changes(&session.worktree, &branch, None).await;
}
Ok(())
}

View File

@ -0,0 +1,98 @@
use anyhow::Result;
use crate::git;
use crate::spinner::Spinner;
use crate::vm;
use super::helpers::{require_session, resolve_conflicts};
const MAX_REBASE_ROUNDS: usize = 10;
pub async fn action(branch: &str) -> Result<()> {
let (root, session) = require_session(branch).await;
let worktree = &session.worktree;
if git::is_dirty(worktree).await {
crate::fmt::die(&format!(
"Branch \"{branch}\" has unsaved changes. Run \"sandlot save {branch}\" first."
));
}
let main = git::main_branch(Some(&root)).await?;
let fetch_spin = Spinner::new("Fetching origin", Some(branch));
// Fetch origin main
let _ = tokio::process::Command::new("git")
.args(["-C", &root, "fetch", "origin", &main])
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.output()
.await;
fetch_spin.set_text(&format!("Rebasing onto origin/{main}"));
let onto = format!("origin/{main}");
let mut conflicts = match git::rebase(&onto, worktree).await {
Ok(c) => c,
Err(e) => {
fetch_spin.fail(&e.to_string());
std::process::exit(1);
}
};
if conflicts.is_empty() {
fetch_spin.succeed(&format!("Rebased {branch} onto {main}"));
return Ok(());
}
fetch_spin.stop();
println!(
"\u{25C6} Rebase conflicts in {} file(s). Resolving with Claude...",
conflicts.len()
);
let resolve_spin = Spinner::new("Starting container", Some(branch));
let result: Result<()> = async {
vm::ensure(&|msg| resolve_spin.set_text(msg)).await?;
vm::set_activity(worktree, branch).await;
let mut round = 1usize;
while !conflicts.is_empty() {
if round > MAX_REBASE_ROUNDS {
anyhow::bail!(
"Exceeded {MAX_REBASE_ROUNDS} conflict resolution rounds \u{2014} aborting rebase"
);
}
resolve_conflicts(&conflicts, worktree, &|file, i, total| {
if total > 1 {
resolve_spin
.set_text(&format!("({i}/{total}) Resolving {file} (round {round})"));
} else {
resolve_spin.set_text(&format!("Resolving {file} (round {round})"));
}
})
.await?;
conflicts = git::rebase_continue(worktree).await?;
round += 1;
}
resolve_spin.succeed(&format!(
"Rebased {branch} onto {main} (resolved {} conflict round(s))",
round - 1
));
Ok(())
}
.await;
if let Err(e) = result {
resolve_spin.fail(&e.to_string());
git::rebase_abort(worktree).await;
std::process::exit(1);
}
vm::clear_activity(worktree, branch).await;
Ok(())
}

View File

@ -0,0 +1,119 @@
use anyhow::Result;
use crate::spinner::Spinner;
use crate::state;
use crate::vm;
use super::helpers::{require_session, save_changes};
pub async fn action(branch: &str, extra: Option<&str>, print: bool) -> Result<()> {
let (root, session) = require_session(branch).await;
let spin = Spinner::new("Starting container", Some(branch));
if let Err(e) = vm::ensure(&|msg| spin.set_text(msg)).await {
spin.fail(&e.to_string());
std::process::exit(1);
}
let mut prompt = r#"
You're a grumpy old senior software engineer. You need to review some code my co-worker wrote.
Launch four agents to review the diff between this branch and main with the following specializations:
1. Checks CLAUDE.md compliance
2. Looks specifically for bugs
3. Also looks specifically for bugs
4. Looks for opportunities to simplify code
Have them focus only on the diff! + lines are added in this branch, - lines are overwritten or deleted. They must focus mostly on the + lines.
Each agent should deliver you a report in this format (the <tags> are just for you, not part of their output):
<agentOutput>
# Problem Identified
Description of problem.
</agentOutput>
Tell each agent: Run `git diff main...HEAD` and focus on the "+" lines, not the "-" lines.
Once the agents are done, look at all their suggestions and let me know what you think.
Give me your opinion in this format, with the :
<grumpySeniorDevResponse>
# {branch name} Review
**OK TO SHIP: yes or no**
# Showstoppers
1. BUG: Bug that both bug hunters found.
2. BUG: Bug that one of the hunters found.
3. COMPLIANCE: Describe CLAUDE.md compliance issue.
# Recommendations
4. BUG: Bug that both bug hunters found.
5. BUG: Bug that one of the hunters found.
6. COMPLIANCE: Describe CLAUDE.md compliance issue.
7. SIMPLIFY: Opportunities to simplify code.
# Optional
8. BUG: Bug that both bug hunters found.
9. BUG: Bug that one of the hunters found.
10. COMPLIANCE: Describe CLAUDE.md compliance issue.
11. SIMPLIFY: Opportunities to simplify code.
# Summary
Your thoughts, in brief.
</grumpySeniorDevResponse>
"#
.to_string();
if let Some(extra_text) = extra {
prompt.push_str("\n\n");
prompt.push_str(extra_text);
}
// Set in_review flag
let mut updated = session.clone();
updated.in_review = Some(true);
state::set_session(&root, updated).await.ok();
let result = if print {
spin.set_text("Running review\u{2026}");
let r = vm::claude(&session.worktree, None, Some(&prompt), false).await;
match r {
Ok((_, Some(ref output))) => {
print!("{output}\n");
}
Ok(_) => {}
Err(ref e) => {
spin.fail(&e.to_string());
}
}
r.map(|_| ())
} else {
spin.succeed("Session ready");
vm::claude(&session.worktree, Some(&prompt), None, false)
.await
.map(|_| ())
};
// Clean up: clear in_review flag
spin.stop();
if let Some(fresh) = state::get_session(&root, &session.branch).await {
let mut fresh = fresh;
fresh.in_review = Some(false);
state::set_session(&root, fresh).await.ok();
}
if !print {
save_changes(&session.worktree, &session.branch, None).await;
}
result
}

View File

@ -0,0 +1,13 @@
use anyhow::Result;
use super::helpers::{require_session, save_changes};
pub async fn action(branch: &str, message: Option<&str>) -> Result<()> {
let (_, session) = require_session(branch).await;
let ok = save_changes(&session.worktree, branch, message).await;
if !ok {
std::process::exit(1);
}
Ok(())
}

View File

@ -0,0 +1,16 @@
use anyhow::Result;
use crate::vm;
use super::helpers::require_session;
pub async fn action(branch: Option<&str>) -> Result<()> {
if let Some(branch) = branch {
let (_, session) = require_session(branch).await;
vm::ensure(&|_| {}).await?;
vm::shell(Some(&session.worktree)).await
} else {
vm::ensure(&|_| {}).await?;
vm::shell(None).await
}
}

View File

@ -0,0 +1,30 @@
use anyhow::Result;
use crate::git;
use super::helpers::require_session;
pub async fn action(branch: &str) -> Result<()> {
let (_, session) = require_session(branch).await;
if let Some(ref prompt) = session.prompt {
eprint!("PROMPT: {prompt}\n\n");
}
let main = git::main_branch(Some(&session.worktree)).await?;
// Run git diff with inherited stdio
let status = std::process::Command::new("git")
.args(["-C", &session.worktree, "diff", &format!("{main}...{branch}")])
.stdin(std::process::Stdio::inherit())
.stdout(std::process::Stdio::inherit())
.stderr(std::process::Stdio::inherit())
.spawn()?
.wait()?;
if !status.success() {
std::process::exit(status.code().unwrap_or(1));
}
Ok(())
}

View File

@ -0,0 +1,83 @@
use anyhow::Result;
use crate::git;
use crate::spinner::Spinner;
use crate::vm;
use super::helpers::require_session;
pub async fn action(branch: &str) -> Result<()> {
let (_, session) = require_session(branch).await;
let worktree = &session.worktree;
if git::is_dirty(worktree).await {
crate::fmt::die(&format!(
"Branch \"{branch}\" has unsaved changes. Run \"sandlot save {branch}\" first."
));
}
let main = git::main_branch(Some(worktree)).await?;
if !git::has_new_commits(worktree).await {
crate::fmt::die(&format!(
"Branch \"{branch}\" has no commits beyond {main}."
));
}
let base = git::merge_base(&main, "HEAD", worktree).await?;
let original_head = git::head_ref(worktree).await?;
let spin = Spinner::new("Squashing", Some(branch));
let mut did_reset = false;
let result: Result<()> = async {
git::reset_soft(&base, worktree).await?;
did_reset = true;
spin.set_text("Starting container");
vm::ensure(&|msg| spin.set_text(msg)).await?;
spin.set_text("Generating commit message");
let diff = git::diff_staged(worktree).await;
if diff.trim().is_empty() {
git::reset_soft(&original_head, worktree).await.ok();
spin.fail("No changes after squash");
std::process::exit(1);
}
let (exit_code, stdout, _) = vm::claude_pipe(
&diff,
"Write a commit message summarizing all changes. Subject line: imperative mood, max 72 characters, no period. Add a blank line then a concise body with the key changes as bullet points. Output only the raw commit message, no quotes or markdown.",
)
.await;
let msg = if exit_code == 0 && !stdout.trim().is_empty() {
stdout
} else {
spin.set_text("AI commit message failed, using fallback");
format!("squash {branch}")
};
git::commit(&msg, worktree).await?;
spin.succeed(&format!("Squashed {branch} into a single commit"));
Ok(())
}
.await;
if let Err(e) = result {
if !did_reset {
spin.fail(&format!("Squash failed: {e}"));
} else {
match git::reset_soft(&original_head, worktree).await {
Ok(_) => spin.fail(&format!("Squash failed, changes restored: {e}")),
Err(_) => spin.fail(&format!(
"Squash failed and rollback failed \u{2014} check \"git reflog\" in the worktree: {e}"
)),
}
}
std::process::exit(1);
}
Ok(())
}

View File

@ -0,0 +1,12 @@
use anyhow::Result;
pub async fn action() -> Result<()> {
let status = std::process::Command::new("bun")
.args(["install", "-g", "@because/sandlot@latest"])
.stdin(std::process::Stdio::inherit())
.stdout(std::process::Stdio::inherit())
.stderr(std::process::Stdio::inherit())
.spawn()?
.wait()?;
std::process::exit(status.code().unwrap_or(1));
}

View File

@ -0,0 +1,222 @@
use anyhow::Result;
use std::collections::HashMap;
use std::path::Path;
use crate::fmt::{CYAN, DIM, GREEN, RED, RESET, YELLOW};
use crate::git;
use crate::spinner::Spinner;
use crate::state;
use crate::vm;
pub async fn create() -> Result<()> {
let spin = Spinner::new("Creating VM", None);
match vm::create(&|msg| spin.set_text(msg)).await {
Ok(()) => {
spin.succeed("VM created");
Ok(())
}
Err(e) => {
spin.fail(&e.to_string());
std::process::exit(1);
}
}
}
pub async fn start() -> Result<()> {
match vm::start().await {
Ok(()) => {
println!("\u{2714} VM started");
Ok(())
}
Err(e) => {
eprintln!("\u{2716} {e}");
std::process::exit(1);
}
}
}
pub async fn shell() -> Result<()> {
vm::ensure(&|_| {}).await?;
vm::shell(None).await
}
pub async fn status(json: bool) -> Result<()> {
let s = vm::status().await;
let sessions = state::load_all().await;
if json {
let json_val = serde_json::json!({
"vm": s,
"sessions": sessions.iter().map(|gs| {
let mut v = serde_json::to_value(&gs.session).unwrap_or_default();
if let Some(obj) = v.as_object_mut() {
obj.insert("repoRoot".to_string(), serde_json::Value::String(gs.repo_root.clone()));
}
v
}).collect::<Vec<_>>(),
});
println!("{}", serde_json::to_string_pretty(&json_val)?);
return Ok(());
}
let status_colors: HashMap<&str, &str> =
[("running", GREEN), ("stopped", YELLOW), ("missing", RED)]
.into_iter()
.collect();
let color = status_colors.get(s).copied().unwrap_or("");
println!("{DIM}VM:{RESET} {color}{s}{RESET}");
if sessions.is_empty() {
println!("\n{DIM}No active sessions.{RESET}");
return Ok(());
}
// Determine statuses
let mut statuses: HashMap<String, &str> = HashMap::new();
for sess in &sessions {
let repo_name = Path::new(&sess.repo_root)
.file_name()
.unwrap_or_default()
.to_string_lossy()
.to_string();
let key = format!("{repo_name}/{}", sess.session.branch);
let status = if vm::is_claude_active(&sess.session.worktree, &sess.session.branch).await {
"active"
} else if git::is_dirty(&sess.session.worktree).await {
"dirty"
} else if git::has_new_commits(&sess.session.worktree).await {
"saved"
} else {
"idle"
};
statuses.insert(key, status);
}
let icons: HashMap<&str, String> = [
("idle", format!("{DIM}\u{25EF}{RESET}")),
("active", format!("{CYAN}\u{25CE}{RESET}")),
("dirty", format!("{YELLOW}\u{25D0}{RESET}")),
("saved", format!("{GREEN}\u{25CF}{RESET}")),
]
.into_iter()
.collect();
let branch_colors: HashMap<&str, &str> = [
("idle", DIM),
("active", CYAN),
("dirty", YELLOW),
("saved", GREEN),
]
.into_iter()
.collect();
let repo_width = sessions
.iter()
.map(|s| {
Path::new(&s.repo_root)
.file_name()
.unwrap_or_default()
.to_string_lossy()
.len()
})
.max()
.unwrap_or(4)
.max(4);
let branch_width = sessions
.iter()
.map(|s| s.session.branch.len())
.max()
.unwrap_or(6)
.max(6);
let cols = crate::fmt::terminal_width();
let prefix_width = repo_width + branch_width + 6;
println!(
"\n {DIM}{:repo_width$} {:branch_width$} PROMPT{RESET}",
"REPO", "BRANCH"
);
for sess in &sessions {
let repo_name = Path::new(&sess.repo_root)
.file_name()
.unwrap_or_default()
.to_string_lossy()
.to_string();
let prompt = sess
.session
.prompt
.as_deref()
.unwrap_or("")
.lines()
.next()
.unwrap_or("");
let key = format!("{repo_name}/{}", sess.session.branch);
let status = statuses.get(key.as_str()).copied().unwrap_or("idle");
let icon = icons.get(status).cloned().unwrap_or_default();
let bc = branch_colors.get(status).copied().unwrap_or(DIM);
let max_prompt = if cols > prefix_width {
cols - prefix_width
} else {
0
};
let truncated = if max_prompt > 3 && prompt.len() > max_prompt {
format!("{}...", &prompt[..max_prompt - 3])
} else {
prompt.to_string()
};
println!(
"{icon} {DIM}{:repo_width$}{RESET} {bc}{:branch_width$}{RESET} {DIM}{truncated}{RESET}",
repo_name, sess.session.branch
);
}
println!(
"\n{DIM}\u{25EF} idle{RESET} \u{00B7} {CYAN}\u{25CE} active{RESET} \u{00B7} {YELLOW}\u{25D0} unsaved{RESET} \u{00B7} {GREEN}\u{25CF} saved{RESET}"
);
Ok(())
}
pub async fn info() -> Result<()> {
vm::ensure(&|_| {}).await?;
vm::neofetch().await
}
pub async fn stop() -> Result<()> {
let spin = Spinner::new("Stopping VM", None);
match vm::stop().await {
Ok(()) => {
spin.succeed("VM stopped");
Ok(())
}
Err(e) => {
spin.fail(&e.to_string());
std::process::exit(1);
}
}
}
pub async fn destroy() -> Result<()> {
let spin = Spinner::new("Destroying VM", None);
match vm::destroy().await {
Ok(()) => {
spin.succeed("VM destroyed");
Ok(())
}
Err(e) => {
spin.fail(&e.to_string());
std::process::exit(1);
}
}
}
pub async fn uncache() -> Result<()> {
let had = vm::clear_cache().await;
if had {
println!("\u{2714} Package cache cleared");
} else {
println!("No cache to clear");
}
Ok(())
}

View File

@ -0,0 +1,111 @@
use anyhow::Result;
use crate::git;
use super::helpers::require_session;
const TEMPLATE: &str = include_str!("diff.html");
fn escape_html(s: &str) -> String {
s.replace('&', "&amp;")
.replace('<', "&lt;")
.replace('>', "&gt;")
.replace('"', "&quot;")
}
fn format_stat(raw: &str) -> String {
let trimmed: String = raw
.lines()
.map(|l| {
if let Some(stripped) = l.strip_prefix(' ') {
stripped
} else {
l
}
})
.collect::<Vec<_>>()
.join("\n");
let escaped = escape_html(&trimmed);
escaped
.lines()
.map(|line| {
if let Some(pipe_pos) = line.find('|') {
let before = &line[..pipe_pos + 1];
let after = &line[pipe_pos + 1..];
let colored: String = after
.chars()
.map(|c| match c {
'+' => "<span style=\"color:var(--add)\">+</span>".to_string(),
'-' => "<span style=\"color:var(--remove)\">-</span>".to_string(),
_ => c.to_string(),
})
.collect();
format!("{before}{colored}")
} else {
line.to_string()
}
})
.collect::<Vec<_>>()
.join("\n")
}
pub async fn action(branch: &str) -> Result<()> {
let (_, session) = require_session(branch).await;
let worktree = &session.worktree;
let main = git::main_branch(Some(worktree)).await?;
let log_range = format!("{main}..{branch}");
let stat_range = format!("{main}...{branch}");
let (diff, log, stat) = tokio::join!(
git::branch_diff(branch, &main, worktree),
git::commit_log(&log_range, worktree),
git::diff_stat(&stat_range, worktree),
);
if diff.trim().is_empty() {
crate::fmt::die(&format!(
"No changes on branch \"{branch}\" compared to {main}."
));
}
let diff_json = serde_json::to_string(&diff)?.replace('<', "\\u003c");
let prompt_section = match &session.prompt {
Some(p) => format!("<p class=\"prompt\">{}</p>", escape_html(p)),
None => String::new(),
};
let log_section = if !log.is_empty() {
format!(
"<div class=\"meta-section\"><h3>Commits</h3><pre>{}</pre></div>",
escape_html(&log)
)
} else {
String::new()
};
let stat_section = if !stat.is_empty() {
format!(
"<div class=\"meta-section\"><h3>Stats</h3><pre>{}</pre></div>",
format_stat(&stat)
)
} else {
String::new()
};
let html = TEMPLATE
.replace("{{BRANCH}}", &escape_html(branch))
.replace("{{PROMPT_SECTION}}", &prompt_section)
.replace("{{LOG_SECTION}}", &log_section)
.replace("{{STAT_SECTION}}", &stat_section)
.replace("{{DIFF_JSON}}", &diff_json);
let tmp_path = format!("/tmp/sandlot-{branch}.html");
tokio::fs::write(&tmp_path, &html).await?;
let _ = tokio::process::Command::new("open")
.arg(&tmp_path)
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.output()
.await;
Ok(())
}

View File

@ -0,0 +1,65 @@
use anyhow::{Result, bail};
use regex::Regex;
use serde::{Deserialize, Serialize};
const MIN_MEMORY_MB: u64 = 512;
pub const DEFAULTS_MEMORY: &str = "16G";
pub const VALID_KEYS: &[&str] = &["memory"];
#[derive(Debug, Serialize, Deserialize, Default, Clone)]
pub struct Config {
#[serde(skip_serializing_if = "Option::is_none")]
pub memory: Option<String>,
}
fn config_dir() -> std::path::PathBuf {
dirs::home_dir()
.expect("cannot find home directory")
.join(".config")
.join("sandlot")
}
fn config_path() -> std::path::PathBuf {
config_dir().join("config.json")
}
pub fn validate_memory(v: &str) -> Result<String> {
let re = Regex::new(r"^[1-9]\d*[GMgm]$").unwrap();
if !re.is_match(v) {
bail!("Invalid memory value: {v} (must be a number followed by G or M, e.g. 16G)");
}
let num: u64 = v[..v.len() - 1].parse().unwrap();
let unit = v.chars().last().unwrap().to_ascii_uppercase();
let mb = if unit == 'G' { num * 1024 } else { num };
if mb < MIN_MEMORY_MB {
bail!("Memory too low: {v} (minimum {MIN_MEMORY_MB}M)");
}
Ok(v.to_uppercase())
}
pub async fn load() -> Config {
let path = config_path();
match tokio::fs::read_to_string(&path).await {
Ok(content) => serde_json::from_str(&content).unwrap_or_default(),
Err(_) => Config::default(),
}
}
pub async fn save(config: &Config) -> Result<()> {
let dir = config_dir();
tokio::fs::create_dir_all(&dir).await?;
let json = serde_json::to_string_pretty(config)? + "\n";
tokio::fs::write(config_path(), json).await?;
Ok(())
}
pub async fn get_memory() -> Option<String> {
load().await.memory
}
pub async fn set_memory(value: String) -> Result<()> {
let mut config = load().await;
config.memory = Some(value);
save(&config).await
}

20
rust-sandlot/src/env.rs Normal file
View File

@ -0,0 +1,20 @@
use regex::Regex;
/// Read the ANTHROPIC_API_KEY from ~/.env. Returns None if not found.
pub async fn get_api_key() -> Option<String> {
let home = dirs::home_dir()?;
let env_file = home.join(".env");
let content = tokio::fs::read_to_string(&env_file).await.ok()?;
let re = Regex::new(r#"(?m)^(?:export\s+)?ANTHROPIC_API_KEY=["']?([^"'\s]+)["']?"#).ok()?;
re.captures(&content)
.and_then(|c| c.get(1))
.map(|m| m.as_str().to_string())
}
/// Read the ANTHROPIC_API_KEY from ~/.env, dying if not found.
pub async fn require_api_key() -> String {
match get_api_key().await {
Some(key) => key,
None => crate::fmt::die("ANTHROPIC_API_KEY not found in ~/.env"),
}
}

104
rust-sandlot/src/fmt.rs Normal file
View File

@ -0,0 +1,104 @@
use std::io::Write;
// ── ANSI escape codes ───────────────────────────────────────────────
pub const RESET: &str = "\x1b[0m";
pub const DIM: &str = "\x1b[2m";
pub const GREEN: &str = "\x1b[32m";
pub const YELLOW: &str = "\x1b[33m";
pub const RED: &str = "\x1b[31m";
pub const MAGENTA: &str = "\x1b[35m";
pub const CYAN: &str = "\x1b[36m";
// ── Formatted output ────────────────────────────────────────────────
pub fn die(message: &str) -> ! {
eprint!("\u{2716} {message}\n");
std::process::exit(1)
}
#[allow(dead_code)]
pub fn success(message: &str) {
eprint!("\u{2714} {message}\n");
}
pub fn info(message: &str) {
eprint!("\u{25C6} {message}\n");
}
// ── Pager ───────────────────────────────────────────────────────────
pub async fn pager(content: &str) {
let lines = content.split('\n').count();
let term_height = terminal_height();
if lines > term_height {
let mut child = match tokio::process::Command::new("less")
.arg("-R")
.stdin(std::process::Stdio::piped())
.stdout(std::process::Stdio::inherit())
.stderr(std::process::Stdio::inherit())
.spawn()
{
Ok(c) => c,
Err(_) => {
print!("{content}");
return;
}
};
if let Some(mut stdin) = child.stdin.take() {
use tokio::io::AsyncWriteExt;
let _ = stdin.write_all(content.as_bytes()).await;
drop(stdin);
}
let _ = child.wait().await;
} else {
print!("{content}");
let _ = std::io::stdout().flush();
}
}
fn terminal_height() -> usize {
// Try to get terminal size
if let Ok(s) = std::env::var("LINES") {
if let Ok(n) = s.parse::<usize>() {
return n;
}
}
// Use ioctl
#[cfg(unix)]
{
use std::mem::MaybeUninit;
unsafe {
let mut ws = MaybeUninit::<libc::winsize>::uninit();
if libc::ioctl(1, libc::TIOCGWINSZ, ws.as_mut_ptr()) == 0 {
let ws = ws.assume_init();
if ws.ws_row > 0 {
return ws.ws_row as usize;
}
}
}
}
24
}
pub fn terminal_width() -> usize {
if let Ok(s) = std::env::var("COLUMNS") {
if let Ok(n) = s.parse::<usize>() {
return n;
}
}
#[cfg(unix)]
{
use std::mem::MaybeUninit;
unsafe {
let mut ws = MaybeUninit::<libc::winsize>::uninit();
if libc::ioctl(1, libc::TIOCGWINSZ, ws.as_mut_ptr()) == 0 {
let ws = ws.assume_init();
if ws.ws_col > 0 {
return ws.ws_col as usize;
}
}
}
}
80
}

435
rust-sandlot/src/git.rs Normal file
View File

@ -0,0 +1,435 @@
use anyhow::{Result, bail};
use std::path::Path;
use tokio::process::Command;
/// Format a git error with a fallback for empty stderr.
fn git_error(action: &str, stderr: &str) -> anyhow::Error {
let msg = stderr.trim();
if msg.is_empty() {
anyhow::anyhow!("{action}: (no output)")
} else {
anyhow::anyhow!("{action}: {msg}")
}
}
async fn run_git_nothrow(cwd: &str, args: &[&str]) -> (i32, String, String) {
match Command::new("git")
.current_dir(cwd)
.args(args)
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::piped())
.output()
.await
{
Ok(output) => (
output.status.code().unwrap_or(1),
String::from_utf8_lossy(&output.stdout).to_string(),
String::from_utf8_lossy(&output.stderr).to_string(),
),
Err(_) => (1, String::new(), String::new()),
}
}
/// Get the repo root from a working directory.
pub async fn repo_root(cwd: Option<&str>) -> Result<String> {
let dir = cwd.unwrap_or(".");
let (code, stdout, _) = run_git_nothrow(dir, &["rev-parse", "--show-toplevel"]).await;
if code != 0 {
bail!("Not a git repository. Run this command from inside a git repo.");
}
Ok(stdout.trim().to_string())
}
/// Get the current branch name.
pub async fn current_branch(cwd: Option<&str>) -> Result<String> {
let dir = cwd.unwrap_or(".");
let (code, stdout, _) = run_git_nothrow(dir, &["rev-parse", "--abbrev-ref", "HEAD"]).await;
if code != 0 {
bail!("Could not determine current branch.");
}
Ok(stdout.trim().to_string())
}
/// Check if a branch exists locally or remotely. Returns "local", "remote", or None.
pub async fn branch_exists(branch: &str, cwd: Option<&str>, fetch: bool) -> Option<&'static str> {
let dir = cwd.unwrap_or(".");
let local_ref = format!("refs/heads/{branch}");
let (code, _, _) = run_git_nothrow(dir, &["show-ref", "--verify", "--quiet", &local_ref]).await;
if code == 0 {
return Some("local");
}
if fetch {
let _ = run_git_nothrow(dir, &["fetch", "origin"]).await;
}
let remote_ref = format!("refs/remotes/origin/{branch}");
let (code, _, _) =
run_git_nothrow(dir, &["show-ref", "--verify", "--quiet", &remote_ref]).await;
if code == 0 {
return Some("remote");
}
None
}
/// Create a worktree for the given branch.
pub async fn create_worktree(
branch: &str,
worktree_path: &str,
cwd: &str,
) -> Result<bool> {
// Clean up stale worktree path if it exists
if Path::new(worktree_path).exists() {
let _ = run_git_nothrow(cwd, &["worktree", "remove", worktree_path, "--force"]).await;
if Path::new(worktree_path).exists() {
tokio::fs::remove_dir_all(worktree_path).await.ok();
}
}
let _ = run_git_nothrow(cwd, &["worktree", "prune"]).await;
let exists = branch_exists(branch, Some(cwd), true).await;
let mut switched_from_branch = false;
let (code, _, stderr) = match exists {
Some("local") => {
let main = main_branch(Some(cwd)).await?;
if branch == main {
bail!("Cannot create a worktree for the main branch \"{main}\".");
}
// If the branch is checked out in the main worktree, switch it to main first
if current_branch(Some(cwd)).await? == branch {
if is_dirty(cwd).await {
bail!("Cannot move branch \"{branch}\" to a worktree: the main worktree has uncommitted changes. Commit or stash them first.");
}
checkout(&main, cwd).await?;
switched_from_branch = true;
}
run_git_nothrow(cwd, &["worktree", "add", worktree_path, branch]).await
}
Some("remote") => {
let tracking = format!("origin/{branch}");
run_git_nothrow(
cwd,
&["worktree", "add", worktree_path, "-b", branch, &tracking],
)
.await
}
_ => {
// New branch from current HEAD
run_git_nothrow(cwd, &["worktree", "add", "-b", branch, worktree_path]).await
}
};
if code != 0 {
if switched_from_branch {
let _ = checkout(branch, cwd).await;
}
return Err(git_error(
&format!("Failed to create worktree for \"{branch}\""),
&stderr,
));
}
Ok(exists != Some("local"))
}
/// Remove a worktree. Silently succeeds if the worktree is already gone.
pub async fn remove_worktree(worktree_path: &str, cwd: &str) -> Result<()> {
let (code, _, _) =
run_git_nothrow(cwd, &["worktree", "remove", worktree_path, "--force"]).await;
if code != 0 {
let _ = run_git_nothrow(cwd, &["worktree", "prune"]).await;
if Path::new(worktree_path).exists() {
tokio::fs::remove_dir_all(worktree_path).await.ok();
}
}
Ok(())
}
/// Delete a local branch.
pub async fn delete_local_branch(branch: &str, cwd: &str) {
let _ = run_git_nothrow(cwd, &["branch", "-D", branch]).await;
}
/// Checkout a branch.
pub async fn checkout(branch: &str, cwd: &str) -> Result<()> {
let (code, _, stderr) = run_git_nothrow(cwd, &["checkout", branch]).await;
if code != 0 {
return Err(git_error(
&format!("Failed to checkout branch \"{branch}\""),
&stderr,
));
}
Ok(())
}
/// Merge a branch into the current branch. Returns conflicted file paths, or empty vec if clean.
pub async fn merge(branch: &str, cwd: &str) -> Result<Vec<String>> {
let (code, _, stderr) = run_git_nothrow(cwd, &["merge", branch]).await;
if code == 0 {
return Ok(vec![]);
}
let (_, unmerged, _) =
run_git_nothrow(cwd, &["diff", "--name-only", "--diff-filter=U"]).await;
let files: Vec<String> = unmerged
.trim()
.split('\n')
.filter(|s| !s.is_empty())
.map(|s| s.to_string())
.collect();
if !files.is_empty() {
return Ok(files);
}
Err(git_error(
&format!("Failed to merge branch \"{branch}\""),
&stderr,
))
}
/// Return the staged diff as text.
pub async fn diff_staged(cwd: &str) -> String {
let (_, stdout, _) = run_git_nothrow(cwd, &["diff", "--staged"]).await;
stdout
}
/// Commit staged changes with a message.
pub async fn commit(message: &str, cwd: &str) -> Result<()> {
let (code, _, stderr) = run_git_nothrow(cwd, &["commit", "-m", message]).await;
if code != 0 {
return Err(git_error("Failed to commit", &stderr));
}
Ok(())
}
/// Accept "theirs" version of a conflicted file.
pub async fn checkout_theirs(file: &str, cwd: &str) -> Result<()> {
let (code, _, stderr) = run_git_nothrow(cwd, &["checkout", "--theirs", "--", file]).await;
if code != 0 {
return Err(git_error(
&format!("Failed to checkout theirs for {file}"),
&stderr,
));
}
Ok(())
}
/// Stage a file.
pub async fn stage_file(file: &str, cwd: &str) -> Result<()> {
let (code, _, stderr) = run_git_nothrow(cwd, &["add", file]).await;
if code != 0 {
return Err(git_error(&format!("Failed to stage {file}"), &stderr));
}
Ok(())
}
/// Finalize a merge commit after resolving conflicts.
pub async fn commit_merge(cwd: &str) -> Result<()> {
let (code, _, stderr) = run_git_nothrow(cwd, &["commit", "--no-edit"]).await;
if code != 0 {
return Err(git_error("Failed to commit merge", &stderr));
}
Ok(())
}
/// Abort an in-progress merge.
pub async fn abort_merge(cwd: &str) {
let _ = run_git_nothrow(cwd, &["merge", "--abort"]).await;
}
/// Soft-reset to a given ref (keeps changes staged).
pub async fn reset_soft(reference: &str, cwd: &str) -> Result<()> {
let (code, _, stderr) = run_git_nothrow(cwd, &["reset", "--soft", reference]).await;
if code != 0 {
bail!(
"Failed to reset to \"{reference}\": {}",
stderr.trim()
);
}
Ok(())
}
/// Get the full SHA of HEAD.
pub async fn head_ref(cwd: &str) -> Result<String> {
let (code, stdout, _) = run_git_nothrow(cwd, &["rev-parse", "HEAD"]).await;
if code != 0 {
bail!("Could not resolve HEAD.");
}
Ok(stdout.trim().to_string())
}
/// Rebase the current branch onto another. Returns conflicted file paths, or empty vec if clean.
pub async fn rebase(onto: &str, cwd: &str) -> Result<Vec<String>> {
// Check for existing rebase state
let (_, rebase_merge, _) =
run_git_nothrow(cwd, &["rev-parse", "--git-path", "rebase-merge"]).await;
let (_, rebase_apply, _) =
run_git_nothrow(cwd, &["rev-parse", "--git-path", "rebase-apply"]).await;
if Path::new(rebase_merge.trim()).exists() || Path::new(rebase_apply.trim()).exists() {
bail!(
"A rebase is already in progress. Run \"git -C {cwd} rebase --abort\" to cancel it first."
);
}
let (code, _, stderr) = run_git_nothrow(cwd, &["rebase", onto]).await;
if code == 0 {
return Ok(vec![]);
}
let (_, unmerged, _) =
run_git_nothrow(cwd, &["diff", "--name-only", "--diff-filter=U"]).await;
let files: Vec<String> = unmerged
.trim()
.split('\n')
.filter(|s| !s.is_empty())
.map(|s| s.to_string())
.collect();
if !files.is_empty() {
return Ok(files);
}
let msg = stderr.trim();
bail!(
"Rebase onto \"{onto}\" failed: {}",
if msg.is_empty() {
"(no output from git)"
} else {
msg
}
);
}
/// Continue a rebase after resolving conflicts. Returns conflicted files for the next commit, or empty if done.
pub async fn rebase_continue(cwd: &str) -> Result<Vec<String>> {
let (code, _, stderr) =
run_git_nothrow(cwd, &["-c", "core.editor=true", "rebase", "--continue"]).await;
if code == 0 {
return Ok(vec![]);
}
let (_, unmerged, _) =
run_git_nothrow(cwd, &["diff", "--name-only", "--diff-filter=U"]).await;
let files: Vec<String> = unmerged
.trim()
.split('\n')
.filter(|s| !s.is_empty())
.map(|s| s.to_string())
.collect();
if !files.is_empty() {
return Ok(files);
}
Err(git_error("Rebase --continue failed", &stderr))
}
/// Abort an in-progress rebase.
pub async fn rebase_abort(cwd: &str) {
let _ = run_git_nothrow(cwd, &["rebase", "--abort"]).await;
}
/// Check if a worktree has uncommitted changes.
pub async fn is_dirty(worktree_path: &str) -> bool {
let (code, stdout, _) = run_git_nothrow(
".",
&["-C", worktree_path, "status", "--porcelain"],
)
.await;
if code != 0 {
return false;
}
!stdout.trim().is_empty()
}
/// Find the merge base (common ancestor) between two refs.
pub async fn merge_base(ref1: &str, ref2: &str, cwd: &str) -> Result<String> {
let (code, stdout, _) = run_git_nothrow(cwd, &["merge-base", ref1, ref2]).await;
if code != 0 {
bail!("Could not find merge base between \"{ref1}\" and \"{ref2}\"");
}
Ok(stdout.trim().to_string())
}
/// Get a one-line-per-commit log for a revision range.
pub async fn commit_log(range: &str, cwd: &str) -> String {
let (code, stdout, _) = run_git_nothrow(cwd, &["log", "--oneline", range]).await;
if code != 0 {
return String::new();
}
stdout.trim().to_string()
}
/// Get a diff stat summary for a revision range.
pub async fn diff_stat(range: &str, cwd: &str) -> String {
let (code, stdout, _) =
run_git_nothrow(cwd, &["diff", "--stat", "--stat-width=68", range]).await;
if code != 0 {
return String::new();
}
stdout.trim().to_string()
}
/// Get the diff for a specific file between two refs.
#[allow(dead_code)]
pub async fn file_diff(ref1: &str, ref2: &str, file: &str, cwd: &str) -> String {
let (code, stdout, _) =
run_git_nothrow(cwd, &["diff", ref1, ref2, "--", file]).await;
if code != 0 {
return String::new();
}
stdout.trim().to_string()
}
/// Check if a branch has commits beyond main.
pub async fn has_new_commits(worktree_path: &str) -> bool {
let main = match main_branch(Some(worktree_path)).await {
Ok(m) => m,
Err(_) => return false,
};
let range = format!("{main}..HEAD");
let (code, stdout, _) =
run_git_nothrow(".", &["-C", worktree_path, "rev-list", &range, "--count"]).await;
if code != 0 {
return false;
}
stdout.trim().parse::<u64>().unwrap_or(0) > 0
}
/// Get the full unified diff of a branch vs main as a string.
pub async fn branch_diff(branch: &str, main: &str, cwd: &str) -> String {
let range = format!("{main}...{branch}");
let (code, stdout, _) = run_git_nothrow(cwd, &["diff", "--no-ext-diff", &range]).await;
if code != 0 {
return String::new();
}
stdout
}
/// Detect the main branch name (main or master).
pub async fn main_branch(cwd: Option<&str>) -> Result<String> {
let dir = cwd.unwrap_or(".");
let (code, _, _) = run_git_nothrow(
".",
&["-C", dir, "rev-parse", "--verify", "--quiet", "refs/heads/main"],
)
.await;
if code == 0 {
return Ok("main".to_string());
}
let (code, _, _) = run_git_nothrow(
".",
&[
"-C",
dir,
"rev-parse",
"--verify",
"--quiet",
"refs/heads/master",
],
)
.await;
if code == 0 {
return Ok("master".to_string());
}
bail!("Could not detect main branch: neither \"main\" nor \"master\" exists.")
}

341
rust-sandlot/src/main.rs Normal file
View File

@ -0,0 +1,341 @@
mod commands;
mod config;
mod env;
mod fmt;
mod git;
mod markdown;
mod spinner;
mod state;
mod vm;
use clap::{Parser, Subcommand};
const VERSION: &str = env!("CARGO_PKG_VERSION");
#[derive(Parser)]
#[command(
name = "sandlot",
about = "Sandboxed development with Claude.",
disable_version_flag = true
)]
struct Cli {
#[arg(short = 'V', long = "version")]
version: bool,
#[command(subcommand)]
command: Option<Commands>,
}
#[derive(Subcommand)]
enum Commands {
/// Show all active sessions
List {
/// Output as JSON
#[arg(long)]
json: bool,
/// Show sessions across all projects
#[arg(short, long)]
all: bool,
},
/// Create a new session and launch Claude
New {
/// branch name or prompt (if it contains spaces)
branch: Option<String>,
/// initial prompt for Claude
prompt: Option<String>,
/// run Claude in non-interactive mode with -p
#[arg(short, long)]
print: Option<String>,
/// skip auto-save after Claude exits
#[arg(short = 'n', long = "no-save")]
no_save: bool,
},
/// Open an existing Claude session
Open {
/// branch name
branch: String,
/// initial prompt for Claude
prompt: Option<String>,
/// run Claude in non-interactive mode with -p
#[arg(short, long)]
print: Option<String>,
/// skip auto-save after Claude exits
#[arg(short = 'n', long = "no-save")]
no_save: bool,
},
/// Remove a worktree and clean up the session
Close {
/// branch name
branch: String,
/// close even if there are unsaved changes
#[arg(short, long)]
force: bool,
},
/// Remove a session (alias for close)
#[command(hide = true)]
Rm {
/// branch name
branch: String,
/// close even if there are unsaved changes
#[arg(short, long)]
force: bool,
},
/// Close the session and check out the branch locally
#[command(alias = "co")]
Checkout {
/// branch name
branch: String,
/// checkout even if there are unsaved changes
#[arg(short, long)]
force: bool,
},
// ── Branch Commands ──
/// Show uncommitted changes, or full branch diff vs main
Diff {
/// branch name
branch: String,
},
/// Show commits on a branch that are not on main
Log {
/// branch name
branch: String,
},
/// Show the prompt and full diff for a branch
Show {
/// branch name
branch: String,
},
/// Open the branch diff in a web browser
Web {
/// branch name
branch: String,
},
/// Stage all changes and commit
Save {
/// branch name
branch: String,
/// commit message (AI-generated if omitted)
message: Option<String>,
},
/// Merge a branch into main and close the session
Merge {
/// branch name
branch: String,
/// allow merging into a non-main branch
#[arg(short, long)]
force: bool,
},
/// Squash all commits on a branch into a single commit
Squash {
/// branch name
branch: String,
},
/// Rebase a branch onto the latest main
Rebase {
/// branch name
branch: String,
},
/// Launch an interactive grumpy code review for a branch
Review {
/// branch name
branch: String,
/// additional instructions to append to the review prompt
prompt: Option<String>,
/// print the review to stdout instead of launching interactive mode
#[arg(short, long)]
print: bool,
},
/// Open a shell in the VM
Shell {
/// branch name (omit for a plain VM shell)
branch: Option<String>,
},
/// Open a file from a session in $EDITOR
Edit {
/// branch name
branch: String,
/// file path relative to worktree root
file: String,
},
/// Print the worktree path for a session
Dir {
/// branch name
branch: String,
},
/// Change to a branch's worktree directory
Cd {
/// branch name
branch: String,
},
// ── Admin Commands ──
/// Get or set configuration (e.g. sandlot config memory 16G)
Config {
/// key [value]
args: Vec<String>,
},
/// Remove stale sessions whose worktrees no longer exist
Cleanup,
/// Manage the sandlot VM
Vm {
#[command(subcommand)]
command: VmCommands,
},
/// Upgrade sandlot to the latest version
Upgrade,
/// Print the version number
Version,
/// Output fish shell completions
Completions {
/// Output a shell script that installs the completions file
#[arg(long)]
install: bool,
},
/// Print shell init script (eval in your shell config)
Init {
/// shell type (fish, bash, zsh)
shell: String,
},
}
#[derive(Subcommand)]
enum VmCommands {
/// Create and provision the VM
Create,
/// Start the VM
Start,
/// Open a shell in the VM
Shell,
/// Show VM status and all sessions across repos
Status {
/// Output as JSON
#[arg(long)]
json: bool,
},
/// Show VM system info (via neofetch)
Info,
/// Stop the VM
Stop,
/// Stop and delete the VM
Destroy,
/// Clear the package cache (next create will re-download)
Uncache,
}
#[tokio::main]
async fn main() {
// Default: `sandlot` → `sandlot list`
let args: Vec<String> = std::env::args().collect();
let effective_args = if args.len() <= 1 {
vec![args[0].clone(), "list".to_string()]
} else {
args
};
let cli = match Cli::try_parse_from(&effective_args) {
Ok(cli) => cli,
Err(e) => {
// clap handles --help and error display
e.exit();
}
};
if cli.version {
let parts: Vec<&str> = VERSION.split('.').collect();
println!("v{}", parts.last().unwrap_or(&VERSION));
return;
}
let result = match cli.command.unwrap_or(Commands::List {
json: false,
all: false,
}) {
Commands::List { json, all } => commands::list::action(json, all).await,
Commands::New {
branch,
prompt,
print,
no_save,
} => commands::new::action(branch, prompt, print, !no_save).await,
Commands::Open {
branch,
prompt,
print,
no_save,
} => commands::open::action(branch, prompt, print, !no_save).await,
Commands::Close { branch, force } | Commands::Rm { branch, force } => {
commands::close::action(&branch, force).await
}
Commands::Checkout { branch, force } => commands::checkout::action(&branch, force).await,
Commands::Diff { branch } => commands::diff::action(&branch).await,
Commands::Log { branch } => commands::log::action(&branch).await,
Commands::Show { branch } => commands::show::action(&branch).await,
Commands::Web { branch } => commands::web::action(&branch).await,
Commands::Save { branch, message } => {
commands::save::action(&branch, message.as_deref()).await
}
Commands::Merge { branch, force } => commands::merge::action(&branch, force).await,
Commands::Squash { branch } => commands::squash::action(&branch).await,
Commands::Rebase { branch } => commands::rebase::action(&branch).await,
Commands::Review {
branch,
prompt,
print,
} => commands::review::action(&branch, prompt.as_deref(), print).await,
Commands::Shell { branch } => commands::shell::action(branch.as_deref()).await,
Commands::Edit { branch, file } => commands::edit::action(&branch, &file).await,
Commands::Dir { branch } => commands::dir::action(&branch).await,
Commands::Cd { branch } => commands::cd::action(&branch),
Commands::Config { args } => commands::config::action(&args).await,
Commands::Cleanup => commands::cleanup::action().await,
Commands::Vm { command } => match command {
VmCommands::Create => commands::vm_cmd::create().await,
VmCommands::Start => commands::vm_cmd::start().await,
VmCommands::Shell => commands::vm_cmd::shell().await,
VmCommands::Status { json } => commands::vm_cmd::status(json).await,
VmCommands::Info => commands::vm_cmd::info().await,
VmCommands::Stop => commands::vm_cmd::stop().await,
VmCommands::Destroy => commands::vm_cmd::destroy().await,
VmCommands::Uncache => commands::vm_cmd::uncache().await,
},
Commands::Upgrade => commands::upgrade::action().await,
Commands::Version => {
let parts: Vec<&str> = VERSION.split('.').collect();
println!("v{}", parts.last().unwrap_or(&VERSION));
Ok(())
}
Commands::Completions { install } => commands::completions::action(install),
Commands::Init { shell } => commands::init::action(&shell),
};
if let Err(e) = result {
eprintln!("\u{2716} {e}");
std::process::exit(1);
}
}

View File

@ -0,0 +1,254 @@
use regex::Regex;
fn strip_ansi(s: &str) -> String {
let re1 = Regex::new(r"\x1b\]8;;[^\x07]*\x07").unwrap();
let re2 = Regex::new(r"\x1b\[[0-9;]*m").unwrap();
let s = re1.replace_all(s, "");
re2.replace_all(&s, "").to_string()
}
fn render_table(block: &str) -> String {
let lines: Vec<&str> = block.trim().split('\n').collect();
if lines.len() < 2 {
return block.to_string();
}
let parse_row = |line: &str| -> Vec<String> {
let l = line.strip_prefix('|').unwrap_or(line);
let l = l.strip_suffix('|').unwrap_or(l);
l.split('|').map(|c| c.trim().to_string()).collect()
};
let header = parse_row(lines[0]);
let sep_cells = parse_row(lines[1]);
let sep_re = Regex::new(r"^:?-+:?$").unwrap();
if !sep_cells.iter().all(|s| sep_re.is_match(s.trim())) {
return block.to_string();
}
let cols = header.len();
let align: Vec<&str> = sep_cells
.iter()
.map(|s| {
let t = s.trim();
if t.starts_with(':') && t.ends_with(':') {
"center"
} else if t.ends_with(':') {
"right"
} else {
"left"
}
})
.collect();
let rows: Vec<Vec<String>> = lines[2..].iter().map(|l| parse_row(l)).collect();
let mut widths = vec![0usize; cols];
for c in 0..cols {
widths[c] = widths[c].max(strip_ansi(header.get(c).map(|s| s.as_str()).unwrap_or("")).len());
for row in &rows {
widths[c] = widths[c].max(strip_ansi(row.get(c).map(|s| s.as_str()).unwrap_or("")).len());
}
}
let pad = |text: &str, width: usize, a: &str| -> String {
let visible = strip_ansi(text).len();
if visible >= width {
return text.to_string();
}
let needed = width - visible;
match a {
"right" => format!("{}{}", " ".repeat(needed), text),
"center" => {
let l = needed / 2;
format!("{}{}{}", " ".repeat(l), text, " ".repeat(needed - l))
}
_ => format!("{}{}", text, " ".repeat(needed)),
}
};
let d = "\x1b[2m";
let r = "\x1b[22m";
let render_row = |cells: &[String], bold: bool| -> String {
let parts: Vec<String> = cells
.iter()
.enumerate()
.map(|(i, c)| pad(c, *widths.get(i).unwrap_or(&0), align.get(i).copied().unwrap_or("left")))
.collect();
if bold {
format!(
"{d}\u{2502}{r} {} {d}\u{2502}{r}",
parts
.iter()
.map(|p| format!("\x1b[1m{p}\x1b[22m"))
.collect::<Vec<_>>()
.join(&format!(" {d}\u{2502}{r} "))
)
} else {
format!(
"{d}\u{2502}{r} {} {d}\u{2502}{r}",
parts.join(&format!(" {d}\u{2502}{r} "))
)
}
};
let hline = |l: &str, m: &str, r_ch: &str| -> String {
let segs: Vec<String> = widths.iter().map(|w| "\u{2500}".repeat(w + 2)).collect();
format!("{d}{l}{}{r_ch}{r}", segs.join(m))
};
let mut out = Vec::new();
out.push(hline("\u{250C}", "\u{252C}", "\u{2510}"));
out.push(render_row(&header, true));
out.push(hline("\u{251C}", "\u{253C}", "\u{2524}"));
for row in &rows {
out.push(render_row(row, false));
}
out.push(hline("\u{2514}", "\u{2534}", "\u{2518}"));
out.join("\n")
}
pub fn render_markdown(text: &str) -> String {
// Extract fenced code blocks before anything else
let mut code_blocks: Vec<String> = Vec::new();
let code_block_re = Regex::new(r"(?m)^```\w*\n([\s\S]*?)^```\s*$").unwrap();
let mut result = code_block_re
.replace_all(text, |caps: &regex::Captures| {
code_blocks.push(caps[1].to_string());
format!("\x00CODEBLOCK{}\x00", code_blocks.len() - 1)
})
.to_string();
// Extract backslash escapes
let mut escapes: Vec<String> = Vec::new();
let esc_re = Regex::new(r"\\([\\`*_~\[\]()#>!\-])").unwrap();
result = esc_re
.replace_all(&result, |caps: &regex::Captures| {
escapes.push(caps[1].to_string());
format!("\x00ESC{}\x00", escapes.len() - 1)
})
.to_string();
// Extract code spans
let mut code_spans: Vec<String> = Vec::new();
let span_re = Regex::new(r"`([^`]+)`").unwrap();
result = span_re
.replace_all(&result, |caps: &regex::Captures| {
code_spans.push(caps[1].to_string());
format!("\x00CODE{}\x00", code_spans.len() - 1)
})
.to_string();
// Links: [text](url) -> OSC 8 terminal hyperlink
let link_re = Regex::new(r#"(?<!!)\[([^\]]+)\]\(([^)]+)\)"#).unwrap();
result = link_re
.replace_all(&result, |caps: &regex::Captures| {
let text = &caps[1];
let href = &caps[2];
let url_re = Regex::new(r#"\s+"[^"]*"$"#).unwrap();
let url = url_re.replace(href, "");
format!("\x1b]8;;{url}\x07\x1b[4;38;5;75m{text}\x1b[24;39m\x1b]8;;\x07")
})
.to_string();
// H1: # Header -> bold+italic+underline
let h1_re = Regex::new(r"(?m)^# (.+)$").unwrap();
result = h1_re
.replace_all(&result, "\x1b[1;3;4m$1\x1b[22;23;24m")
.to_string();
// H2/H3: ## Header -> bold
let h2_re = Regex::new(r"(?m)^#{2,6} (.+)$").unwrap();
result = h2_re
.replace_all(&result, "\x1b[1m$1\x1b[22m")
.to_string();
// Blockquotes: > text -> dim+italic
let bq_re = Regex::new(r"(?m)^> (.+)$").unwrap();
result = bq_re
.replace_all(&result, "\x1b[2;3m$1\x1b[22;23m")
.to_string();
// Bare blockquote lines
let bq_bare_re = Regex::new(r"(?m)^>\s*$").unwrap();
result = bq_bare_re.replace_all(&result, "").to_string();
// Task lists: - [x] -> green check, - [ ] -> dim box
let task_x_re = Regex::new(r"(?m)^(\s*)[-*] \[x\] (.+)$").unwrap();
result = task_x_re
.replace_all(&result, "$1\x1b[32m\u{2713}\x1b[39m $2")
.to_string();
let task_o_re = Regex::new(r"(?m)^(\s*)[-*] \[ \] (.+)$").unwrap();
result = task_o_re
.replace_all(&result, "$1\x1b[2m\u{2610}\x1b[22m $2")
.to_string();
// Bold: **text**
let bold_re = Regex::new(r"\*\*(.+?)\*\*").unwrap();
result = bold_re
.replace_all(&result, "\x1b[1m$1\x1b[22m")
.to_string();
// Italic: *text*
let italic_re = Regex::new(r"\*(.+?)\*").unwrap();
result = italic_re
.replace_all(&result, "\x1b[3m$1\x1b[23m")
.to_string();
// Restore code spans as light blue
let code_restore_re = Regex::new(r"\x00CODE(\d+)\x00").unwrap();
result = code_restore_re
.replace_all(&result, |caps: &regex::Captures| {
let i: usize = caps[1].parse().unwrap();
format!(
"\x1b[38;5;147m{}\x1b[39m",
code_spans.get(i).map(|s| s.as_str()).unwrap_or("")
)
})
.to_string();
// Restore backslash escapes
let esc_restore_re = Regex::new(r"\x00ESC(\d+)\x00").unwrap();
result = esc_restore_re
.replace_all(&result, |caps: &regex::Captures| {
let i: usize = caps[1].parse().unwrap();
escapes.get(i).map(|s| s.as_str()).unwrap_or("").to_string()
})
.to_string();
// Tables: pipe tables with box-drawing
let table_re = Regex::new(r"(?m)^(\|[^\n]+\|\n)(\|[\s:|\-]+\|\n)((?:\|[^\n]+\|\n?)*)").unwrap();
result = table_re
.replace_all(&result, |caps: &regex::Captures| {
render_table(&caps[0])
})
.to_string();
// Restore code blocks
let cb_restore_re = Regex::new(r"\x00CODEBLOCK(\d+)\x00").unwrap();
result = cb_restore_re
.replace_all(&result, |caps: &regex::Captures| {
let i: usize = caps[1].parse().unwrap();
code_blocks.get(i).map(|s| s.as_str()).unwrap_or("").to_string()
})
.to_string();
// Breathe: add blank line before list starts
let mut lines: Vec<String> = result.split('\n').map(|s| s.to_string()).collect();
let list_re = Regex::new(r"^[\s]*[-*] ").unwrap();
let mut i = lines.len();
while i > 1 {
i -= 1;
if list_re.is_match(&lines[i])
&& !lines[i - 1].trim().is_empty()
&& !list_re.is_match(&lines[i - 1])
{
lines.insert(i, String::new());
}
}
lines.join("\n")
}

View File

@ -0,0 +1,99 @@
use std::io::Write;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, Mutex};
const FRAMES: &[&str] = &[
"\u{280B}", "\u{2819}", "\u{2839}", "\u{2838}", "\u{283C}", "\u{2834}", "\u{2826}",
"\u{2827}", "\u{2807}", "\u{280F}",
];
pub struct Spinner {
text: Arc<Mutex<String>>,
prefix_tag: String,
running: Arc<AtomicBool>,
handle: Option<std::thread::JoinHandle<()>>,
debug: bool,
}
impl Spinner {
pub fn new(text: &str, prefix: Option<&str>) -> Self {
let debug = std::env::var("DEBUG").is_ok_and(|v| !v.is_empty());
let prefix_tag = match prefix {
Some(p) => format!("\x1b[2m[{p}]\x1b[22m "),
None => String::new(),
};
if debug {
eprint!("\u{25B8} {}{text}\n", prefix_tag);
return Self {
text: Arc::new(Mutex::new(text.to_string())),
prefix_tag,
running: Arc::new(AtomicBool::new(false)),
handle: None,
debug: true,
};
}
let text = Arc::new(Mutex::new(text.to_string()));
let running = Arc::new(AtomicBool::new(true));
let t_text = text.clone();
let t_running = running.clone();
let t_tag = prefix_tag.clone();
let handle = std::thread::spawn(move || {
let mut i = 0usize;
while t_running.load(Ordering::Relaxed) {
let txt = t_text.lock().unwrap().clone();
eprint!("\r\x1b[2K{} {t_tag}{txt}", FRAMES[i % FRAMES.len()]);
let _ = std::io::stderr().flush();
i += 1;
std::thread::sleep(std::time::Duration::from_millis(80));
}
});
Self {
text,
prefix_tag,
running,
handle: Some(handle),
debug,
}
}
pub fn set_text(&self, t: &str) {
if self.debug {
eprint!("\u{25B8} {}{t}\n", self.prefix_tag);
return;
}
*self.text.lock().unwrap() = t.to_string();
}
pub fn succeed(&self, msg: &str) {
self.stop_thread();
eprint!("\r\x1b[2K\u{2714} {}{msg}\n", self.prefix_tag);
}
pub fn fail(&self, msg: &str) {
self.stop_thread();
eprint!("\r\x1b[2K\u{2716} {}{msg}\n", self.prefix_tag);
}
pub fn stop(&self) {
self.stop_thread();
eprint!("\r\x1b[2K");
let _ = std::io::stderr().flush();
}
fn stop_thread(&self) {
self.running.store(false, Ordering::Relaxed);
}
}
impl Drop for Spinner {
fn drop(&mut self) {
self.running.store(false, Ordering::Relaxed);
if let Some(h) = self.handle.take() {
let _ = h.join();
}
}
}

160
rust-sandlot/src/state.rs Normal file
View File

@ -0,0 +1,160 @@
use anyhow::Result;
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::path::{Path, PathBuf};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Session {
pub branch: String,
pub worktree: String,
pub created_at: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub prompt: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub in_review: Option<bool>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct State {
pub sessions: HashMap<String, Session>,
}
#[derive(Debug, Clone)]
pub struct GlobalSession {
pub session: Session,
pub repo_root: String,
}
fn state_path(repo_root: &str) -> PathBuf {
Path::new(repo_root).join(".sandlot").join("state.json")
}
pub async fn load(repo_root: &str) -> State {
let path = state_path(repo_root);
match tokio::fs::read_to_string(&path).await {
Ok(content) => serde_json::from_str(&content).unwrap_or_default(),
Err(_) => State::default(),
}
}
pub async fn save(repo_root: &str, state: &State) -> Result<()> {
let path = state_path(repo_root);
let dir = Path::new(repo_root).join(".sandlot");
tokio::fs::create_dir_all(&dir).await?;
// Ensure dir exists via .gitkeep
let gitkeep = dir.join(".gitkeep");
if !gitkeep.exists() {
tokio::fs::write(&gitkeep, "").await.ok();
}
let tmp_path = format!("{}.tmp", path.display());
let json = serde_json::to_string_pretty(state)? + "\n";
tokio::fs::write(&tmp_path, &json).await?;
tokio::fs::rename(&tmp_path, &path).await?;
Ok(())
}
pub async fn get_session(repo_root: &str, branch: &str) -> Option<Session> {
let state = load(repo_root).await;
state.sessions.get(branch).cloned()
}
pub async fn set_session(repo_root: &str, session: Session) -> Result<()> {
let mut state = load(repo_root).await;
state.sessions.insert(session.branch.clone(), session);
save(repo_root, &state).await
}
pub async fn remove_session(repo_root: &str, branch: &str) -> Result<()> {
let mut state = load(repo_root).await;
state.sessions.remove(branch);
save(repo_root, &state).await
}
/// Discover all sessions across all repos by scanning ~/.sandlot/
pub async fn load_all() -> Vec<GlobalSession> {
let home = match dirs::home_dir() {
Some(h) => h,
None => return vec![],
};
let sandlot_dir = home.join(".sandlot");
let mut all = Vec::new();
let mut seen = std::collections::HashSet::new();
let mut repo_dirs = match tokio::fs::read_dir(&sandlot_dir).await {
Ok(rd) => rd,
Err(_) => return vec![],
};
while let Ok(Some(entry)) = repo_dirs.next_entry().await {
let name = entry.file_name().to_string_lossy().to_string();
if name.starts_with('.') {
continue;
}
let ft = match entry.file_type().await {
Ok(ft) => ft,
Err(_) => continue,
};
if !ft.is_dir() {
continue;
}
let repo_dir = sandlot_dir.join(&name);
let mut repo_root: Option<String> = None;
// Find the main repo root from a worktree's .git pointer
let mut branch_entries = match tokio::fs::read_dir(&repo_dir).await {
Ok(be) => be,
Err(_) => continue,
};
while let Ok(Some(be)) = branch_entries.next_entry().await {
let be_name = be.file_name().to_string_lossy().to_string();
if be_name.starts_with('.') {
continue;
}
let be_ft = match be.file_type().await {
Ok(ft) => ft,
Err(_) => continue,
};
if !be_ft.is_dir() {
continue;
}
let dot_git = repo_dir.join(&be_name).join(".git");
if let Ok(content) = tokio::fs::read_to_string(&dot_git).await {
if let Some(m) = regex::Regex::new(r"(?m)^gitdir:\s*(.+)")
.ok()
.and_then(|re| re.captures(&content))
.and_then(|c| c.get(1))
{
let gitdir = m.as_str().trim();
// gitdir: /path/to/repo/.git/worktrees/<name>
let main_git = regex::Regex::new(r"/worktrees/[^/]+$")
.unwrap()
.replace(gitdir, "");
let main_git_path = Path::new(main_git.as_ref());
if let Some(parent) = main_git_path.parent() {
repo_root = Some(parent.to_string_lossy().to_string());
}
break;
}
}
}
if let Some(ref root) = repo_root {
if seen.contains(root) {
continue;
}
seen.insert(root.clone());
let st = load(root).await;
for session in st.sessions.into_values() {
all.push(GlobalSession {
session,
repo_root: root.clone(),
});
}
}
}
all
}

908
rust-sandlot/src/vm.rs Normal file
View File

@ -0,0 +1,908 @@
use anyhow::{Result, bail};
use std::path::Path;
use tokio::process::Command;
use uuid::Uuid;
const CONTAINER_NAME: &str = "sandlot";
const USER: &str = "ubuntu";
const CLAUDE_BIN: &str = "/home/ubuntu/.local/bin/claude";
const CONTAINER_PATH: &str = "/sandlot/bin:/sandlot/.cargo/bin:/sandlot/.go/bin:/sandlot/.gopath/bin:/home/ubuntu/.local/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin";
const CONTAINER_ENV: &[(&str, &str)] = &[
("RUSTUP_HOME", "/sandlot/.rustup"),
("CARGO_HOME", "/sandlot/.cargo"),
("GOROOT", "/sandlot/.go"),
("GOPATH", "/sandlot/.gopath"),
("RUSTC_WRAPPER", "/sandlot/.cargo/bin/sccache"),
("SCCACHE_DIR", "/sandlot/.sccache"),
];
fn debug_mode() -> bool {
std::env::var("DEBUG").is_ok_and(|v| !v.is_empty())
}
fn home_dir() -> String {
dirs::home_dir()
.expect("cannot find home directory")
.to_string_lossy()
.to_string()
}
fn cache_dir() -> String {
format!("{}/.sandlot/.cache", home_dir())
}
/// Translate a host path to its corresponding container path.
pub fn container_path(host_path: &str) -> String {
let home = home_dir();
let sandlot_prefix = format!("{home}/.sandlot");
let dev_prefix = format!("{home}/dev");
let code_prefix = format!("{home}/code");
if host_path.starts_with(&sandlot_prefix) {
return format!("/sandlot{}", &host_path[sandlot_prefix.len()..]);
}
if host_path.starts_with(&dev_prefix) {
return format!("/host/dev{}", &host_path[dev_prefix.len()..]);
}
if host_path.starts_with(&code_prefix) {
return format!("/host/code{}", &host_path[code_prefix.len()..]);
}
host_path.to_string()
}
fn require_container() {
if which::which("container").is_err() {
eprintln!("\u{2716} Apple Container is not installed. Install it with: brew install container");
std::process::exit(1);
}
}
/// Run a shell command, returning error on failure.
async fn run(args: &[&str], step: &str) -> Result<()> {
let mut cmd = Command::new(args[0]);
cmd.args(&args[1..]);
if !debug_mode() {
cmd.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null());
}
let output = cmd.output().await?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string();
let detail = if !stderr.is_empty() {
stderr
} else if !stdout.is_empty() {
stdout
} else {
"(no output)".to_string()
};
bail!("{step} failed (exit {}):\n{detail}", output.status.code().unwrap_or(1));
}
Ok(())
}
/// Check which host source directories exist.
fn host_mounts(home: &str) -> (bool, bool) {
let dev = Path::new(&format!("{home}/dev")).exists();
let code = Path::new(&format!("{home}/code")).exists();
(dev, code)
}
/// Check whether the package cache is populated.
async fn has_cached_tooling() -> bool {
let cache = cache_dir();
for f in &["bun", "claude", "neofetch", "nvim.tar.gz"] {
if !Path::new(&format!("{cache}/{f}")).exists() {
return false;
}
}
true
}
async fn create_container(home: &str) -> Result<()> {
let (dev, code) = host_mounts(home);
let memory = match crate::config::get_memory().await {
Some(m) => match crate::config::validate_memory(&m) {
Ok(v) => v,
Err(e) => {
crate::fmt::info(&format!("Invalid memory config, using default: {e}"));
crate::config::DEFAULTS_MEMORY.to_string()
}
},
None => crate::config::DEFAULTS_MEMORY.to_string(),
};
let mut args: Vec<String> = vec![
"container".into(), "run".into(), "-d".into(),
"--name".into(), CONTAINER_NAME.into(),
"-m".into(), memory,
];
if dev {
args.push("--mount".into());
args.push(format!(
"type=bind,source={home}/dev,target=/host/dev,readonly"
));
}
if code {
args.push("--mount".into());
args.push(format!(
"type=bind,source={home}/code,target=/host/code,readonly"
));
}
args.push("-v".into());
args.push(format!("{home}/.sandlot:/sandlot"));
args.push("ubuntu:24.04".into());
args.push("sleep".into());
args.push("infinity".into());
let mut cmd = Command::new(&args[0]);
cmd.args(&args[1..]);
if !debug_mode() {
cmd.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null());
}
let output = cmd.output().await?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string();
bail!(
"Container creation failed (exit {}):\n{}",
output.status.code().unwrap_or(1),
if !stderr.is_empty() { stderr } else if !stdout.is_empty() { stdout } else { "(no output)".to_string() }
);
}
Ok(())
}
async fn install_packages(cached: bool) -> Result<()> {
let packages = if cached {
"curl git fish build-essential"
} else {
"curl git fish unzip build-essential"
};
run(
&[
"container", "exec", CONTAINER_NAME, "bash", "-c",
&format!("apt update && apt install -y {packages}"),
],
"Package installation",
)
.await
}
async fn create_host_symlinks(home: &str) -> Result<()> {
let (dev, code) = host_mounts(home);
let mut cmds = vec![
format!("mkdir -p '{home}'"),
format!("ln -s /sandlot '{home}/.sandlot'"),
];
if dev {
cmds.push(format!("ln -s /host/dev '{home}/dev'"));
}
if code {
cmds.push(format!("ln -s /host/code '{home}/code'"));
}
run(
&[
"container", "exec", CONTAINER_NAME, "bash", "-c",
&cmds.join(" && "),
],
"Symlink creation",
)
.await
}
async fn install_tooling(cached: bool, log: &dyn Fn(&str)) -> Result<()> {
// Ensure cache directory exists
tokio::fs::create_dir_all(cache_dir()).await.ok();
if cached {
log("Installing packages (cached)");
run(
&[
"container", "exec", "--user", USER, CONTAINER_NAME,
"bash", "-c", "mkdir -p ~/.local/bin",
],
"Create bin directory",
)
.await?;
run(
&[
"container", "exec", "--user", USER, CONTAINER_NAME,
"bash", "-c",
"cp /sandlot/.cache/bun /sandlot/.cache/claude /sandlot/.cache/neofetch ~/.local/bin/ && chmod +x ~/.local/bin/bun ~/.local/bin/claude ~/.local/bin/neofetch && ln -sf bun ~/.local/bin/bunx",
],
"Install cached binaries",
)
.await?;
run(
&[
"container", "exec", "--user", USER, CONTAINER_NAME,
"bash", "-c",
"tar xzf /sandlot/.cache/nvim.tar.gz -C ~/.local --strip-components=1",
],
"Install cached Neovim",
)
.await?;
return Ok(());
}
log("Installing Bun");
run(
&[
"container", "exec", "--user", USER, CONTAINER_NAME,
"env", &format!("BUN_INSTALL=/home/{USER}/.local"),
"bash", "-c", "curl -fsSL https://bun.sh/install | bash",
],
"Bun installation",
)
.await?;
log("Installing Claude Code");
run(
&[
"container", "exec", "--user", USER, CONTAINER_NAME,
"bash", "-c", "curl -fsSL https://claude.ai/install.sh | bash",
],
"Claude Code installation",
)
.await?;
log("Installing neofetch");
run(
&[
"container", "exec", "--user", USER, CONTAINER_NAME,
"bash", "-c",
"curl -fsSL https://raw.githubusercontent.com/dylanaraps/neofetch/master/neofetch -o ~/.local/bin/neofetch && chmod +x ~/.local/bin/neofetch",
],
"neofetch installation",
)
.await?;
log("Installing Neovim");
run(
&[
"container", "exec", "--user", USER, CONTAINER_NAME,
"bash", "-c",
"curl -fsSL https://github.com/neovim/neovim/releases/latest/download/nvim-linux-arm64.tar.gz -o /tmp/nvim.tar.gz && tar xzf /tmp/nvim.tar.gz -C ~/.local --strip-components=1",
],
"Neovim installation",
)
.await?;
// Cache binaries
let _ = Command::new("container")
.args([
"exec", "--user", USER, CONTAINER_NAME,
"bash", "-c",
"cp ~/.local/bin/bun ~/.local/bin/claude ~/.local/bin/neofetch /sandlot/.cache/ && cp /tmp/nvim.tar.gz /sandlot/.cache/nvim.tar.gz",
])
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.output()
.await;
install_persistent_tooling(log).await?;
Ok(())
}
async fn install_persistent_tooling(log: &dyn Fn(&str)) -> Result<()> {
// Rust
let has_rust = Command::new("container")
.args(["exec", "--user", USER, CONTAINER_NAME, "test", "-x", "/sandlot/.cargo/bin/rustc"])
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.status()
.await?;
if !has_rust.success() {
log("Installing Rust");
run(
&[
"container", "exec", "--user", USER, CONTAINER_NAME,
"env",
&format!("RUSTUP_HOME={}", CONTAINER_ENV.iter().find(|e| e.0 == "RUSTUP_HOME").unwrap().1),
&format!("CARGO_HOME={}", CONTAINER_ENV.iter().find(|e| e.0 == "CARGO_HOME").unwrap().1),
"bash", "-c",
"curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y",
],
"Rust installation",
)
.await?;
// Add musl target
let cargo_home = CONTAINER_ENV.iter().find(|e| e.0 == "CARGO_HOME").unwrap().1;
let rustup_home = CONTAINER_ENV.iter().find(|e| e.0 == "RUSTUP_HOME").unwrap().1;
run(
&[
"container", "exec", "--user", USER, CONTAINER_NAME,
"env",
&format!("RUSTUP_HOME={rustup_home}"),
&format!("CARGO_HOME={cargo_home}"),
&format!("PATH={cargo_home}/bin:$PATH"),
"rustup", "target", "add", "aarch64-unknown-linux-musl",
],
"Rust musl target",
)
.await?;
}
// Cargo config
let has_cargo_config = Command::new("container")
.args(["exec", "--user", USER, CONTAINER_NAME, "test", "-f", "/sandlot/.cargo/config.toml"])
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.status()
.await?;
if !has_cargo_config.success() {
let cargo_config = r#"[target.aarch64-unknown-linux-musl]\nlinker = "rust-lld"\n\n[build]\ntarget = "aarch64-unknown-linux-musl"\n"#;
let _ = Command::new("container")
.args([
"exec", "--user", USER, CONTAINER_NAME,
"bash", "-c",
&format!("echo -e '{cargo_config}' > /sandlot/.cargo/config.toml"),
])
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.output()
.await;
}
// sccache
let has_sccache = Command::new("container")
.args(["exec", "--user", USER, CONTAINER_NAME, "test", "-x", "/sandlot/.cargo/bin/sccache"])
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.status()
.await?;
if !has_sccache.success() {
log("Installing sccache");
let sccache_version = "v0.14.0";
let sccache_archive = format!("sccache-{sccache_version}-aarch64-unknown-linux-musl.tar.gz");
let sccache_url = format!("https://github.com/mozilla/sccache/releases/download/{sccache_version}/{sccache_archive}");
run(
&[
"container", "exec", "--user", USER, CONTAINER_NAME,
"bash", "-c",
&format!(
"curl -fsSL {sccache_url} | tar xz -C /tmp && cp /tmp/sccache-{sccache_version}-aarch64-unknown-linux-musl/sccache /sandlot/.cargo/bin/sccache && chmod +x /sandlot/.cargo/bin/sccache && rm -rf /tmp/sccache-{sccache_version}-aarch64-unknown-linux-musl"
),
],
"sccache installation",
)
.await?;
let _ = Command::new("container")
.args(["exec", "--user", USER, CONTAINER_NAME, "bash", "-c", "mkdir -p /sandlot/.sccache"])
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.output()
.await;
}
// Go
let has_go = Command::new("container")
.args(["exec", "--user", USER, CONTAINER_NAME, "test", "-x", "/sandlot/.go/bin/go"])
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.status()
.await?;
if !has_go.success() {
log("Installing Go");
run(
&[
"container", "exec", "--user", USER, CONTAINER_NAME,
"bash", "-c",
"mkdir -p /sandlot/.go && curl -fsSL https://go.dev/dl/go1.24.1.linux-arm64.tar.gz | tar xz -C /sandlot/.go --strip-components=1",
],
"Go installation",
)
.await?;
let _ = Command::new("container")
.args(["exec", "--user", USER, CONTAINER_NAME, "bash", "-c", "mkdir -p /sandlot/.gopath"])
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.output()
.await;
}
Ok(())
}
async fn install_script(home: &str, name: &str, content: &str) -> Result<()> {
let tmp = format!("{home}/.sandlot/.{name}.tmp");
tokio::fs::write(&tmp, content).await?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
tokio::fs::set_permissions(&tmp, std::fs::Permissions::from_mode(0o755)).await?;
}
let _ = Command::new("container")
.args([
"exec", "--user", USER, CONTAINER_NAME,
"bash", "-c",
&format!("cp /sandlot/.{name}.tmp ~/.local/bin/{name}"),
])
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.output()
.await;
tokio::fs::remove_file(&tmp).await.ok();
Ok(())
}
async fn configure_environment(home: &str, api_key: &str) -> Result<()> {
// Git identity
let git_name = Command::new("git")
.args(["config", "user.name"])
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::null())
.output()
.await
.map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string())
.unwrap_or_default();
let git_email = Command::new("git")
.args(["config", "user.email"])
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::null())
.output()
.await
.map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string())
.unwrap_or_default();
if !git_name.is_empty() {
let _ = Command::new("container")
.args(["exec", "--user", USER, CONTAINER_NAME, "git", "config", "--global", "user.name", &git_name])
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.output()
.await;
}
if !git_email.is_empty() {
let _ = Command::new("container")
.args(["exec", "--user", USER, CONTAINER_NAME, "git", "config", "--global", "user.email", &git_email])
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.output()
.await;
}
// Claude settings
let activity_bin = format!("/home/{USER}/.local/bin/sandlot-activity");
let hooks = serde_json::json!({
"UserPromptSubmit": [{"hooks": [{"type": "command", "command": format!("{activity_bin} active")}]}],
"PreToolUse": [{"hooks": [{"type": "command", "command": format!("{activity_bin} active")}]}],
});
let status_line = serde_json::json!({
"type": "command",
"command": format!("/home/{USER}/.local/bin/sandlot-statusline"),
});
let settings = serde_json::json!({
"apiKeyHelper": "~/.claude/api-key-helper.sh",
"skipDangerousModePermissionPrompt": true,
"hooks": hooks,
"statusLine": status_line,
});
let claude_json = serde_json::json!({
"hasCompletedOnboarding": true,
"effortCalloutDismissed": true,
"projects": { "/": { "hasTrustDialogAccepted": true } },
});
let settings_json = serde_json::to_string(&settings)?;
let claude_json_str = serde_json::to_string(&claude_json)?;
// API key helper (write to temp file so key never appears in ps)
let escaped_key = api_key.replace('\'', "'\\''");
let tmp = format!("{home}/.sandlot/.api-key-helper.tmp");
tokio::fs::write(&tmp, format!("#!/bin/sh\necho '{escaped_key}'\n")).await?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
tokio::fs::set_permissions(&tmp, std::fs::Permissions::from_mode(0o755)).await?;
}
let _ = Command::new("container")
.args([
"exec", "--user", USER, CONTAINER_NAME,
"bash", "-c",
"mkdir -p ~/.claude && cp /sandlot/.api-key-helper.tmp ~/.claude/api-key-helper.sh",
])
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.output()
.await;
tokio::fs::remove_file(&tmp).await.ok();
// Activity hook script
install_script(
home,
"sandlot-activity",
"#!/bin/bash\nP=\"${CLAUDE_PROJECT_DIR%/}\"\necho \"$1\" > \"$(dirname \"$P\")/.activity-$(basename \"$P\")\"\n",
)
.await?;
// Statusline script
install_script(
home,
"sandlot-statusline",
"#!/bin/bash\ninput=$(cat)\ncwd=$(echo \"$input\" | grep -oP '\"cwd\"\\s*:\\s*\"\\K[^\"]+' | head -1)\n[ -n \"$cwd\" ] && printf '\\033[36m\u{2387} %s\\033[0m\\n' \"$(basename \"$cwd\")\"\n",
)
.await?;
// Write Claude settings
let _ = Command::new("container")
.args([
"exec", "--user", USER, CONTAINER_NAME,
"bash", "-c",
&format!(
"mkdir -p ~/.claude\necho '{settings_json}' > ~/.claude/settings.json\necho '{claude_json_str}' > ~/.claude.json\n"
),
])
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.output()
.await;
Ok(())
}
// ── Public API ──────────────────────────────────────────────────────
/// Create and provision the container from scratch.
pub async fn create(log: &dyn Fn(&str)) -> Result<()> {
require_container();
let api_key = crate::env::require_api_key().await;
let s = status().await;
if s != "missing" {
bail!("Container already exists. Use 'sandlot vm destroy' first to recreate it.");
}
let home = home_dir();
let cached = has_cached_tooling().await;
log("Pulling image & creating container");
create_container(&home).await?;
log("Installing packages");
install_packages(cached).await?;
create_host_symlinks(&home).await?;
install_tooling(cached, log).await?;
log("Configuring environment");
configure_environment(&home, &api_key).await?;
Ok(())
}
/// Start a stopped container.
pub async fn start() -> Result<()> {
require_container();
let s = status().await;
if s == "running" {
return Ok(());
}
if s == "missing" {
bail!("Container does not exist. Use 'sandlot vm create' first.");
}
run(&["container", "start", CONTAINER_NAME], "Container start").await
}
/// Ensure the sandlot container exists and is running.
pub async fn ensure(log: &dyn Fn(&str)) -> Result<()> {
require_container();
crate::env::require_api_key().await;
// Ensure container daemon is running
let mut cmd = Command::new("container");
cmd.args(["system", "start", "--enable-kernel-install"]);
if !debug_mode() {
cmd.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null());
}
let _ = cmd.output().await;
let s = status().await;
if s == "running" {
return Ok(());
}
if s == "stopped" {
return start().await;
}
create(log).await
}
/// Check container status.
pub async fn status() -> &'static str {
let output = Command::new("container")
.args(["list", "--format", "json", "--all"])
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::null())
.output()
.await;
let output = match output {
Ok(o) => o,
Err(_) => return "missing",
};
let text = String::from_utf8_lossy(&output.stdout);
let containers: Vec<serde_json::Value> = match serde_json::from_str(text.trim()) {
Ok(v) => v,
Err(_) => return "missing",
};
for c in &containers {
if c.get("configuration")
.and_then(|cfg| cfg.get("id"))
.and_then(|id| id.as_str())
== Some(CONTAINER_NAME)
{
let status_str = c
.get("status")
.and_then(|s| s.as_str())
.unwrap_or("")
.to_lowercase();
return if status_str == "running" {
"running"
} else {
"stopped"
};
}
}
"missing"
}
/// Launch claude in the container at the given workdir.
pub fn claude<'a>(
workdir: &'a str,
prompt: Option<&'a str>,
print: Option<&'a str>,
continue_session: bool,
) -> std::pin::Pin<Box<dyn std::future::Future<Output = Result<(i32, Option<String>)>> + Send + 'a>> {
Box::pin(async move {
let cwd = container_path(workdir);
let home = home_dir();
let (dev, code) = host_mounts(&home);
let mut system_prompt_lines = vec![
"You are running inside a sandlot container (Apple Container, ubuntu:24.04).".to_string(),
format!("Your working directory is {cwd}, a git worktree managed by sandlot."),
];
if dev {
system_prompt_lines.push("The host's ~/dev is mounted read-only at /host/dev.".to_string());
}
if code {
system_prompt_lines.push("The host's ~/code is mounted read-only at /host/code.".to_string());
}
system_prompt_lines.push("The host's ~/.sandlot is mounted at /sandlot.".to_string());
system_prompt_lines.push("Bun is installed at ~/.local/bin/bun. Use bun instead of node/npm.".to_string());
system_prompt_lines.push("Rust (cargo/rustc) is installed at /sandlot/.cargo/. Go is installed at /sandlot/.go/. sccache is configured as RUSTC_WRAPPER for build caching.".to_string());
if print.is_some() {
system_prompt_lines.push("IMPORTANT: Do not use plan mode. Do not call the EnterPlanMode tool. Proceed directly with the task.".to_string());
}
let system_prompt = system_prompt_lines.join("\n");
let term = std::env::var("TERM").unwrap_or_else(|_| "xterm-256color".to_string());
let mut env_args: Vec<String> = vec![
format!("TERM={term}"),
format!("PATH={CONTAINER_PATH}"),
];
for (k, v) in CONTAINER_ENV {
env_args.push(format!("{k}={v}"));
}
let mut args: Vec<String> = vec![
"container".into(), "exec".into(), "-it".into(),
"--user".into(), USER.into(),
"--workdir".into(), cwd.clone(),
CONTAINER_NAME.into(), "env".into(),
];
args.extend(env_args);
args.extend([
CLAUDE_BIN.into(),
"--dangerously-skip-permissions".into(),
"--model".into(), "claude-opus-4-6".into(),
"--effort".into(), "max".into(),
"--append-system-prompt".into(), system_prompt,
]);
if continue_session {
args.push("--continue".into());
}
if let Some(p) = print {
args.push("-p".into());
args.push(p.into());
} else if let Some(p) = prompt {
args.push(p.into());
}
if print.is_some() {
let mut cmd = std::process::Command::new(&args[0]);
cmd.args(&args[1..])
.stdin(std::process::Stdio::inherit())
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::inherit());
let child = cmd.spawn()?;
let output = child.wait_with_output()?;
let exit_code = output.status.code().unwrap_or(1);
let stdout = String::from_utf8_lossy(&output.stdout).to_string();
if exit_code != 0 && continue_session {
crate::fmt::info("Retrying without --continue");
return claude(workdir, prompt, print, false).await;
}
return Ok((exit_code, Some(stdout)));
}
let mut cmd = std::process::Command::new(&args[0]);
cmd.args(&args[1..])
.stdin(std::process::Stdio::inherit())
.stdout(std::process::Stdio::inherit())
.stderr(std::process::Stdio::inherit());
let status = cmd.spawn()?.wait()?;
let exit_code = status.code().unwrap_or(1);
if exit_code != 0 && continue_session {
crate::fmt::info("Retrying without --continue");
return claude(workdir, prompt, print, false).await;
}
Ok((exit_code, None))
})
}
/// Open an interactive fish shell in the container.
pub async fn shell(workdir: Option<&str>) -> Result<()> {
let mut args: Vec<String> = vec![
"container".into(), "exec".into(), "-it".into(),
"--user".into(), USER.into(),
];
if let Some(wd) = workdir {
args.push("--workdir".into());
args.push(container_path(wd));
}
let mut env_args: Vec<String> = vec![
"TERM=xterm-256color".into(),
format!("PATH={CONTAINER_PATH}"),
];
for (k, v) in CONTAINER_ENV {
env_args.push(format!("{k}={v}"));
}
args.push(CONTAINER_NAME.into());
args.push("env".into());
args.extend(env_args);
args.push("fish".into());
args.push("--login".into());
let status = std::process::Command::new(&args[0])
.args(&args[1..])
.stdin(std::process::Stdio::inherit())
.stdout(std::process::Stdio::inherit())
.stderr(std::process::Stdio::inherit())
.spawn()?
.wait()?;
let _ = status;
Ok(())
}
/// Run neofetch in the container.
pub async fn neofetch() -> Result<()> {
let mut env_args: Vec<String> = vec![format!("PATH={CONTAINER_PATH}")];
for (k, v) in CONTAINER_ENV {
env_args.push(format!("{k}={v}"));
}
let mut args: Vec<String> = vec![
"container".into(), "exec".into(),
"--user".into(), USER.into(),
CONTAINER_NAME.into(), "env".into(),
];
args.extend(env_args);
args.push("neofetch".into());
let status = std::process::Command::new(&args[0])
.args(&args[1..])
.stdin(std::process::Stdio::inherit())
.stdout(std::process::Stdio::inherit())
.stderr(std::process::Stdio::inherit())
.spawn()?
.wait()?;
let _ = status;
Ok(())
}
/// Run a bash command in the container at the given workdir, capturing output.
pub async fn exec(workdir: &str, command: &str) -> (i32, String, String) {
let env_exports: String = CONTAINER_ENV
.iter()
.map(|(k, v)| format!("export {k}={v}"))
.collect::<Vec<_>>()
.join("; ");
let full_cmd = format!("export PATH={CONTAINER_PATH}; {env_exports}; {command}");
let output = Command::new("container")
.args([
"exec", "--user", USER,
"--workdir", &container_path(workdir),
CONTAINER_NAME, "bash", "-c", &full_cmd,
])
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::piped())
.output()
.await;
match output {
Ok(o) => (
o.status.code().unwrap_or(1),
String::from_utf8_lossy(&o.stdout).trim().to_string(),
String::from_utf8_lossy(&o.stderr).trim().to_string(),
),
Err(_) => (1, String::new(), String::new()),
}
}
/// Pipe input text to Claude in the container with a prompt, returning the output.
pub async fn claude_pipe(input: &str, prompt: &str) -> (i32, String, String) {
let tmp_name = format!(".claude-pipe-{}", Uuid::new_v4());
let home = home_dir();
let tmp_path = format!("{home}/.sandlot/{tmp_name}");
tokio::fs::write(&tmp_path, input).await.ok();
let escaped_prompt = prompt.replace('"', "\\\"");
let result = exec(
&format!("{home}/.sandlot"),
&format!(
"cat /sandlot/{tmp_name} | claude --model claude-opus-4-6 --effort max -p \"{escaped_prompt}\""
),
)
.await;
tokio::fs::remove_file(&tmp_path).await.ok();
result
}
/// Check if Claude is actively working in the given worktree.
pub async fn is_claude_active(worktree: &str, branch: &str) -> bool {
let parent = Path::new(worktree).parent().unwrap_or(Path::new("."));
let file = parent.join(format!(".activity-{branch}"));
match tokio::fs::read_to_string(&file).await {
Ok(content) => content.trim() == "active",
Err(_) => false,
}
}
/// Set the activity marker for a worktree.
pub async fn set_activity(worktree: &str, branch: &str) {
let parent = Path::new(worktree).parent().unwrap_or(Path::new("."));
let file = parent.join(format!(".activity-{branch}"));
tokio::fs::write(&file, "active\n").await.ok();
}
/// Remove the activity marker file for a worktree.
pub async fn clear_activity(worktree: &str, branch: &str) {
let parent = Path::new(worktree).parent().unwrap_or(Path::new("."));
let file = parent.join(format!(".activity-{branch}"));
tokio::fs::remove_file(&file).await.ok();
}
/// Stop the container.
pub async fn stop() -> Result<()> {
let _ = Command::new("container")
.args(["stop", CONTAINER_NAME])
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.output()
.await;
Ok(())
}
/// Stop and delete the container.
pub async fn destroy() -> Result<()> {
stop().await?;
let _ = Command::new("container")
.args(["delete", CONTAINER_NAME])
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.output()
.await;
Ok(())
}
/// Clear the package cache.
pub async fn clear_cache() -> bool {
let cache = cache_dir();
let existed = Path::new(&format!("{cache}/bun")).exists();
tokio::fs::remove_dir_all(&cache).await.ok();
existed
}

View File

@ -35,7 +35,7 @@ const program = new Command()
program program
.name("sandlot") .name("sandlot")
.description("Sandboxed development with Pi.") .description("Sandboxed development with Claude.")
.configureHelp({ styleTitle: (str) => `${yellow}${str}${reset}` }) .configureHelp({ styleTitle: (str) => `${yellow}${str}${reset}` })
.helpOption(false) .helpOption(false)
.addOption(new Option("-h, --help").hideHelp()) .addOption(new Option("-h, --help").hideHelp())
@ -59,19 +59,19 @@ program
program program
.command("new") .command("new")
.argument("[branch]", "branch name or prompt (if it contains spaces)") .argument("[branch]", "branch name or prompt (if it contains spaces)")
.argument("[prompt]", "initial prompt for Pi") .argument("[prompt]", "initial prompt for Claude")
.option("-p, --print <prompt>", "run Pi in non-interactive mode with -p") .option("-p, --print <prompt>", "run Claude in non-interactive mode with -p")
.option("-n, --no-save", "skip auto-save after Pi exits") .option("-n, --no-save", "skip auto-save after Claude exits")
.description("Create a new session and launch Pi") .description("Create a new session and launch Claude")
.action(newAction) .action(newAction)
program program
.command("open") .command("open")
.argument("<branch>", "branch name") .argument("<branch>", "branch name")
.argument("[prompt]", "initial prompt for Pi") .argument("[prompt]", "initial prompt for Claude")
.option("-p, --print <prompt>", "run Pi in non-interactive mode with -p") .option("-p, --print <prompt>", "run Claude in non-interactive mode with -p")
.option("-n, --no-save", "skip auto-save after Pi exits") .option("-n, --no-save", "skip auto-save after Claude exits")
.description("Open an existing Pi session") .description("Open an existing Claude session")
.action(openAction) .action(openAction)
program program

View File

@ -2,19 +2,14 @@ import { die } from "../fmt.ts"
import * as config from "../config.ts" import * as config from "../config.ts"
const VALID_KEYS = Object.keys(config.DEFAULTS) as config.Key[] const VALID_KEYS = Object.keys(config.DEFAULTS) as config.Key[]
const ARRAY_KEYS = Object.entries(config.DEFAULTS).filter(([, v]) => Array.isArray(v)).map(([k]) => k)
export async function action(args: string[]) { export async function action(args: string[]) {
if (args.length === 0) { if (args.length === 0) {
const cfg = await config.load() const cfg = await config.load()
for (const key of VALID_KEYS) { for (const key of VALID_KEYS) {
const val = cfg[key] const val = cfg[key]
if (Array.isArray(config.DEFAULTS[key])) { const display = val ?? `${config.DEFAULTS[key]} (default)`
const arr = (val as string[] | undefined) ?? [] console.log(`${key} = ${display}`)
console.log(`${key} = ${arr.length ? arr.join(", ") : "(empty)"}`)
} else {
console.log(`${key} = ${val ?? `${config.DEFAULTS[key]} (default)`}`)
}
} }
return return
} }
@ -22,32 +17,6 @@ export async function action(args: string[]) {
const [key, ...rest] = args const [key, ...rest] = args
if (!VALID_KEYS.includes(key as config.Key)) die(`Unknown config key: ${key}\nAvailable keys: ${VALID_KEYS.join(", ")}`) if (!VALID_KEYS.includes(key as config.Key)) die(`Unknown config key: ${key}\nAvailable keys: ${VALID_KEYS.join(", ")}`)
// Array keys: `config hosts add foo.local`, `config hosts rm foo.local`
if (ARRAY_KEYS.includes(key)) {
const current = ((await config.get(key as config.Key)) ?? []) as string[]
if (rest.length === 0) {
if (current.length === 0) console.log("(empty)")
else current.forEach(v => console.log(v))
return
}
const [op, ...values] = rest
if (op === "add") {
if (values.length === 0) die("Usage: sandlot config hosts add <hostname>")
const updated = [...new Set([...current, ...values])]
await config.set(key as config.Key, updated as any)
updated.forEach(v => console.log(v))
} else if (op === "rm" || op === "remove") {
if (values.length === 0) die("Usage: sandlot config hosts rm <hostname>")
const updated = current.filter(v => !values.includes(v))
await config.set(key as config.Key, updated as any)
if (updated.length === 0) console.log("(empty)")
else updated.forEach(v => console.log(v))
} else {
die(`Unknown operation: ${op}\nUsage: sandlot config ${key} add|rm <value>`)
}
return
}
if (rest.length === 0) { if (rest.length === 0) {
const val = await config.get(key as config.Key) const val = await config.get(key as config.Key)
console.log(val ?? `${config.DEFAULTS[key as config.Key]} (default)`) console.log(val ?? `${config.DEFAULTS[key as config.Key]} (default)`)

View File

@ -104,7 +104,7 @@ const SKIP_RESOLVE = new Set([
"yarn.lock", "yarn.lock",
]) ])
/** Resolve conflict markers in files using Pi, then stage them. */ /** Resolve conflict markers in files using Claude, then stage them. */
export async function resolveConflicts( export async function resolveConflicts(
files: string[], files: string[],
cwd: string, cwd: string,
@ -124,13 +124,13 @@ export async function resolveConflicts(
throw new Error(`Failed to read conflicted file: ${file}`) throw new Error(`Failed to read conflicted file: ${file}`)
}) })
const resolved = await vm.piPipe( const resolved = await vm.claudePipe(
content, content,
"resolve this merge conflict. output ONLY the resolved file content with no markdown fences, no explanation, no surrounding text.", "resolve this merge conflict. output ONLY the resolved file content with no markdown fences, no explanation, no surrounding text.",
) )
if (resolved.exitCode !== 0 || !resolved.stdout.trim()) { if (resolved.exitCode !== 0 || !resolved.stdout.trim()) {
throw new Error(`Pi failed to resolve ${file}: ${resolved.stderr.trim() || "(no output)"}`) throw new Error(`Claude failed to resolve ${file}: ${resolved.stderr.trim() || "(no output)"}`)
} }
await Bun.write(join(cwd, file), resolved.stdout.trimEnd() + "\n") await Bun.write(join(cwd, file), resolved.stdout.trimEnd() + "\n")
@ -162,7 +162,7 @@ export async function mergeAndClose(branch: string, opts?: { force?: boolean }):
return return
} }
// Resolve conflicts with Pi // Resolve conflicts with Claude
spin.text = `Resolving ${conflicts.length} conflict(s)` spin.text = `Resolving ${conflicts.length} conflict(s)`
try { try {
@ -210,7 +210,7 @@ export async function saveChanges(worktree: string, branch: string, message?: st
spin.text = "Generating commit message" spin.text = "Generating commit message"
const diff = await $`git -C ${worktree} diff --staged`.nothrow().quiet().text() const diff = await $`git -C ${worktree} diff --staged`.nothrow().quiet().text()
const gen = await vm.piPipe( const gen = await vm.claudePipe(
diff, diff,
"Write a commit message for this diff. Subject line: imperative mood, max 72 characters, no period. If the changes warrant it, add a blank line then a brief body explaining why, not what. Output only the raw commit message, no quotes or markdown.", "Write a commit message for this diff. Subject line: imperative mood, max 72 characters, no period. If the changes warrant it, add a blank line then a brief body explaining why, not what. Output only the raw commit message, no quotes or markdown.",
) )

View File

@ -1,4 +1,5 @@
import { basename } from "path" import { basename } from "path"
import { homedir } from "os"
import { stat } from "fs/promises" import { stat } from "fs/promises"
import * as git from "../git.ts" import * as git from "../git.ts"
import * as vm from "../vm.ts" import * as vm from "../vm.ts"
@ -43,7 +44,7 @@ async function resolveStatus(
): Promise<string> { ): Promise<string> {
try { await stat(s.worktree) } catch { return "idle" } try { await stat(s.worktree) } catch { return "idle" }
if (vmRunning) { if (vmRunning) {
const active = await vm.isPiActive(s.worktree, s.branch).catch(() => false) const active = await vm.isClaudeActive(s.worktree, s.branch).catch(() => false)
if (active && s.in_review) return "review" if (active && s.in_review) return "review"
if (active) return "active" if (active) return "active"
} }
@ -57,7 +58,7 @@ async function resolveStatus(
} }
} }
/** Clear in_review flags for sessions where Pi is no longer active. */ /** Clear in_review flags for sessions where Claude is no longer active. */
async function clearStaleReviews( async function clearStaleReviews(
sessions: state.GlobalSession[], sessions: state.GlobalSession[],
statusMap: Map<state.GlobalSession, string>, statusMap: Map<state.GlobalSession, string>,
@ -74,9 +75,26 @@ async function clearStaleReviews(
} }
} }
async function backfillPrompts(_sessions: { worktree: string; prompt?: string }[], _vmRunning: boolean) { async function backfillPrompts(sessions: { worktree: string; prompt?: string }[], vmRunning: boolean) {
// Pi doesn't maintain a history.jsonl like Claude did. if (!vmRunning) return
// Prompts are populated from state.json at session creation time. const needsPrompt = sessions.filter(s => !s.prompt)
if (needsPrompt.length === 0) return
const result = await vm.exec(homedir() + "/.sandlot", "cat /home/ubuntu/.claude/history.jsonl 2>/dev/null").catch(() => null)
if (!result || result.exitCode !== 0 || !result.stdout) return
const byProject = new Map<string, string>()
for (const line of result.stdout.split("\n")) {
if (!line) continue
try {
const e = JSON.parse(line)
if (e.project && e.display) byProject.set(e.project, e.display)
} catch {}
}
for (const s of needsPrompt) {
const display = byProject.get(vm.containerPath(s.worktree))
if (display) s.prompt = display
}
} }
// ── Command ────────────────────────────────────────────────────────── // ── Command ──────────────────────────────────────────────────────────

View File

@ -126,7 +126,7 @@ export async function action(
if (opts.print) { if (opts.print) {
spin.text = "Running prompt…" spin.text = "Running prompt…"
const result = await vm.pi(worktreeAbs, { prompt, print: opts.print }) const result = await vm.claude(worktreeAbs, { prompt, print: opts.print })
if (result.output) { if (result.output) {
spin.stop() spin.stop()
process.stdout.write(renderMarkdown(result.output) + "\n") process.stdout.write(renderMarkdown(result.output) + "\n")
@ -134,7 +134,7 @@ export async function action(
spin.succeed("Done") spin.succeed("Done")
} }
} else { } else {
await vm.pi(worktreeAbs, { prompt, print: opts.print }) await vm.claude(worktreeAbs, { prompt, print: opts.print })
} }
await vm.clearActivity(worktreeAbs, branch) await vm.clearActivity(worktreeAbs, branch)

View File

@ -21,7 +21,7 @@ export async function action(
if (opts.print) { if (opts.print) {
spin.text = "Running prompt…" spin.text = "Running prompt…"
const result = await vm.pi(session.worktree, { prompt, print: opts.print, continue: true }) const result = await vm.claude(session.worktree, { prompt, print: opts.print, continue: true })
if (result.output) { if (result.output) {
spin.stop() spin.stop()
process.stdout.write(renderMarkdown(result.output) + "\n") process.stdout.write(renderMarkdown(result.output) + "\n")
@ -30,7 +30,7 @@ export async function action(
} }
} else { } else {
spin.succeed("Session ready") spin.succeed("Session ready")
await vm.pi(session.worktree, { prompt, print: opts.print, continue: true }) await vm.claude(session.worktree, { prompt, print: opts.print, continue: true })
} }
await vm.clearActivity(session.worktree, branch) await vm.clearActivity(session.worktree, branch)

View File

@ -34,7 +34,7 @@ export async function action(branch: string) {
} }
fetchSpin.stop() fetchSpin.stop()
console.log(`◆ Rebase conflicts in ${conflicts.length} file(s). Resolving with Pi...`) console.log(`◆ Rebase conflicts in ${conflicts.length} file(s). Resolving with Claude...`)
const resolveSpin = spinner("Starting container", branch) const resolveSpin = spinner("Starting container", branch)
try { try {

View File

@ -74,11 +74,11 @@ Your thoughts, in brief.
try { try {
if (opts.print) { if (opts.print) {
spin.text = "Running review…" spin.text = "Running review…"
const result = await vm.pi(session.worktree, { print: prompt }) const result = await vm.claude(session.worktree, { print: prompt })
if (result.output) process.stdout.write(result.output + "\n") if (result.output) process.stdout.write(result.output + "\n")
} else { } else {
spin.succeed("Session ready") spin.succeed("Session ready")
await vm.pi(session.worktree, { prompt }) await vm.claude(session.worktree, { prompt })
} }
} finally { } finally {
spin.stop() spin.stop()

View File

@ -40,7 +40,7 @@ export async function action(branch: string) {
process.exit(1) process.exit(1)
} }
const gen = await vm.piPipe( const gen = await vm.claudePipe(
diff, diff,
"Write a commit message summarizing all changes. Subject line: imperative mood, max 72 characters, no period. Add a blank line then a concise body with the key changes as bullet points. Output only the raw commit message, no quotes or markdown.", "Write a commit message summarizing all changes. Subject line: imperative mood, max 72 characters, no period. Add a blank line then a concise body with the key changes as bullet points. Output only the raw commit message, no quotes or markdown.",
) )

View File

@ -70,7 +70,7 @@ export function register(program: Command) {
sessions.map(async (sess): Promise<[string, string]> => { sessions.map(async (sess): Promise<[string, string]> => {
const key = `${basename(sess.repoRoot)}/${sess.branch}` const key = `${basename(sess.repoRoot)}/${sess.branch}`
try { try {
if (await vm.isPiActive(sess.worktree, sess.branch)) return [key, "active"] if (await vm.isClaudeActive(sess.worktree, sess.branch)) return [key, "active"]
if (await git.isDirty(sess.worktree)) return [key, "dirty"] if (await git.isDirty(sess.worktree)) return [key, "dirty"]
if (await git.hasNewCommits(sess.worktree)) return [key, "saved"] if (await git.hasNewCommits(sess.worktree)) return [key, "saved"]
} catch {} } catch {}

View File

@ -5,16 +5,14 @@ import { join } from "path"
const CONFIG_DIR = join(homedir(), ".config", "sandlot") const CONFIG_DIR = join(homedir(), ".config", "sandlot")
const CONFIG_PATH = join(CONFIG_DIR, "config.json") const CONFIG_PATH = join(CONFIG_DIR, "config.json")
export const DEFAULTS: Record<string, string | string[]> = { export const DEFAULTS = {
memory: "16G", memory: "16G",
hosts: [], } as const
}
export type Key = keyof typeof DEFAULTS export type Key = keyof typeof DEFAULTS
export interface Config { export interface Config {
memory?: string memory?: string
hosts?: string[]
} }
const MIN_MEMORY_MB = 512 const MIN_MEMORY_MB = 512

130
src/vm.ts
View File

@ -9,7 +9,7 @@ import { get as getConfig, DEFAULTS, validateMemory } from "./config.ts"
const DEBUG = !!process.env.DEBUG const DEBUG = !!process.env.DEBUG
const CONTAINER_NAME = "sandlot" const CONTAINER_NAME = "sandlot"
const USER = "ubuntu" const USER = "ubuntu"
const PI_BIN = `/sandlot/.pi-bin/pi/pi` const CLAUDE_BIN = `/home/${USER}/.local/bin/claude`
const CONTAINER_PATH = `/sandlot/bin:/sandlot/.cargo/bin:/sandlot/.go/bin:/sandlot/.gopath/bin:/home/${USER}/.local/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin` const CONTAINER_PATH = `/sandlot/bin:/sandlot/.cargo/bin:/sandlot/.go/bin:/sandlot/.gopath/bin:/home/${USER}/.local/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin`
const CONTAINER_ENV = { const CONTAINER_ENV = {
RUSTUP_HOME: "/sandlot/.rustup", RUSTUP_HOME: "/sandlot/.rustup",
@ -85,8 +85,8 @@ async function createContainer(home: string): Promise<void> {
/** Install base system packages (as root). */ /** Install base system packages (as root). */
async function installPackages(cached: boolean): Promise<void> { async function installPackages(cached: boolean): Promise<void> {
const packages = cached const packages = cached
? "curl git fish build-essential avahi-daemon libnss-mdns" ? "curl git fish build-essential"
: "curl git fish unzip build-essential avahi-daemon libnss-mdns" : "curl git fish unzip build-essential"
await run( await run(
$`container exec ${CONTAINER_NAME} bash -c ${`apt update && apt install -y ${packages}`}`, $`container exec ${CONTAINER_NAME} bash -c ${`apt update && apt install -y ${packages}`}`,
"Package installation") "Package installation")
@ -107,12 +107,12 @@ const CACHE_DIR = join(homedir(), '.sandlot', '.cache')
/** Check whether the package cache is populated. */ /** Check whether the package cache is populated. */
async function hasCachedTooling(): Promise<boolean> { async function hasCachedTooling(): Promise<boolean> {
const files = ['bun', 'neofetch', 'nvim.tar.gz'] const files = ['bun', 'claude', 'neofetch', 'nvim.tar.gz']
const checks = await Promise.all(files.map(f => Bun.file(join(CACHE_DIR, f)).exists())) const checks = await Promise.all(files.map(f => Bun.file(join(CACHE_DIR, f)).exists()))
return checks.every(Boolean) return checks.every(Boolean)
} }
/** Install Bun, neofetch, and Neovim using cached binaries when available. Pi is installed persistently. */ /** Install Bun, Claude Code, neofetch, and Neovim using cached binaries when available. */
async function installTooling(cached: boolean, log?: (msg: string) => void): Promise<void> { async function installTooling(cached: boolean, log?: (msg: string) => void): Promise<void> {
// Ensure cache directory exists on the host (mounted at /sandlot/.cache in the container) // Ensure cache directory exists on the host (mounted at /sandlot/.cache in the container)
await $`mkdir -p ${CACHE_DIR}`.quiet() await $`mkdir -p ${CACHE_DIR}`.quiet()
@ -123,12 +123,11 @@ async function installTooling(cached: boolean, log?: (msg: string) => void): Pro
$`container exec --user ${USER} ${CONTAINER_NAME} bash -c ${"mkdir -p ~/.local/bin"}`, $`container exec --user ${USER} ${CONTAINER_NAME} bash -c ${"mkdir -p ~/.local/bin"}`,
"Create bin directory") "Create bin directory")
await run( await run(
$`container exec --user ${USER} ${CONTAINER_NAME} bash -c ${"cp /sandlot/.cache/bun /sandlot/.cache/neofetch ~/.local/bin/ && chmod +x ~/.local/bin/bun ~/.local/bin/neofetch && ln -sf bun ~/.local/bin/bunx"}`, $`container exec --user ${USER} ${CONTAINER_NAME} bash -c ${"cp /sandlot/.cache/bun /sandlot/.cache/claude /sandlot/.cache/neofetch ~/.local/bin/ && chmod +x ~/.local/bin/bun ~/.local/bin/claude ~/.local/bin/neofetch && ln -sf bun ~/.local/bin/bunx"}`,
"Install cached binaries") "Install cached binaries")
await run( await run(
$`container exec --user ${USER} ${CONTAINER_NAME} bash -c ${"tar xzf /sandlot/.cache/nvim.tar.gz -C ~/.local --strip-components=1"}`, $`container exec --user ${USER} ${CONTAINER_NAME} bash -c ${"tar xzf /sandlot/.cache/nvim.tar.gz -C ~/.local --strip-components=1"}`,
"Install cached Neovim") "Install cached Neovim")
await installPersistentTooling(log)
return return
} }
@ -137,6 +136,11 @@ async function installTooling(cached: boolean, log?: (msg: string) => void): Pro
$`container exec --user ${USER} ${CONTAINER_NAME} env BUN_INSTALL=/home/${USER}/.local bash -c ${"curl -fsSL https://bun.sh/install | bash"}`, $`container exec --user ${USER} ${CONTAINER_NAME} env BUN_INSTALL=/home/${USER}/.local bash -c ${"curl -fsSL https://bun.sh/install | bash"}`,
"Bun installation") "Bun installation")
log?.("Installing Claude Code")
await run(
$`container exec --user ${USER} ${CONTAINER_NAME} bash -c ${"curl -fsSL https://claude.ai/install.sh | bash"}`,
"Claude Code installation")
log?.("Installing neofetch") log?.("Installing neofetch")
await run( await run(
$`container exec --user ${USER} ${CONTAINER_NAME} bash -c ${"curl -fsSL https://raw.githubusercontent.com/dylanaraps/neofetch/master/neofetch -o ~/.local/bin/neofetch && chmod +x ~/.local/bin/neofetch"}`, $`container exec --user ${USER} ${CONTAINER_NAME} bash -c ${"curl -fsSL https://raw.githubusercontent.com/dylanaraps/neofetch/master/neofetch -o ~/.local/bin/neofetch && chmod +x ~/.local/bin/neofetch"}`,
@ -148,22 +152,13 @@ async function installTooling(cached: boolean, log?: (msg: string) => void): Pro
"Neovim installation") "Neovim installation")
// Cache binaries for next time // Cache binaries for next time
await $`container exec --user ${USER} ${CONTAINER_NAME} bash -c ${"cp ~/.local/bin/bun ~/.local/bin/neofetch /sandlot/.cache/ && cp /tmp/nvim.tar.gz /sandlot/.cache/nvim.tar.gz"}`.nothrow().quiet() await $`container exec --user ${USER} ${CONTAINER_NAME} bash -c ${"cp ~/.local/bin/bun ~/.local/bin/claude ~/.local/bin/neofetch /sandlot/.cache/ && cp /tmp/nvim.tar.gz /sandlot/.cache/nvim.tar.gz"}`.nothrow().quiet()
await installPersistentTooling(log) await installPersistentTooling(log)
} }
/** Install Pi, Rust, and Go to /sandlot/ so they persist across container recreates. */ /** Install Rust and Go to /sandlot/ so they persist across container recreates. */
async function installPersistentTooling(log?: (msg: string) => void): Promise<void> { async function installPersistentTooling(log?: (msg: string) => void): Promise<void> {
// Pi — skip if already installed on the persistent mount
const hasPi = await $`container exec --user ${USER} ${CONTAINER_NAME} test -x /sandlot/.pi-bin/pi/pi`.nothrow().quiet()
if (hasPi.exitCode !== 0) {
log?.("Installing Pi")
await run(
$`container exec --user ${USER} ${CONTAINER_NAME} bash -c ${"mkdir -p /sandlot/.pi-bin && curl -fsSL https://github.com/badlogic/pi-mono/releases/latest/download/pi-linux-arm64.tar.gz | tar xz -C /sandlot/.pi-bin"}`,
"Pi installation")
}
// Rust — skip if already installed on the persistent mount // Rust — skip if already installed on the persistent mount
const hasRust = await $`container exec --user ${USER} ${CONTAINER_NAME} test -x /sandlot/.cargo/bin/rustc`.nothrow().quiet() const hasRust = await $`container exec --user ${USER} ${CONTAINER_NAME} test -x /sandlot/.cargo/bin/rustc`.nothrow().quiet()
if (hasRust.exitCode !== 0) { if (hasRust.exitCode !== 0) {
@ -217,72 +212,40 @@ async function installScript(home: string, name: string, content: string): Promi
await Bun.file(tmp).unlink() await Bun.file(tmp).unlink()
} }
/** Configure git identity, API key (via auth.json), activity extension, and Pi settings. */ /** Configure git identity, API key helper, activity hook, and Claude settings. */
async function configureEnvironment(home: string, apiKey: string): Promise<void> { async function configureEnvironment(home: string, apiKey: string): Promise<void> {
const gitName = (await $`git config user.name`.quiet().text()).trim() const gitName = (await $`git config user.name`.quiet().text()).trim()
const gitEmail = (await $`git config user.email`.quiet().text()).trim() const gitEmail = (await $`git config user.email`.quiet().text()).trim()
if (gitName) await $`container exec --user ${USER} ${CONTAINER_NAME} git config --global user.name ${gitName}`.quiet() if (gitName) await $`container exec --user ${USER} ${CONTAINER_NAME} git config --global user.name ${gitName}`.quiet()
if (gitEmail) await $`container exec --user ${USER} ${CONTAINER_NAME} git config --global user.email ${gitEmail}`.quiet() if (gitEmail) await $`container exec --user ${USER} ${CONTAINER_NAME} git config --global user.email ${gitEmail}`.quiet()
const settingsJson = JSON.stringify({ const activityBin = `/home/${USER}/.local/bin/sandlot-activity`
defaultProvider: "anthropic", const hooks = {
defaultModel: "claude-opus-4-6", UserPromptSubmit: [{ hooks: [{ type: "command", command: `${activityBin} active` }] }],
defaultThinkingLevel: "high", PreToolUse: [{ hooks: [{ type: "command", command: `${activityBin} active` }] }],
quietStartup: true, }
}) const statusLine = { type: "command", command: `/home/${USER}/.local/bin/sandlot-statusline` }
const authJson = JSON.stringify({ const settingsJson = JSON.stringify({ apiKeyHelper: "~/.claude/api-key-helper.sh", skipDangerousModePermissionPrompt: true, hooks, statusLine })
anthropic: { type: "api_key", key: apiKey }, const claudeJson = JSON.stringify({ hasCompletedOnboarding: true, effortCalloutDismissed: true, projects: { "/": { hasTrustDialogAccepted: true } } })
})
// Write auth.json to a temp file and copy it in so the key // Write the helper script to a temp file and copy it in so the key
// never appears in a process argument visible in `ps`. // never appears in a process argument visible in `ps`.
const tmp = `${home}/.sandlot/.auth-json.tmp` const tmp = `${home}/.sandlot/.api-key-helper.tmp`
await Bun.write(tmp, authJson) await Bun.write(tmp, `#!/bin/sh\necho '${apiKey.replace(/'/g, "'\\''")}'\n`)
await $`container exec --user ${USER} ${CONTAINER_NAME} bash -c ${"mkdir -p ~/.pi/agent && cp /sandlot/.auth-json.tmp ~/.pi/agent/auth.json && chmod 600 ~/.pi/agent/auth.json"}`.quiet() await $`chmod +x ${tmp}`.quiet()
await $`container exec --user ${USER} ${CONTAINER_NAME} bash -c ${"mkdir -p ~/.claude && cp /sandlot/.api-key-helper.tmp ~/.claude/api-key-helper.sh"}`.quiet()
await Bun.file(tmp).unlink() await Bun.file(tmp).unlink()
// Write the activity-tracking extension await installScript(home, "sandlot-activity", `#!/bin/bash\nP="\${CLAUDE_PROJECT_DIR%/}"\necho "$1" > "$(dirname "$P")/.activity-$(basename "$P")"\n`)
const extensionContent = `import { writeFileSync } from "fs"; await installScript(home, "sandlot-statusline", `#!/bin/bash\ninput=$(cat)\ncwd=$(echo "$input" | grep -oP '"cwd"\\s*:\\s*"\\K[^"]+' | head -1)\n[ -n "$cwd" ] && printf '\\033[36m\u2387 %s\\033[0m\\n' "$(basename "$cwd")"\n`)
import { dirname, basename, join } from "path";
export default function (pi) {
function markActive(ctx) {
try {
const cwd = ctx.cwd;
const file = join(dirname(cwd), ".activity-" + basename(cwd));
writeFileSync(file, "active\\n");
} catch {}
}
pi.on("before_agent_start", async (_event, ctx) => { markActive(ctx); });
pi.on("tool_call", async (_event, ctx) => { markActive(ctx); });
}
`
const extTmp = `${home}/.sandlot/.sandlot-activity-ext.tmp`
await Bun.write(extTmp, extensionContent)
await $`container exec --user ${USER} ${CONTAINER_NAME} bash -c ${"mkdir -p ~/.pi/agent/extensions && cp /sandlot/.sandlot-activity-ext.tmp ~/.pi/agent/extensions/sandlot-activity.ts"}`.quiet()
await Bun.file(extTmp).unlink()
await $`container exec --user ${USER} ${CONTAINER_NAME} bash -c ${` await $`container exec --user ${USER} ${CONTAINER_NAME} bash -c ${`
mkdir -p ~/.pi/agent mkdir -p ~/.claude
echo '${settingsJson}' > ~/.pi/agent/settings.json echo '${settingsJson}' > ~/.claude/settings.json
echo '${claudeJson}' > ~/.claude.json
`}`.quiet() `}`.quiet()
} }
/** Resolve hostnames on the host (via mDNS) and add them to the container's /etc/hosts.
* Reads the "hosts" array from config (e.g. ["claude.toes.local"]). */
async function syncLocalHosts(): Promise<void> {
const hostnames = (await getConfig("hosts")) as string[] | undefined
if (!hostnames?.length) return
const entries: string[] = []
for (const name of hostnames) {
const out = (await $`dscacheutil -q host -a name ${name}`.nothrow().quiet().text()).trim()
const match = out.match(/ip_address:\s+(\S+)/)
if (match) entries.push(`${match[1]} ${name}`)
}
if (!entries.length) return
const block = entries.join("\\n")
await $`container exec ${CONTAINER_NAME} bash -c ${`grep -v '# sandlot-hosts' /etc/hosts > /tmp/hosts.clean; echo -e '${block}' | sed 's/$/ # sandlot-hosts/' >> /tmp/hosts.clean; cp /tmp/hosts.clean /etc/hosts`}`.nothrow().quiet()
}
// ── create() ──────────────────────────────────────────────────────── // ── create() ────────────────────────────────────────────────────────
/** Create and provision the container from scratch. Fails if it already exists. */ /** Create and provision the container from scratch. Fails if it already exists. */
@ -310,7 +273,6 @@ export async function create(log?: (msg: string) => void): Promise<void> {
log?.("Configuring environment") log?.("Configuring environment")
await configureEnvironment(home, apiKey) await configureEnvironment(home, apiKey)
await syncLocalHosts()
} }
/** Start a stopped container. */ /** Start a stopped container. */
@ -320,7 +282,6 @@ export async function start(): Promise<void> {
if (s === "running") return if (s === "running") return
if (s === "missing") throw new Error("Container does not exist. Use 'sandlot vm create' first.") if (s === "missing") throw new Error("Container does not exist. Use 'sandlot vm create' first.")
await run($`container start ${CONTAINER_NAME}`, "Container start") await run($`container start ${CONTAINER_NAME}`, "Container start")
await syncLocalHosts()
} }
/** Ensure the sandlot container exists and is running. Creates and provisions on first use. */ /** Ensure the sandlot container exists and is running. Creates and provisions on first use. */
@ -333,10 +294,7 @@ export async function ensure(log?: (msg: string) => void): Promise<void> {
else await $`container system start --enable-kernel-install`.nothrow().quiet() else await $`container system start --enable-kernel-install`.nothrow().quiet()
const s = await status() const s = await status()
if (s === "running") { if (s === "running") return
await syncLocalHosts()
return
}
if (s === "stopped") { if (s === "stopped") {
await start() await start()
@ -360,8 +318,8 @@ export async function status(): Promise<"running" | "stopped" | "missing"> {
} }
} }
/** Launch pi in the container at the given workdir. */ /** Launch claude in the container at the given workdir. */
export async function pi(workdir: string, opts?: { prompt?: string; print?: string; continue?: boolean }): Promise<{ exitCode: number; output?: string }> { export async function claude(workdir: string, opts?: { prompt?: string; print?: string; continue?: boolean }): Promise<{ exitCode: number; output?: string }> {
const cwd = containerPath(workdir) const cwd = containerPath(workdir)
const mounts = hostMounts(homedir()) const mounts = hostMounts(homedir())
const systemPromptLines = [ const systemPromptLines = [
@ -382,7 +340,7 @@ export async function pi(workdir: string, opts?: { prompt?: string; print?: stri
const term = process.env.TERM || "xterm-256color" const term = process.env.TERM || "xterm-256color"
const envArgs = [`TERM=${term}`, `PATH=${CONTAINER_PATH}`, ...Object.entries(CONTAINER_ENV).map(([k, v]) => `${k}=${v}`)] const envArgs = [`TERM=${term}`, `PATH=${CONTAINER_PATH}`, ...Object.entries(CONTAINER_ENV).map(([k, v]) => `${k}=${v}`)]
const args = ["container", "exec", "-it", "--user", USER, "--workdir", cwd, CONTAINER_NAME, "env", ...envArgs, PI_BIN, "--append-system-prompt", systemPrompt] const args = ["container", "exec", "-it", "--user", USER, "--workdir", cwd, CONTAINER_NAME, "env", ...envArgs, CLAUDE_BIN, "--dangerously-skip-permissions", "--model", "claude-opus-4-6", "--effort", "max", "--append-system-prompt", systemPrompt]
if (opts?.continue) args.push("--continue") if (opts?.continue) args.push("--continue")
if (opts?.print) args.push("-p", opts.print) if (opts?.print) args.push("-p", opts.print)
else if (opts?.prompt) args.push(opts.prompt) else if (opts?.prompt) args.push(opts.prompt)
@ -393,7 +351,7 @@ export async function pi(workdir: string, opts?: { prompt?: string; print?: stri
const exitCode = await proc.exited const exitCode = await proc.exited
if (exitCode !== 0 && opts?.continue) { if (exitCode !== 0 && opts?.continue) {
info("Retrying without --continue") info("Retrying without --continue")
return pi(workdir, { ...opts, continue: false }) return claude(workdir, { ...opts, continue: false })
} }
return { exitCode, output } return { exitCode, output }
} }
@ -402,7 +360,7 @@ export async function pi(workdir: string, opts?: { prompt?: string; print?: stri
const exitCode = await proc.exited const exitCode = await proc.exited
if (exitCode !== 0 && opts?.continue) { if (exitCode !== 0 && opts?.continue) {
info("Retrying without --continue") info("Retrying without --continue")
return pi(workdir, { ...opts, continue: false }) return claude(workdir, { ...opts, continue: false })
} }
return { exitCode } return { exitCode }
} }
@ -437,23 +395,23 @@ export async function exec(workdir: string, command: string): Promise<{ exitCode
} }
} }
/** Pipe input text to Pi in the container with a prompt, returning the output. */ /** Pipe input text to Claude in the container with a prompt, returning the output. */
export async function piPipe(input: string, prompt: string): Promise<{ exitCode: number; stdout: string; stderr: string }> { export async function claudePipe(input: string, prompt: string): Promise<{ exitCode: number; stdout: string; stderr: string }> {
const tmpName = `.pi-pipe-${crypto.randomUUID()}` const tmpName = `.claude-pipe-${crypto.randomUUID()}`
const tmpPath = join(homedir(), '.sandlot', tmpName) const tmpPath = join(homedir(), '.sandlot', tmpName)
try { try {
await Bun.write(tmpPath, input) await Bun.write(tmpPath, input)
return await exec( return await exec(
join(homedir(), '.sandlot'), join(homedir(), '.sandlot'),
`cat /sandlot/${tmpName} | /sandlot/.pi-bin/pi/pi -p "${prompt.replace(/"/g, '\\"')}"`, `cat /sandlot/${tmpName} | claude --model claude-opus-4-6 --effort max -p "${prompt.replace(/"/g, '\\"')}"`,
) )
} finally { } finally {
await Bun.file(tmpPath).unlink().catch(() => {}) await Bun.file(tmpPath).unlink().catch(() => {})
} }
} }
/** Check if Pi is actively working in the given worktree (based on activity hook). */ /** Check if Claude is actively working in the given worktree (based on activity hook). */
export async function isPiActive(worktree: string, branch: string): Promise<boolean> { export async function isClaudeActive(worktree: string, branch: string): Promise<boolean> {
const file = `${dirname(worktree)}/.activity-${branch}` const file = `${dirname(worktree)}/.activity-${branch}`
try { try {
const content = await Bun.file(file).text() const content = await Bun.file(file).text()