Compare commits

...

4 Commits

Author SHA1 Message Date
83787ab868 Add init and cd shell integration commands 2026-03-11 14:34:49 -07:00
4a465f7787 Improve review prompt 2026-03-11 14:09:47 -07:00
56742224a5 Revert "Add stale worktree detection to list command"
This reverts commit 003c42bc03.
2026-03-11 13:57:27 -07:00
ea2a3752fe 0.0.25 2026-03-11 13:31:31 -07:00
9 changed files with 178 additions and 33 deletions

View File

@ -11,7 +11,7 @@ function __sandlot_sessions
end end
# All top-level subcommands (used for __fish_seen_subcommand_from checks) # All top-level subcommands (used for __fish_seen_subcommand_from checks)
set -l __sandlot_cmds new list open review shell close rm merge save diff show log dir cleanup vm set -l __sandlot_cmds new list open review shell close rm merge save diff show log dir cd init cleanup vm
set -l __sandlot_vm_cmds create start shell status info stop destroy set -l __sandlot_vm_cmds create start shell status info stop destroy
# Disable file completions # Disable file completions
@ -31,12 +31,14 @@ complete -c sandlot -n "not __fish_seen_subcommand_from $__sandlot_cmds" -a diff
complete -c sandlot -n "not __fish_seen_subcommand_from $__sandlot_cmds" -a show -d 'Show prompt and diff for a branch' complete -c sandlot -n "not __fish_seen_subcommand_from $__sandlot_cmds" -a show -d 'Show prompt and diff for a branch'
complete -c sandlot -n "not __fish_seen_subcommand_from $__sandlot_cmds" -a log -d 'Show commits not on main' complete -c sandlot -n "not __fish_seen_subcommand_from $__sandlot_cmds" -a log -d 'Show commits not on main'
complete -c sandlot -n "not __fish_seen_subcommand_from $__sandlot_cmds" -a dir -d 'Print the worktree path' complete -c sandlot -n "not __fish_seen_subcommand_from $__sandlot_cmds" -a dir -d 'Print the worktree path'
complete -c sandlot -n "not __fish_seen_subcommand_from $__sandlot_cmds" -a cd -d 'Change to a branch worktree directory'
complete -c sandlot -n "not __fish_seen_subcommand_from $__sandlot_cmds" -a init -d 'Print shell init script'
complete -c sandlot -n "not __fish_seen_subcommand_from $__sandlot_cmds" -a cleanup -d 'Remove stale sessions' complete -c sandlot -n "not __fish_seen_subcommand_from $__sandlot_cmds" -a cleanup -d 'Remove stale sessions'
complete -c sandlot -n "not __fish_seen_subcommand_from $__sandlot_cmds" -a vm -d 'Manage the sandlot VM' complete -c sandlot -n "not __fish_seen_subcommand_from $__sandlot_cmds" -a vm -d 'Manage the sandlot VM'
# ── Session branch completions ─────────────────────────────────────── # ── Session branch completions ───────────────────────────────────────
for cmd in open review shell close rm merge save diff show log dir for cmd in open review shell close rm merge save diff show log dir cd
complete -c sandlot -n "__fish_seen_subcommand_from $cmd; and not __fish_seen_subcommand_from vm" -a '(__sandlot_sessions)' complete -c sandlot -n "__fish_seen_subcommand_from $cmd; and not __fish_seen_subcommand_from vm" -a '(__sandlot_sessions)'
end end
@ -71,5 +73,9 @@ complete -c sandlot -n "__fish_seen_subcommand_from vm; and not __fish_seen_subc
# ── Global options ─────────────────────────────────────────────────── # ── Global options ───────────────────────────────────────────────────
# ── init shell completions ───────────────────────────────────────
complete -c sandlot -n '__fish_seen_subcommand_from init' -a 'fish bash zsh' -d 'Shell type'
complete -c sandlot -s h -l help -d 'Show help' complete -c sandlot -s h -l help -d 'Show help'
complete -c sandlot -s V -l version -d 'Show version' complete -c sandlot -s V -l version -d 'Show version'

View File

