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",
"dependencies": {
"commander": "^14.0.3",
"commander": "^13.1.0",
},
"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@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=="],
}

View File

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

View File

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

View File

@ -1,5 +1,6 @@
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) {
@ -12,26 +13,27 @@ export async function action(branch: string) {
process.exit(1)
}
let args: string[]
let diff: string
if (status.text().trim().length > 0) {
// Show uncommitted changes (both staged and unstaged)
const hasHead = await $`git -C ${session.worktree} rev-parse --verify HEAD`.nothrow().quiet()
args = hasHead.exitCode === 0 ? ["diff", "HEAD"] : ["diff"]
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(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)
// 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)
}
await pager(diff)
}

View File

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

View File

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

View File

@ -1,24 +1,23 @@
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)
if (session.prompt) {
process.stderr.write(`PROMPT: ${session.prompt}\n\n`)
}
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)
}
// 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)
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)")
.action(async () => {
await vm.ensure()
await vm.neofetch()
await vm.info()
})
vmCmd

View File

@ -2,7 +2,6 @@ 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"
@ -202,7 +201,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 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. */
@ -238,7 +237,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<{ 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 systemPromptLines = [
"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) {
const proc = Bun.spawn(args, { stdin: "inherit", stdout: "pipe", stderr: "inherit" })
const output = await new Response(proc.stdout).text()
const exitCode = await proc.exited
if (exitCode !== 0 && opts?.continue) {
info("Retrying without --continue")
return claude(workdir, { ...opts, continue: false })
}
return { exitCode, output }
await proc.exited
return output
}
const proc = Bun.spawn(args, { stdin: "inherit", stdout: "inherit", stderr: "inherit" })
const exitCode = await proc.exited
if (exitCode !== 0 && opts?.continue) {
info("Retrying without --continue")
return claude(workdir, { ...opts, continue: false })
}
return { exitCode }
await proc.exited
}
/** 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. */
export async function neofetch(): Promise<void> {
export async function info(): Promise<void> {
const proc = Bun.spawn(
["container", "exec", "--user", USER, CONTAINER_NAME, "env", `PATH=${CONTAINER_PATH}`, "neofetch"],
{ stdin: "inherit", stdout: "inherit", stderr: "inherit" },