820 lines
30 KiB
TypeScript
Executable File
820 lines
30 KiB
TypeScript
Executable File
#!/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"
|
|
|
|
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)
|
|
|
|
// ── 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)
|
|
}
|
|
}
|
|
|
|
program
|
|
.command("new")
|
|
.argument("[branch]", "branch name or prompt (if it contains spaces)")
|
|
.argument("[prompt]", "initial prompt for Claude")
|
|
.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)
|
|
|
|
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 ──────────────────────────────────────────────────────
|
|
|
|
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)
|
|
|
|
// 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> ─────────────────────────────────────────────
|
|
|
|
program
|
|
.command("open")
|
|
.argument("<branch>", "branch name")
|
|
.argument("[prompt]", "initial prompt for Claude")
|
|
.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)
|
|
|
|
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, continue: true })
|
|
}
|
|
|
|
if (opts.save !== false) await saveChanges(session.worktree, branch)
|
|
})
|
|
|
|
// ── sandlot review <branch> ──────────────────────────────────────────
|
|
|
|
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)
|
|
|
|
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> ───────────────────────────────────────────
|
|
|
|
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
|
|
}
|
|
|
|
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)
|
|
})
|
|
|
|
program
|
|
.command("close")
|
|
.argument("<branch>", "branch name")
|
|
.option("-f, --force", "close even if there are unsaved changes")
|
|
.description("Remove a worktree and clean up the session")
|
|
.action((branch: string, opts: { force?: boolean }) => closeAction(branch, opts))
|
|
|
|
program
|
|
.command("rm", { hidden: true })
|
|
.argument("<branch>", "branch name")
|
|
.option("-f, --force", "close even if there are unsaved changes")
|
|
.action((branch: string, opts: { force?: boolean }) => closeAction(branch, opts))
|
|
|
|
// ── 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 ok = await saveChanges(session.worktree, branch, message)
|
|
if (!ok) process.exit(1)
|
|
})
|
|
|
|
// ── sandlot diff <branch> ───────────────────────────────────────────
|
|
|
|
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)
|
|
}
|
|
|
|
// 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> ───────────────────────────────────────────
|
|
|
|
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)
|
|
}
|
|
|
|
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> ────────────────────────────────────────────
|
|
|
|
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)
|
|
}
|
|
|
|
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> ────────────────────────────────────────────
|
|
|
|
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)
|
|
}
|
|
|
|
process.stdout.write(session.worktree + "\n")
|
|
})
|
|
|
|
// ── 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}`)
|
|
}
|
|
})
|
|
|
|
// ── sandlot vm ───────────────────────────────────────────────────────
|
|
|
|
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")
|
|
})
|
|
|
|
// ── 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
|
|
|
|
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
|
|
|
|
# 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 {
|
|
const root = await git.repoRoot()
|
|
const st = await state.load(root)
|
|
if (Object.keys(st.sessions).length > 0) {
|
|
process.argv.push("list")
|
|
}
|
|
} catch {}
|
|
}
|
|
|
|
program.parseAsync().catch((err) => {
|
|
console.error(`✖ ${err.message ?? err}`)
|
|
process.exit(1)
|
|
})
|