sandlot/src/cli.ts

251 lines
7.4 KiB
TypeScript
Executable File

#!/usr/bin/env bun
import { Command } from "commander"
import { basename, join } from "path"
import { homedir } from "os"
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 Lima VMs").version(pkg.version)
// ── sandlot new <branch> ──────────────────────────────────────────────
program
.command("new")
.argument("<branch>", "branch name")
.argument("[prompt]", "initial prompt for Claude")
.description("Create a new session and launch Claude")
.action(async (branch: string, prompt?: 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")
await git.createWorktree(branch, worktreeAbs, root)
spin.text = "Starting VM"
await vm.ensure()
spin.succeed("Session ready")
await state.setSession(root, {
branch,
worktree: worktreeAbs,
created_at: new Date().toISOString(),
})
await vm.claude(worktreeAbs, prompt)
})
// ── 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 <branch> ─────────────────────────────────────────────
program
.command("open")
.argument("<branch>", "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 VM")
await vm.ensure()
spin.succeed("Session ready")
await vm.claude(session.worktree)
})
// ── sandlot close <branch> ───────────────────────────────────────────
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)
console.log(`Removed worktree ${worktreeAbs}/`)
await git.deleteLocalBranch(branch, root)
console.log(`Deleted local branch ${branch}`)
if (session) {
await state.removeSession(root, 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()
await git.merge(branch, root)
console.log(`Merged ${branch} into current branch`)
await closeAction(branch)
})
program
.command("close")
.argument("<branch>", "branch name")
.description("Remove a worktree and clean up the session")
.action(closeAction)
program
.command("rm", { hidden: true })
.argument("<branch>", "branch name")
.action(closeAction)
// ── sandlot save <branch> ───────────────────────────────────────────
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)
}
const worktreeAbs = session.worktree
const spin = spinner("Starting VM")
await vm.ensure()
spin.text = "Staging changes"
await vm.exec(worktreeAbs, "git add .")
const check = await vm.exec(worktreeAbs, "git diff --staged --quiet")
if (check.exitCode === 0) {
spin.fail("No changes to commit")
process.exit(1)
}
let msg: string
if (message) {
msg = message
} else {
spin.text = "Generating commit message"
const gen = await vm.exec(
worktreeAbs,
'git diff --staged | claude -p "write a short commit message summarizing these changes. output only the message, no quotes or extra text" > /tmp/sandlot_commit_msg',
)
if (gen.exitCode !== 0) {
spin.fail("Failed to generate commit message")
if (gen.stderr) console.error(gen.stderr)
process.exit(1)
}
const { stdout } = await vm.exec(worktreeAbs, "cat /tmp/sandlot_commit_msg")
await vm.exec(worktreeAbs, "rm -f /tmp/sandlot_commit_msg")
msg = stdout
}
spin.text = "Committing"
const commit = await vm.exec(worktreeAbs, `git commit -m ${JSON.stringify(msg)}`)
if (commit.exitCode !== 0) {
spin.fail("Commit failed")
if (commit.stderr) console.error(commit.stderr)
process.exit(1)
}
spin.succeed(`Saved: ${msg}`)
})
// ── 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)
})