Compare commits
No commits in common. "05f1126a65b34dbc07589dcaf9f22ae82a4e0c6b" and "b46511efe351fa75910c25fa40921dc08148dfa7" have entirely different histories.
05f1126a65
...
b46511efe3
32
CLAUDE.md
32
CLAUDE.md
|
|
@ -10,7 +10,7 @@
|
|||
|
||||
- **Runtime**: [Bun](https://bun.sh) (not Node.js — use `bun` for everything)
|
||||
- **Language**: TypeScript (strict, ESNext, bundler module resolution)
|
||||
- **CLI parsing**: [Commander.js](https://github.com/tj/commander.js) 14.x
|
||||
- **CLI parsing**: [Commander.js](https://github.com/tj/commander.js) 13.x
|
||||
- **Containers**: Apple Container (`brew install container`)
|
||||
- **Entry point**: `src/cli.ts` (shebang: `#!/usr/bin/env bun`, runs directly without compilation)
|
||||
|
||||
|
|
@ -38,24 +38,22 @@ src/
|
|||
spinner.ts # CLI progress spinner (braille frames)
|
||||
commands/
|
||||
new.ts # Create session, derive branch name from prompt
|
||||
open.ts # Re-enter existing session (always uses --continue)
|
||||
open.ts # Re-enter existing session
|
||||
close.ts # Remove worktree and clean up session
|
||||
list.ts # Show all active sessions with status
|
||||
save.ts # Stage all changes and commit
|
||||
merge.ts # Merge branch into main with conflict resolution
|
||||
squash.ts # Squash-merge branch into main (AI commit message) and close
|
||||
rebase.ts # Rebase branch onto main with conflict resolution
|
||||
review.ts # Launch grumpy code review with Claude
|
||||
diff.ts # Show uncommitted changes or full branch diff
|
||||
show.ts # Show prompt and full diff for a branch
|
||||
log.ts # Show commits on branch not on main
|
||||
dir.ts # Print worktree path for a session
|
||||
edit.ts # Open a session file in $EDITOR (with path escape check)
|
||||
shell.ts # Open fish shell in container
|
||||
cleanup.ts # Remove stale sessions
|
||||
vm.ts # VM subcommands (create, start, stop, destroy, status, info, uncache)
|
||||
vm.ts # VM subcommands (create, start, stop, destroy, status, info)
|
||||
completions.ts # Fish shell completions generator
|
||||
helpers.ts # Shared: requireSession, resolveConflicts, mergeAndClose, saveChanges
|
||||
helpers.ts # Shared: requireSession, resolveConflicts, saveChanges
|
||||
```
|
||||
|
||||
## Architecture
|
||||
|
|
@ -79,15 +77,11 @@ Each module has a single responsibility. No classes — only exported async func
|
|||
|
||||
- Image: `ubuntu:24.04`
|
||||
- User: `ubuntu`
|
||||
- Memory limit: 4G
|
||||
- Mounts: `~/dev` **read-only** at `/host`, `~/.sandlot` read-write at `/sandlot`
|
||||
- Host symlinks: creates `~/dev` → `/host` and `~/.sandlot` → `/sandlot` inside the container so host-absolute worktree paths resolve correctly
|
||||
- `containerPath()` in `vm.ts` translates host paths to container paths (`~/.sandlot/…` → `/sandlot/…`, `~/dev/…` → `/host/…`)
|
||||
- Provisioned once on first use: apt installs `curl git fish unzip`, then installs Bun, Claude Code, neofetch, and Neovim
|
||||
- Binary caching: after first install, binaries are cached in `~/.sandlot/.cache/` (bun, claude, neofetch, nvim.tar.gz) — subsequent `vm create` uses cached copies, skipping downloads
|
||||
- Mounts: `~/dev` and `~/.sandlot` from host
|
||||
- Provisioned once on first use: installs `curl git neofetch fish unzip`, Bun, Claude Code, git identity, API key helper, activity tracking hook
|
||||
- API key: read from `~/.env` on host (`ANTHROPIC_API_KEY=...`), written to a temp file and copied as `~/.claude/api-key-helper.sh` in the container (never passed as a process argument)
|
||||
- Claude settings: `skipDangerousModePermissionPrompt: true`, activity tracking hooks (`UserPromptSubmit` / `Stop`) in container
|
||||
- Also writes `~/.claude.json` with `hasCompletedOnboarding: true` and `effortCalloutDismissed: true`
|
||||
- Also writes `~/.claude.json` with `hasCompletedOnboarding: true` to skip first-run prompts
|
||||
|
||||
## Shell Command Pattern
|
||||
|
||||
|
|
@ -137,20 +131,12 @@ Always use `.nothrow()` for commands that may fail non-fatally. Use `.quiet()` t
|
|||
|
||||
- `vm.exec()` prepends `export PATH=$HOME/.local/bin:$PATH` so `claude` binary is found
|
||||
- `vm.claude()` uses `Bun.spawn` with `stdin/stdout/stderr: "inherit"` for interactive TTY; in print mode (`-p`), captures stdout via pipe and returns the output
|
||||
- `vm.claude()` runs Claude with `--dangerously-skip-permissions`, `--model claude-opus-4-6`, and `--append-system-prompt` (system prompt describes the container environment)
|
||||
- `vm.claude()` retry logic: if exit code is non-zero and `--continue` was used, retries without `--continue`
|
||||
- `vm.claudePipe()` writes input to a temp file in `~/.sandlot/`, pipes it to `claude -p` inside the container, and returns the result — used for commit message generation and conflict resolution
|
||||
- `vm.isClaudeActive()` reads activity marker files written by the in-container `sandlot-activity` hook script
|
||||
- Branch creation in `createWorktree()` handles three cases: local branch, remote branch (tracks origin), new branch from HEAD
|
||||
- `sandlot new` accepts a prompt instead of a branch name — derives a 2-word branch name via the Anthropic API (Haiku), falling back to simple text munging
|
||||
- `sandlot open` always passes `continue: true` to `vm.claude()` to resume the previous conversation
|
||||
- `sandlot save` uses `vm.claudePipe()` to generate commit messages from the staged diff
|
||||
- `sandlot merge`, `sandlot squash`, and `sandlot rebase` use `vm.claudePipe()` to resolve merge conflicts automatically
|
||||
- `sandlot squash` generates an AI commit message for the squash commit via `claudePipe()`; falls back to `"squash <branch>"`
|
||||
- `sandlot merge` and `sandlot squash` both delegate to `mergeAndClose()` in `helpers.ts`, which merges, resolves conflicts, commits, and then calls `closeAction()` to clean up
|
||||
- `sandlot list` discovers missing session prompts by parsing Claude's `history.jsonl` from inside the container
|
||||
- `sandlot list` shows four status icons: idle (dim `◌`), active (cyan `◯`), dirty/unsaved (yellow `◎`), saved (green `●`)
|
||||
- `sandlot merge` and `sandlot rebase` use `vm.claudePipe()` to resolve merge conflicts automatically
|
||||
- `sandlot new` and `sandlot open` auto-save changes when Claude exits (disable with `--no-save`)
|
||||
- `sandlot close` has a hidden `rm` alias
|
||||
- Default behavior (no subcommand): always runs `list` (which prints "No active sessions." if empty)
|
||||
- Default behavior (no subcommand): shows `list` if sessions exist, otherwise shows help
|
||||
- `.sandlot/` should be in the repo's `.gitignore`
|
||||
|
|
|
|||
|
|
@ -18,7 +18,6 @@ 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 editAction } from "./commands/edit.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"
|
||||
|
|
@ -140,13 +139,6 @@ program
|
|||
.description("Open a shell in the VM")
|
||||
.action(shellAction)
|
||||
|
||||
program
|
||||
.command("edit")
|
||||
.argument("<branch>", "branch name")
|
||||
.argument("<file>", "file path relative to worktree root")
|
||||
.description("Open a file from a session in $EDITOR")
|
||||
.action(editAction)
|
||||
|
||||
program
|
||||
.command("dir")
|
||||
.argument("<branch>", "branch name")
|
||||
|
|
|
|||
|
|
@ -1,35 +0,0 @@
|
|||
import { resolve } from "path"
|
||||
import { existsSync } from "fs"
|
||||
import { die } from "../fmt.ts"
|
||||
import { requireSession } from "./helpers.ts"
|
||||
|
||||
export async function action(branch: string, file: string) {
|
||||
const editor = process.env.EDITOR
|
||||
if (!editor) {
|
||||
die("$EDITOR is not set.")
|
||||
}
|
||||
|
||||
const { session } = await requireSession(branch)
|
||||
const worktree = resolve(session.worktree)
|
||||
const path = resolve(worktree, file)
|
||||
|
||||
if (!path.startsWith(worktree + "/") && path !== worktree) {
|
||||
die("File path escapes the worktree.")
|
||||
}
|
||||
|
||||
if (!existsSync(path)) {
|
||||
die(`File not found: ${file}`)
|
||||
}
|
||||
|
||||
const [cmd, ...args] = editor.split(/\s+/)
|
||||
const proc = Bun.spawn([cmd, ...args, path], {
|
||||
stdin: "inherit",
|
||||
stdout: "inherit",
|
||||
stderr: "inherit",
|
||||
})
|
||||
|
||||
const exitCode = await proc.exited
|
||||
if (exitCode !== 0) {
|
||||
die(`Editor exited with code ${exitCode}.`)
|
||||
}
|
||||
}
|
||||
|
|
@ -52,23 +52,20 @@ export async function mergeAndClose(branch: string, opts?: { squash?: boolean })
|
|||
}
|
||||
|
||||
const label = opts?.squash ? "Squash-merged" : "Merged"
|
||||
const spin = spinner("Merging", branch)
|
||||
const conflicts = await git.merge(branch, root, opts?.squash ? { squash: true } : undefined)
|
||||
|
||||
if (conflicts.length === 0) {
|
||||
if (opts?.squash) {
|
||||
spin.text = "Starting container"
|
||||
await vm.ensure((msg) => { spin.text = msg })
|
||||
spin.text = "Generating commit message"
|
||||
await squashCommit(branch, root)
|
||||
}
|
||||
spin.succeed(`${label} ${branch} into current branch`)
|
||||
console.log(`✔ ${label} ${branch} into current branch`)
|
||||
await closeAction(branch)
|
||||
return
|
||||
}
|
||||
|
||||
// Resolve conflicts with Claude
|
||||
spin.text = `Resolving ${conflicts.length} conflict(s)`
|
||||
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 })
|
||||
|
|
|
|||
|
|
@ -1,74 +1,3 @@
|
|||
function stripAnsi(s: string): string {
|
||||
return s.replace(/\x1b\]8;;[^\x07]*\x07/g, "").replace(/\x1b\[[0-9;]*m/g, "")
|
||||
}
|
||||
|
||||
function renderTable(block: string): string {
|
||||
const lines = block.trim().split("\n")
|
||||
if (lines.length < 2) return block
|
||||
|
||||
const parseRow = (line: string): string[] =>
|
||||
line.replace(/^\|/, "").replace(/\|$/, "").split("|").map(c => c.trim())
|
||||
|
||||
const header = parseRow(lines[0])
|
||||
const sepCells = parseRow(lines[1])
|
||||
|
||||
// Validate separator row
|
||||
if (!sepCells.every(s => /^:?-+:?$/.test(s.trim()))) return block
|
||||
|
||||
const cols = header.length
|
||||
const align: ("left" | "center" | "right")[] = sepCells.map(s => {
|
||||
const t = s.trim()
|
||||
if (t.startsWith(":") && t.endsWith(":")) return "center"
|
||||
if (t.endsWith(":")) return "right"
|
||||
return "left"
|
||||
})
|
||||
|
||||
const rows = lines.slice(2).map(parseRow)
|
||||
|
||||
// Column widths based on visible text (no ANSI codes)
|
||||
const widths = new Array(cols).fill(0)
|
||||
for (let c = 0; c < cols; c++) {
|
||||
widths[c] = Math.max(widths[c], stripAnsi(header[c] ?? "").length)
|
||||
for (const row of rows) {
|
||||
widths[c] = Math.max(widths[c], stripAnsi(row[c] ?? "").length)
|
||||
}
|
||||
}
|
||||
|
||||
const pad = (text: string, width: number, a: "left" | "center" | "right"): string => {
|
||||
const needed = width - stripAnsi(text).length
|
||||
if (needed <= 0) return text
|
||||
if (a === "right") return " ".repeat(needed) + text
|
||||
if (a === "center") {
|
||||
const l = Math.floor(needed / 2)
|
||||
return " ".repeat(l) + text + " ".repeat(needed - l)
|
||||
}
|
||||
return text + " ".repeat(needed)
|
||||
}
|
||||
|
||||
const D = "\x1b[2m" // dim
|
||||
const R = "\x1b[22m" // reset intensity
|
||||
|
||||
const renderRow = (cells: string[], bold: boolean): string => {
|
||||
const parts = cells.map((c, i) => pad(c ?? "", widths[i] ?? 0, align[i] ?? "left"))
|
||||
if (bold) {
|
||||
return `${D}│${R} ${parts.map(p => `\x1b[1m${p}\x1b[22m`).join(` ${D}│${R} `)} ${D}│${R}`
|
||||
}
|
||||
return `${D}│${R} ${parts.join(` ${D}│${R} `)} ${D}│${R}`
|
||||
}
|
||||
|
||||
const hline = (l: string, m: string, r: string): string => {
|
||||
return `${D}${l}${widths.map(w => "─".repeat(w + 2)).join(m)}${r}${R}`
|
||||
}
|
||||
|
||||
const out: string[] = []
|
||||
out.push(hline("┌", "┬", "┐"))
|
||||
out.push(renderRow(header, true))
|
||||
out.push(hline("├", "┼", "┤"))
|
||||
for (const row of rows) out.push(renderRow(row, false))
|
||||
out.push(hline("└", "┴", "┘"))
|
||||
return out.join("\n")
|
||||
}
|
||||
|
||||
export function renderMarkdown(text: string): string {
|
||||
// Extract fenced code blocks before anything else
|
||||
const codeBlocks: string[] = []
|
||||
|
|
@ -128,14 +57,6 @@ export function renderMarkdown(text: string): string {
|
|||
// Restore backslash escapes as literal characters
|
||||
result = result.replace(/\x00ESC(\d+)\x00/g, (_, i) => escapes[parseInt(i)])
|
||||
|
||||
// Tables: render pipe tables with box-drawing characters
|
||||
// Processed after inline formatting so cell contents are styled,
|
||||
// but before code block restoration so tables inside code blocks are ignored.
|
||||
result = result.replace(
|
||||
/^(\|[^\n]+\|\n)(\|[\s:|-]+\|\n)((?:\|[^\n]+\|\n?)*)/gm,
|
||||
(match) => renderTable(match),
|
||||
)
|
||||
|
||||
// Restore fenced code blocks as plain text
|
||||
result = result.replace(/\x00CODEBLOCK(\d+)\x00/g, (_, i) => codeBlocks[parseInt(i)])
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user