#!/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 { 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 ────────────────────────────────────────────── 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 { // 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 ", "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 = { idle: `${dim}◌${reset}`, active: `${cyan}◯${reset}`, dirty: `${yellow}◎${reset}`, saved: `${green}●${reset}` } const branchColors: Record = { 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 ───────────────────────────────────────────── program .command("open") .argument("", "branch name") .argument("[prompt]", "initial prompt for Claude") .option("-p, --print ", "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 ────────────────────────────────────────── program .command("review") .argument("", "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 ─────────────────────────────────────────── 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 ─────────────────────────────────────────── 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 ────────────────────────────────────────── program .command("merge") .argument("", "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 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 name") .option("-f, --force", "close even if there are unsaved changes") .action((branch: string, opts: { force?: boolean }) => closeAction(branch, opts)) // ── sandlot save ─────────────────────────────────────────── program .command("save") .argument("", "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 ─────────────────────────────────────────── program .command("diff") .argument("", "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 ─────────────────────────────────────────── program .command("show") .argument("", "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 ──────────────────────────────────────────── program .command("log") .argument("", "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 ──────────────────────────────────────────── program .command("dir") .argument("", "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) })