#!/usr/bin/env bun import { Command } from "commander" import { $} from "bun" 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" const pkg = await Bun.file(new URL("../package.json", import.meta.url)).json() const program = new Command() program.name("sandlot").description("Branch-based development with git worktrees and Apple Container").version(pkg.version) // ── sandlot new ────────────────────────────────────────────── program .command("new") .argument("", "branch name") .argument("[prompt]", "initial prompt for Claude") .option("-p, --print ", "run Claude in non-interactive mode with -p") .description("Create a new session and launch Claude") .action(async (branch: string, prompt: string | undefined, opts: { print?: string }) => { 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") 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 }) 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) } await state.setSession(root, { branch, worktree: worktreeAbs, created_at: new Date().toISOString(), }) if (opts.print) console.log(`Running prompt…`) await vm.claude(worktreeAbs, { prompt, print: opts.print }) }) // ── sandlot list ────────────────────────────────────────────────────── program .command("list") .description("Show all active sessions") .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) if (opts.json) { console.log(JSON.stringify(sessions, null, 2)) return } if (sessions.length === 0) { console.log("No active sessions.") return } const branchWidth = Math.max(6, ...sessions.map((s) => s.branch.length)) console.log(`${"BRANCH".padEnd(branchWidth)} WORKTREE`) for (const s of sessions) { console.log(`${s.branch.padEnd(branchWidth)} ${s.worktree}/`) } }) // ── sandlot open ───────────────────────────────────────────── program .command("open") .argument("", "branch name") .description("Re-enter an existing 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) } const spin = spinner("Starting container") await vm.ensure((msg) => { spin.text = msg }) spin.succeed("Session ready") await vm.claude(session.worktree) }) // ── sandlot close ─────────────────────────────────────────── const closeAction = async (branch: string) => { const root = await git.repoRoot() const session = await state.getSession(root, branch) const worktreeAbs = session?.worktree ?? join(homedir(), '.sandlot', basename(root), branch) await git.removeWorktree(worktreeAbs, root) .then(() => console.log(`Removed worktree ${worktreeAbs}/`)) .catch((e) => console.warn(`Failed to remove worktree ${worktreeAbs}: ${e.message}`)) await unlink(join(root, '.sandlot', branch)) .then(() => console.log(`Removed symlink .sandlot/${branch}`)) .catch(() => {}) // symlink may not exist await git.deleteLocalBranch(branch, root) .then(() => console.log(`Deleted local branch ${branch}`)) .catch((e) => console.warn(`Failed to delete local branch ${branch}: ${e.message}`)) if (session) { await state.removeSession(root, branch) } } // ── sandlot merge ────────────────────────────────────────── program .command("merge") .argument("", "branch name") .description("Merge a branch into main and close the session") .action(async (branch: string) => { const root = await git.repoRoot() 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") 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) }) program .command("close") .argument("", "branch name") .description("Remove a worktree and clean up the session") .action(closeAction) program .command("rm", { hidden: true }) .argument("", "branch name") .action(closeAction) // ── sandlot save ─────────────────────────────────────────── program .command("save") .argument("", "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) } const wt = session.worktree const spin = spinner("Staging changes") // Run git on the host — the worktree's .git references host paths and // ~/dev is mounted read-only in the container, so git must run here. await $`git -C ${wt} add .`.nothrow().quiet() const check = await $`git -C ${wt} diff --staged --quiet`.nothrow().quiet() if (check.exitCode === 0) { spin.fail("No changes to commit") process.exit(1) } 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 ${wt} 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) process.exit(1) } msg = gen.stdout } spin.text = "Committing" const commit = await $`git -C ${wt} commit -m ${msg}`.nothrow().quiet() if (commit.exitCode !== 0) { spin.fail("Commit failed") if (commit.stderr) console.error(commit.stderr.toString().trim()) process.exit(1) } spin.succeed(`Saved: ${msg}`) }) // ── sandlot diff ─────────────────────────────────────────── program .command("diff") .argument("", "branch name") .description("Show unstaged changes in a session's worktree") .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) } const result = await $`git -C ${session.worktree} diff`.nothrow() if (result.exitCode !== 0) { console.error("git diff failed") process.exit(1) } }) // ── sandlot vm ─────────────────────────────────────────────────────── const vmCmd = program.command("vm").description("Manage the sandlot VM") 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") }) program.parseAsync().catch((err) => { console.error(err.message ?? err) process.exit(1) })