251 lines
7.4 KiB
TypeScript
Executable File
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)
|
|
})
|