Refactor cli.ts into per-command modules with shared utilities

This commit is contained in:
Chris Wanstrath 2026-02-20 15:43:15 -08:00
parent 3c1f42e985
commit 14cad28488
24 changed files with 1021 additions and 752 deletions

156
REVIEW.md Normal file
View File

@ -0,0 +1,156 @@
# Code Review
## Overall Impression
This is a well-scoped project with a clear mental model. The module boundaries are sensible, the dependency footprint is minimal, and the functional (no classes) approach fits the problem well. The codebase reads quickly, which is valuable.
That said, there are structural patterns that will become friction points as you add commands or change behavior. Most of the feedback here is about **extracting repeated patterns** and **reducing the surface area of cli.ts**.
---
## 1. cli.ts is carrying too much weight
At ~820 lines, `cli.ts` is the entry point, the command registry, and the implementation of every command handler. That's three jobs. Each command handler is 20-60 lines of real logic — session lookup, spinner management, error handling, VM orchestration — all inline.
Right now there are 12+ commands. Adding a 13th means appending more logic to a file that's already the longest in the project. There's no natural place to put "command-adjacent" helpers either, so things like `saveChanges`, `branchFromPrompt`, and `fallbackBranchName` just float in the file above the command they serve.
Consider a `commands/` directory where each command (or logical group) is its own file exporting an action function. `cli.ts` becomes just the Commander wiring — argument definitions, descriptions, and `.action(handler)` calls. This makes each command independently readable and testable.
---
## 2. Repeated session-lookup boilerplate
This exact pattern appears in **8 commands** (open, review, shell, save, diff, show, log, dir):
```typescript
const root = await git.repoRoot()
const session = await state.getSession(root, branch)
if (!session) {
console.error(`✖ No session found for branch "${branch}".`)
process.exit(1)
}
```
A helper like `requireSession(branch): Promise<{ root: string; session: Session }>` would cut four lines per command and give you a single place to improve the error message later (e.g., suggesting `sandlot list` to see available sessions).
---
## 3. Duplicated "pipe through temp file into container" pattern
Both `saveChanges` (line 44-51 of cli.ts) and the merge conflict resolver (line 447-455) follow the same flow:
1. Write content to a temp file under `~/.sandlot/`
2. `cat` it into `claude -p` inside the container
3. Capture output
4. Delete the temp file
This should be a single helper in `vm.ts`, something like:
```typescript
export async function claudePipe(input: string, prompt: string): Promise<string>
```
The current approach is fragile — each call site has to remember to clean up the temp file, choose a temp path that won't collide, and handle errors. Centralizing this also makes it easier to switch to stdin piping later.
---
## 4. API key parsing is duplicated
`branchFromPrompt()` in cli.ts (lines 87-92) and `create()` in vm.ts (lines 83-88) both independently read `~/.env` and parse `ANTHROPIC_API_KEY` with the same regex. If you ever change the env file location or key format, you need to find both.
Extract this to a shared utility — maybe in a `env.ts` or even just a function in `config.ts`, which is currently underutilized.
---
## 5. config.ts is dead code
`config.ts` defines a config schema and loader, but nothing imports it. `vm.ts` hardcodes `-m 4G` and `ubuntu:24.04`. This is confusing for someone reading the codebase — it looks like configuration is supported but it silently isn't.
Either wire it in or remove it. Dead code that looks intentional is worse than no code at all, because it misleads people about how the system works.
---
## 6. Pager logic is duplicated
The `diff` and `show` commands both have identical pager-fallback logic (lines 551-560 and 590-599):
```typescript
const lines = output.split("\n").length
const termHeight = process.stdout.rows || 24
if (lines > termHeight) {
const pager = Bun.spawn(["less", "-R"], { ... })
// ...
}
```
This is a natural utility function: `pipeToPagedOutput(content: string)`.
---
## 7. ANSI codes are scattered and ad-hoc
The `list` command defines its own color constants inline (lines 255-261). The spinner uses raw escape codes. `markdown.ts` uses them too. The error/success icons (`✖`, `✔`, `◆`) are hardcoded at each call site.
A small `fmt.ts` or extending `spinner.ts` into a more general terminal output module would centralize this. You don't need a full library — just a handful of named helpers like `fmt.dim()`, `fmt.green()`, `fmt.success()`, `fmt.error()`. This also makes it trivial to add `--no-color` support later.
---
## 8. `git.branchExists()` has a surprising side effect
This function is named like a pure query, but it calls `git fetch origin` on every invocation (line 29). That means every `sandlot new` makes a network round-trip, which could be slow or fail on spotty connections. The caller (`createWorktree`) has no indication this will happen.
At minimum, document this. Ideally, separate the fetch from the check, or accept a `fetch?: boolean` option.
---
## 9. `vm.create()` is doing too many things
This ~90-line function handles: container creation, apt package installation, host symlink setup, Bun installation, Claude Code installation, git config, API key provisioning, activity hook installation, and settings file creation.
Breaking this into named phases (even just private helper functions within `vm.ts`) would make it easier to debug provisioning failures and to modify individual steps. Something like:
```
createContainer() → installPackages() → installTooling() → configureEnvironment()
```
---
## 10. State file has no concurrency protection
`state.ts` does load → modify → save with no locking. If a user runs `sandlot new foo` and `sandlot close bar` at the same time, one write can clobber the other. For a CLI tool this is unlikely but not impossible — especially since `sandlot new` is long-running (it starts a container, launches Claude, and then saves on exit).
A simple approach: use an atomic write pattern (write to `.state.json.tmp`, then rename). That at least prevents partial writes. Full advisory locking is probably overkill.
---
## 11. Inconsistent error presentation
Comparing error output across commands:
- `new`: `✖ Branch name or prompt is required.`
- `show`: `No session found for branch "${branch}".` (no icon)
- `log`: `✖ git log failed` (lowercase, terse)
- `vm start`: `✖ ${(err as Error).message ?? err}` (raw error message)
A consistent `die(message)` or `fatal(message)` function that always formats the same way would clean this up.
---
## 12. The fish completions string will rot
The ~55-line hardcoded fish completions block (lines 746-801) needs to be manually updated whenever a command, option, or subcommand changes. It's easy to forget.
You could generate this from Commander's own command tree at runtime — Commander exposes the registered commands and options. This way completions are always in sync.
---
## Summary of Suggested Extractions
| New module | What moves there |
|---|---|
| `commands/*.ts` | Individual command handlers out of cli.ts |
| `fmt.ts` | ANSI helpers, `die()`, `success()`, pager |
| `env.ts` (or extend `config.ts`) | API key reading, env file parsing |
| `vm.ts` internal helpers | Break `create()` into phases; add `claudePipe()` |
The architecture is sound. These are refinements to make the codebase match the quality of the design. The core module split (`git` / `vm` / `state`) is right — it's the glue layer in `cli.ts` that needs the most attention.

View File

