diff --git a/REVIEW.md b/REVIEW.md new file mode 100644 index 0000000..7815e7d --- /dev/null +++ b/REVIEW.md @@ -0,0 +1,156 @@ +# Code Review + +## Overall Impression + +This is a well-scoped project with a clear mental model. The module boundaries are sensible, the dependency footprint is minimal, and the functional (no classes) approach fits the problem well. The codebase reads quickly, which is valuable. + +That said, there are structural patterns that will become friction points as you add commands or change behavior. Most of the feedback here is about **extracting repeated patterns** and **reducing the surface area of cli.ts**. + +--- + +## 1. cli.ts is carrying too much weight + +At ~820 lines, `cli.ts` is the entry point, the command registry, and the implementation of every command handler. That's three jobs. Each command handler is 20-60 lines of real logic — session lookup, spinner management, error handling, VM orchestration — all inline. + +Right now there are 12+ commands. Adding a 13th means appending more logic to a file that's already the longest in the project. There's no natural place to put "command-adjacent" helpers either, so things like `saveChanges`, `branchFromPrompt`, and `fallbackBranchName` just float in the file above the command they serve. + +Consider a `commands/` directory where each command (or logical group) is its own file exporting an action function. `cli.ts` becomes just the Commander wiring — argument definitions, descriptions, and `.action(handler)` calls. This makes each command independently readable and testable. + +--- + +## 2. Repeated session-lookup boilerplate + +This exact pattern appears in **8 commands** (open, review, shell, save, diff, show, log, dir): + +```typescript +const root = await git.repoRoot() +const session = await state.getSession(root, branch) +if (!session) { + console.error(`✖ No session found for branch "${branch}".`) + process.exit(1) +} +``` + +A helper like `requireSession(branch): Promise<{ root: string; session: Session }>` would cut four lines per command and give you a single place to improve the error message later (e.g., suggesting `sandlot list` to see available sessions). + +--- + +## 3. Duplicated "pipe through temp file into container" pattern + +Both `saveChanges` (line 44-51 of cli.ts) and the merge conflict resolver (line 447-455) follow the same flow: + +1. Write content to a temp file under `~/.sandlot/` +2. `cat` it into `claude -p` inside the container +3. Capture output +4. Delete the temp file + +This should be a single helper in `vm.ts`, something like: + +```typescript +export async function claudePipe(input: string, prompt: string): Promise +``` + +The current approach is fragile — each call site has to remember to clean up the temp file, choose a temp path that won't collide, and handle errors. Centralizing this also makes it easier to switch to stdin piping later. + +--- + +## 4. API key parsing is duplicated + +`branchFromPrompt()` in cli.ts (lines 87-92) and `create()` in vm.ts (lines 83-88) both independently read `~/.env` and parse `ANTHROPIC_API_KEY` with the same regex. If you ever change the env file location or key format, you need to find both. + +Extract this to a shared utility — maybe in a `env.ts` or even just a function in `config.ts`, which is currently underutilized. + +--- + +## 5. config.ts is dead code + +`config.ts` defines a config schema and loader, but nothing imports it. `vm.ts` hardcodes `-m 4G` and `ubuntu:24.04`. This is confusing for someone reading the codebase — it looks like configuration is supported but it silently isn't. + +Either wire it in or remove it. Dead code that looks intentional is worse than no code at all, because it misleads people about how the system works. + +--- + +## 6. Pager logic is duplicated + +The `diff` and `show` commands both have identical pager-fallback logic (lines 551-560 and 590-599): + +```typescript +const lines = output.split("\n").length +const termHeight = process.stdout.rows || 24 +if (lines > termHeight) { + const pager = Bun.spawn(["less", "-R"], { ... }) + // ... +} +``` + +This is a natural utility function: `pipeToPagedOutput(content: string)`. + +--- + +## 7. ANSI codes are scattered and ad-hoc + +The `list` command defines its own color constants inline (lines 255-261). The spinner uses raw escape codes. `markdown.ts` uses them too. The error/success icons (`✖`, `✔`, `◆`) are hardcoded at each call site. + +A small `fmt.ts` or extending `spinner.ts` into a more general terminal output module would centralize this. You don't need a full library — just a handful of named helpers like `fmt.dim()`, `fmt.green()`, `fmt.success()`, `fmt.error()`. This also makes it trivial to add `--no-color` support later. + +--- + +## 8. `git.branchExists()` has a surprising side effect + +This function is named like a pure query, but it calls `git fetch origin` on every invocation (line 29). That means every `sandlot new` makes a network round-trip, which could be slow or fail on spotty connections. The caller (`createWorktree`) has no indication this will happen. + +At minimum, document this. Ideally, separate the fetch from the check, or accept a `fetch?: boolean` option. + +--- + +## 9. `vm.create()` is doing too many things + +This ~90-line function handles: container creation, apt package installation, host symlink setup, Bun installation, Claude Code installation, git config, API key provisioning, activity hook installation, and settings file creation. + +Breaking this into named phases (even just private helper functions within `vm.ts`) would make it easier to debug provisioning failures and to modify individual steps. Something like: + +``` +createContainer() → installPackages() → installTooling() → configureEnvironment() +``` + +--- + +## 10. State file has no concurrency protection + +`state.ts` does load → modify → save with no locking. If a user runs `sandlot new foo` and `sandlot close bar` at the same time, one write can clobber the other. For a CLI tool this is unlikely but not impossible — especially since `sandlot new` is long-running (it starts a container, launches Claude, and then saves on exit). + +A simple approach: use an atomic write pattern (write to `.state.json.tmp`, then rename). That at least prevents partial writes. Full advisory locking is probably overkill. + +--- + +## 11. Inconsistent error presentation + +Comparing error output across commands: + +- `new`: `✖ Branch name or prompt is required.` +- `show`: `No session found for branch "${branch}".` (no icon) +- `log`: `✖ git log failed` (lowercase, terse) +- `vm start`: `✖ ${(err as Error).message ?? err}` (raw error message) + +A consistent `die(message)` or `fatal(message)` function that always formats the same way would clean this up. + +--- + +## 12. The fish completions string will rot + +The ~55-line hardcoded fish completions block (lines 746-801) needs to be manually updated whenever a command, option, or subcommand changes. It's easy to forget. + +You could generate this from Commander's own command tree at runtime — Commander exposes the registered commands and options. This way completions are always in sync. + +--- + +## Summary of Suggested Extractions + +| New module | What moves there | +|---|---| +| `commands/*.ts` | Individual command handlers out of cli.ts | +| `fmt.ts` | ANSI helpers, `die()`, `success()`, pager | +| `env.ts` (or extend `config.ts`) | API key reading, env file parsing | +| `vm.ts` internal helpers | Break `create()` into phases; add `claudePipe()` | + +The architecture is sound. These are refinements to make the codebase match the quality of the design. The core module split (`git` / `vm` / `state`) is right — it's the glue layer in `cli.ts` that needs the most attention. diff --git a/src/cli.ts b/src/cli.ts index 74c4269..9c39638 100755 --- a/src/cli.ts +++ b/src/cli.ts @@ -1,16 +1,23 @@ #!/usr/bin/env bun import { Command } from "commander" -import { $} from "bun" -import { basename, join } from "path" -import { homedir } from "os" -import { existsSync } from "fs" -import { mkdir, symlink, unlink } from "fs/promises" import * as git from "./git.ts" -import * as vm from "./vm.ts" import * as state from "./state.ts" -import { spinner } from "./spinner.ts" -import { renderMarkdown } from "./markdown.ts" +import { action as newAction } from "./commands/new.ts" +import { action as listAction } from "./commands/list.ts" +import { action as openAction } from "./commands/open.ts" +import { action as reviewAction } from "./commands/review.ts" +import { action as shellAction } from "./commands/shell.ts" +import { closeAction } from "./commands/close.ts" +import { action as mergeAction } from "./commands/merge.ts" +import { action as saveAction } from "./commands/save.ts" +import { action as diffAction } from "./commands/diff.ts" +import { action as showAction } from "./commands/show.ts" +import { action as logAction } from "./commands/log.ts" +import { action as dirAction } from "./commands/dir.ts" +import { action as cleanupAction } from "./commands/cleanup.ts" +import { register as registerVmCommands } from "./commands/vm.ts" +import { action as completionsAction } from "./commands/completions.ts" const pkg = await Bun.file(new URL("../package.json", import.meta.url)).json() @@ -18,109 +25,7 @@ const program = new Command() program.name("sandlot").description("Branch-based development with git worktrees and Apple Container").version(pkg.version) -// ── save helper ───────────────────────────────────────────────────── - -/** Stage all changes, generate a commit message, and commit. Returns true on success. */ -async function saveChanges(worktree: string, branch: string, message?: string): Promise { - const spin = spinner("Staging changes", branch) - - await $`git -C ${worktree} add .`.nothrow().quiet() - - const check = await $`git -C ${worktree} diff --staged --quiet`.nothrow().quiet() - if (check.exitCode === 0) { - spin.fail("No changes to commit") - return false - } - - let msg: string - if (message) { - msg = message - } else { - spin.text = "Starting container" - await vm.ensure((m) => { spin.text = m }) - - spin.text = "Generating commit message" - const diff = await $`git -C ${worktree} diff --staged`.nothrow().quiet().text() - const tmpPath = join(homedir(), '.sandlot', '.sandlot-diff-tmp') - await Bun.write(tmpPath, diff) - - const gen = await vm.exec( - join(homedir(), '.sandlot'), - 'cat /sandlot/.sandlot-diff-tmp | claude -p "write a short commit message summarizing these changes. output only the message, no quotes or extra text"', - ) - await Bun.file(tmpPath).unlink().catch(() => {}) - - if (gen.exitCode !== 0) { - spin.fail("Failed to generate commit message") - if (gen.stderr) console.error(gen.stderr) - return false - } - msg = gen.stdout - } - - spin.text = "Committing" - const commit = await $`git -C ${worktree} commit -m ${msg}`.nothrow().quiet() - if (commit.exitCode !== 0) { - spin.fail("Commit failed") - if (commit.stderr) console.error(commit.stderr.toString().trim()) - return false - } - - spin.succeed(`Saved: ${msg}`) - return true -} - -// ── sandlot new ────────────────────────────────────────────── - -function fallbackBranchName(text: string): string { - return text - .toLowerCase() - .replace(/[^a-z0-9\s-]/g, "") - .trim() - .split(/\s+/) - .slice(0, 2) - .join("-") -} - -async function branchFromPrompt(text: string): Promise { - // Read API key from ~/.env - const envFile = Bun.file(join(homedir(), ".env")) - if (!(await envFile.exists())) return fallbackBranchName(text) - - const envContent = await envFile.text() - const apiKey = envContent.match(/^(?:export\s+)?ANTHROPIC_API_KEY=["']?([^"'\s]+)["']?/m)?.[1] - if (!apiKey) return fallbackBranchName(text) - - try { - const res = await fetch("https://api.anthropic.com/v1/messages", { - method: "POST", - headers: { - "content-type": "application/json", - "x-api-key": apiKey, - "anthropic-version": "2023-06-01", - }, - body: JSON.stringify({ - model: "claude-haiku-4-5-20251001", - max_tokens: 15, - temperature: 0, - messages: [{ role: "user", content: `Generate a 2-word git branch name (lowercase, hyphen-separated) for this task:\n\n${text}\n\nOutput ONLY the branch name, nothing else.` }], - }), - }) - - if (!res.ok) return fallbackBranchName(text) - - const body = await res.json() as any - const name = body.content?.[0]?.text?.trim() - .toLowerCase() - .replace(/[^a-z0-9-]/g, "-") - .replace(/-+/g, "-") - .replace(/^-|-$/g, "") - - return name && name.length > 0 && name.length <= 50 ? name : fallbackBranchName(text) - } catch { - return fallbackBranchName(text) - } -} +// ── sandlot new ────────────────────────────────────────────────────── program .command("new") @@ -129,159 +34,17 @@ program .option("-p, --print ", "run Claude in non-interactive mode with -p") .option("-n, --no-save", "skip auto-save after Claude exits") .description("Create a new session and launch Claude") - .action(async (branch: string | undefined, prompt: string | undefined, opts: { print?: string; save?: boolean }) => { - // No branch given — derive from -p prompt - if (!branch && opts.print) { - branch = await branchFromPrompt(opts.print) - } else if (!branch) { - console.error("✖ Branch name or prompt is required.") - process.exit(1) - } else if (branch.includes(" ")) { - // If the "branch" contains spaces, it's actually a prompt — derive the branch name - prompt = branch - branch = await branchFromPrompt(branch) - } - const root = await git.repoRoot() - const worktreeAbs = join(homedir(), '.sandlot', basename(root), branch) + .action(newAction) - const existing = await state.getSession(root, branch) - if (existing) { - console.error(`✖ Session "${branch}" already exists. Use "sandlot open ${branch}" to re-enter it.`) - process.exit(1) - } - - const spin = spinner("Creating worktree", branch) - try { - await git.createWorktree(branch, worktreeAbs, root) - await mkdir(join(root, '.sandlot'), { recursive: true }) - await symlink(worktreeAbs, join(root, '.sandlot', branch)) - - spin.text = "Starting container" - await vm.ensure((msg) => { spin.text = msg }) - if (!opts.print) spin.succeed("Session ready") - } catch (err) { - spin.fail(String((err as Error).message ?? err)) - await git.removeWorktree(worktreeAbs, root).catch(() => {}) - await git.deleteLocalBranch(branch, root).catch(() => {}) - await unlink(join(root, '.sandlot', branch)).catch(() => {}) - process.exit(1) - } - - const effectivePrompt = opts.print || prompt - await state.setSession(root, { - branch, - worktree: worktreeAbs, - created_at: new Date().toISOString(), - ...(effectivePrompt ? { prompt: effectivePrompt } : {}), - }) - - if (opts.print) { - spin.text = "Running prompt…" - const output = await vm.claude(worktreeAbs, { prompt, print: opts.print }) - if (output) { - spin.stop() - process.stdout.write(renderMarkdown(output) + "\n") - } else { - spin.succeed("Done") - } - } else { - await vm.claude(worktreeAbs, { prompt, print: opts.print }) - } - - if (opts.save !== false) await saveChanges(worktreeAbs, branch) - }) - -// ── sandlot list ────────────────────────────────────────────────────── +// ── sandlot list ───────────────────────────────────────────────────── program .command("list") .description("Show all active sessions (◌ idle · ◯ working · ◎ unsaved · ● saved)") .option("--json", "Output as JSON") - .action(async (opts: { json?: boolean }) => { - const root = await git.repoRoot() - const st = await state.load(root) - const sessions = Object.values(st.sessions) + .action(listAction) - // Discover prompts from Claude history for sessions that lack one - const needsPrompt = sessions.filter(s => !s.prompt) - if (needsPrompt.length > 0 && (await vm.status()) === "running") { - try { - const result = await vm.exec(homedir() + "/.sandlot", "cat /home/ubuntu/.claude/history.jsonl 2>/dev/null") - if (result.exitCode === 0 && result.stdout) { - const entries = result.stdout.split("\n").filter(Boolean).map(line => { - try { return JSON.parse(line) } catch { return null } - }).filter(Boolean) - - for (const s of needsPrompt) { - const cPath = vm.containerPath(s.worktree) - const match = entries.find((e: any) => e.project === cPath) - if (match?.display) { - s.prompt = match.display - } - } - } - } catch {} - } - - if (opts.json) { - console.log(JSON.stringify(sessions, null, 2)) - return - } - - if (sessions.length === 0) { - if (opts.json) console.log("[]") - else console.log("◆ No active sessions.") - return - } - - // Determine status for each session in parallel - const statusEntries = await Promise.all( - sessions.map(async (s): Promise<[string, string]> => { - if (await vm.isClaudeActive(s.worktree, s.branch)) return [s.branch, "active"] - const dirty = await git.isDirty(s.worktree) - if (dirty) return [s.branch, "dirty"] - const commits = await git.hasNewCommits(s.worktree) - return [s.branch, commits ? "saved" : "idle"] - }) - ) - const statuses = Object.fromEntries(statusEntries) - - if (opts.json) { - const withStatus = sessions.map(s => ({ ...s, status: statuses[s.branch] })) - console.log(JSON.stringify(withStatus, null, 2)) - return - } - - const reset = "\x1b[0m" - const dim = "\x1b[2m" - const bold = "\x1b[1m" - const green = "\x1b[32m" - const yellow = "\x1b[33m" - const cyan = "\x1b[36m" - const white = "\x1b[37m" - - const icons: Record = { idle: `${dim}◌${reset}`, active: `${cyan}◯${reset}`, dirty: `${yellow}◎${reset}`, saved: `${green}●${reset}` } - const branchColors: Record = { idle: dim, active: cyan, dirty: yellow, saved: green } - const branchWidth = Math.max(6, ...sessions.map((s) => s.branch.length)) - const cols = process.stdout.columns || 80 - const prefixWidth = branchWidth + 4 - - console.log(` ${dim}${"BRANCH".padEnd(branchWidth)} PROMPT${reset}`) - - for (const s of sessions) { - const prompt = s.prompt ?? "" - const status = statuses[s.branch] - const icon = icons[status] - const bc = branchColors[status] - const maxPrompt = cols - prefixWidth - const truncated = maxPrompt > 3 && prompt.length > maxPrompt ? prompt.slice(0, maxPrompt - 3) + "..." : prompt - console.log(`${icon} ${bc}${s.branch.padEnd(branchWidth)}${reset} ${dim}${truncated}${reset}`) - } - - console.log(`\n${dim}◌ idle${reset} · ${cyan}◯ active${reset} · ${yellow}◎ unsaved${reset} · ${green}● saved${reset}`) - }) - -// ── sandlot open ───────────────────────────────────────────── +// ── sandlot open ───────────────────────────────────────────────────── program .command("open") @@ -290,188 +53,26 @@ program .option("-p, --print ", "run Claude in non-interactive mode with -p") .option("-n, --no-save", "skip auto-save after Claude exits") .description("Re-enter an existing session") - .action(async (branch: string, prompt: string | undefined, opts: { print?: string; save?: boolean }) => { - const root = await git.repoRoot() - const session = await state.getSession(root, branch) + .action(openAction) - if (!session) { - console.error(`✖ No session found for branch "${branch}".`) - process.exit(1) - } - - const effectivePrompt = opts.print || prompt - if (effectivePrompt) { - await state.setSession(root, { ...session, prompt: effectivePrompt }) - } - - const spin = spinner("Starting container", branch) - await vm.ensure((msg) => { spin.text = msg }) - - if (opts.print) { - spin.text = "Running prompt…" - const output = await vm.claude(session.worktree, { prompt, print: opts.print }) - if (output) { - spin.stop() - process.stdout.write(renderMarkdown(output) + "\n") - } else { - spin.succeed("Done") - } - } else { - spin.succeed("Session ready") - await vm.claude(session.worktree, { prompt, print: opts.print }) - } - - if (opts.save !== false) await saveChanges(session.worktree, branch) - }) - -// ── sandlot review ────────────────────────────────────────── +// ── sandlot review ─────────────────────────────────────────────────── program .command("review") .argument("", "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(async (branch: string, opts: { print?: boolean }) => { - const root = await git.repoRoot() - const session = await state.getSession(root, branch) + .action(reviewAction) - if (!session) { - console.error(`✖ No session found for branch "${branch}".`) - process.exit(1) - } - - const spin = spinner("Starting container", branch) - await vm.ensure((msg) => { spin.text = msg }) - - const prompt = "You're a grumpy old senior software engineer. Take a look at the diff between this branch and main, then let me know your thoughts. My co-worker made these changes." - - if (opts.print) { - spin.text = "Running review…" - const output = await vm.claude(session.worktree, { print: prompt }) - spin.stop() - if (output) process.stdout.write(output + "\n") - } else { - spin.succeed("Session ready") - await vm.claude(session.worktree, { prompt }) - } - }) - -// ── sandlot shell ─────────────────────────────────────────── +// ── 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(async (branch?: string) => { - if (!branch) { - await vm.ensure() - await vm.shell() - return - } + .action(shellAction) - const root = await git.repoRoot() - const session = await state.getSession(root, branch) - if (!session) { - console.error(`✖ No session found for branch "${branch}".`) - process.exit(1) - } - - await vm.ensure() - await vm.shell(session.worktree) - }) - -// ── sandlot close ─────────────────────────────────────────── - -const closeAction = async (branch: string, opts: { force?: boolean } = {}) => { - const root = await git.repoRoot() - const session = await state.getSession(root, branch) - const worktreeAbs = session?.worktree ?? join(homedir(), '.sandlot', basename(root), branch) - - if (!opts.force && session && await git.isDirty(worktreeAbs)) { - console.error(`✖ Branch "${branch}" has unsaved changes. Run "sandlot save ${branch}" first, or use -f to force.`) - process.exit(1) - } - - await vm.clearActivity(worktreeAbs, branch) - - await git.removeWorktree(worktreeAbs, root) - .catch((e) => console.warn(`⚠ Failed to remove worktree: ${e.message}`)) - - await unlink(join(root, '.sandlot', branch)) - .catch(() => {}) // symlink may not exist - - await git.deleteLocalBranch(branch, root) - .catch((e) => console.warn(`⚠ Failed to delete branch ${branch}: ${e.message}`)) - - if (session) { - await state.removeSession(root, branch) - } - - console.log(`✔ Closed session ${branch}`) -} - -// ── sandlot merge ────────────────────────────────────────── - -program - .command("merge") - .argument("", "branch name") - .description("Merge a branch into main and close the session") - .action(async (branch: string) => { - const root = await git.repoRoot() - const session = await state.getSession(root, branch) - - if (session && await git.isDirty(session.worktree)) { - console.error(`✖ Branch "${branch}" has unsaved changes. Run "sandlot save ${branch}" first.`) - process.exit(1) - } - - const conflicts = await git.merge(branch, root) - - if (conflicts.length === 0) { - console.log(`✔ Merged ${branch} into current branch`) - await closeAction(branch) - return - } - - // Resolve conflicts with Claude - console.log(`◆ Merge conflicts in ${conflicts.length} file(s). Resolving with Claude...`) - const spin = spinner("Starting container", branch) - - try { - await vm.ensure((msg) => { spin.text = msg }) - - for (const file of conflicts) { - spin.text = `Resolving ${file}` - const content = await Bun.file(join(root, file)).text() - - const tmpPath = join(homedir(), '.sandlot', '.conflict-tmp') - await Bun.write(tmpPath, content) - - const resolved = await vm.exec( - join(homedir(), '.sandlot'), - 'cat /sandlot/.conflict-tmp | claude -p "resolve this merge conflict. output ONLY the resolved file content with no markdown fences, no explanation, no surrounding text."', - ) - - await Bun.file(tmpPath).unlink().catch(() => {}) - - if (resolved.exitCode !== 0) { - throw new Error(`Claude failed to resolve ${file}: ${resolved.stderr}`) - } - - await Bun.write(join(root, file), resolved.stdout + "\n") - await git.stageFile(file, root) - } - - await git.commitMerge(root) - spin.succeed(`Resolved ${conflicts.length} conflict(s) and merged ${branch}`) - } catch (err) { - spin.fail(String((err as Error).message ?? err)) - await git.abortMerge(root) - process.exit(1) - } - - await closeAction(branch) - }) +// ── sandlot close ──────────────────────────────────────────────────── program .command("close") @@ -486,322 +87,75 @@ program .option("-f, --force", "close even if there are unsaved changes") .action((branch: string, opts: { force?: boolean }) => closeAction(branch, opts)) -// ── sandlot save ─────────────────────────────────────────── +// ── sandlot merge ──────────────────────────────────────────────────── + +program + .command("merge") + .argument("", "branch name") + .description("Merge a branch into main and close the session") + .action(mergeAction) + +// ── sandlot save ───────────────────────────────────────────────────── program .command("save") .argument("", "branch name") .argument("[message]", "commit message (AI-generated if omitted)") .description("Stage all changes and commit") - .action(async (branch: string, message?: string) => { - const root = await git.repoRoot() - const session = await state.getSession(root, branch) - if (!session) { - console.error(`✖ No session found for branch "${branch}".`) - process.exit(1) - } + .action(saveAction) - const ok = await saveChanges(session.worktree, branch, message) - if (!ok) process.exit(1) - }) - -// ── sandlot diff ─────────────────────────────────────────── +// ── sandlot diff ───────────────────────────────────────────────────── program .command("diff") .argument("", "branch name") .description("Show uncommitted changes, or full branch diff vs main") - .action(async (branch: string) => { - const root = await git.repoRoot() - const session = await state.getSession(root, branch) - if (!session) { - console.error(`✖ No session found for branch "${branch}".`) - process.exit(1) - } + .action(diffAction) - // Check for uncommitted changes (staged + unstaged) - const status = await $`git -C ${session.worktree} status --porcelain`.nothrow().quiet() - if (status.exitCode !== 0) { - console.error("✖ git status failed") - process.exit(1) - } - - let diff: 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() - } - } else { - // No uncommitted changes — show full branch diff vs main - const main = await git.mainBranch(root) - 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() - } - - const lines = diff.split("\n").length - const termHeight = process.stdout.rows || 24 - if (lines > termHeight) { - const pager = Bun.spawn(["less", "-R"], { stdin: "pipe", stdout: "inherit", stderr: "inherit" }) - pager.stdin.write(diff) - pager.stdin.end() - await pager.exited - } else { - process.stdout.write(diff) - } - }) - -// ── sandlot show ─────────────────────────────────────────── +// ── sandlot show ───────────────────────────────────────────────────── program .command("show") .argument("", "branch name") .description("Show the prompt and full diff for a branch (for code review)") - .action(async (branch: string) => { - const root = await git.repoRoot() - const session = await state.getSession(root, branch) - if (!session) { - console.error(`No session found for branch "${branch}".`) - process.exit(1) - } + .action(showAction) - const main = await git.mainBranch(root) - 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` - } - output += result.text() - - const lines = output.split("\n").length - const termHeight = process.stdout.rows || 24 - if (lines > termHeight) { - const pager = Bun.spawn(["less", "-R"], { stdin: "pipe", stdout: "inherit", stderr: "inherit" }) - pager.stdin.write(output) - pager.stdin.end() - await pager.exited - } else { - process.stdout.write(output) - } - }) - -// ── sandlot log ──────────────────────────────────────────── +// ── sandlot log ────────────────────────────────────────────────────── program .command("log") .argument("", "branch name") .description("Show commits on a branch that are not on main") - .action(async (branch: string) => { - const root = await git.repoRoot() - const session = await state.getSession(root, branch) - if (!session) { - console.error(`✖ No session found for branch "${branch}".`) - process.exit(1) - } + .action(logAction) - const result = await $`git -C ${session.worktree} log main..HEAD`.nothrow() - if (result.exitCode !== 0) { - console.error("✖ git log failed") - process.exit(1) - } - }) - -// ── sandlot dir ──────────────────────────────────────────── +// ── sandlot dir ────────────────────────────────────────────────────── program .command("dir") .argument("", "branch name") .description("Print the worktree path for a session") - .action(async (branch: string) => { - const root = await git.repoRoot() - const session = await state.getSession(root, branch) - if (!session) { - console.error(`✖ No session found for branch "${branch}".`) - process.exit(1) - } + .action(dirAction) - process.stdout.write(session.worktree + "\n") - }) - -// ── sandlot cleanup ───────────────────────────────────────────────── +// ── sandlot cleanup ────────────────────────────────────────────────── program .command("cleanup") .description("Remove stale sessions whose worktrees no longer exist") - .action(async () => { - const root = await git.repoRoot() - const st = await state.load(root) - const sessions = Object.values(st.sessions) - - if (sessions.length === 0) { - console.log("No sessions to clean up.") - return - } - - const stale = sessions.filter(s => !existsSync(s.worktree)) - - if (stale.length === 0) { - console.log("No stale sessions found.") - return - } - - for (const s of stale) { - await state.removeSession(root, s.branch) - await unlink(join(root, '.sandlot', s.branch)).catch(() => {}) - console.log(`✔ Removed stale session: ${s.branch}`) - } - }) + .action(cleanupAction) // ── sandlot vm ─────────────────────────────────────────────────────── -const vmCmd = program.command("vm").description("Manage the sandlot VM") +registerVmCommands(program) -vmCmd - .command("create") - .description("Create and provision the VM") - .action(async () => { - const spin = spinner("Creating VM") - try { - await vm.create((msg) => { spin.text = msg }) - spin.succeed("VM created") - } catch (err) { - spin.fail(String((err as Error).message ?? err)) - process.exit(1) - } - }) - -vmCmd - .command("start") - .description("Start the VM") - .action(async () => { - try { - await vm.start() - console.log("✔ VM started") - } catch (err) { - console.error(`✖ ${(err as Error).message ?? err}`) - process.exit(1) - } - }) - -vmCmd - .command("shell") - .description("Open a shell in the VM") - .action(async () => { - await vm.ensure() - await vm.shell() - }) - -vmCmd - .command("status") - .description("Show VM status") - .action(async () => { - const s = await vm.status() - console.log(s) - }) - -vmCmd - .command("info") - .description("Show VM system info (via neofetch)") - .action(async () => { - await vm.ensure() - await vm.info() - }) - -vmCmd - .command("stop") - .description("Stop the VM") - .action(async () => { - await vm.stop() - console.log("✔ VM stopped") - }) - -vmCmd - .command("destroy") - .description("Stop and delete the VM") - .action(async () => { - await vm.destroy() - console.log("✔ VM destroyed") - }) - -// ── sandlot completions ───────────────────────────────────────────── +// ── sandlot completions ────────────────────────────────────────────── program .command("completions") .description("Output fish shell completions") - .action(() => { - process.stdout.write(`# Fish completions for sandlot -# Install: sandlot completions > ~/.config/fish/completions/sandlot.fish + .action(() => completionsAction(program)) -complete -c sandlot -f +// ── Default: show list if sessions exist, otherwise help ───────────── -function __sandlot_sessions - command sandlot list --json 2>/dev/null | string match -r '"branch":\\s*"[^"]+"' | string replace -r '.*"branch":\\s*"([^"]+)".*' '$1' -end - -# Subcommands -complete -c sandlot -n __fish_use_subcommand -a new -d "Create a new session and launch Claude" -complete -c sandlot -n __fish_use_subcommand -a list -d "Show all active sessions" -complete -c sandlot -n __fish_use_subcommand -a open -d "Re-enter an existing session" -complete -c sandlot -n __fish_use_subcommand -a review -d "Launch an interactive code review" -complete -c sandlot -n __fish_use_subcommand -a shell -d "Open a shell in the VM" -complete -c sandlot -n __fish_use_subcommand -a close -d "Remove a worktree and clean up" -complete -c sandlot -n __fish_use_subcommand -a merge -d "Merge a branch into main and close" -complete -c sandlot -n __fish_use_subcommand -a save -d "Stage all changes and commit" -complete -c sandlot -n __fish_use_subcommand -a diff -d "Show changes for a branch" -complete -c sandlot -n __fish_use_subcommand -a show -d "Show prompt and full diff" -complete -c sandlot -n __fish_use_subcommand -a log -d "Show commits not on main" -complete -c sandlot -n __fish_use_subcommand -a dir -d "Print the worktree path" -complete -c sandlot -n __fish_use_subcommand -a cleanup -d "Remove stale sessions" -complete -c sandlot -n __fish_use_subcommand -a vm -d "Manage the sandlot VM" -complete -c sandlot -n __fish_use_subcommand -a completions -d "Output fish shell completions" - -# Branch completions for commands that take a branch -complete -c sandlot -n "__fish_seen_subcommand_from open close merge save diff show log dir review shell" -xa "(__sandlot_sessions)" - -# new -complete -c sandlot -n "__fish_seen_subcommand_from new" -s p -l print -d "Run Claude non-interactively" -r -complete -c sandlot -n "__fish_seen_subcommand_from new" -s n -l no-save -d "Skip auto-save after Claude exits" - -# open -complete -c sandlot -n "__fish_seen_subcommand_from open" -s p -l print -d "Run Claude non-interactively" -r -complete -c sandlot -n "__fish_seen_subcommand_from open" -s n -l no-save -d "Skip auto-save after Claude exits" - -# list -complete -c sandlot -n "__fish_seen_subcommand_from list" -l json -d "Output as JSON" - -# close -complete -c sandlot -n "__fish_seen_subcommand_from close" -s f -l force -d "Close even with unsaved changes" - -# review -complete -c sandlot -n "__fish_seen_subcommand_from review" -s p -l print -d "Print review to stdout" - -# vm subcommands -set -l __sandlot_vm_subs create start shell status info stop destroy -complete -c sandlot -n "__fish_seen_subcommand_from vm; and not __fish_seen_subcommand_from \$__sandlot_vm_subs" -a create -d "Create and provision the VM" -complete -c sandlot -n "__fish_seen_subcommand_from vm; and not __fish_seen_subcommand_from \$__sandlot_vm_subs" -a start -d "Start the VM" -complete -c sandlot -n "__fish_seen_subcommand_from vm; and not __fish_seen_subcommand_from \$__sandlot_vm_subs" -a shell -d "Open a shell in the VM" -complete -c sandlot -n "__fish_seen_subcommand_from vm; and not __fish_seen_subcommand_from \$__sandlot_vm_subs" -a status -d "Show VM status" -complete -c sandlot -n "__fish_seen_subcommand_from vm; and not __fish_seen_subcommand_from \$__sandlot_vm_subs" -a info -d "Show VM system info" -complete -c sandlot -n "__fish_seen_subcommand_from vm; and not __fish_seen_subcommand_from \$__sandlot_vm_subs" -a stop -d "Stop the VM" -complete -c sandlot -n "__fish_seen_subcommand_from vm; and not __fish_seen_subcommand_from \$__sandlot_vm_subs" -a destroy -d "Stop and delete the VM" -`) - }) - -// Default: show list if sessions exist, otherwise help const args = process.argv.slice(2) if (args.length === 0) { try { diff --git a/src/commands/cleanup.ts b/src/commands/cleanup.ts new file mode 100644 index 0000000..599b05b --- /dev/null +++ b/src/commands/cleanup.ts @@ -0,0 +1,29 @@ +import { join } from "path" +import { existsSync } from "fs" +import { unlink } from "fs/promises" +import * as git from "../git.ts" +import * as state from "../state.ts" + +export async function action() { + const root = await git.repoRoot() + const st = await state.load(root) + const sessions = Object.values(st.sessions) + + if (sessions.length === 0) { + console.log("No sessions to clean up.") + return + } + + const stale = sessions.filter(s => !existsSync(s.worktree)) + + if (stale.length === 0) { + console.log("No stale sessions found.") + return + } + + for (const s of stale) { + await state.removeSession(root, s.branch) + await unlink(join(root, '.sandlot', s.branch)).catch(() => {}) + console.log(`✔ Removed stale session: ${s.branch}`) + } +} diff --git a/src/commands/close.ts b/src/commands/close.ts new file mode 100644 index 0000000..8e9c0bf --- /dev/null +++ b/src/commands/close.ts @@ -0,0 +1,35 @@ +import { join } from "path" +import { homedir } from "os" +import { basename } from "path" +import { unlink } from "fs/promises" +import * as git from "../git.ts" +import * as vm from "../vm.ts" +import * as state from "../state.ts" +import { die } from "../fmt.ts" + +export async function closeAction(branch: string, opts: { force?: boolean } = {}) { + const root = await git.repoRoot() + const session = await state.getSession(root, branch) + const worktreeAbs = session?.worktree ?? join(homedir(), '.sandlot', basename(root), branch) + + if (!opts.force && session && await git.isDirty(worktreeAbs)) { + die(`Branch "${branch}" has unsaved changes. Run "sandlot save ${branch}" first, or use -f to force.`) + } + + await vm.clearActivity(worktreeAbs, branch) + + await git.removeWorktree(worktreeAbs, root) + .catch((e) => console.warn(`⚠ Failed to remove worktree: ${e.message}`)) + + await unlink(join(root, '.sandlot', branch)) + .catch(() => {}) // symlink may not exist + + await git.deleteLocalBranch(branch, root) + .catch((e) => console.warn(`⚠ Failed to delete branch ${branch}: ${e.message}`)) + + if (session) { + await state.removeSession(root, branch) + } + + console.log(`✔ Closed session ${branch}`) +} diff --git a/src/commands/completions.ts b/src/commands/completions.ts new file mode 100644 index 0000000..81250f3 --- /dev/null +++ b/src/commands/completions.ts @@ -0,0 +1,73 @@ +import type { Command, Option } from "commander" + +/** Generate fish completions dynamically from the Commander program tree. */ +export function action(program: Command) { + const lines: string[] = [ + "# Fish completions for sandlot (auto-generated)", + "# Install: sandlot completions > ~/.config/fish/completions/sandlot.fish", + "", + "complete -c sandlot -f", + "", + "function __sandlot_sessions", + ` command sandlot list --json 2>/dev/null | string match -r '"branch":\\s*"[^"]+"' | string replace -r '.*"branch":\\s*"([^"]+)".*' '$1'`, + "end", + "", + ] + + const branchCommands: string[] = [] + + for (const cmd of program.commands) { + if ((cmd as any)._hidden) continue + const name = cmd.name() + const desc = cmd.description() + const subs = cmd.commands as Command[] + + if (subs.length > 0) { + // Parent command (e.g. "vm") — register it plus its subcommands + lines.push(`complete -c sandlot -n __fish_use_subcommand -a ${name} -d ${esc(desc)}`) + const subNames = subs.filter(s => !(s as any)._hidden).map(s => s.name()) + const guard = `"__fish_seen_subcommand_from ${name}; and not __fish_seen_subcommand_from ${subNames.join(" ")}"` + for (const sub of subs) { + if ((sub as any)._hidden) continue + lines.push(`complete -c sandlot -n ${guard} -a ${sub.name()} -d ${esc(sub.description())}`) + } + // Options on subcommands + for (const sub of subs) { + if ((sub as any)._hidden) continue + emitOptions(lines, `${name} ${sub.name()}`, sub.options) + } + } else { + // Leaf command + lines.push(`complete -c sandlot -n __fish_use_subcommand -a ${name} -d ${esc(desc)}`) + emitOptions(lines, name, cmd.options) + + // Track commands that accept a / [branch] argument + const hasBranch = cmd.registeredArguments.some(a => a.name() === "branch") + if (hasBranch) branchCommands.push(name) + } + } + + // Session completions for all branch-taking commands + if (branchCommands.length > 0) { + lines.push("") + lines.push(`complete -c sandlot -n "__fish_seen_subcommand_from ${branchCommands.join(" ")}" -xa "(__sandlot_sessions)"`) + } + + lines.push("") + process.stdout.write(lines.join("\n") + "\n") +} + +function esc(s: string): string { + return `"${s.replace(/"/g, '\\"')}"` +} + +function emitOptions(lines: string[], cmdName: string, options: readonly Option[]) { + for (const opt of options) { + const parts = [`complete -c sandlot -n "__fish_seen_subcommand_from ${cmdName}"`] + if (opt.short) parts.push(`-s ${opt.short.replace("-", "")}`) + if (opt.long) parts.push(`-l ${opt.long.replace("--", "")}`) + parts.push(`-d ${esc(opt.description)}`) + if (opt.required) parts.push("-r") + lines.push(parts.join(" ")) + } +} diff --git a/src/commands/diff.ts b/src/commands/diff.ts new file mode 100644 index 0000000..b372585 --- /dev/null +++ b/src/commands/diff.ts @@ -0,0 +1,39 @@ +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) + + // Check for uncommitted changes (staged + unstaged) + const status = await $`git -C ${session.worktree} status --porcelain`.nothrow().quiet() + if (status.exitCode !== 0) { + console.error("✖ git status failed") + process.exit(1) + } + + let diff: 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() + } + } 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() + } + + await pager(diff) +} diff --git a/src/commands/dir.ts b/src/commands/dir.ts new file mode 100644 index 0000000..75f6449 --- /dev/null +++ b/src/commands/dir.ts @@ -0,0 +1,7 @@ +import { requireSession } from "./helpers.ts" + +export async function action(branch: string) { + const { session } = await requireSession(branch) + + process.stdout.write(session.worktree + "\n") +} diff --git a/src/commands/helpers.ts b/src/commands/helpers.ts new file mode 100644 index 0000000..9f0d1d5 --- /dev/null +++ b/src/commands/helpers.ts @@ -0,0 +1,64 @@ +import { $ } from "bun" +import * as git from "../git.ts" +import * as vm from "../vm.ts" +import * as state from "../state.ts" +import { spinner } from "../spinner.ts" +import { die } from "../fmt.ts" +import type { Session } from "../state.ts" + +/** Look up a session by branch, dying if it doesn't exist. */ +export async function requireSession(branch: string): Promise<{ root: string; session: Session }> { + const root = await git.repoRoot() + const session = await state.getSession(root, branch) + if (!session) { + die(`No session found for branch "${branch}".`) + } + return { root, session } +} + +/** Stage all changes, generate a commit message, and commit. Returns true on success. */ +export async function saveChanges(worktree: string, branch: string, message?: string): Promise { + const spin = spinner("Staging changes", branch) + + await $`git -C ${worktree} add .`.nothrow().quiet() + + const check = await $`git -C ${worktree} diff --staged --quiet`.nothrow().quiet() + if (check.exitCode === 0) { + spin.fail("No changes to commit") + return false + } + + let msg: string + if (message) { + msg = message + } else { + spin.text = "Starting container" + await vm.ensure((m) => { spin.text = m }) + + spin.text = "Generating commit message" + const diff = await $`git -C ${worktree} diff --staged`.nothrow().quiet().text() + + const gen = await vm.claudePipe( + diff, + "write a short commit message summarizing these changes. output only the message, no quotes or extra text", + ) + + if (gen.exitCode !== 0) { + spin.fail("Failed to generate commit message") + if (gen.stderr) console.error(gen.stderr) + return false + } + msg = gen.stdout + } + + spin.text = "Committing" + const commit = await $`git -C ${worktree} commit -m ${msg}`.nothrow().quiet() + if (commit.exitCode !== 0) { + spin.fail("Commit failed") + if (commit.stderr) console.error(commit.stderr.toString().trim()) + return false + } + + spin.succeed(`Saved: ${msg}`) + return true +} diff --git a/src/commands/list.ts b/src/commands/list.ts new file mode 100644 index 0000000..af86c5c --- /dev/null +++ b/src/commands/list.ts @@ -0,0 +1,81 @@ +import { homedir } from "os" +import * as git from "../git.ts" +import * as vm from "../vm.ts" +import * as state from "../state.ts" +import { reset, dim, bold, green, yellow, cyan, white } from "../fmt.ts" + +export async function action(opts: { json?: boolean }) { + const root = await git.repoRoot() + const st = await state.load(root) + const sessions = Object.values(st.sessions) + + // Discover prompts from Claude history for sessions that lack one + const needsPrompt = sessions.filter(s => !s.prompt) + if (needsPrompt.length > 0 && (await vm.status()) === "running") { + try { + const result = await vm.exec(homedir() + "/.sandlot", "cat /home/ubuntu/.claude/history.jsonl 2>/dev/null") + if (result.exitCode === 0 && result.stdout) { + const entries = result.stdout.split("\n").filter(Boolean).map(line => { + try { return JSON.parse(line) } catch { return null } + }).filter(Boolean) + + for (const s of needsPrompt) { + const cPath = vm.containerPath(s.worktree) + const match = entries.find((e: any) => e.project === cPath) + if (match?.display) { + s.prompt = match.display + } + } + } + } catch {} + } + + if (opts.json) { + console.log(JSON.stringify(sessions, null, 2)) + return + } + + if (sessions.length === 0) { + if (opts.json) console.log("[]") + else console.log("◆ No active sessions.") + return + } + + // Determine status for each session in parallel + const statusEntries = await Promise.all( + sessions.map(async (s): Promise<[string, string]> => { + if (await vm.isClaudeActive(s.worktree, s.branch)) return [s.branch, "active"] + const dirty = await git.isDirty(s.worktree) + if (dirty) return [s.branch, "dirty"] + const commits = await git.hasNewCommits(s.worktree) + return [s.branch, commits ? "saved" : "idle"] + }) + ) + const statuses = Object.fromEntries(statusEntries) + + if (opts.json) { + const withStatus = sessions.map(s => ({ ...s, status: statuses[s.branch] })) + console.log(JSON.stringify(withStatus, null, 2)) + return + } + + const icons: Record = { idle: `${dim}◌${reset}`, active: `${cyan}◯${reset}`, dirty: `${yellow}◎${reset}`, saved: `${green}●${reset}` } + const branchColors: Record = { idle: dim, active: cyan, dirty: yellow, saved: green } + const branchWidth = Math.max(6, ...sessions.map((s) => s.branch.length)) + const cols = process.stdout.columns || 80 + const prefixWidth = branchWidth + 4 + + console.log(` ${dim}${"BRANCH".padEnd(branchWidth)} PROMPT${reset}`) + + for (const s of sessions) { + const prompt = s.prompt ?? "" + const status = statuses[s.branch] + const icon = icons[status] + const bc = branchColors[status] + const maxPrompt = cols - prefixWidth + const truncated = maxPrompt > 3 && prompt.length > maxPrompt ? prompt.slice(0, maxPrompt - 3) + "..." : prompt + console.log(`${icon} ${bc}${s.branch.padEnd(branchWidth)}${reset} ${dim}${truncated}${reset}`) + } + + console.log(`\n${dim}◌ idle${reset} · ${cyan}◯ active${reset} · ${yellow}◎ unsaved${reset} · ${green}● saved${reset}`) +} diff --git a/src/commands/log.ts b/src/commands/log.ts new file mode 100644 index 0000000..e0424f6 --- /dev/null +++ b/src/commands/log.ts @@ -0,0 +1,12 @@ +import { $ } from "bun" +import { die } from "../fmt.ts" +import { requireSession } from "./helpers.ts" + +export async function action(branch: string) { + const { session } = await requireSession(branch) + + const result = await $`git -C ${session.worktree} log main..HEAD`.nothrow() + if (result.exitCode !== 0) { + die("git log failed") + } +} diff --git a/src/commands/merge.ts b/src/commands/merge.ts new file mode 100644 index 0000000..abc6db2 --- /dev/null +++ b/src/commands/merge.ts @@ -0,0 +1,58 @@ +import { join } from "path" +import * as git from "../git.ts" +import * as vm from "../vm.ts" +import * as state from "../state.ts" +import { spinner } from "../spinner.ts" +import { die } from "../fmt.ts" +import { closeAction } from "./close.ts" + +export async function action(branch: string) { + const root = await git.repoRoot() + const session = await state.getSession(root, branch) + + if (session && await git.isDirty(session.worktree)) { + die(`Branch "${branch}" has unsaved changes. Run "sandlot save ${branch}" first.`) + } + + const conflicts = await git.merge(branch, root) + + if (conflicts.length === 0) { + console.log(`✔ Merged ${branch} into current branch`) + await closeAction(branch) + return + } + + // Resolve conflicts with Claude + console.log(`◆ Merge conflicts in ${conflicts.length} file(s). Resolving with Claude...`) + const spin = spinner("Starting container", branch) + + try { + await vm.ensure((msg) => { spin.text = msg }) + + for (const file of conflicts) { + spin.text = `Resolving ${file}` + const content = await Bun.file(join(root, file)).text() + + const resolved = await vm.claudePipe( + content, + "resolve this merge conflict. output ONLY the resolved file content with no markdown fences, no explanation, no surrounding text.", + ) + + if (resolved.exitCode !== 0) { + throw new Error(`Claude failed to resolve ${file}: ${resolved.stderr}`) + } + + await Bun.write(join(root, file), resolved.stdout + "\n") + await git.stageFile(file, root) + } + + await git.commitMerge(root) + spin.succeed(`Resolved ${conflicts.length} conflict(s) and merged ${branch}`) + } catch (err) { + spin.fail(String((err as Error).message ?? err)) + await git.abortMerge(root) + process.exit(1) + } + + await closeAction(branch) +} diff --git a/src/commands/new.ts b/src/commands/new.ts new file mode 100644 index 0000000..a0988f6 --- /dev/null +++ b/src/commands/new.ts @@ -0,0 +1,121 @@ +import { basename, join } from "path" +import { homedir } from "os" +import { mkdir, symlink, unlink } from "fs/promises" +import * as git from "../git.ts" +import * as vm from "../vm.ts" +import * as state from "../state.ts" +import { spinner } from "../spinner.ts" +import { getApiKey } from "../env.ts" +import { renderMarkdown } from "../markdown.ts" +import { saveChanges } from "./helpers.ts" + +function fallbackBranchName(text: string): string { + return text + .toLowerCase() + .replace(/[^a-z0-9\s-]/g, "") + .trim() + .split(/\s+/) + .slice(0, 2) + .join("-") +} + +async function branchFromPrompt(text: string): Promise { + const apiKey = await getApiKey() + if (!apiKey) return fallbackBranchName(text) + + try { + const res = await fetch("https://api.anthropic.com/v1/messages", { + method: "POST", + headers: { + "content-type": "application/json", + "x-api-key": apiKey, + "anthropic-version": "2023-06-01", + }, + body: JSON.stringify({ + model: "claude-haiku-4-5-20251001", + max_tokens: 15, + temperature: 0, + messages: [{ role: "user", content: `Generate a 2-word git branch name (lowercase, hyphen-separated) for this task:\n\n${text}\n\nOutput ONLY the branch name, nothing else.` }], + }), + }) + + if (!res.ok) return fallbackBranchName(text) + + const body = await res.json() as any + const name = body.content?.[0]?.text?.trim() + .toLowerCase() + .replace(/[^a-z0-9-]/g, "-") + .replace(/-+/g, "-") + .replace(/^-|-$/g, "") + + return name && name.length > 0 && name.length <= 50 ? name : fallbackBranchName(text) + } catch { + return fallbackBranchName(text) + } +} + +export async function action( + branch: string | undefined, + prompt: string | undefined, + opts: { print?: string; save?: boolean }, +) { + // No branch given — derive from -p prompt + if (!branch && opts.print) { + branch = await branchFromPrompt(opts.print) + } else if (!branch) { + console.error("✖ Branch name or prompt is required.") + process.exit(1) + } else if (branch.includes(" ")) { + // If the "branch" contains spaces, it's actually a prompt — derive the branch name + prompt = branch + branch = await branchFromPrompt(branch) + } + const root = await git.repoRoot() + const worktreeAbs = join(homedir(), '.sandlot', basename(root), branch) + + const existing = await state.getSession(root, branch) + if (existing) { + console.error(`✖ Session "${branch}" already exists. Use "sandlot open ${branch}" to re-enter it.`) + process.exit(1) + } + + const spin = spinner("Creating worktree", branch) + try { + await git.createWorktree(branch, worktreeAbs, root) + await mkdir(join(root, '.sandlot'), { recursive: true }) + await symlink(worktreeAbs, join(root, '.sandlot', branch)) + + spin.text = "Starting container" + await vm.ensure((msg) => { spin.text = msg }) + if (!opts.print) spin.succeed("Session ready") + } catch (err) { + spin.fail(String((err as Error).message ?? err)) + await git.removeWorktree(worktreeAbs, root).catch(() => {}) + await git.deleteLocalBranch(branch, root).catch(() => {}) + await unlink(join(root, '.sandlot', branch)).catch(() => {}) + process.exit(1) + } + + const effectivePrompt = opts.print || prompt + await state.setSession(root, { + branch, + worktree: worktreeAbs, + created_at: new Date().toISOString(), + ...(effectivePrompt ? { prompt: effectivePrompt } : {}), + }) + + if (opts.print) { + spin.text = "Running prompt…" + const output = await vm.claude(worktreeAbs, { prompt, print: opts.print }) + if (output) { + spin.stop() + process.stdout.write(renderMarkdown(output) + "\n") + } else { + spin.succeed("Done") + } + } else { + await vm.claude(worktreeAbs, { prompt, print: opts.print }) + } + + if (opts.save !== false) await saveChanges(worktreeAbs, branch) +} diff --git a/src/commands/open.ts b/src/commands/open.ts new file mode 100644 index 0000000..90c0ed2 --- /dev/null +++ b/src/commands/open.ts @@ -0,0 +1,37 @@ +import * as vm from "../vm.ts" +import * as state from "../state.ts" +import { spinner } from "../spinner.ts" +import { renderMarkdown } from "../markdown.ts" +import { requireSession, saveChanges } from "./helpers.ts" + +export async function action( + branch: string, + prompt: string | undefined, + opts: { print?: string; save?: boolean }, +) { + const { root, session } = await requireSession(branch) + + const effectivePrompt = opts.print || prompt + if (effectivePrompt) { + await state.setSession(root, { ...session, prompt: effectivePrompt }) + } + + const spin = spinner("Starting container", branch) + await vm.ensure((msg) => { spin.text = msg }) + + if (opts.print) { + spin.text = "Running prompt…" + const output = await vm.claude(session.worktree, { prompt, print: opts.print }) + if (output) { + spin.stop() + process.stdout.write(renderMarkdown(output) + "\n") + } else { + spin.succeed("Done") + } + } else { + spin.succeed("Session ready") + await vm.claude(session.worktree, { prompt, print: opts.print }) + } + + if (opts.save !== false) await saveChanges(session.worktree, branch) +} diff --git a/src/commands/review.ts b/src/commands/review.ts new file mode 100644 index 0000000..96fed73 --- /dev/null +++ b/src/commands/review.ts @@ -0,0 +1,22 @@ +import * as vm from "../vm.ts" +import { spinner } from "../spinner.ts" +import { requireSession } from "./helpers.ts" + +export async function action(branch: string, opts: { print?: boolean }) { + const { session } = await requireSession(branch) + + const spin = spinner("Starting container", branch) + await vm.ensure((msg) => { spin.text = msg }) + + const prompt = "You're a grumpy old senior software engineer. Take a look at the diff between this branch and main, then let me know your thoughts. My co-worker made these changes." + + if (opts.print) { + spin.text = "Running review…" + const output = await vm.claude(session.worktree, { print: prompt }) + spin.stop() + if (output) process.stdout.write(output + "\n") + } else { + spin.succeed("Session ready") + await vm.claude(session.worktree, { prompt }) + } +} diff --git a/src/commands/save.ts b/src/commands/save.ts new file mode 100644 index 0000000..7b46bb9 --- /dev/null +++ b/src/commands/save.ts @@ -0,0 +1,8 @@ +import { requireSession, saveChanges } from "./helpers.ts" + +export async function action(branch: string, message?: string) { + const { session } = await requireSession(branch) + + const ok = await saveChanges(session.worktree, branch, message) + if (!ok) process.exit(1) +} diff --git a/src/commands/shell.ts b/src/commands/shell.ts new file mode 100644 index 0000000..b22c5c4 --- /dev/null +++ b/src/commands/shell.ts @@ -0,0 +1,15 @@ +import * as vm from "../vm.ts" +import { requireSession } from "./helpers.ts" + +export async function action(branch?: string) { + if (!branch) { + await vm.ensure() + await vm.shell() + return + } + + const { session } = await requireSession(branch) + + await vm.ensure() + await vm.shell(session.worktree) +} diff --git a/src/commands/show.ts b/src/commands/show.ts new file mode 100644 index 0000000..68ec167 --- /dev/null +++ b/src/commands/show.ts @@ -0,0 +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) + + 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` + } + output += result.text() + + await pager(output) +} diff --git a/src/commands/vm.ts b/src/commands/vm.ts new file mode 100644 index 0000000..789d2a8 --- /dev/null +++ b/src/commands/vm.ts @@ -0,0 +1,74 @@ +import type { Command } from "commander" +import * as vm from "../vm.ts" +import { spinner } from "../spinner.ts" + +export function register(program: Command) { + const vmCmd = program.command("vm").description("Manage the sandlot VM") + + vmCmd + .command("create") + .description("Create and provision the VM") + .action(async () => { + const spin = spinner("Creating VM") + try { + await vm.create((msg) => { spin.text = msg }) + spin.succeed("VM created") + } catch (err) { + spin.fail(String((err as Error).message ?? err)) + process.exit(1) + } + }) + + vmCmd + .command("start") + .description("Start the VM") + .action(async () => { + try { + await vm.start() + console.log("✔ VM started") + } catch (err) { + console.error(`✖ ${(err as Error).message ?? err}`) + process.exit(1) + } + }) + + vmCmd + .command("shell") + .description("Open a shell in the VM") + .action(async () => { + await vm.ensure() + await vm.shell() + }) + + vmCmd + .command("status") + .description("Show VM status") + .action(async () => { + const s = await vm.status() + console.log(s) + }) + + vmCmd + .command("info") + .description("Show VM system info (via neofetch)") + .action(async () => { + await vm.ensure() + await vm.info() + }) + + vmCmd + .command("stop") + .description("Stop the VM") + .action(async () => { + await vm.stop() + console.log("✔ VM stopped") + }) + + vmCmd + .command("destroy") + .description("Stop and delete the VM") + .action(async () => { + await vm.destroy() + console.log("✔ VM destroyed") + }) +} diff --git a/src/config.ts b/src/config.ts deleted file mode 100644 index bafe53f..0000000 --- a/src/config.ts +++ /dev/null @@ -1,28 +0,0 @@ -import { join } from "path" - -export interface VmConfig { - cpus?: number - memory?: string - image?: string - mounts?: Record -} - -export interface SandlotConfig { - vm?: VmConfig -} - -const DEFAULT_CONFIG: SandlotConfig = { - vm: { image: "ubuntu:24.04" }, -} - -export async function loadConfig(repoRoot: string): Promise { - const path = join(repoRoot, "sandlot.json") - const file = Bun.file(path) - if (await file.exists()) { - const userConfig = await file.json() - return { - vm: { ...DEFAULT_CONFIG.vm, ...userConfig.vm }, - } - } - return DEFAULT_CONFIG -} diff --git a/src/env.ts b/src/env.ts new file mode 100644 index 0000000..39b1367 --- /dev/null +++ b/src/env.ts @@ -0,0 +1,11 @@ +import { homedir } from "os" +import { join } from "path" + +/** Read the ANTHROPIC_API_KEY from ~/.env. Returns undefined if not found. */ +export async function getApiKey(): Promise { + const envFile = Bun.file(join(homedir(), ".env")) + if (!(await envFile.exists())) return undefined + + const envContent = await envFile.text() + return envContent.match(/^(?:export\s+)?ANTHROPIC_API_KEY=["']?([^"'\s]+)["']?/m)?.[1] +} diff --git a/src/fmt.ts b/src/fmt.ts new file mode 100644 index 0000000..3591f44 --- /dev/null +++ b/src/fmt.ts @@ -0,0 +1,39 @@ +// ── ANSI escape codes ─────────────────────────────────────────────── + +export const reset = "\x1b[0m" +export const dim = "\x1b[2m" +export const bold = "\x1b[1m" +export const green = "\x1b[32m" +export const yellow = "\x1b[33m" +export const cyan = "\x1b[36m" +export const white = "\x1b[37m" + +// ── Formatted output ──────────────────────────────────────────────── + +export function die(message: string): never { + process.stderr.write(`✖ ${message}\n`) + process.exit(1) +} + +export function success(message: string) { + process.stderr.write(`✔ ${message}\n`) +} + +export function info(message: string) { + process.stderr.write(`◆ ${message}\n`) +} + +// ── Pager ─────────────────────────────────────────────────────────── + +export async function pager(content: string): Promise { + const lines = content.split("\n").length + const termHeight = process.stdout.rows || 24 + if (lines > termHeight) { + const p = Bun.spawn(["less", "-R"], { stdin: "pipe", stdout: "inherit", stderr: "inherit" }) + p.stdin.write(content) + p.stdin.end() + await p.exited + } else { + process.stdout.write(content) + } +} diff --git a/src/git.ts b/src/git.ts index e8f6a7c..f7db35a 100644 --- a/src/git.ts +++ b/src/git.ts @@ -21,12 +21,14 @@ export async function currentBranch(cwd?: string): Promise { } /** Check if a branch exists locally or remotely. Returns "local", "remote", or null. */ -export async function branchExists(branch: string, cwd?: string): Promise<"local" | "remote" | null> { +export async function branchExists(branch: string, cwd?: string, opts?: { fetch?: boolean }): Promise<"local" | "remote" | null> { const dir = cwd ?? "." const local = await $`git show-ref --verify --quiet refs/heads/${branch}`.cwd(dir).nothrow().quiet() if (local.exitCode === 0) return "local" - await $`git fetch origin`.cwd(dir).nothrow().quiet() + if (opts?.fetch) { + await $`git fetch origin`.cwd(dir).nothrow().quiet() + } const remote = await $`git show-ref --verify --quiet refs/remotes/origin/${branch}`.cwd(dir).nothrow().quiet() if (remote.exitCode === 0) return "remote" @@ -44,7 +46,7 @@ export async function createWorktree(branch: string, worktreePath: string, cwd: } await $`git worktree prune`.cwd(cwd).nothrow().quiet() - const exists = await branchExists(branch, cwd) + const exists = await branchExists(branch, cwd, { fetch: true }) let result if (exists === "local") { diff --git a/src/state.ts b/src/state.ts index 40bb41c..f5cdede 100644 --- a/src/state.ts +++ b/src/state.ts @@ -1,4 +1,5 @@ import { join } from "path" +import { rename } from "fs/promises" export interface Session { branch: string @@ -26,9 +27,11 @@ export async function load(repoRoot: string): Promise { export async function save(repoRoot: string, state: State): Promise { const path = statePath(repoRoot) + const tmpPath = path + ".tmp" const dir = join(repoRoot, ".sandlot") await Bun.write(join(dir, ".gitkeep"), "") // ensure dir exists - await Bun.write(path, JSON.stringify(state, null, 2) + "\n") + await Bun.write(tmpPath, JSON.stringify(state, null, 2) + "\n") + await rename(tmpPath, path) } export async function getSession(repoRoot: string, branch: string): Promise { diff --git a/src/vm.ts b/src/vm.ts index c749e96..8c5b599 100644 --- a/src/vm.ts +++ b/src/vm.ts @@ -1,6 +1,7 @@ import { $ } from "bun" import { homedir } from "os" -import { dirname } from "path" +import { dirname, join } from "path" +import { getApiKey } from "./env.ts" const CONTAINER_NAME = "sandlot" const USER = "ubuntu" @@ -36,33 +37,31 @@ async function run(cmd: ReturnType, step: string): Promise { } } -/** Create and provision the container from scratch. Fails if it already exists. */ -export async function create(log?: (msg: string) => void): Promise { - requireContainer() +// ── create() helpers (internal) ────────────────────────────────────── - const s = await status() - if (s !== "missing") { - throw new Error("Container already exists. Use 'sandlot vm destroy' first to recreate it.") - } - - const home = homedir() - log?.("Pulling image & creating container") +/** Pull the image and start the container in detached mode. */ +async function createContainer(home: string): Promise { await run( $`container run -d --name ${CONTAINER_NAME} -m 4G --mount type=bind,source=${home}/dev,target=/host,readonly -v ${home}/.sandlot:/sandlot ubuntu:24.04 sleep infinity`, "Container creation") +} - // Provision (as root) - log?.("Installing packages") +/** Install base system packages (as root). */ +async function installPackages(): Promise { await run( $`container exec ${CONTAINER_NAME} bash -c ${"apt update && apt install -y curl git neofetch fish unzip"}`, "Package installation") +} - // Create symlinks so git worktree absolute paths from the host resolve inside the container +/** Create symlinks so git worktree absolute paths from the host resolve inside the container. */ +async function createHostSymlinks(home: string): Promise { await run( $`container exec ${CONTAINER_NAME} bash -c ${`mkdir -p '${home}' && ln -s /host '${home}/dev' && ln -s /sandlot '${home}/.sandlot'`}`, "Symlink creation") +} - // Install Bun and Claude Code (as ubuntu user) +/** Install Bun and Claude Code (as ubuntu user). */ +async function installTooling(log?: (msg: string) => void): Promise { log?.("Installing Bun") await run( $`container exec --user ${USER} ${CONTAINER_NAME} env BUN_INSTALL=/home/${USER}/.local bash -c ${"curl -fsSL https://bun.sh/install | bash"}`, @@ -72,21 +71,15 @@ export async function create(log?: (msg: string) => void): Promise { await run( $`container exec --user ${USER} ${CONTAINER_NAME} bash -c ${"curl -fsSL https://claude.ai/install.sh | bash"}`, "Claude Code installation") +} - log?.("Configuring environment") +/** Configure git identity, API key helper, activity hook, and Claude settings. */ +async function configureEnvironment(home: string, log?: (msg: string) => void, apiKey?: string): Promise { const gitName = (await $`git config user.name`.quiet().text()).trim() const gitEmail = (await $`git config user.email`.quiet().text()).trim() if (gitName) await $`container exec --user ${USER} ${CONTAINER_NAME} git config --global user.name ${gitName}`.quiet() if (gitEmail) await $`container exec --user ${USER} ${CONTAINER_NAME} git config --global user.email ${gitEmail}`.quiet() - // Configure claude to use API key from host ~/.env (skip login prompt) - let apiKey: string | undefined - const envFile = Bun.file(`${home}/.env`) - if (await envFile.exists()) { - const envContent = await envFile.text() - apiKey = envContent.match(/^(?:export\s+)?ANTHROPIC_API_KEY=["']?([^"'\s]+)["']?/m)?.[1] - } - if (!apiKey) { log?.("Warning: ANTHROPIC_API_KEY not found in ~/.env — claude will require manual login") } @@ -130,6 +123,33 @@ echo '${claudeJson}' > ~/.claude.json `}`.quiet() } +// ── create() ──────────────────────────────────────────────────────── + +/** Create and provision the container from scratch. Fails if it already exists. */ +export async function create(log?: (msg: string) => void): Promise { + requireContainer() + + const s = await status() + if (s !== "missing") { + throw new Error("Container already exists. Use 'sandlot vm destroy' first to recreate it.") + } + + const home = homedir() + + log?.("Pulling image & creating container") + await createContainer(home) + + log?.("Installing packages") + await installPackages() + await createHostSymlinks(home) + + await installTooling(log) + + log?.("Configuring environment") + const apiKey = await getApiKey() + await configureEnvironment(home, log, apiKey) +} + /** Start a stopped container. */ export async function start(): Promise { requireContainer() @@ -230,6 +250,20 @@ export async function exec(workdir: string, command: string): Promise<{ exitCode } } +/** Pipe input text to Claude in the container with a prompt, returning the output. */ +export async function claudePipe(input: string, prompt: string): Promise<{ exitCode: number; stdout: string; stderr: string }> { + const tmpPath = join(homedir(), '.sandlot', '.claude-pipe-tmp') + try { + await Bun.write(tmpPath, input) + return await exec( + join(homedir(), '.sandlot'), + `cat /sandlot/.claude-pipe-tmp | claude -p "${prompt.replace(/"/g, '\\"')}"`, + ) + } finally { + await Bun.file(tmpPath).unlink().catch(() => {}) + } +} + /** Check if Claude is actively working in the given worktree (based on activity hook). */ export async function isClaudeActive(worktree: string, branch: string): Promise { const file = `${dirname(worktree)}/.activity-${branch}`