@ -1,6 +1,6 @@
{ {
"name": "@because/sandlot", "name": "@because/sandlot",
"version": "0.0.24", "version": "0.0.25",
"description": "Sandboxed, branch-based development with Claude", "description": "Sandboxed, branch-based development with Claude",
"type": "module", "type": "module",
"bin": { "bin": {

View File

@ -24,6 +24,8 @@ import { action as editAction } from "./commands/edit.ts"
import { action as cleanupAction } from "./commands/cleanup.ts" import { action as cleanupAction } from "./commands/cleanup.ts"
import { register as registerVmCommands } from "./commands/vm.ts" import { register as registerVmCommands } from "./commands/vm.ts"
import { action as completionsAction } from "./commands/completions.ts" import { action as completionsAction } from "./commands/completions.ts"
import { action as initAction } from "./commands/init.ts"
import { action as cdAction } from "./commands/cd.ts"
const pkg = await Bun.file(new URL("../package.json", import.meta.url)).json() const pkg = await Bun.file(new URL("../package.json", import.meta.url)).json()
@ -172,6 +174,12 @@ program
.description("Print the worktree path for a session") .description("Print the worktree path for a session")
.action(dirAction) .action(dirAction)
program
.command("cd")
.argument("<branch>", "branch name")
.description("Change to a branch's worktree directory")
.action(cdAction)
// ── Admin ─────────────────────────────────────────────────────────── // ── Admin ───────────────────────────────────────────────────────────
program.commandsGroup("Admin Commands:") program.commandsGroup("Admin Commands:")
@ -208,6 +216,12 @@ 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))
program
.command("init")
.argument("<shell>", "shell type (fish, bash, zsh)")
.description("Print shell init script (eval in your shell config)")
.action((shell: string) => initAction(program, shell))
// ── Default: `sandlot` → `sandlot list` ───────────────────────────── // ── Default: `sandlot` → `sandlot list` ─────────────────────────────
if (process.argv.length === 2) { if (process.argv.length === 2) {

7
src/commands/cd.ts Normal file
View File

@ -0,0 +1,7 @@
import { die } from "../fmt.ts"
export function action(): never {
die(
`"sandlot cd" requires shell integration.\n\nAdd one of these to your shell config:\n\n Fish (~/.config/fish/config.fish):\n sandlot init fish | source\n\n Bash (~/.bashrc):\n eval "$(sandlot init bash)"\n\n Zsh (~/.zshrc):\n eval "$(sandlot init zsh)"`
)
}

View File

@ -1,22 +1,9 @@
import type { Command, Option } from "commander" import type { Command, Option } from "commander"
/** Generate fish completions dynamically from the Commander program tree. */ /** Generate fish completion lines from the Commander program tree. */
export function action(program: Command, opts: { install?: boolean } = {}) { export function generateFishCompletions(program: Command): string[] {
if (opts.install) {
const dest = "~/.config/fish/completions/sandlot.fish"
const lines = [
"#!/bin/sh",
`mkdir -p ~/.config/fish/completions`,
`sandlot completions > ${dest}`,
`echo "Installed fish completions to ${dest}"`,
]
process.stdout.write(lines.join("\n") + "\n")
return
}
const lines: string[] = [ const lines: string[] = [
"# Fish completions for sandlot (auto-generated)", "# Fish completions for sandlot (auto-generated)",
"# Install: sandlot completions > ~/.config/fish/completions/sandlot.fish",
"", "",
"complete -c sandlot -f", "complete -c sandlot -f",
"", "",
@ -65,6 +52,26 @@ export function action(program: Command, opts: { install?: boolean } = {}) {
} }
lines.push("") lines.push("")
return lines
}
/** Generate fish completions dynamically from the Commander program tree. */
export function action(program: Command, opts: { install?: boolean } = {}) {
if (opts.install) {
const dest = "~/.config/fish/completions/sandlot.fish"
const lines = [
"#!/bin/sh",
`mkdir -p ~/.config/fish/completions`,
`sandlot completions > ${dest}`,
`echo "Installed fish completions to ${dest}"`,
]
process.stdout.write(lines.join("\n") + "\n")
return
}
const lines = generateFishCompletions(program)
// Add install hint at the top
lines.splice(1, 0, "# Install: sandlot completions > ~/.config/fish/completions/sandlot.fish")
process.stdout.write(lines.join("\n") + "\n") process.stdout.write(lines.join("\n") + "\n")
} }

88
src/commands/init.ts Normal file
View File

@ -0,0 +1,88 @@
import type { Command } from "commander"
import { die } from "../fmt.ts"
import { generateFishCompletions } from "./completions.ts"
export function action(program: Command, shell: string) {
switch (shell) {
case "fish":
return emitFish(program)
case "bash":
return emitBash(program)
case "zsh":
return emitZsh(program)
default:
die(`Unsupported shell: ${shell}. Supported shells: fish, bash, zsh`)
}
}
function emitFish(program: Command) {
const lines: string[] = [
"function sandlot --wraps sandlot --description 'Sandlot CLI wrapper'",
" if test (count $argv) -ge 1; and test \"$argv[1]\" = cd",
" set -l dir (command sandlot dir $argv[2..])",
" and cd $dir",
" else",
" command sandlot $argv",
" end",
"end",
"",
...generateFishCompletions(program),
]
process.stdout.write(lines.join("\n") + "\n")
}
function emitBash(program: Command) {
const { subcommands, branchCommands } = collectCommands(program)
const lines: string[] = [
"sandlot() {",
' if [ "$#" -ge 1 ] && [ "$1" = "cd" ]; then',
" local dir",
' dir="$(command sandlot dir "${@:2}")" && cd "$dir"',
" else",
' command sandlot "$@"',
" fi",
"}",
"",
"_sandlot_completions() {",
' local cur prev',
' cur="${COMP_WORDS[COMP_CWORD]}"',
' prev="${COMP_WORDS[COMP_CWORD-1]}"',
"",
" if [ \"$COMP_CWORD\" -eq 1 ]; then",
` COMPREPLY=( $(compgen -W "${subcommands.join(" ")}" -- "$cur") )`,
" return",
" fi",
"",
` case "$prev" in`,
` ${branchCommands.join("|")})`,
' local branches',
' branches="$(command sandlot list --json 2>/dev/null | grep -o \'"branch": *"[^"]*"\' | sed \'s/.*"\\([^"]*\\)"$/\\1/\')"',
' COMPREPLY=( $(compgen -W "$branches" -- "$cur") )',
" return",
" ;;",
" esac",
"}",
"complete -F _sandlot_completions sandlot",
]
process.stdout.write(lines.join("\n") + "\n")
}
function emitZsh(program: Command) {
// Prepend bashcompinit setup, then emit the same bash script
process.stdout.write("autoload -Uz bashcompinit && bashcompinit\n")
emitBash(program)
}
function collectCommands(program: Command): { subcommands: string[]; branchCommands: string[] } {
const subcommands: string[] = []
const branchCommands: string[] = []
for (const cmd of program.commands) {
if ((cmd as any)._hidden) continue
subcommands.push(cmd.name())
const hasBranch = cmd.registeredArguments.some(a => a.name() === "branch")
if (hasBranch) branchCommands.push(cmd.name())
}
return { subcommands, branchCommands }
}

View File

@ -46,7 +46,6 @@ export async function action(opts: { json?: boolean }) {
// Determine status for each session in parallel // Determine status for each session in parallel
const statusEntries = await Promise.all( const statusEntries = await Promise.all(
sessions.map(async (s): Promise<[string, string]> => { sessions.map(async (s): Promise<[string, string]> => {
if (!(await git.isValidWorktree(s.worktree))) return [s.branch, "stale"]
if (await vm.isClaudeActive(s.worktree, s.branch)) return [s.branch, "active"] if (await vm.isClaudeActive(s.worktree, s.branch)) return [s.branch, "active"]
const dirty = await git.isDirty(s.worktree) const dirty = await git.isDirty(s.worktree)
if (dirty) return [s.branch, "dirty"] if (dirty) return [s.branch, "dirty"]
@ -62,8 +61,8 @@ export async function action(opts: { json?: boolean }) {
return return
} }
const icons: Record<string, string> = { idle: `${dim}${reset}`, active: `${cyan}${reset}`, dirty: `${yellow}${reset}`, saved: `${green}${reset}`, stale: `${red}${reset}` } const icons: Record<string, string> = { idle: `${dim}${reset}`, active: `${cyan}${reset}`, dirty: `${yellow}${reset}`, saved: `${green}${reset}` }
const branchColors: Record<string, string> = { idle: dim, active: cyan, dirty: yellow, saved: green, stale: red } const branchColors: Record<string, string> = { idle: dim, active: cyan, dirty: yellow, saved: green }
const branchWidth = Math.max(6, ...sessions.map((s) => s.branch.length)) const branchWidth = Math.max(6, ...sessions.map((s) => s.branch.length))
const cols = process.stdout.columns || 80 const cols = process.stdout.columns || 80
const prefixWidth = branchWidth + 4 const prefixWidth = branchWidth + 4
@ -71,7 +70,7 @@ export async function action(opts: { json?: boolean }) {
console.log(` ${dim}${"BRANCH".padEnd(branchWidth)} PROMPT${reset}`) console.log(` ${dim}${"BRANCH".padEnd(branchWidth)} PROMPT${reset}`)
for (const s of sessions) { for (const s of sessions) {
const prompt = status === "stale" ? "broken worktree — close with -f to clean up" : (s.prompt ?? "").split("\n")[0] const prompt = (s.prompt ?? "").split("\n")[0]
const status = statuses[s.branch] const status = statuses[s.branch]
const icon = icons[status] const icon = icons[status]
const bc = branchColors[status] const bc = branchColors[status]
@ -80,9 +79,7 @@ export async function action(opts: { json?: boolean }) {
console.log(`${icon} ${bc}${s.branch.padEnd(branchWidth)}${reset} ${dim}${truncated}${reset}`) console.log(`${icon} ${bc}${s.branch.padEnd(branchWidth)}${reset} ${dim}${truncated}${reset}`)
} }
const hasStale = Object.values(statuses).includes("stale") console.log(`\n${dim}◯ idle${reset} · ${cyan}◎ active${reset} · ${yellow}◐ unsaved${reset} · ${green}● saved${reset}`)
const legend = `${dim}◯ idle${reset} · ${cyan}◎ active${reset} · ${yellow}◐ unsaved${reset} · ${green}● saved${reset}`
console.log(`\n${legend}${hasStale ? ` · ${red}✖ stale${reset} (run ${dim}sandlot close -f <branch>${reset} to clean up)` : ""}`)
if ((await vm.status()) !== "running") { if ((await vm.status()) !== "running") {
console.log(`\n${red}VM is not running.${reset}`) console.log(`\n${red}VM is not running.${reset}`)

View File

@ -29,6 +29,39 @@ Description of problem.
Tell each agent: Run \`git diff main...HEAD\` and focus on the "+" lines, not the "-" lines. Tell each agent: Run \`git diff main...HEAD\` and focus on the "+" lines, not the "-" lines.
Once the agents are done, look at all their suggestions and let me know what you think. Once the agents are done, look at all their suggestions and let me know what you think.
Give me your opinion in this format, with the :
<grumpySeniorDevResponse>
# {branch name} Review
**OK TO SHIP: yes or no**
# Showstoppers
1. BUG: Bug that both bug hunters found.
2. BUG: Bug that one of the hunters found.
3. COMPLIANCE: Describe CLAUDE.md compliance issue.
# Recommendations
4. BUG: Bug that both bug hunters found.
5. BUG: Bug that one of the hunters found.
6. COMPLIANCE: Describe CLAUDE.md compliance issue.
7. SIMPLIFY: Opportunities to simplify code.
# Optional
8. BUG: Bug that both bug hunters found.
9. BUG: Bug that one of the hunters found.
10. COMPLIANCE: Describe CLAUDE.md compliance issue.
11. SIMPLIFY: Opportunities to simplify code.
# Summary
Your thoughts, in brief.
</grumpySeniorDevResponse>
` `
if (extra) prompt += "\n\n" + extra if (extra) prompt += "\n\n" + extra

View File

@ -189,12 +189,6 @@ export async function rebaseAbort(cwd: string): Promise<void> {
} }
/** Check if a worktree has uncommitted changes. */ /** Check if a worktree has uncommitted changes. */
/** Check if a worktree path is a valid git directory. */
export async function isValidWorktree(worktreePath: string): Promise<boolean> {
const result = await $`git -C ${worktreePath} rev-parse --git-dir`.nothrow().quiet()
return result.exitCode === 0
}
export async function isDirty(worktreePath: string): Promise<boolean> { export async function isDirty(worktreePath: string): Promise<boolean> {
const result = await $`git -C ${worktreePath} status --porcelain`.nothrow().quiet() const result = await $`git -C ${worktreePath} status --porcelain`.nothrow().quiet()
if (result.exitCode !== 0) return false if (result.exitCode !== 0) return false
@ -233,8 +227,7 @@ export async function fileDiff(ref1: string, ref2: string, file: string, cwd: st
/** Check if a branch has commits beyond main. */ /** Check if a branch has commits beyond main. */
export async function hasNewCommits(worktreePath: string): Promise<boolean> { export async function hasNewCommits(worktreePath: string): Promise<boolean> {
let main: string const main = await mainBranch(worktreePath)
try { main = await mainBranch(worktreePath) } catch { return false }
const result = await $`git -C ${worktreePath} rev-list ${main}..HEAD --count`.nothrow().quiet() const result = await $`git -C ${worktreePath} rev-list ${main}..HEAD --count`.nothrow().quiet()
if (result.exitCode !== 0) return false if (result.exitCode !== 0) return false
return parseInt(result.text().trim(), 10) > 0 return parseInt(result.text().trim(), 10) > 0