rust rewrite

This commit is contained in:
Chris Wanstrath 2026-04-10 11:13:00 -07:00
parent 7645f01678
commit 92b64fcf3c
40 changed files with 7663 additions and 0 deletions

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
}