Refactor cli.ts into per-command modules with shared utilities
This commit is contained in:
parent
3c1f42e985
commit
14cad28488
156
REVIEW.md
Normal file
156
REVIEW.md
Normal file
|
|
@ -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<string>
|
||||
```
|
||||
|
||||
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.
|
||||
746
src/cli.ts
746
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<boolean> {
|
||||
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 <branch> ──────────────────────────────────────────────
|
||||
|
||||
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<string> {
|
||||
// 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 <prompt>", "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<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 }
|
||||
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 <branch> ─────────────────────────────────────────────
|
||||
// ── sandlot open ─────────────────────────────────────────────────────
|
||||
|
||||
program
|
||||
.command("open")
|
||||
|
|
@ -290,188 +53,26 @@ program
|
|||
.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")
|
||||
.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 <branch> ──────────────────────────────────────────
|
||||
// ── 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(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 <branch> ───────────────────────────────────────────
|
||||
// ── 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 <branch> ───────────────────────────────────────────
|
||||
|
||||
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 <branch> ──────────────────────────────────────────
|
||||
|
||||
program
|
||||
.command("merge")
|
||||
.argument("<branch>", "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 <branch> ───────────────────────────────────────────
|
||||
// ── sandlot merge ────────────────────────────────────────────────────
|
||||
|
||||
program
|
||||
.command("merge")
|
||||
.argument("<branch>", "branch name")
|
||||
.description("Merge a branch into main and close the session")
|
||||
.action(mergeAction)
|
||||
|
||||
// ── sandlot save ─────────────────────────────────────────────────────
|
||||
|
||||
program
|
||||
.command("save")
|
||||
.argument("<branch>", "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 <branch> ───────────────────────────────────────────
|
||||
// ── sandlot diff ─────────────────────────────────────────────────────
|
||||
|
||||
program
|
||||
.command("diff")
|
||||
.argument("<branch>", "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 <branch> ───────────────────────────────────────────
|
||||
// ── sandlot show ─────────────────────────────────────────────────────
|
||||
|
||||
program
|
||||
.command("show")
|
||||
.argument("<branch>", "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 <branch> ────────────────────────────────────────────
|
||||
// ── sandlot log ──────────────────────────────────────────────────────
|
||||
|
||||
program
|
||||
.command("log")
|
||||
.argument("<branch>", "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 <branch> ────────────────────────────────────────────
|
||||
// ── sandlot dir ──────────────────────────────────────────────────────
|
||||
|
||||
program
|
||||
.command("dir")
|
||||
.argument("<branch>", "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 {
|
||||
|
|
|
|||
29
src/commands/cleanup.ts
Normal file
29
src/commands/cleanup.ts
Normal file
|
|
@ -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}`)
|
||||
}
|
||||
}
|
||||
35
src/commands/close.ts
Normal file
35
src/commands/close.ts
Normal file
|
|
@ -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}`)
|
||||
}
|
||||
73
src/commands/completions.ts
Normal file
73
src/commands/completions.ts
Normal file
|
|
@ -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> / [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(" "))
|
||||
}
|
||||
}
|
||||
39
src/commands/diff.ts
Normal file
39
src/commands/diff.ts
Normal file
|
|
@ -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)
|
||||
}
|
||||
7
src/commands/dir.ts
Normal file
7
src/commands/dir.ts
Normal file
|
|
@ -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")
|
||||
}
|
||||
64
src/commands/helpers.ts
Normal file
64
src/commands/helpers.ts
Normal file
|
|
@ -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<boolean> {
|
||||
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
|
||||
}
|
||||
81
src/commands/list.ts
Normal file
81
src/commands/list.ts
Normal file
|
|
@ -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<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 }
|
||||
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}`)
|
||||
}
|
||||
12
src/commands/log.ts
Normal file
12
src/commands/log.ts
Normal file
|
|
@ -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")
|
||||
}
|
||||
}
|
||||
58
src/commands/merge.ts
Normal file
58
src/commands/merge.ts
Normal file
|
|
@ -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)
|
||||
}
|
||||
121
src/commands/new.ts
Normal file
121
src/commands/new.ts
Normal file
|
|
@ -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<string> {
|
||||
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)
|
||||
}
|
||||
37
src/commands/open.ts
Normal file
37
src/commands/open.ts
Normal file
|
|
@ -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)
|
||||
}
|
||||
22
src/commands/review.ts
Normal file
22
src/commands/review.ts
Normal file
|
|
@ -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 })
|
||||
}
|
||||
}
|
||||
8
src/commands/save.ts
Normal file
8
src/commands/save.ts
Normal file
|
|
@ -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)
|
||||
}
|
||||
15
src/commands/shell.ts
Normal file
15
src/commands/shell.ts
Normal file
|
|
@ -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)
|
||||
}
|
||||
23
src/commands/show.ts
Normal file
23
src/commands/show.ts
Normal file
|
|
@ -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)
|
||||
}
|
||||
74
src/commands/vm.ts
Normal file
74
src/commands/vm.ts
Normal file
|
|
@ -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")
|
||||
})
|
||||
}
|
||||
|
|
@ -1,28 +0,0 @@
|
|||
import { join } from "path"
|
||||
|
||||
export interface VmConfig {
|
||||
cpus?: number
|
||||
memory?: string
|
||||
image?: string
|
||||
mounts?: Record<string, string>
|
||||
}
|
||||
|
||||
export interface SandlotConfig {
|
||||
vm?: VmConfig
|
||||
}
|
||||
|
||||
const DEFAULT_CONFIG: SandlotConfig = {
|
||||
vm: { image: "ubuntu:24.04" },
|
||||
}
|
||||
|
||||
export async function loadConfig(repoRoot: string): Promise<SandlotConfig> {
|
||||
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
|
||||
}
|
||||
11
src/env.ts
Normal file
11
src/env.ts
Normal file
|
|
@ -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<string | undefined> {
|
||||
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]
|
||||
}
|
||||
39
src/fmt.ts
Normal file
39
src/fmt.ts
Normal file
|
|
@ -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<void> {
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
|
@ -21,12 +21,14 @@ export async function currentBranch(cwd?: string): Promise<string> {
|
|||
}
|
||||
|
||||
/** 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") {
|
||||
|
|
|
|||
|
|
@ -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<State> {
|
|||
|
||||
export async function save(repoRoot: string, state: State): Promise<void> {
|
||||
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<Session | undefined> {
|
||||
|
|
|
|||
82
src/vm.ts
82
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<typeof $>, step: string): Promise<void> {
|
|||
}
|
||||
}
|
||||
|
||||
/** Create and provision the container from scratch. Fails if it already exists. */
|
||||
export async function create(log?: (msg: string) => void): Promise<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
|||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<boolean> {
|
||||
const file = `${dirname(worktree)}/.activity-${branch}`
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user