Compare commits

...

11 Commits

10 changed files with 123 additions and 126 deletions

View File

@ -5,7 +5,7 @@
"": {
"name": "sandlot",
"dependencies": {
"commander": "^13.1.0",
"commander": "^14.0.3",
},
"devDependencies": {
"@types/bun": "^1.3.9",
@ -19,7 +19,7 @@
"bun-types": ["bun-types@1.3.9", "https://npm.nose.space/bun-types/-/bun-types-1.3.9.tgz", { "dependencies": { "@types/node": "*" } }, "sha512-+UBWWOakIP4Tswh0Bt0QD0alpTY8cb5hvgiYeWCMet9YukHbzuruIEeXC2D7nMJPB12kbh8C7XJykSexEqGKJg=="],
"commander": ["commander@13.1.0", "https://npm.nose.space/commander/-/commander-13.1.0.tgz", {}, "sha512-/rFeCpNJQbhSZjGVwO9RFV3xPqbnERS8MmIQzCtD/zl6gpJuV/bMLuN92oG3F7d8oDEHHRrujSXNUr8fpjntKw=="],
"commander": ["commander@14.0.3", "", {}, "sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw=="],
"undici-types": ["undici-types@5.26.5", "https://npm.nose.space/undici-types/-/undici-types-5.26.5.tgz", {}, "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA=="],
}

View File

@ -6,7 +6,7 @@
"sandlot": "./src/cli.ts"
},
"dependencies": {
"commander": "^13.1.0"
"commander": "^14.0.3"
},
"scripts": {
"test:markdown": "bun src/test-markdown.ts"

View File

@ -1,6 +1,7 @@
#!/usr/bin/env bun
import { Command } from "commander"
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"
@ -25,9 +26,26 @@ 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)
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", () => {
console.log(pkg.version)
process.exit(0)
})
// ── sandlot new ──────────────────────────────────────────────────────
// ── Sessions ────────────────────────────────────────────────────────
program
.command("list")
.description("Show all active sessions")
.option("--json", "Output as JSON")
.action(listAction)
program
.command("new")
@ -38,44 +56,15 @@ program
.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")
.description("Open an existing Claude 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")
@ -90,31 +79,27 @@ program
.description("Remove a session (alias for close)")
.action((branch: string, opts: { force?: boolean }) => closeAction(branch, opts))
// ── sandlot merge ────────────────────────────────────────────────────
// ── Branch ──────────────────────────────────────────────────────────
program.commandsGroup("Branch Commands:")
program
.command("merge")
.command("diff")
.argument("<branch>", "branch name")
.description("Merge a branch into main and close the session")
.action(mergeAction)
// ── sandlot squash ───────────────────────────────────────────────────
.description("Show uncommitted changes, or full branch diff vs main")
.action(diffAction)
program
.command("squash")
.command("log")
.argument("<branch>", "branch name")
.description("Squash-merge a branch into main and close the session")
.action(squashAction)
// ── sandlot rebase ───────────────────────────────────────────────────
.description("Show commits on a branch that are not on main")
.action(logAction)
program
.command("rebase")
.command("show")
.argument("<branch>", "branch name")
.description("Rebase a branch onto the latest main")
.action(rebaseAction)
// ── sandlot save ─────────────────────────────────────────────────────
.description("Show the prompt and full diff for a branch")
.action(showAction)
program
.command("save")
@ -123,31 +108,36 @@ program
.description("Stage all changes and commit")
.action(saveAction)
// ── sandlot diff ─────────────────────────────────────────────────────
program
.command("merge")
.argument("<branch>", "branch name")
.description("Merge a branch into main and close the session")
.action(mergeAction)
program
.command("diff")
.command("squash")
.argument("<branch>", "branch name")
.description("Show uncommitted changes, or full branch diff vs main")
.action(diffAction)
// ── sandlot show ─────────────────────────────────────────────────────
.description("Squash-merge a branch into main and close the session")
.action(squashAction)
program
.command("show")
.command("rebase")
.argument("<branch>", "branch name")
.description("Show the prompt and full diff for a branch (for code review)")
.action(showAction)
// ── sandlot log ──────────────────────────────────────────────────────
.description("Rebase a branch onto the latest main")
.action(rebaseAction)
program
.command("log")
.command("review")
.argument("<branch>", "branch name")
.description("Show commits on a branch that are not on main")
.action(logAction)
.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 dir ──────────────────────────────────────────────────────
program
.command("shell")
.argument("[branch]", "branch name (omit for a plain VM shell)")
.description("Open a shell in the VM")
.action(shellAction)
program
.command("dir")
@ -155,18 +145,23 @@ program
.description("Print the worktree path for a session")
.action(dirAction)
// ── sandlot cleanup ──────────────────────────────────────────────────
// ── Admin ───────────────────────────────────────────────────────────
program.commandsGroup("Admin Commands:")
program
.command("cleanup")
.description("Remove stale sessions whose worktrees no longer exist")
.action(cleanupAction)
// ── sandlot vm ───────────────────────────────────────────────────────
registerVmCommands(program)
// ── sandlot completions ──────────────────────────────────────────────
program
.command("version")
.description("Print the version number")
.action(() => {
console.log(pkg.version)
})
program
.command("completions")
@ -174,17 +169,10 @@ program
.description("Output fish shell completions")
.action((opts: { install?: boolean }) => completionsAction(program, opts))
// ── Default: show list if sessions exist, otherwise help ─────────────
// ── Default: `sandlot` → `sandlot list` ─────────────────────────────
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 {}
if (process.argv.length === 2) {
process.argv.push("list")
}
program.parseAsync().catch((err) => {

View File

@ -1,6 +1,5 @@
import { $ } from "bun"
import * as git from "../git.ts"
import { pager } from "../fmt.ts"
import { requireSession } from "./helpers.ts"
export async function action(branch: string) {
@ -13,27 +12,26 @@ export async function action(branch: string) {
process.exit(1)
}
let diff: string
let args: 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()
}
const hasHead = await $`git -C ${session.worktree} rev-parse --verify HEAD`.nothrow().quiet()
args = hasHead.exitCode === 0 ? ["diff", "HEAD"] : ["diff"]
} else {
// No uncommitted changes — show full branch diff vs main
const main = await git.mainBranch(session.worktree)
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()
args = ["diff", `${main}...${branch}`]
}
await pager(diff)
// Run git diff with inherited stdio so external diff tools (e.g. difftastic)
// see a real TTY and git can use its own pager
const proc = Bun.spawn(["git", "-C", session.worktree, ...args], {
stdin: "inherit",
stdout: "inherit",
stderr: "inherit",
})
const exitCode = await proc.exited
if (exitCode !== 0) {
process.exit(exitCode)
}
}

