sandlot/src/cli.ts
Chris Wanstrath f7d876776b Add per-user config system with sandlot config command
Allow users to configure container memory limit (default 32G) via
`sandlot config memory <value>` instead of hardcoding it.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-20 10:13:20 -07:00

244 lines
8.4 KiB
TypeScript
Executable File

#!/usr/bin/env bun
import { Command, Option } from "commander"
import { yellow, reset } from "./fmt.ts"
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 { action as closeAction } from "./commands/close.ts"
import { action as checkoutAction } from "./commands/checkout.ts"
import { action as mergeAction } from "./commands/merge.ts"
import { action as squashAction } from "./commands/squash.ts"
import { action as rebaseAction } from "./commands/rebase.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 webAction } from "./commands/web.ts"
import { action as logAction } from "./commands/log.ts"
import { action as dirAction } from "./commands/dir.ts"
import { action as editAction } from "./commands/edit.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"
import { action as initAction } from "./commands/init.ts"
import { action as cdAction } from "./commands/cd.ts"
import { action as configAction } from "./commands/config.ts"
const pkg = await Bun.file(new URL("../package.json", import.meta.url)).json()
const program = new Command()
program
.name("sandlot")
.description("Sandboxed development with Claude.")
.configureHelp({ styleTitle: (str) => `${yellow}${str}${reset}` })
.helpOption(false)
.addOption(new Option("-h, --help").hideHelp())
.on("option:help", () => program.help())
.addOption(new Option("-V, --version").hideHelp())
.on("option:version", () => {
const versionParts = pkg.version.split('.')
console.log(`v${versionParts.at(-1)}`)
process.exit(0)
})
// ── Sessions ────────────────────────────────────────────────────────
program
.command("list")
.description("Show all active sessions")
.option("--json", "Output as JSON")
.action(listAction)
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)
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("Open an existing Claude session")
.action(openAction)
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")
.description("Remove a session (alias for close)")
.action((branch: string, opts: { force?: boolean }) => closeAction(branch, opts))
program
.command("checkout")
.alias("co")
.argument("<branch>", "branch name")
.option("-f, --force", "checkout even if there are unsaved changes")
.description("Close the session and check out the branch locally")
.action((branch: string, opts: { force?: boolean }) => checkoutAction(branch, opts))
// ── Branch ──────────────────────────────────────────────────────────
program.commandsGroup("Branch Commands:")
program
.command("diff")
.argument("<branch>", "branch name")
.description("Show uncommitted changes, or full branch diff vs main")
.action(diffAction)
program
.command("log")
.argument("<branch>", "branch name")
.description("Show commits on a branch that are not on main")
.action(logAction)
program
.command("show")
.argument("<branch>", "branch name")
.description("Show the prompt and full diff for a branch")
.action(showAction)
program
.command("web")
.argument("<branch>", "branch name")
.description("Open the branch diff in a web browser")
.action(webAction)
program
.command("save")
.argument("<branch>", "branch name")
.argument("[message]", "commit message (AI-generated if omitted)")
.description("Stage all changes and commit")
.action(saveAction)
program
.command("merge")
.argument("<branch>", "branch name")
.option("-f, --force", "allow merging into a non-main branch")
.description("Merge a branch into main and close the session")
.action((branch: string, opts: { force?: boolean }) => mergeAction(branch, opts))
program
.command("squash")
.argument("<branch>", "branch name")
.option("-f, --force", "allow merging into a non-main branch")
.description("Squash-merge a branch into main and close the session")
.action((branch: string, opts: { force?: boolean }) => squashAction(branch, opts))
program
.command("rebase")
.argument("<branch>", "branch name")
.description("Rebase a branch onto the latest main")
.action(rebaseAction)
program
.command("review")
.argument("<branch>", "branch name")
.argument("[prompt]", "additional instructions to append to the review prompt")
.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)
program
.command("shell")
.argument("[branch]", "branch name (omit for a plain VM shell)")
.description("Open a shell in the VM")
.action(shellAction)
program
.command("edit")
.argument("<branch>", "branch name")
.argument("<file>", "file path relative to worktree root")
.description("Open a file from a session in $EDITOR")
.action(editAction)
program
.command("dir")
.argument("<branch>", "branch name")
.description("Print the worktree path for a session")
.action(dirAction)
program
.command("cd")
.argument("<branch>", "branch name")
.description("Change to a branch's worktree directory")
.action(cdAction)
// ── Admin ───────────────────────────────────────────────────────────
program.commandsGroup("Admin Commands:")
program
.command("config")
.argument("[args...]", "key [value]")
.description("Get or set configuration (e.g. sandlot config memory 16G)")
.action(configAction)
program
.command("cleanup")
.description("Remove stale sessions whose worktrees no longer exist")
.action(cleanupAction)
registerVmCommands(program)
program
.command("upgrade")
.description("Upgrade sandlot to the latest version")
.action(async () => {
const result = await Bun.spawn(["bun", "install", "-g", "@because/sandlot@latest"], {
stdin: "inherit",
stdout: "inherit",
stderr: "inherit",
}).exited
process.exit(result)
})
program
.command("version")
.description("Print the version number")
.action(() => {
const versionParts = pkg.version.split('.')
console.log(`v${versionParts.at(-1)}`)
})
program
.command("completions")
.option("--install", "Output a shell script that installs the completions file")
.description("Output fish shell completions")
.action((opts: { install?: boolean }) => completionsAction(program, opts))
program
.command("init")
.argument("<shell>", "shell type (fish, bash, zsh)")
.description("Print shell init script (eval in your shell config)")
.action((shell: string) => initAction(program, shell))
// ── Default: `sandlot` → `sandlot list` ─────────────────────────────
if (process.argv.length === 2) {
process.argv.push("list")
}
program.parseAsync().catch((err) => {
console.error(`${err.message ?? err}`)
process.exit(1)
})