Compare commits

..

No commits in common. "b46511efe351fa75910c25fa40921dc08148dfa7" and "cfb9ed6172b049f1605c5da6b80f8921c4e61e15" have entirely different histories.

10 changed files with 127 additions and 124 deletions

View File

@ -5,7 +5,7 @@
"": { "": {
"name": "sandlot", "name": "sandlot",
"dependencies": { "dependencies": {
"commander": "^14.0.3", "commander": "^13.1.0",
}, },
"devDependencies": { "devDependencies": {
"@types/bun": "^1.3.9", "@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=="], "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@14.0.3", "", {}, "sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw=="], "commander": ["commander@13.1.0", "https://npm.nose.space/commander/-/commander-13.1.0.tgz", {}, "sha512-/rFeCpNJQbhSZjGVwO9RFV3xPqbnERS8MmIQzCtD/zl6gpJuV/bMLuN92oG3F7d8oDEHHRrujSXNUr8fpjntKw=="],
"undici-types": ["undici-types@5.26.5", "https://npm.nose.space/undici-types/-/undici-types-5.26.5.tgz", {}, "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA=="], "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" "sandlot": "./src/cli.ts"
}, },
"dependencies": { "dependencies": {
"commander": "^14.0.3" "commander": "^13.1.0"
}, },
"scripts": { "scripts": {
"test:markdown": "bun src/test-markdown.ts" "test:markdown": "bun src/test-markdown.ts"

View File

@ -1,7 +1,6 @@
#!/usr/bin/env bun #!/usr/bin/env bun
import { Command, Option } from "commander" import { Command } from "commander"
import { yellow, reset } from "./fmt.ts"
import * as git from "./git.ts" import * as git from "./git.ts"
import * as state from "./state.ts" import * as state from "./state.ts"
import { action as newAction } from "./commands/new.ts" import { action as newAction } from "./commands/new.ts"
@ -26,26 +25,9 @@ const pkg = await Bun.file(new URL("../package.json", import.meta.url)).json()
const program = new Command() const program = new Command()
program program.name("sandlot").description("Branch-based development with git worktrees and Apple Container").version(pkg.version)
.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)
})
// ── Sessions ──────────────────────────────────────────────────────── // ── sandlot new ──────────────────────────────────────────────────────
program
.command("list")
.description("Show all active sessions")
.option("--json", "Output as JSON")
.action(listAction)
program program
.command("new") .command("new")
@ -56,15 +38,44 @@ program
.description("Create a new session and launch Claude") .description("Create a new session and launch Claude")
.action(newAction) .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 program
.command("open") .command("open")
.argument("<branch>", "branch name") .argument("<branch>", "branch name")
.argument("[prompt]", "initial prompt for Claude") .argument("[prompt]", "initial prompt for Claude")
.option("-p, --print <prompt>", "run Claude in non-interactive mode with -p") .option("-p, --print <prompt>", "run Claude in non-interactive mode with -p")
.option("-n, --no-save", "skip auto-save after Claude exits") .option("-n, --no-save", "skip auto-save after Claude exits")
.description("Open an existing Claude session") .description("Re-enter an existing session")
.action(openAction) .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 program
.command("close") .command("close")
.argument("<branch>", "branch name") .argument("<branch>", "branch name")
@ -79,27 +90,31 @@ program
.description("Remove a session (alias for close)") .description("Remove a session (alias for close)")
.action((branch: string, opts: { force?: boolean }) => closeAction(branch, opts)) .action((branch: string, opts: { force?: boolean }) => closeAction(branch, opts))
// ── Branch ────────────────────────────────────────────────────────── // ── sandlot merge ────────────────────────────────────────────────────
program.commandsGroup("Branch Commands:")
program program
.command("diff") .command("merge")
.argument("<branch>", "branch name") .argument("<branch>", "branch name")
.description("Show uncommitted changes, or full branch diff vs main") .description("Merge a branch into main and close the session")
.action(diffAction) .action(mergeAction)
// ── sandlot squash ───────────────────────────────────────────────────
program program
.command("log") .command("squash")
.argument("<branch>", "branch name") .argument("<branch>", "branch name")
.description("Show commits on a branch that are not on main") .description("Squash-merge a branch into main and close the session")
.action(logAction) .action(squashAction)
// ── sandlot rebase ───────────────────────────────────────────────────
program program
.command("show") .command("rebase")
.argument("<branch>", "branch name") .argument("<branch>", "branch name")
.description("Show the prompt and full diff for a branch") .description("Rebase a branch onto the latest main")
.action(showAction) .action(rebaseAction)
// ── sandlot save ─────────────────────────────────────────────────────
program program
.command("save") .command("save")
@ -108,36 +123,31 @@ program
.description("Stage all changes and commit") .description("Stage all changes and commit")
.action(saveAction) .action(saveAction)
program // ── sandlot diff ─────────────────────────────────────────────────────
.command("merge")
.argument("<branch>", "branch name")
.description("Merge a branch into main and close the session")
.action(mergeAction)
program program
.command("squash") .command("diff")
.argument("<branch>", "branch name") .argument("<branch>", "branch name")
.description("Squash-merge a branch into main and close the session") .description("Show uncommitted changes, or full branch diff vs main")
.action(squashAction) .action(diffAction)
// ── sandlot show ─────────────────────────────────────────────────────
program program
.command("rebase") .command("show")
.argument("<branch>", "branch name") .argument("<branch>", "branch name")
.description("Rebase a branch onto the latest main") .description("Show the prompt and full diff for a branch (for code review)")
.action(rebaseAction) .action(showAction)
// ── sandlot log ──────────────────────────────────────────────────────
program program
.command("review") .command("log")
.argument("<branch>", "branch name") .argument("<branch>", "branch name")
.option("-p, --print", "print the review to stdout instead of launching interactive mode") .description("Show commits on a branch that are not on main")
.description("Launch an interactive grumpy code review for a branch") .action(logAction)
.action(reviewAction)
program // ── sandlot dir ──────────────────────────────────────────────────────
.command("shell")
.argument("[branch]", "branch name (omit for a plain VM shell)")
.description("Open a shell in the VM")
.action(shellAction)
program program
.command("dir") .command("dir")
@ -145,23 +155,18 @@ program
.description("Print the worktree path for a session") .description("Print the worktree path for a session")
.action(dirAction) .action(dirAction)
// ── Admin ─────────────────────────────────────────────────────────── // ── sandlot cleanup ──────────────────────────────────────────────────
program.commandsGroup("Admin Commands:")
program program
.command("cleanup") .command("cleanup")
.description("Remove stale sessions whose worktrees no longer exist") .description("Remove stale sessions whose worktrees no longer exist")
.action(cleanupAction) .action(cleanupAction)
// ── sandlot vm ───────────────────────────────────────────────────────
registerVmCommands(program) registerVmCommands(program)
program // ── sandlot completions ──────────────────────────────────────────────
.command("version")
.description("Print the version number")
.action(() => {
console.log(pkg.version)
})
program program
.command("completions") .command("completions")
@ -169,10 +174,17 @@ program
.description("Output fish shell completions") .description("Output fish shell completions")
.action((opts: { install?: boolean }) => completionsAction(program, opts)) .action((opts: { install?: boolean }) => completionsAction(program, opts))
// ── Default: `sandlot` → `sandlot list` ───────────────────────────── // ── Default: show list if sessions exist, otherwise help ─────────────
if (process.argv.length === 2) { 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") process.argv.push("list")
}
} catch {}
} }
program.parseAsync().catch((err) => { program.parseAsync().catch((err) => {

View File

@ -1,5 +1,6 @@
import { $ } from "bun" import { $ } from "bun"
import * as git from "../git.ts" import * as git from "../git.ts"
import { pager } from "../fmt.ts"
import { requireSession } from "./helpers.ts" import { requireSession } from "./helpers.ts"
export async function action(branch: string) { export async function action(branch: string) {
@ -12,26 +13,27 @@ export async function action(branch: string) {
process.exit(1) process.exit(1)
} }
let args: string[] let diff: string
if (status.text().trim().length > 0) { if (status.text().trim().length > 0) {
// Show uncommitted changes (both staged and unstaged) // Show uncommitted changes (both staged and unstaged)
const hasHead = await $`git -C ${session.worktree} rev-parse --verify HEAD`.nothrow().quiet() const result = await $`git -C ${session.worktree} diff --color=always HEAD`.nothrow().quiet()
args = hasHead.exitCode === 0 ? ["diff", "HEAD"] : ["diff"] 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 { } else {
// No uncommitted changes — show full branch diff vs main // No uncommitted changes — show full branch diff vs main
const main = await git.mainBranch(session.worktree) const main = await git.mainBranch(session.worktree)
args = ["diff", `${main}...${branch}`] 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()
} }
// Run git diff with inherited stdio so external diff tools (e.g. difftastic) await pager(diff)
// 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) { if (opts.print) {
spin.text = "Running prompt…" spin.text = "Running prompt…"
const result = await vm.claude(worktreeAbs, { prompt, print: opts.print }) const output = await vm.claude(worktreeAbs, { prompt, print: opts.print })
if (result.output) { if (output) {
spin.stop() spin.stop()
process.stdout.write(renderMarkdown(result.output) + "\n") process.stdout.write(renderMarkdown(output) + "\n")
} else { } else {
spin.succeed("Done") spin.succeed("Done")
} }

View File

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

View File

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

View File

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

View File

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

View File

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