View File

@ -105,10 +105,10 @@ export async function action(
if (opts.print) {
spin.text = "Running prompt…"
const output = await vm.claude(worktreeAbs, { prompt, print: opts.print })
if (output) {
const result = await vm.claude(worktreeAbs, { prompt, print: opts.print })
if (result.output) {
spin.stop()
process.stdout.write(renderMarkdown(output) + "\n")
process.stdout.write(renderMarkdown(result.output) + "\n")
} else {
spin.succeed("Done")
}

View File

@ -21,10 +21,10 @@ export async function action(
if (opts.print) {
spin.text = "Running prompt…"
const output = await vm.claude(session.worktree, { prompt, print: opts.print })
if (output) {
const result = await vm.claude(session.worktree, { prompt, print: opts.print, continue: true })
if (result.output) {
spin.stop()
process.stdout.write(renderMarkdown(output) + "\n")
process.stdout.write(renderMarkdown(result.output) + "\n")
} else {
spin.succeed("Done")
}

View File

@ -12,9 +12,9 @@ export async function action(branch: string, opts: { print?: boolean }) {
if (opts.print) {
spin.text = "Running review…"
const output = await vm.claude(session.worktree, { print: prompt })
const result = await vm.claude(session.worktree, { print: prompt })
spin.stop()
if (output) process.stdout.write(output + "\n")
if (result.output) process.stdout.write(result.output + "\n")
} else {
spin.succeed("Session ready")
await vm.claude(session.worktree, { prompt })

View File

@ -1,23 +1,24 @@
import { $ } from "bun"
import * as git from "../git.ts"
import { pager } from "../fmt.ts"
import { requireSession } from "./helpers.ts"
export async function action(branch: string) {
const { session } = await requireSession(branch)
const main = await git.mainBranch(session.worktree)
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`
process.stderr.write(`PROMPT: ${session.prompt}\n\n`)
}
output += result.text()
await pager(output)
const main = await git.mainBranch(session.worktree)
// Run git diff with inherited stdio so external diff tools (e.g. difftastic)
// see a real TTY and git can use its own pager
const proc = Bun.spawn(["git", "-C", session.worktree, "diff", `${main}...${branch}`], {
stdin: "inherit",
stdout: "inherit",
stderr: "inherit",
})
const exitCode = await proc.exited
if (exitCode !== 0) {
process.exit(exitCode)
}
}

View File

@ -53,7 +53,7 @@ export function register(program: Command) {
.description("Show VM system info (via neofetch)")
.action(async () => {
await vm.ensure()
await vm.info()
await vm.neofetch()
})
vmCmd

View File

@ -2,6 +2,7 @@ import { $ } from "bun"
import { homedir } from "os"
import { dirname, join } from "path"
import { getApiKey } from "./env.ts"
import { info } from "./fmt.ts"
const CONTAINER_NAME = "sandlot"
const USER = "ubuntu"
@ -201,7 +202,7 @@ export async function start(): Promise<void> {
const s = await status()
if (s === "running") return
if (s === "missing") throw new Error("Container does not exist. Use 'sandlot vm create' first.")
await $`container start ${CONTAINER_NAME}`.quiet()
await run($`container start ${CONTAINER_NAME}`, "Container start")
}
/** Ensure the sandlot container exists and is running. Creates and provisions on first use. */
@ -237,7 +238,7 @@ export async function status(): Promise<"running" | "stopped" | "missing"> {
}
/** Launch claude in the container at the given workdir. */
export async function claude(workdir: string, opts?: { prompt?: string; print?: string; continue?: boolean }): Promise<string | void> {
export async function claude(workdir: string, opts?: { prompt?: string; print?: string; continue?: boolean }): Promise<{ exitCode: number; output?: string }> {
const cwd = containerPath(workdir)
const systemPromptLines = [
"You are running inside a sandlot container (Apple Container, ubuntu:24.04).",
@ -260,12 +261,21 @@ export async function claude(workdir: string, opts?: { prompt?: string; print?:
if (opts?.print) {
const proc = Bun.spawn(args, { stdin: "inherit", stdout: "pipe", stderr: "inherit" })
const output = await new Response(proc.stdout).text()
await proc.exited
return output
const exitCode = await proc.exited
if (exitCode !== 0 && opts?.continue) {
info("Retrying without --continue")
return claude(workdir, { ...opts, continue: false })
}
return { exitCode, output }
}
const proc = Bun.spawn(args, { stdin: "inherit", stdout: "inherit", stderr: "inherit" })
await proc.exited
const exitCode = await proc.exited
if (exitCode !== 0 && opts?.continue) {
info("Retrying without --continue")
return claude(workdir, { ...opts, continue: false })
}
return { exitCode }
}
/** Open an interactive fish shell in the container, optionally in a specific directory. */
@ -278,7 +288,7 @@ export async function shell(workdir?: string): Promise<void> {
}
/** Run neofetch in the container. */
export async function info(): Promise<void> {
export async function neofetch(): Promise<void> {
const proc = Bun.spawn(
["container", "exec", "--user", USER, CONTAINER_NAME, "env", `PATH=${CONTAINER_PATH}`, "neofetch"],
{ stdin: "inherit", stdout: "inherit", stderr: "inherit" },