7.4 KiB
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):
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:
- Write content to a temp file under
~/.sandlot/ catit intoclaude -pinside the container- Capture output
- Delete the temp file
This should be a single helper in vm.ts, something like:
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):
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.