import { $ } from "bun" import { homedir } from "os" const CONTAINER_NAME = "sandlot" const USER = "ubuntu" const CLAUDE_BIN = `/home/${USER}/.local/bin/claude` /** Translate a host path to its corresponding container path. */ export function containerPath(hostPath: string): string { const home = homedir() if (hostPath.startsWith(`${home}/.sandlot`)) { return "/sandlot" + hostPath.slice(`${home}/.sandlot`.length) } if (hostPath.startsWith(`${home}/dev`)) { return "/host" + hostPath.slice(`${home}/dev`.length) } return hostPath } function requireContainer(): void { if (!Bun.which("container")) { console.error('✖ Apple Container is not installed. Install it with: brew install container') process.exit(1) } } /** Ensure the sandlot container exists and is running. Creates and provisions on first use. */ export async function ensure(log?: (msg: string) => void): Promise { requireContainer() // Ensure the container daemon is running await $`container system start`.nothrow().quiet() const s = await status() if (s === "running") return if (s === "stopped") { await $`container start ${CONTAINER_NAME}`.quiet() return } // Create from scratch const home = homedir() log?.("Pulling image & creating container") await $`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`.quiet() // Provision (as root) log?.("Installing packages") await $`container exec ${CONTAINER_NAME} bash -c ${"apt update && apt install -y curl git neofetch fish"}`.quiet() // Create symlinks so git worktree absolute paths from the host resolve inside the container await $`container exec ${CONTAINER_NAME} bash -c ${`mkdir -p '${home}' && ln -s /host '${home}/dev' && ln -s /sandlot '${home}/.sandlot'`}`.quiet() // Install Claude Code and configure (as ubuntu user) log?.("Installing Claude Code") await $`container exec --user ${USER} ${CONTAINER_NAME} bash -c ${"curl -fsSL https://claude.ai/install.sh | bash"}`.quiet() log?.("Configuring environment") 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") } const settingsJson = JSON.stringify(apiKey ? { apiKeyHelper: "~/.claude/api-key-helper.sh", skipDangerousModePermissionPrompt: true } : { skipDangerousModePermissionPrompt: true }) const claudeJson = JSON.stringify({ hasCompletedOnboarding: true, effortCalloutDismissed: true }) // Write the helper script to a temp file and copy it in so the key // never appears in a process argument visible in `ps`. if (apiKey) { const tmp = `${home}/.sandlot/.api-key-helper.tmp` await Bun.write(tmp, `#!/bin/sh\necho '${apiKey.replace(/'/g, "'\\''")}'\n`) await $`chmod +x ${tmp}`.quiet() await $`container exec --user ${USER} ${CONTAINER_NAME} bash -c ${"mkdir -p ~/.claude && cp /sandlot/.api-key-helper.tmp ~/.claude/api-key-helper.sh"}`.quiet() await Bun.file(tmp).unlink() } await $`container exec --user ${USER} ${CONTAINER_NAME} bash -c ${` mkdir -p ~/.claude echo '${settingsJson}' > ~/.claude/settings.json echo '${claudeJson}' > ~/.claude.json `}`.quiet() } /** Check container status. */ export async function status(): Promise<"running" | "stopped" | "missing"> { const result = await $`container list --format json --all`.nothrow().quiet().text() try { const containers = JSON.parse(result.trim()) const container = containers.find((c: any) => c.configuration?.id === CONTAINER_NAME) if (!container) return "missing" return container.status?.toLowerCase() === "running" ? "running" : "stopped" } catch { return "missing" } } /** Launch claude in the container at the given workdir. */ export async function claude(workdir: string, opts?: { prompt?: string; print?: string }): Promise { const cwd = containerPath(workdir) const systemPrompt = [ "You are running inside a sandlot container (Apple Container, ubuntu:24.04).", `Your working directory is ${cwd}, a git worktree managed by sandlot.`, "The host's ~/dev is mounted read-only at /host.", "The host's ~/.sandlot is mounted at /sandlot.", ].join("\n") const term = process.env.TERM || "xterm-256color" const args = ["container", "exec", "-it", "--user", USER, "--workdir", cwd, CONTAINER_NAME, "env", `TERM=${term}`, CLAUDE_BIN, "--dangerously-skip-permissions", "--model", "claude-opus-4-6", "--append-system-prompt", systemPrompt] if (opts?.print) args.push("-p", opts.print) else if (opts?.prompt) args.push(opts.prompt) if (opts?.print) { const proc = Bun.spawn(args, { stdin: "inherit", stdout: "pipe", stderr: "inherit" }) const output = await new Response(proc.stdout).text() await proc.exited return output } const proc = Bun.spawn(args, { stdin: "inherit", stdout: "inherit", stderr: "inherit" }) await proc.exited } /** Open an interactive fish shell in the container. */ export async function shell(): Promise { const proc = Bun.spawn( ["container", "exec", "-it", "--user", USER, CONTAINER_NAME, "env", "TERM=xterm-256color", "fish", "--login"], { stdin: "inherit", stdout: "inherit", stderr: "inherit" }, ) await proc.exited } /** Run neofetch in the container. */ export async function info(): Promise { const proc = Bun.spawn( ["container", "exec", "--user", USER, CONTAINER_NAME, "neofetch"], { stdin: "inherit", stdout: "inherit", stderr: "inherit" }, ) await proc.exited } /** Run a bash command in the container at the given workdir, capturing output. */ export async function exec(workdir: string, command: string): Promise<{ exitCode: number; stdout: string; stderr: string }> { const result = await $`container exec --user ${USER} --workdir ${containerPath(workdir)} ${CONTAINER_NAME} bash -c ${"export PATH=$HOME/.local/bin:$PATH; " + command}`.nothrow().quiet() return { exitCode: result.exitCode, stdout: result.stdout.toString().trim(), stderr: result.stderr.toString().trim(), } } /** Get host paths of worktrees where claude is currently running in the container. */ export async function activeWorktrees(): Promise { const s = await status() if (s !== "running") return [] const result = await $`container exec ${CONTAINER_NAME} bash -c ${'for pid in $(pgrep -x claude 2>/dev/null); do readlink /proc/$pid/cwd 2>/dev/null; done'}`.nothrow().quiet().text() const home = homedir() return result.trim().split("\n").filter(Boolean).map(p => { if (p.startsWith("/sandlot")) return `${home}/.sandlot${p.slice("/sandlot".length)}` if (p.startsWith("/host")) return `${home}/dev${p.slice("/host".length)}` return p }) } /** Stop the container. */ export async function stop(): Promise { await $`container stop ${CONTAINER_NAME}`.nothrow().quiet() } /** Stop and delete the container. */ export async function destroy(): Promise { await stop() await $`container delete ${CONTAINER_NAME}`.nothrow().quiet() }