Compare commits
11 Commits
cfb9ed6172
...
b46511efe3
| Author | SHA1 | Date | |
|---|---|---|---|
| b46511efe3 | |||
| 33e47a802c | |||
| 6114540764 | |||
| 6ec203e67f | |||
| 5b9998b138 | |||
| 2eba71ce87 | |||
| 2d0940f7c6 | |||
| 4cde0c2086 | |||
| 10ebfcf754 | |||
| c2cf0518e7 | |||
| 89eb5e382b |
4
bun.lock
4
bun.lock
|
|
@ -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=="],
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
144
src/cli.ts
144
src/cli.ts
|
|
@ -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) => {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 })
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
22
src/vm.ts
22
src/vm.ts
|
|
@ -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" },
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user