@ -1,16 +1,23 @@
#!/usr/bin/env bun
import { Command } from "commander"
import { $} from "bun"
import { basename, join } from "path"
import { homedir } from "os"
import { existsSync } from "fs"
import { mkdir, symlink, unlink } from "fs/promises"
import * as git from "./git.ts"
import * as vm from "./vm.ts"
import * as state from "./state.ts"
import { spinner } from "./spinner.ts"
import { renderMarkdown } from "./markdown.ts"
import { action as newAction } from "./commands/new.ts"
import { action as listAction } from "./commands/list.ts"
import { action as openAction } from "./commands/open.ts"
import { action as reviewAction } from "./commands/review.ts"
import { action as shellAction } from "./commands/shell.ts"
import { closeAction } from "./commands/close.ts"
import { action as mergeAction } from "./commands/merge.ts"
import { action as saveAction } from "./commands/save.ts"
import { action as diffAction } from "./commands/diff.ts"
import { action as showAction } from "./commands/show.ts"
import { action as logAction } from "./commands/log.ts"
import { action as dirAction } from "./commands/dir.ts"
import { action as cleanupAction } from "./commands/cleanup.ts"
import { register as registerVmCommands } from "./commands/vm.ts"
import { action as completionsAction } from "./commands/completions.ts"
const pkg = await Bun.file(new URL("../package.json", import.meta.url)).json()
@ -18,109 +25,7 @@ const program = new Command()
program.name("sandlot").description("Branch-based development with git worktrees and Apple Container").version(pkg.version)
// ── save helper ─────────────────────────────────────────────────────
/** Stage all changes, generate a commit message, and commit. Returns true on success. */
async function saveChanges(worktree: string, branch: string, message?: string): Promise<boolean> {
const spin = spinner("Staging changes", branch)
await $`git -C ${worktree} add .`.nothrow().quiet()
const check = await $`git -C ${worktree} diff --staged --quiet`.nothrow().quiet()
if (check.exitCode === 0) {
spin.fail("No changes to commit")
return false
}
let msg: string
if (message) {
msg = message
} else {
spin.text = "Starting container"
await vm.ensure((m) => { spin.text = m })
spin.text = "Generating commit message"
const diff = await $`git -C ${worktree} diff --staged`.nothrow().quiet().text()
const tmpPath = join(homedir(), '.sandlot', '.sandlot-diff-tmp')
await Bun.write(tmpPath, diff)
const gen = await vm.exec(
join(homedir(), '.sandlot'),
'cat /sandlot/.sandlot-diff-tmp | claude -p "write a short commit message summarizing these changes. output only the message, no quotes or extra text"',
)
await Bun.file(tmpPath).unlink().catch(() => {})
if (gen.exitCode !== 0) {
spin.fail("Failed to generate commit message")
if (gen.stderr) console.error(gen.stderr)
return false
}
msg = gen.stdout
}
spin.text = "Committing"
const commit = await $`git -C ${worktree} commit -m ${msg}`.nothrow().quiet()
if (commit.exitCode !== 0) {
spin.fail("Commit failed")
if (commit.stderr) console.error(commit.stderr.toString().trim())
return false
}
spin.succeed(`Saved: ${msg}`)
return true
}
// ── sandlot new <branch> ──────────────────────────────────────────────
function fallbackBranchName(text: string): string {
return text
.toLowerCase()
.replace(/[^a-z0-9\s-]/g, "")
.trim()
.split(/\s+/)
.slice(0, 2)
.join("-")
}
async function branchFromPrompt(text: string): Promise<string> {
// Read API key from ~/.env
const envFile = Bun.file(join(homedir(), ".env"))
if (!(await envFile.exists())) return fallbackBranchName(text)
const envContent = await envFile.text()
const apiKey = envContent.match(/^(?:export\s+)?ANTHROPIC_API_KEY=["']?([^"'\s]+)["']?/m)?.[1]
if (!apiKey) return fallbackBranchName(text)
try {
const res = await fetch("https://api.anthropic.com/v1/messages", {
method: "POST",
headers: {
"content-type": "application/json",
"x-api-key": apiKey,
"anthropic-version": "2023-06-01",
},
body: JSON.stringify({
model: "claude-haiku-4-5-20251001",
max_tokens: 15,
temperature: 0,
messages: [{ role: "user", content: `Generate a 2-word git branch name (lowercase, hyphen-separated) for this task:\n\n${text}\n\nOutput ONLY the branch name, nothing else.` }],
}),
})
if (!res.ok) return fallbackBranchName(text)
const body = await res.json() as any
const name = body.content?.[0]?.text?.trim()
.toLowerCase()
.replace(/[^a-z0-9-]/g, "-")
.replace(/-+/g, "-")
.replace(/^-|-$/g, "")
return name && name.length > 0 && name.length <= 50 ? name : fallbackBranchName(text)
} catch {
return fallbackBranchName(text)
}
}
// ── sandlot new ──────────────────────────────────────────────────────
program
.command("new")
@ -129,159 +34,17 @@ program
.option("-p, --print <prompt>", "run Claude in non-interactive mode with -p")
.option("-n, --no-save", "skip auto-save after Claude exits")
.description("Create a new session and launch Claude")
.action(async (branch: string | undefined, prompt: string | undefined, opts: { print?: string; save?: boolean }) => {
// No branch given — derive from -p prompt
if (!branch && opts.print) {
branch = await branchFromPrompt(opts.print)
} else if (!branch) {
console.error("✖ Branch name or prompt is required.")
process.exit(1)
} else if (branch.includes(" ")) {
// If the "branch" contains spaces, it's actually a prompt — derive the branch name
prompt = branch
branch = await branchFromPrompt(branch)
}
const root = await git.repoRoot()
const worktreeAbs = join(homedir(), '.sandlot', basename(root), branch)
.action(newAction)
const existing = await state.getSession(root, branch)
if (existing) {
console.error(`✖ Session "${branch}" already exists. Use "sandlot open ${branch}" to re-enter it.`)
process.exit(1)
}
const spin = spinner("Creating worktree", branch)
try {
await git.createWorktree(branch, worktreeAbs, root)
await mkdir(join(root, '.sandlot'), { recursive: true })
await symlink(worktreeAbs, join(root, '.sandlot', branch))
spin.text = "Starting container"
await vm.ensure((msg) => { spin.text = msg })
if (!opts.print) spin.succeed("Session ready")
} catch (err) {
spin.fail(String((err as Error).message ?? err))
await git.removeWorktree(worktreeAbs, root).catch(() => {})
await git.deleteLocalBranch(branch, root).catch(() => {})
await unlink(join(root, '.sandlot', branch)).catch(() => {})
process.exit(1)
}
const effectivePrompt = opts.print || prompt
await state.setSession(root, {
branch,
worktree: worktreeAbs,
created_at: new Date().toISOString(),
...(effectivePrompt ? { prompt: effectivePrompt } : {}),
})
if (opts.print) {
spin.text = "Running prompt…"
const output = await vm.claude(worktreeAbs, { prompt, print: opts.print })
if (output) {
spin.stop()
process.stdout.write(renderMarkdown(output) + "\n")
} else {
spin.succeed("Done")
}
} else {
await vm.claude(worktreeAbs, { prompt, print: opts.print })
}
if (opts.save !== false) await saveChanges(worktreeAbs, branch)
})
// ── sandlot list ──────────────────────────────────────────────────────
// ── sandlot list ─────────────────────────────────────────────────────
program
.command("list")
.description("Show all active sessions (◌ idle · ◯ working · ◎ unsaved · ● saved)")
.option("--json", "Output as JSON")
.action(async (opts: { json?: boolean }) => {
const root = await git.repoRoot()
const st = await state.load(root)
const sessions = Object.values(st.sessions)
.action(listAction)
// Discover prompts from Claude history for sessions that lack one
const needsPrompt = sessions.filter(s => !s.prompt)
if (needsPrompt.length > 0 && (await vm.status()) === "running") {
try {
const result = await vm.exec(homedir() + "/.sandlot", "cat /home/ubuntu/.claude/history.jsonl 2>/dev/null")
if (result.exitCode === 0 && result.stdout) {
const entries = result.stdout.split("\n").filter(Boolean).map(line => {
try { return JSON.parse(line) } catch { return null }
}).filter(Boolean)
for (const s of needsPrompt) {
const cPath = vm.containerPath(s.worktree)
const match = entries.find((e: any) => e.project === cPath)
if (match?.display) {
s.prompt = match.display
}
}
}
} catch {}
}
if (opts.json) {
console.log(JSON.stringify(sessions, null, 2))
return
}
if (sessions.length === 0) {
if (opts.json) console.log("[]")
else console.log("◆ No active sessions.")
return
}
// Determine status for each session in parallel
const statusEntries = await Promise.all(
sessions.map(async (s): Promise<[string, string]> => {
if (await vm.isClaudeActive(s.worktree, s.branch)) return [s.branch, "active"]
const dirty = await git.isDirty(s.worktree)
if (dirty) return [s.branch, "dirty"]
const commits = await git.hasNewCommits(s.worktree)
return [s.branch, commits ? "saved" : "idle"]
})
)
const statuses = Object.fromEntries(statusEntries)
if (opts.json) {
const withStatus = sessions.map(s => ({ ...s, status: statuses[s.branch] }))
console.log(JSON.stringify(withStatus, null, 2))
return
}
const reset = "\x1b[0m"
const dim = "\x1b[2m"
const bold = "\x1b[1m"
const green = "\x1b[32m"
const yellow = "\x1b[33m"
const cyan = "\x1b[36m"
const white = "\x1b[37m"
const icons: Record<string, string> = { idle: `${dim}${reset}`, active: `${cyan}${reset}`, dirty: `${yellow}${reset}`, saved: `${green}${reset}` }
const branchColors: Record<string, string> = { idle: dim, active: cyan, dirty: yellow, saved: green }
const branchWidth = Math.max(6, ...sessions.map((s) => s.branch.length))
const cols = process.stdout.columns || 80
const prefixWidth = branchWidth + 4
console.log(` ${dim}${"BRANCH".padEnd(branchWidth)} PROMPT${reset}`)
for (const s of sessions) {
const prompt = s.prompt ?? ""
const status = statuses[s.branch]
const icon = icons[status]
const bc = branchColors[status]
const maxPrompt = cols - prefixWidth
const truncated = maxPrompt > 3 && prompt.length > maxPrompt ? prompt.slice(0, maxPrompt - 3) + "..." : prompt
console.log(`${icon} ${bc}${s.branch.padEnd(branchWidth)}${reset} ${dim}${truncated}${reset}`)
}
console.log(`\n${dim}◌ idle${reset} · ${cyan}◯ active${reset} · ${yellow}◎ unsaved${reset} · ${green}● saved${reset}`)
})
// ── sandlot open <branch> ─────────────────────────────────────────────
// ── sandlot open ─────────────────────────────────────────────────────
program
.command("open")
@ -290,188 +53,26 @@ program
.option("-p, --print <prompt>", "run Claude in non-interactive mode with -p")
.option("-n, --no-save", "skip auto-save after Claude exits")
.description("Re-enter an existing session")
.action(async (branch: string, prompt: string | undefined, opts: { print?: string; save?: boolean }) => {
const root = await git.repoRoot()
const session = await state.getSession(root, branch)
.action(openAction)
if (!session) {
console.error(`✖ No session found for branch "${branch}".`)
process.exit(1)
}
const effectivePrompt = opts.print || prompt
if (effectivePrompt) {
await state.setSession(root, { ...session, prompt: effectivePrompt })
}
const spin = spinner("Starting container", branch)
await vm.ensure((msg) => { spin.text = msg })
if (opts.print) {
spin.text = "Running prompt…"
const output = await vm.claude(session.worktree, { prompt, print: opts.print })
if (output) {
spin.stop()
process.stdout.write(renderMarkdown(output) + "\n")
} else {
spin.succeed("Done")
}
} else {
spin.succeed("Session ready")
await vm.claude(session.worktree, { prompt, print: opts.print })
}
if (opts.save !== false) await saveChanges(session.worktree, branch)
})
// ── sandlot review <branch> ──────────────────────────────────────────
// ── sandlot review ───────────────────────────────────────────────────
program
.command("review")
.argument("<branch>", "branch name")
.option("-p, --print", "print the review to stdout instead of launching interactive mode")
.description("Launch an interactive grumpy code review for a branch")
.action(async (branch: string, opts: { print?: boolean }) => {
const root = await git.repoRoot()
const session = await state.getSession(root, branch)
.action(reviewAction)
if (!session) {
console.error(`✖ No session found for branch "${branch}".`)
process.exit(1)
}
const spin = spinner("Starting container", branch)
await vm.ensure((msg) => { spin.text = msg })
const prompt = "You're a grumpy old senior software engineer. Take a look at the diff between this branch and main, then let me know your thoughts. My co-worker made these changes."
if (opts.print) {
spin.text = "Running review…"
const output = await vm.claude(session.worktree, { print: prompt })
spin.stop()
if (output) process.stdout.write(output + "\n")
} else {
spin.succeed("Session ready")
await vm.claude(session.worktree, { prompt })
}
})
// ── sandlot shell <branch> ───────────────────────────────────────────
// ── sandlot shell ────────────────────────────────────────────────────
program
.command("shell")
.argument("[branch]", "branch name (omit for a plain VM shell)")
.description("Open a shell in the VM (at the session's worktree if branch given)")
.action(async (branch?: string) => {
if (!branch) {
await vm.ensure()
await vm.shell()
return
}
.action(shellAction)
const root = await git.repoRoot()
const session = await state.getSession(root, branch)
if (!session) {
console.error(`✖ No session found for branch "${branch}".`)
process.exit(1)
}
await vm.ensure()
await vm.shell(session.worktree)
})
// ── sandlot close <branch> ───────────────────────────────────────────
const closeAction = async (branch: string, opts: { force?: boolean } = {}) => {
const root = await git.repoRoot()
const session = await state.getSession(root, branch)
const worktreeAbs = session?.worktree ?? join(homedir(), '.sandlot', basename(root), branch)
if (!opts.force && session && await git.isDirty(worktreeAbs)) {
console.error(`✖ Branch "${branch}" has unsaved changes. Run "sandlot save ${branch}" first, or use -f to force.`)
process.exit(1)
}
await vm.clearActivity(worktreeAbs, branch)
await git.removeWorktree(worktreeAbs, root)
.catch((e) => console.warn(`⚠ Failed to remove worktree: ${e.message}`))
await unlink(join(root, '.sandlot', branch))
.catch(() => {}) // symlink may not exist
await git.deleteLocalBranch(branch, root)
.catch((e) => console.warn(`⚠ Failed to delete branch ${branch}: ${e.message}`))
if (session) {
await state.removeSession(root, branch)
}
console.log(`✔ Closed session ${branch}`)
}
// ── sandlot merge <branch> ──────────────────────────────────────────
program
.command("merge")
.argument("<branch>", "branch name")
.description("Merge a branch into main and close the session")
.action(async (branch: string) => {
const root = await git.repoRoot()
const session = await state.getSession(root, branch)
if (session && await git.isDirty(session.worktree)) {
console.error(`✖ Branch "${branch}" has unsaved changes. Run "sandlot save ${branch}" first.`)
process.exit(1)
}
const conflicts = await git.merge(branch, root)
if (conflicts.length === 0) {
console.log(`✔ Merged ${branch} into current branch`)
await closeAction(branch)
return
}
// Resolve conflicts with Claude
console.log(`◆ Merge conflicts in ${conflicts.length} file(s). Resolving with Claude...`)
const spin = spinner("Starting container", branch)
try {
await vm.ensure((msg) => { spin.text = msg })
for (const file of conflicts) {
spin.text = `Resolving ${file}`
const content = await Bun.file(join(root, file)).text()
const tmpPath = join(homedir(), '.sandlot', '.conflict-tmp')
await Bun.write(tmpPath, content)
const resolved = await vm.exec(
join(homedir(), '.sandlot'),
'cat /sandlot/.conflict-tmp | claude -p "resolve this merge conflict. output ONLY the resolved file content with no markdown fences, no explanation, no surrounding text."',
)
await Bun.file(tmpPath).unlink().catch(() => {})
if (resolved.exitCode !== 0) {
throw new Error(`Claude failed to resolve ${file}: ${resolved.stderr}`)
}
await Bun.write(join(root, file), resolved.stdout + "\n")
await git.stageFile(file, root)
}
await git.commitMerge(root)
spin.succeed(`Resolved ${conflicts.length} conflict(s) and merged ${branch}`)
} catch (err) {
spin.fail(String((err as Error).message ?? err))
await git.abortMerge(root)
process.exit(1)
}
await closeAction(branch)
})
// ── sandlot close ────────────────────────────────────────────────────
program
.command("close")
@ -486,322 +87,75 @@ program
.option("-f, --force", "close even if there are unsaved changes")
.action((branch: string, opts: { force?: boolean }) => closeAction(branch, opts))
// ── sandlot save <branch> ───────────────────────────────────────────
// ── sandlot merge ────────────────────────────────────────────────────
program
.command("merge")
.argument("<branch>", "branch name")
.description("Merge a branch into main and close the session")
.action(mergeAction)
// ── sandlot save ─────────────────────────────────────────────────────
program
.command("save")
.argument("<branch>", "branch name")
.argument("[message]", "commit message (AI-generated if omitted)")
.description("Stage all changes and commit")
.action(async (branch: string, message?: string) => {
const root = await git.repoRoot()
const session = await state.getSession(root, branch)
if (!session) {
console.error(`✖ No session found for branch "${branch}".`)
process.exit(1)
}
.action(saveAction)
const ok = await saveChanges(session.worktree, branch, message)
if (!ok) process.exit(1)
})
// ── sandlot diff <branch> ───────────────────────────────────────────
// ── sandlot diff ─────────────────────────────────────────────────────
program
.command("diff")
.argument("<branch>", "branch name")
.description("Show uncommitted changes, or full branch diff vs main")
.action(async (branch: string) => {
const root = await git.repoRoot()
const session = await state.getSession(root, branch)
if (!session) {
console.error(`✖ No session found for branch "${branch}".`)
process.exit(1)
}
.action(diffAction)
// Check for uncommitted changes (staged + unstaged)
const status = await $`git -C ${session.worktree} status --porcelain`.nothrow().quiet()
if (status.exitCode !== 0) {
console.error("✖ git status failed")
process.exit(1)
}
let diff: string
if (status.text().trim().length > 0) {
// Show uncommitted changes (both staged and unstaged)
const result = await $`git -C ${session.worktree} diff --color=always HEAD`.nothrow().quiet()
if (result.exitCode !== 0) {
// HEAD may not exist yet (no commits); fall back to showing all tracked + untracked
const fallback = await $`git -C ${session.worktree} diff --color=always`.nothrow().quiet()
diff = fallback.text()
} else {
diff = result.text()
}
} else {
// No uncommitted changes — show full branch diff vs main
const main = await git.mainBranch(root)
const result = await $`git -C ${session.worktree} diff --color=always ${main}...${branch}`.nothrow().quiet()
if (result.exitCode !== 0) {
console.error("✖ git diff failed")
process.exit(1)
}
diff = result.text()
}
const lines = diff.split("\n").length
const termHeight = process.stdout.rows || 24
if (lines > termHeight) {
const pager = Bun.spawn(["less", "-R"], { stdin: "pipe", stdout: "inherit", stderr: "inherit" })
pager.stdin.write(diff)
pager.stdin.end()
await pager.exited
} else {
process.stdout.write(diff)
}
})
// ── sandlot show <branch> ───────────────────────────────────────────
// ── sandlot show ─────────────────────────────────────────────────────
program
.command("show")
.argument("<branch>", "branch name")
.description("Show the prompt and full diff for a branch (for code review)")
.action(async (branch: string) => {
const root = await git.repoRoot()
const session = await state.getSession(root, branch)
if (!session) {
console.error(`No session found for branch "${branch}".`)
process.exit(1)
}
.action(showAction)
const main = await git.mainBranch(root)
const result = await $`git -C ${session.worktree} diff --color=always ${main}...${branch}`.nothrow().quiet()
if (result.exitCode !== 0) {
console.error("git diff failed")
process.exit(1)
}
let output = ""
if (session.prompt) {
output += `PROMPT: ${session.prompt}\n\n`
}
output += result.text()
const lines = output.split("\n").length
const termHeight = process.stdout.rows || 24
if (lines > termHeight) {
const pager = Bun.spawn(["less", "-R"], { stdin: "pipe", stdout: "inherit", stderr: "inherit" })
pager.stdin.write(output)
pager.stdin.end()
await pager.exited
} else {
process.stdout.write(output)
}
})
// ── sandlot log <branch> ────────────────────────────────────────────
// ── sandlot log ──────────────────────────────────────────────────────
program
.command("log")
.argument("<branch>", "branch name")
.description("Show commits on a branch that are not on main")
.action(async (branch: string) => {
const root = await git.repoRoot()
const session = await state.getSession(root, branch)
if (!session) {
console.error(`✖ No session found for branch "${branch}".`)
process.exit(1)
}
.action(logAction)
const result = await $`git -C ${session.worktree} log main..HEAD`.nothrow()
if (result.exitCode !== 0) {
console.error("✖ git log failed")
process.exit(1)
}
})
// ── sandlot dir <branch> ────────────────────────────────────────────
// ── sandlot dir ──────────────────────────────────────────────────────
program
.command("dir")
.argument("<branch>", "branch name")
.description("Print the worktree path for a session")
.action(async (branch: string) => {
const root = await git.repoRoot()
const session = await state.getSession(root, branch)
if (!session) {
console.error(`✖ No session found for branch "${branch}".`)
process.exit(1)
}
.action(dirAction)
process.stdout.write(session.worktree + "\n")
})
// ── sandlot cleanup ─────────────────────────────────────────────────
// ── sandlot cleanup ──────────────────────────────────────────────────
program
.command("cleanup")
.description("Remove stale sessions whose worktrees no longer exist")
.action(async () => {
const root = await git.repoRoot()
const st = await state.load(root)
const sessions = Object.values(st.sessions)
if (sessions.length === 0) {
console.log("No sessions to clean up.")
return
}
const stale = sessions.filter(s => !existsSync(s.worktree))
if (stale.length === 0) {
console.log("No stale sessions found.")
return
}
for (const s of stale) {
await state.removeSession(root, s.branch)
await unlink(join(root, '.sandlot', s.branch)).catch(() => {})
console.log(`✔ Removed stale session: ${s.branch}`)
}
})
.action(cleanupAction)
// ── sandlot vm ───────────────────────────────────────────────────────
const vmCmd = program.command("vm").description("Manage the sandlot VM")
registerVmCommands(program)
vmCmd
.command("create")
.description("Create and provision the VM")
.action(async () => {
const spin = spinner("Creating VM")
try {
await vm.create((msg) => { spin.text = msg })
spin.succeed("VM created")
} catch (err) {
spin.fail(String((err as Error).message ?? err))
process.exit(1)
}
})
vmCmd
.command("start")
.description("Start the VM")
.action(async () => {
try {
await vm.start()
console.log("✔ VM started")
} catch (err) {
console.error(`${(err as Error).message ?? err}`)
process.exit(1)
}
})
vmCmd
.command("shell")
.description("Open a shell in the VM")
.action(async () => {
await vm.ensure()
await vm.shell()
})
vmCmd
.command("status")
.description("Show VM status")
.action(async () => {
const s = await vm.status()
console.log(s)
})
vmCmd
.command("info")
.description("Show VM system info (via neofetch)")
.action(async () => {
await vm.ensure()
await vm.info()
})
vmCmd
.command("stop")
.description("Stop the VM")
.action(async () => {
await vm.stop()
console.log("✔ VM stopped")
})
vmCmd
.command("destroy")
.description("Stop and delete the VM")
.action(async () => {
await vm.destroy()
console.log("✔ VM destroyed")
})
// ── sandlot completions ─────────────────────────────────────────────
// ── sandlot completions ──────────────────────────────────────────────
program
.command("completions")
.description("Output fish shell completions")
.action(() => {
process.stdout.write(`# Fish completions for sandlot
# Install: sandlot completions > ~/.config/fish/completions/sandlot.fish
.action(() => completionsAction(program))
complete -c sandlot -f
// ── Default: show list if sessions exist, otherwise help ─────────────
function __sandlot_sessions
command sandlot list --json 2>/dev/null | string match -r '"branch":\\s*"[^"]+"' | string replace -r '.*"branch":\\s*"([^"]+)".*' '$1'
end
# Subcommands
complete -c sandlot -n __fish_use_subcommand -a new -d "Create a new session and launch Claude"
complete -c sandlot -n __fish_use_subcommand -a list -d "Show all active sessions"
complete -c sandlot -n __fish_use_subcommand -a open -d "Re-enter an existing session"
complete -c sandlot -n __fish_use_subcommand -a review -d "Launch an interactive code review"
complete -c sandlot -n __fish_use_subcommand -a shell -d "Open a shell in the VM"
complete -c sandlot -n __fish_use_subcommand -a close -d "Remove a worktree and clean up"
complete -c sandlot -n __fish_use_subcommand -a merge -d "Merge a branch into main and close"
complete -c sandlot -n __fish_use_subcommand -a save -d "Stage all changes and commit"
complete -c sandlot -n __fish_use_subcommand -a diff -d "Show changes for a branch"
complete -c sandlot -n __fish_use_subcommand -a show -d "Show prompt and full diff"
complete -c sandlot -n __fish_use_subcommand -a log -d "Show commits not on main"
complete -c sandlot -n __fish_use_subcommand -a dir -d "Print the worktree path"
complete -c sandlot -n __fish_use_subcommand -a cleanup -d "Remove stale sessions"
complete -c sandlot -n __fish_use_subcommand -a vm -d "Manage the sandlot VM"
complete -c sandlot -n __fish_use_subcommand -a completions -d "Output fish shell completions"
# Branch completions for commands that take a branch
complete -c sandlot -n "__fish_seen_subcommand_from open close merge save diff show log dir review shell" -xa "(__sandlot_sessions)"
# new
complete -c sandlot -n "__fish_seen_subcommand_from new" -s p -l print -d "Run Claude non-interactively" -r
complete -c sandlot -n "__fish_seen_subcommand_from new" -s n -l no-save -d "Skip auto-save after Claude exits"
# open
complete -c sandlot -n "__fish_seen_subcommand_from open" -s p -l print -d "Run Claude non-interactively" -r
complete -c sandlot -n "__fish_seen_subcommand_from open" -s n -l no-save -d "Skip auto-save after Claude exits"
# list
complete -c sandlot -n "__fish_seen_subcommand_from list" -l json -d "Output as JSON"
# close
complete -c sandlot -n "__fish_seen_subcommand_from close" -s f -l force -d "Close even with unsaved changes"
# review
complete -c sandlot -n "__fish_seen_subcommand_from review" -s p -l print -d "Print review to stdout"
# vm subcommands
set -l __sandlot_vm_subs create start shell status info stop destroy
complete -c sandlot -n "__fish_seen_subcommand_from vm; and not __fish_seen_subcommand_from \$__sandlot_vm_subs" -a create -d "Create and provision the VM"
complete -c sandlot -n "__fish_seen_subcommand_from vm; and not __fish_seen_subcommand_from \$__sandlot_vm_subs" -a start -d "Start the VM"
complete -c sandlot -n "__fish_seen_subcommand_from vm; and not __fish_seen_subcommand_from \$__sandlot_vm_subs" -a shell -d "Open a shell in the VM"
complete -c sandlot -n "__fish_seen_subcommand_from vm; and not __fish_seen_subcommand_from \$__sandlot_vm_subs" -a status -d "Show VM status"
complete -c sandlot -n "__fish_seen_subcommand_from vm; and not __fish_seen_subcommand_from \$__sandlot_vm_subs" -a info -d "Show VM system info"
complete -c sandlot -n "__fish_seen_subcommand_from vm; and not __fish_seen_subcommand_from \$__sandlot_vm_subs" -a stop -d "Stop the VM"
complete -c sandlot -n "__fish_seen_subcommand_from vm; and not __fish_seen_subcommand_from \$__sandlot_vm_subs" -a destroy -d "Stop and delete the VM"
`)
})
// Default: show list if sessions exist, otherwise help
const args = process.argv.slice(2)
if (args.length === 0) {
try {

29
src/commands/cleanup.ts Normal file
View File

@ -0,0 +1,29 @@
import { join } from "path"
import { existsSync } from "fs"
import { unlink } from "fs/promises"
import * as git from "../git.ts"
import * as state from "../state.ts"
export async function action() {
const root = await git.repoRoot()
const st = await state.load(root)
const sessions = Object.values(st.sessions)
if (sessions.length === 0) {
console.log("No sessions to clean up.")
return
}
const stale = sessions.filter(s => !existsSync(s.worktree))
if (stale.length === 0) {
console.log("No stale sessions found.")
return
}
for (const s of stale) {
await state.removeSession(root, s.branch)
await unlink(join(root, '.sandlot', s.branch)).catch(() => {})
console.log(`✔ Removed stale session: ${s.branch}`)
}
}

35
src/commands/close.ts Normal file
View File

@ -0,0 +1,35 @@
import { join } from "path"
import { homedir } from "os"
import { basename } from "path"
import { unlink } from "fs/promises"
import * as git from "../git.ts"
import * as vm from "../vm.ts"
import * as state from "../state.ts"
import { die } from "../fmt.ts"
export async function closeAction(branch: string, opts: { force?: boolean } = {}) {
const root = await git.repoRoot()
const session = await state.getSession(root, branch)
const worktreeAbs = session?.worktree ?? join(homedir(), '.sandlot', basename(root), branch)
if (!opts.force && session && await git.isDirty(worktreeAbs)) {
die(`Branch "${branch}" has unsaved changes. Run "sandlot save ${branch}" first, or use -f to force.`)
}
await vm.clearActivity(worktreeAbs, branch)
await git.removeWorktree(worktreeAbs, root)
.catch((e) => console.warn(`⚠ Failed to remove worktree: ${e.message}`))
await unlink(join(root, '.sandlot', branch))
.catch(() => {}) // symlink may not exist
await git.deleteLocalBranch(branch, root)
.catch((e) => console.warn(`⚠ Failed to delete branch ${branch}: ${e.message}`))
if (session) {
await state.removeSession(root, branch)
}
console.log(`✔ Closed session ${branch}`)
}

View File

@ -0,0 +1,73 @@
import type { Command, Option } from "commander"
/** Generate fish completions dynamically from the Commander program tree. */
export function action(program: Command) {
const lines: string[] = [
"# Fish completions for sandlot (auto-generated)",
"# Install: sandlot completions > ~/.config/fish/completions/sandlot.fish",
"",
"complete -c sandlot -f",
"",
"function __sandlot_sessions",
` command sandlot list --json 2>/dev/null | string match -r '"branch":\\s*"[^"]+"' | string replace -r '.*"branch":\\s*"([^"]+)".*' '$1'`,
"end",
"",
]
const branchCommands: string[] = []
for (const cmd of program.commands) {
if ((cmd as any)._hidden) continue
const name = cmd.name()
const desc = cmd.description()
const subs = cmd.commands as Command[]
if (subs.length > 0) {
// Parent command (e.g. "vm") — register it plus its subcommands
lines.push(`complete -c sandlot -n __fish_use_subcommand -a ${name} -d ${esc(desc)}`)
const subNames = subs.filter(s => !(s as any)._hidden).map(s => s.name())
const guard = `"__fish_seen_subcommand_from ${name}; and not __fish_seen_subcommand_from ${subNames.join(" ")}"`
for (const sub of subs) {
if ((sub as any)._hidden) continue
lines.push(`complete -c sandlot -n ${guard} -a ${sub.name()} -d ${esc(sub.description())}`)
}
// Options on subcommands
for (const sub of subs) {
if ((sub as any)._hidden) continue
emitOptions(lines, `${name} ${sub.name()}`, sub.options)
}
} else {
// Leaf command
lines.push(`complete -c sandlot -n __fish_use_subcommand -a ${name} -d ${esc(desc)}`)
emitOptions(lines, name, cmd.options)
// Track commands that accept a <branch> / [branch] argument
const hasBranch = cmd.registeredArguments.some(a => a.name() === "branch")
if (hasBranch) branchCommands.push(name)
}
}
// Session completions for all branch-taking commands
if (branchCommands.length > 0) {
lines.push("")
lines.push(`complete -c sandlot -n "__fish_seen_subcommand_from ${branchCommands.join(" ")}" -xa "(__sandlot_sessions)"`)
}
lines.push("")
process.stdout.write(lines.join("\n") + "\n")
}
function esc(s: string): string {
return `"${s.replace(/"/g, '\\"')}"`
}
function emitOptions(lines: string[], cmdName: string, options: readonly Option[]) {
for (const opt of options) {
const parts = [`complete -c sandlot -n "__fish_seen_subcommand_from ${cmdName}"`]
if (opt.short) parts.push(`-s ${opt.short.replace("-", "")}`)
if (opt.long) parts.push(`-l ${opt.long.replace("--", "")}`)
parts.push(`-d ${esc(opt.description)}`)
if (opt.required) parts.push("-r")
lines.push(parts.join(" "))
}
}

39
src/commands/diff.ts Normal file
View File

@ -0,0 +1,39 @@
import { $ } from "bun"
import * as git from "../git.ts"
import { pager } from "../fmt.ts"
import { requireSession } from "./helpers.ts"
export async function action(branch: string) {
const { session } = await requireSession(branch)
// Check for uncommitted changes (staged + unstaged)
const status = await $`git -C ${session.worktree} status --porcelain`.nothrow().quiet()
if (status.exitCode !== 0) {
console.error("✖ git status failed")
process.exit(1)
}
let diff: string
if (status.text().trim().length > 0) {
// Show uncommitted changes (both staged and unstaged)
const result = await $`git -C ${session.worktree} diff --color=always HEAD`.nothrow().quiet()
if (result.exitCode !== 0) {
// HEAD may not exist yet (no commits); fall back to showing all tracked + untracked
const fallback = await $`git -C ${session.worktree} diff --color=always`.nothrow().quiet()
diff = fallback.text()
} else {
diff = result.text()
}
} else {
// No uncommitted changes — show full branch diff vs main
const main = await git.mainBranch(session.worktree)
const result = await $`git -C ${session.worktree} diff --color=always ${main}...${branch}`.nothrow().quiet()
if (result.exitCode !== 0) {
console.error("✖ git diff failed")
process.exit(1)
}
diff = result.text()
}
await pager(diff)
}

7
src/commands/dir.ts Normal file
View File

@ -0,0 +1,7 @@
import { requireSession } from "./helpers.ts"
export async function action(branch: string) {
const { session } = await requireSession(branch)
process.stdout.write(session.worktree + "\n")
}

64
src/commands/helpers.ts Normal file
View File

@ -0,0 +1,64 @@
import { $ } from "bun"
import * as git from "../git.ts"
import * as vm from "../vm.ts"
import * as state from "../state.ts"
import { spinner } from "../spinner.ts"
import { die } from "../fmt.ts"
import type { Session } from "../state.ts"
/** Look up a session by branch, dying if it doesn't exist. */
export async function requireSession(branch: string): Promise<{ root: string; session: Session }> {
const root = await git.repoRoot()
const session = await state.getSession(root, branch)
if (!session) {
die(`No session found for branch "${branch}".`)
}
return { root, session }
}
/** Stage all changes, generate a commit message, and commit. Returns true on success. */
export async function saveChanges(worktree: string, branch: string, message?: string): Promise<boolean> {
const spin = spinner("Staging changes", branch)
await $`git -C ${worktree} add .`.nothrow().quiet()
const check = await $`git -C ${worktree} diff --staged --quiet`.nothrow().quiet()
if (check.exitCode === 0) {
spin.fail("No changes to commit")
return false
}
let msg: string
if (message) {
msg = message
} else {
spin.text = "Starting container"
await vm.ensure((m) => { spin.text = m })
spin.text = "Generating commit message"
const diff = await $`git -C ${worktree} diff --staged`.nothrow().quiet().text()
const gen = await vm.claudePipe(
diff,
"write a short commit message summarizing these changes. output only the message, no quotes or extra text",
)
if (gen.exitCode !== 0) {
spin.fail("Failed to generate commit message")
if (gen.stderr) console.error(gen.stderr)
return false
}
msg = gen.stdout
}
spin.text = "Committing"
const commit = await $`git -C ${worktree} commit -m ${msg}`.nothrow().quiet()
if (commit.exitCode !== 0) {
spin.fail("Commit failed")
if (commit.stderr) console.error(commit.stderr.toString().trim())
return false
}
spin.succeed(`Saved: ${msg}`)
return true
}

81
src/commands/list.ts Normal file
View File

@ -0,0 +1,81 @@
import { homedir } from "os"
import * as git from "../git.ts"
import * as vm from "../vm.ts"
import * as state from "../state.ts"
import { reset, dim, bold, green, yellow, cyan, white } from "../fmt.ts"
export async function action(opts: { json?: boolean }) {
const root = await git.repoRoot()
const st = await state.load(root)
const sessions = Object.values(st.sessions)
// Discover prompts from Claude history for sessions that lack one
const needsPrompt = sessions.filter(s => !s.prompt)
if (needsPrompt.length > 0 && (await vm.status()) === "running") {
try {
const result = await vm.exec(homedir() + "/.sandlot", "cat /home/ubuntu/.claude/history.jsonl 2>/dev/null")
if (result.exitCode === 0 && result.stdout) {
const entries = result.stdout.split("\n").filter(Boolean).map(line => {
try { return JSON.parse(line) } catch { return null }
}).filter(Boolean)
for (const s of needsPrompt) {
const cPath = vm.containerPath(s.worktree)
const match = entries.find((e: any) => e.project === cPath)
if (match?.display) {
s.prompt = match.display
}
}
}
} catch {}
}
if (opts.json) {
console.log(JSON.stringify(sessions, null, 2))
return
}
if (sessions.length === 0) {
if (opts.json) console.log("[]")
else console.log("◆ No active sessions.")
return
}
// Determine status for each session in parallel
const statusEntries = await Promise.all(
sessions.map(async (s): Promise<[string, string]> => {
if (await vm.isClaudeActive(s.worktree, s.branch)) return [s.branch, "active"]
const dirty = await git.isDirty(s.worktree)
if (dirty) return [s.branch, "dirty"]
const commits = await git.hasNewCommits(s.worktree)
return [s.branch, commits ? "saved" : "idle"]
})
)
const statuses = Object.fromEntries(statusEntries)
if (opts.json) {
const withStatus = sessions.map(s => ({ ...s, status: statuses[s.branch] }))
console.log(JSON.stringify(withStatus, null, 2))
return
}
const icons: Record<string, string> = { idle: `${dim}${reset}`, active: `${cyan}${reset}`, dirty: `${yellow}${reset}`, saved: `${green}${reset}` }
const branchColors: Record<string, string> = { idle: dim, active: cyan, dirty: yellow, saved: green }
const branchWidth = Math.max(6, ...sessions.map((s) => s.branch.length))
const cols = process.stdout.columns || 80
const prefixWidth = branchWidth + 4
console.log(` ${dim}${"BRANCH".padEnd(branchWidth)} PROMPT${reset}`)
for (const s of sessions) {
const prompt = s.prompt ?? ""
const status = statuses[s.branch]
const icon = icons[status]
const bc = branchColors[status]
const maxPrompt = cols - prefixWidth
const truncated = maxPrompt > 3 && prompt.length > maxPrompt ? prompt.slice(0, maxPrompt - 3) + "..." : prompt
console.log(`${icon} ${bc}${s.branch.padEnd(branchWidth)}${reset} ${dim}${truncated}${reset}`)
}
console.log(`\n${dim}◌ idle${reset} · ${cyan}◯ active${reset} · ${yellow}◎ unsaved${reset} · ${green}● saved${reset}`)
}

12
src/commands/log.ts Normal file
View File

@ -0,0 +1,12 @@
import { $ } from "bun"
import { die } from "../fmt.ts"
import { requireSession } from "./helpers.ts"
export async function action(branch: string) {
const { session } = await requireSession(branch)
const result = await $`git -C ${session.worktree} log main..HEAD`.nothrow()
if (result.exitCode !== 0) {
die("git log failed")
}
}

58
src/commands/merge.ts Normal file
View File

@ -0,0 +1,58 @@
import { join } from "path"
import * as git from "../git.ts"
import * as vm from "../vm.ts"
import * as state from "../state.ts"
import { spinner } from "../spinner.ts"
import { die } from "../fmt.ts"
import { closeAction } from "./close.ts"
export async function action(branch: string) {
const root = await git.repoRoot()
const session = await state.getSession(root, branch)
if (session && await git.isDirty(session.worktree)) {
die(`Branch "${branch}" has unsaved changes. Run "sandlot save ${branch}" first.`)
}
const conflicts = await git.merge(branch, root)
if (conflicts.length === 0) {
console.log(`✔ Merged ${branch} into current branch`)
await closeAction(branch)
return
}
// Resolve conflicts with Claude
console.log(`◆ Merge conflicts in ${conflicts.length} file(s). Resolving with Claude...`)
const spin = spinner("Starting container", branch)
try {
await vm.ensure((msg) => { spin.text = msg })
for (const file of conflicts) {
spin.text = `Resolving ${file}`
const content = await Bun.file(join(root, file)).text()
const resolved = await vm.claudePipe(
content,
"resolve this merge conflict. output ONLY the resolved file content with no markdown fences, no explanation, no surrounding text.",
)
if (resolved.exitCode !== 0) {
throw new Error(`Claude failed to resolve ${file}: ${resolved.stderr}`)
}
await Bun.write(join(root, file), resolved.stdout + "\n")
await git.stageFile(file, root)
}
await git.commitMerge(root)
spin.succeed(`Resolved ${conflicts.length} conflict(s) and merged ${branch}`)
} catch (err) {
spin.fail(String((err as Error).message ?? err))
await git.abortMerge(root)
process.exit(1)
}
await closeAction(branch)
}

121
src/commands/new.ts Normal file
View File

@ -0,0 +1,121 @@
import { basename, join } from "path"
import { homedir } from "os"
import { mkdir, symlink, unlink } from "fs/promises"
import * as git from "../git.ts"
import * as vm from "../vm.ts"
import * as state from "../state.ts"
import { spinner } from "../spinner.ts"
import { getApiKey } from "../env.ts"
import { renderMarkdown } from "../markdown.ts"
import { saveChanges } from "./helpers.ts"
function fallbackBranchName(text: string): string {
return text
.toLowerCase()
.replace(/[^a-z0-9\s-]/g, "")
.trim()
.split(/\s+/)
.slice(0, 2)
.join("-")
}
async function branchFromPrompt(text: string): Promise<string> {
const apiKey = await getApiKey()
if (!apiKey) return fallbackBranchName(text)
try {
const res = await fetch("https://api.anthropic.com/v1/messages", {
method: "POST",
headers: {
"content-type": "application/json",
"x-api-key": apiKey,
"anthropic-version": "2023-06-01",
},
body: JSON.stringify({
model: "claude-haiku-4-5-20251001",
max_tokens: 15,
temperature: 0,
messages: [{ role: "user", content: `Generate a 2-word git branch name (lowercase, hyphen-separated) for this task:\n\n${text}\n\nOutput ONLY the branch name, nothing else.` }],
}),
})
if (!res.ok) return fallbackBranchName(text)
const body = await res.json() as any
const name = body.content?.[0]?.text?.trim()
.toLowerCase()
.replace(/[^a-z0-9-]/g, "-")
.replace(/-+/g, "-")
.replace(/^-|-$/g, "")
return name && name.length > 0 && name.length <= 50 ? name : fallbackBranchName(text)
} catch {
return fallbackBranchName(text)
}
}
export async function action(
branch: string | undefined,
prompt: string | undefined,
opts: { print?: string; save?: boolean },
) {
// No branch given — derive from -p prompt
if (!branch && opts.print) {
branch = await branchFromPrompt(opts.print)
} else if (!branch) {
console.error("✖ Branch name or prompt is required.")
process.exit(1)
} else if (branch.includes(" ")) {
// If the "branch" contains spaces, it's actually a prompt — derive the branch name
prompt = branch
branch = await branchFromPrompt(branch)
}
const root = await git.repoRoot()
const worktreeAbs = join(homedir(), '.sandlot', basename(root), branch)
const existing = await state.getSession(root, branch)
if (existing) {
console.error(`✖ Session "${branch}" already exists. Use "sandlot open ${branch}" to re-enter it.`)
process.exit(1)
}
const spin = spinner("Creating worktree", branch)
try {
await git.createWorktree(branch, worktreeAbs, root)
await mkdir(join(root, '.sandlot'), { recursive: true })
await symlink(worktreeAbs, join(root, '.sandlot', branch))
spin.text = "Starting container"
await vm.ensure((msg) => { spin.text = msg })
if (!opts.print) spin.succeed("Session ready")
} catch (err) {
spin.fail(String((err as Error).message ?? err))
await git.removeWorktree(worktreeAbs, root).catch(() => {})
await git.deleteLocalBranch(branch, root).catch(() => {})
await unlink(join(root, '.sandlot', branch)).catch(() => {})
process.exit(1)
}
const effectivePrompt = opts.print || prompt
await state.setSession(root, {
branch,
worktree: worktreeAbs,
created_at: new Date().toISOString(),
...(effectivePrompt ? { prompt: effectivePrompt } : {}),
})
if (opts.print) {
spin.text = "Running prompt…"
const output = await vm.claude(worktreeAbs, { prompt, print: opts.print })
if (output) {
spin.stop()
process.stdout.write(renderMarkdown(output) + "\n")
} else {
spin.succeed("Done")
}
} else {
await vm.claude(worktreeAbs, { prompt, print: opts.print })
}
if (opts.save !== false) await saveChanges(worktreeAbs, branch)
}

37
src/commands/open.ts Normal file
View File

@ -0,0 +1,37 @@
import * as vm from "../vm.ts"
import * as state from "../state.ts"
import { spinner } from "../spinner.ts"
import { renderMarkdown } from "../markdown.ts"
import { requireSession, saveChanges } from "./helpers.ts"
export async function action(
branch: string,
prompt: string | undefined,
opts: { print?: string; save?: boolean },
) {
const { root, session } = await requireSession(branch)
const effectivePrompt = opts.print || prompt
if (effectivePrompt) {
await state.setSession(root, { ...session, prompt: effectivePrompt })
}
const spin = spinner("Starting container", branch)
await vm.ensure((msg) => { spin.text = msg })
if (opts.print) {
spin.text = "Running prompt…"
const output = await vm.claude(session.worktree, { prompt, print: opts.print })
if (output) {
spin.stop()
process.stdout.write(renderMarkdown(output) + "\n")
} else {
spin.succeed("Done")
}
} else {
spin.succeed("Session ready")
await vm.claude(session.worktree, { prompt, print: opts.print })
}
if (opts.save !== false) await saveChanges(session.worktree, branch)
}

22
src/commands/review.ts Normal file
View File

@ -0,0 +1,22 @@
import * as vm from "../vm.ts"
import { spinner } from "../spinner.ts"
import { requireSession } from "./helpers.ts"
export async function action(branch: string, opts: { print?: boolean }) {
const { session } = await requireSession(branch)
const spin = spinner("Starting container", branch)
await vm.ensure((msg) => { spin.text = msg })
const prompt = "You're a grumpy old senior software engineer. Take a look at the diff between this branch and main, then let me know your thoughts. My co-worker made these changes."
if (opts.print) {
spin.text = "Running review…"
const output = await vm.claude(session.worktree, { print: prompt })
spin.stop()
if (output) process.stdout.write(output + "\n")
} else {
spin.succeed("Session ready")
await vm.claude(session.worktree, { prompt })
}
}

8
src/commands/save.ts Normal file
View File

@ -0,0 +1,8 @@
import { requireSession, saveChanges } from "./helpers.ts"
export async function action(branch: string, message?: string) {
const { session } = await requireSession(branch)
const ok = await saveChanges(session.worktree, branch, message)
if (!ok) process.exit(1)
}

15
src/commands/shell.ts Normal file
View File

@ -0,0 +1,15 @@
import * as vm from "../vm.ts"
import { requireSession } from "./helpers.ts"
export async function action(branch?: string) {
if (!branch) {
await vm.ensure()
await vm.shell()
return
}
const { session } = await requireSession(branch)
await vm.ensure()
await vm.shell(session.worktree)
}

23
src/commands/show.ts Normal file
View File

@ -0,0 +1,23 @@
import { $ } from "bun"
import * as git from "../git.ts"
import { pager } from "../fmt.ts"
import { requireSession } from "./helpers.ts"
export async function action(branch: string) {
const { session } = await requireSession(branch)
const main = await git.mainBranch(session.worktree)
const result = await $`git -C ${session.worktree} diff --color=always ${main}...${branch}`.nothrow().quiet()
if (result.exitCode !== 0) {
console.error("git diff failed")
process.exit(1)
}
let output = ""
if (session.prompt) {
output += `PROMPT: ${session.prompt}\n\n`
}
output += result.text()
await pager(output)
}

74
src/commands/vm.ts Normal file
View File

@ -0,0 +1,74 @@
import type { Command } from "commander"
import * as vm from "../vm.ts"
import { spinner } from "../spinner.ts"
export function register(program: Command) {
const vmCmd = program.command("vm").description("Manage the sandlot VM")
vmCmd
.command("create")
.description("Create and provision the VM")
.action(async () => {
const spin = spinner("Creating VM")
try {
await vm.create((msg) => { spin.text = msg })
spin.succeed("VM created")
} catch (err) {
spin.fail(String((err as Error).message ?? err))
process.exit(1)
}
})
vmCmd
.command("start")
.description("Start the VM")
.action(async () => {
try {
await vm.start()
console.log("✔ VM started")
} catch (err) {
console.error(`${(err as Error).message ?? err}`)
process.exit(1)
}
})
vmCmd
.command("shell")
.description("Open a shell in the VM")
.action(async () => {
await vm.ensure()
await vm.shell()
})
vmCmd
.command("status")
.description("Show VM status")
.action(async () => {
const s = await vm.status()
console.log(s)
})
vmCmd
.command("info")
.description("Show VM system info (via neofetch)")
.action(async () => {
await vm.ensure()
await vm.info()
})
vmCmd
.command("stop")
.description("Stop the VM")
.action(async () => {
await vm.stop()
console.log("✔ VM stopped")
})
vmCmd
.command("destroy")
.description("Stop and delete the VM")
.action(async () => {
await vm.destroy()
console.log("✔ VM destroyed")
})
}

View File

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

11
src/env.ts Normal file
View File

@ -0,0 +1,11 @@
import { homedir } from "os"
import { join } from "path"
/** Read the ANTHROPIC_API_KEY from ~/.env. Returns undefined if not found. */
export async function getApiKey(): Promise<string | undefined> {
const envFile = Bun.file(join(homedir(), ".env"))
if (!(await envFile.exists())) return undefined
const envContent = await envFile.text()
return envContent.match(/^(?:export\s+)?ANTHROPIC_API_KEY=["']?([^"'\s]+)["']?/m)?.[1]
}

39
src/fmt.ts Normal file
View File

@ -0,0 +1,39 @@
// ── ANSI escape codes ───────────────────────────────────────────────
export const reset = "\x1b[0m"
export const dim = "\x1b[2m"
export const bold = "\x1b[1m"
export const green = "\x1b[32m"
export const yellow = "\x1b[33m"
export const cyan = "\x1b[36m"
export const white = "\x1b[37m"
// ── Formatted output ────────────────────────────────────────────────
export function die(message: string): never {
process.stderr.write(`${message}\n`)
process.exit(1)
}
export function success(message: string) {
process.stderr.write(`${message}\n`)
}
export function info(message: string) {
process.stderr.write(`${message}\n`)
}
// ── Pager ───────────────────────────────────────────────────────────
export async function pager(content: string): Promise<void> {
const lines = content.split("\n").length
const termHeight = process.stdout.rows || 24
if (lines > termHeight) {
const p = Bun.spawn(["less", "-R"], { stdin: "pipe", stdout: "inherit", stderr: "inherit" })
p.stdin.write(content)
p.stdin.end()
await p.exited
} else {
process.stdout.write(content)
}
}

View File

@ -21,12 +21,14 @@ export async function currentBranch(cwd?: string): Promise<string> {
}
/** Check if a branch exists locally or remotely. Returns "local", "remote", or null. */
export async function branchExists(branch: string, cwd?: string): Promise<"local" | "remote" | null> {
export async function branchExists(branch: string, cwd?: string, opts?: { fetch?: boolean }): Promise<"local" | "remote" | null> {
const dir = cwd ?? "."
const local = await $`git show-ref --verify --quiet refs/heads/${branch}`.cwd(dir).nothrow().quiet()
if (local.exitCode === 0) return "local"
await $`git fetch origin`.cwd(dir).nothrow().quiet()
if (opts?.fetch) {
await $`git fetch origin`.cwd(dir).nothrow().quiet()
}
const remote = await $`git show-ref --verify --quiet refs/remotes/origin/${branch}`.cwd(dir).nothrow().quiet()
if (remote.exitCode === 0) return "remote"
@ -44,7 +46,7 @@ export async function createWorktree(branch: string, worktreePath: string, cwd:
}
await $`git worktree prune`.cwd(cwd).nothrow().quiet()
const exists = await branchExists(branch, cwd)
const exists = await branchExists(branch, cwd, { fetch: true })
let result
if (exists === "local") {

View File

@ -1,4 +1,5 @@
import { join } from "path"
import { rename } from "fs/promises"
export interface Session {
branch: string
@ -26,9 +27,11 @@ export async function load(repoRoot: string): Promise<State> {
export async function save(repoRoot: string, state: State): Promise<void> {
const path = statePath(repoRoot)
const tmpPath = path + ".tmp"
const dir = join(repoRoot, ".sandlot")
await Bun.write(join(dir, ".gitkeep"), "") // ensure dir exists
await Bun.write(path, JSON.stringify(state, null, 2) + "\n")
await Bun.write(tmpPath, JSON.stringify(state, null, 2) + "\n")
await rename(tmpPath, path)
}
export async function getSession(repoRoot: string, branch: string): Promise<Session | undefined> {

View File

@ -1,6 +1,7 @@
import { $ } from "bun"
import { homedir } from "os"
import { dirname } from "path"
import { dirname, join } from "path"
import { getApiKey } from "./env.ts"
const CONTAINER_NAME = "sandlot"
const USER = "ubuntu"
@ -36,33 +37,31 @@ async function run(cmd: ReturnType<typeof $>, step: string): Promise<void> {
}
}
/** Create and provision the container from scratch. Fails if it already exists. */
export async function create(log?: (msg: string) => void): Promise<void> {
requireContainer()
// ── create() helpers (internal) ──────────────────────────────────────
const s = await status()
if (s !== "missing") {
throw new Error("Container already exists. Use 'sandlot vm destroy' first to recreate it.")
}
const home = homedir()
log?.("Pulling image & creating container")
/** Pull the image and start the container in detached mode. */
async function createContainer(home: string): Promise<void> {
await run(
$`container run -d --name ${CONTAINER_NAME} -m 4G --mount type=bind,source=${home}/dev,target=/host,readonly -v ${home}/.sandlot:/sandlot ubuntu:24.04 sleep infinity`,
"Container creation")
}
// Provision (as root)
log?.("Installing packages")
/** Install base system packages (as root). */
async function installPackages(): Promise<void> {
await run(
$`container exec ${CONTAINER_NAME} bash -c ${"apt update && apt install -y curl git neofetch fish unzip"}`,
"Package installation")
}
// Create symlinks so git worktree absolute paths from the host resolve inside the container
/** Create symlinks so git worktree absolute paths from the host resolve inside the container. */
async function createHostSymlinks(home: string): Promise<void> {
await run(
$`container exec ${CONTAINER_NAME} bash -c ${`mkdir -p '${home}' && ln -s /host '${home}/dev' && ln -s /sandlot '${home}/.sandlot'`}`,
"Symlink creation")
}
// Install Bun and Claude Code (as ubuntu user)
/** Install Bun and Claude Code (as ubuntu user). */
async function installTooling(log?: (msg: string) => void): Promise<void> {
log?.("Installing Bun")
await run(
$`container exec --user ${USER} ${CONTAINER_NAME} env BUN_INSTALL=/home/${USER}/.local bash -c ${"curl -fsSL https://bun.sh/install | bash"}`,
@ -72,21 +71,15 @@ export async function create(log?: (msg: string) => void): Promise<void> {
await run(
$`container exec --user ${USER} ${CONTAINER_NAME} bash -c ${"curl -fsSL https://claude.ai/install.sh | bash"}`,
"Claude Code installation")
}
log?.("Configuring environment")
/** Configure git identity, API key helper, activity hook, and Claude settings. */
async function configureEnvironment(home: string, log?: (msg: string) => void, apiKey?: string): Promise<void> {
const gitName = (await $`git config user.name`.quiet().text()).trim()
const gitEmail = (await $`git config user.email`.quiet().text()).trim()
if (gitName) await $`container exec --user ${USER} ${CONTAINER_NAME} git config --global user.name ${gitName}`.quiet()
if (gitEmail) await $`container exec --user ${USER} ${CONTAINER_NAME} git config --global user.email ${gitEmail}`.quiet()
// Configure claude to use API key from host ~/.env (skip login prompt)
let apiKey: string | undefined
const envFile = Bun.file(`${home}/.env`)
if (await envFile.exists()) {
const envContent = await envFile.text()
apiKey = envContent.match(/^(?:export\s+)?ANTHROPIC_API_KEY=["']?([^"'\s]+)["']?/m)?.[1]
}
if (!apiKey) {
log?.("Warning: ANTHROPIC_API_KEY not found in ~/.env — claude will require manual login")
}
@ -130,6 +123,33 @@ echo '${claudeJson}' > ~/.claude.json
`}`.quiet()
}
// ── create() ────────────────────────────────────────────────────────
/** Create and provision the container from scratch. Fails if it already exists. */
export async function create(log?: (msg: string) => void): Promise<void> {
requireContainer()
const s = await status()
if (s !== "missing") {
throw new Error("Container already exists. Use 'sandlot vm destroy' first to recreate it.")
}
const home = homedir()
log?.("Pulling image & creating container")
await createContainer(home)
log?.("Installing packages")
await installPackages()
await createHostSymlinks(home)
await installTooling(log)
log?.("Configuring environment")
const apiKey = await getApiKey()
await configureEnvironment(home, log, apiKey)
}
/** Start a stopped container. */
export async function start(): Promise<void> {
requireContainer()
@ -230,6 +250,20 @@ export async function exec(workdir: string, command: string): Promise<{ exitCode
}
}
/** Pipe input text to Claude in the container with a prompt, returning the output. */
export async function claudePipe(input: string, prompt: string): Promise<{ exitCode: number; stdout: string; stderr: string }> {
const tmpPath = join(homedir(), '.sandlot', '.claude-pipe-tmp')
try {
await Bun.write(tmpPath, input)
return await exec(
join(homedir(), '.sandlot'),
`cat /sandlot/.claude-pipe-tmp | claude -p "${prompt.replace(/"/g, '\\"')}"`,
)
} finally {
await Bun.file(tmpPath).unlink().catch(() => {})
}
}
/** Check if Claude is actively working in the given worktree (based on activity hook). */
export async function isClaudeActive(worktree: string, branch: string): Promise<boolean> {
const file = `${dirname(worktree)}/.activity-${branch}`