sandlot/src/cli.ts

174 lines
7.4 KiB
TypeScript
Executable File

#!/usr/bin/env bun
import { Command } from "commander"
import * as git from "./git.ts"
import * as state from "./state.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()
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]", "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(newAction)
// ── sandlot list ─────────────────────────────────────────────────────
program
.command("list")
.description("Show all active sessions (◌ idle · ◯ working · ◎ unsaved · ● saved)")
.option("--json", "Output as JSON")
.action(listAction)
// ── sandlot open ─────────────────────────────────────────────────────
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(openAction)
// ── 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(reviewAction)
// ── 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(shellAction)
// ── sandlot close ────────────────────────────────────────────────────
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 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(saveAction)
// ── sandlot diff ─────────────────────────────────────────────────────
program
.command("diff")
.argument("<branch>", "branch name")
.description("Show uncommitted changes, or full branch diff vs main")
.action(diffAction)
// ── sandlot show ─────────────────────────────────────────────────────
program
.command("show")
.argument("<branch>", "branch name")
.description("Show the prompt and full diff for a branch (for code review)")
.action(showAction)
// ── sandlot log ──────────────────────────────────────────────────────
program
.command("log")
.argument("<branch>", "branch name")
.description("Show commits on a branch that are not on main")
.action(logAction)
// ── sandlot dir ──────────────────────────────────────────────────────
program
.command("dir")
.argument("<branch>", "branch name")
.description("Print the worktree path for a session")
.action(dirAction)
// ── sandlot cleanup ──────────────────────────────────────────────────
program
.command("cleanup")
.description("Remove stale sessions whose worktrees no longer exist")
.action(cleanupAction)
// ── sandlot vm ───────────────────────────────────────────────────────
registerVmCommands(program)
// ── sandlot completions ──────────────────────────────────────────────
program
.command("completions")
.description("Output fish shell completions")
.action(() => completionsAction(program))
// ── 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)
})