import { $ } from "bun" import { homedir } from "os" import { dirname, join } from "path" import { getApiKey } from "./env.ts" import { info } from "./fmt.ts" const CONTAINER_NAME = "sandlot" const USER = "ubuntu" const CLAUDE_BIN = `/home/${USER}/.local/bin/claude` const CONTAINER_PATH = `/home/${USER}/.local/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin` /** 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/dev" + hostPath.slice(`${home}/dev`.length) } if (hostPath.startsWith(`${home}/Code`)) { return "/host/Code" + hostPath.slice(`${home}/Code`.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) } } /** Run a shell command, logging stderr on failure. */ async function run(cmd: ReturnType, step: string): Promise { const result = await cmd.nothrow().quiet() if (result.exitCode !== 0) { const stderr = result.stderr.toString().trim() const stdout = result.stdout.toString().trim() const detail = stderr || stdout || "(no output)" throw new Error(`${step} failed (exit ${result.exitCode}):\n${detail}`) } } // ── create() helpers (internal) ────────────────────────────────────── /** Check which host source directories exist. */ async function hostMounts(home: string): Promise<{ dev: boolean; code: boolean }> { const [dev, code] = await Promise.all([ Bun.file(`${home}/dev`).exists().catch(() => false), Bun.file(`${home}/Code`).exists().catch(() => false), ]) return { dev, code } } /** Pull the image and start the container in detached mode. */ async function createContainer(home: string): Promise { const mounts = await hostMounts(home) const args = ["container", "run", "-d", "--name", CONTAINER_NAME, "-m", "4G"] if (mounts.dev) args.push("--mount", `type=bind,source=${home}/dev,target=/host/dev,readonly`) if (mounts.code) args.push("--mount", `type=bind,source=${home}/Code,target=/host/Code,readonly`) args.push("-v", `${home}/.sandlot:/sandlot`, "ubuntu:24.04", "sleep", "infinity") const result = await $`${args}`.nothrow().quiet() if (result.exitCode !== 0) { const stderr = result.stderr.toString().trim() const stdout = result.stdout.toString().trim() throw new Error(`Container creation failed (exit ${result.exitCode}):\n${stderr || stdout || "(no output)"}`) } } /** Install base system packages (as root). */ async function installPackages(cached: boolean): Promise { const packages = cached ? "curl git fish" : "curl git fish unzip" await run( $`container exec ${CONTAINER_NAME} bash -c ${`apt update && apt install -y ${packages}`}`, "Package installation") } /** Create symlinks so git worktree absolute paths from the host resolve inside the container. */ async function createHostSymlinks(home: string): Promise { const mounts = await hostMounts(home) const cmds = [`mkdir -p '${home}'`, `ln -s /sandlot '${home}/.sandlot'`] if (mounts.dev) cmds.push(`ln -s /host/dev '${home}/dev'`) if (mounts.code) cmds.push(`ln -s /host/Code '${home}/Code'`) await run( $`container exec ${CONTAINER_NAME} bash -c ${cmds.join(" && ")}`, "Symlink creation") } const CACHE_DIR = join(homedir(), '.sandlot', '.cache') /** Check whether the package cache is populated. */ async function hasCachedTooling(): Promise { const files = ['bun', 'claude', 'neofetch', 'nvim.tar.gz'] const checks = await Promise.all(files.map(f => Bun.file(join(CACHE_DIR, f)).exists())) return checks.every(Boolean) } /** Install Bun, Claude Code, neofetch, and Neovim using cached binaries when available. */ async function installTooling(cached: boolean, log?: (msg: string) => void): Promise { // Ensure cache directory exists on the host (mounted at /sandlot/.cache in the container) await $`mkdir -p ${CACHE_DIR}`.quiet() if (cached) { log?.("Installing packages (cached)") await run( $`container exec --user ${USER} ${CONTAINER_NAME} bash -c ${"mkdir -p ~/.local/bin"}`, "Create bin directory") await run( $`container exec --user ${USER} ${CONTAINER_NAME} bash -c ${"cp /sandlot/.cache/bun /sandlot/.cache/claude /sandlot/.cache/neofetch ~/.local/bin/ && chmod +x ~/.local/bin/bun ~/.local/bin/claude ~/.local/bin/neofetch && ln -sf bun ~/.local/bin/bunx"}`, "Install cached binaries") await run( $`container exec --user ${USER} ${CONTAINER_NAME} bash -c ${"tar xzf /sandlot/.cache/nvim.tar.gz -C ~/.local --strip-components=1"}`, "Install cached Neovim") return } 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"}`, "Bun installation") log?.("Installing Claude Code") await run( $`container exec --user ${USER} ${CONTAINER_NAME} bash -c ${"curl -fsSL https://claude.ai/install.sh | bash"}`, "Claude Code installation") log?.("Installing neofetch") await run( $`container exec --user ${USER} ${CONTAINER_NAME} bash -c ${"curl -fsSL https://raw.githubusercontent.com/dylanaraps/neofetch/master/neofetch -o ~/.local/bin/neofetch && chmod +x ~/.local/bin/neofetch"}`, "neofetch installation") log?.("Installing Neovim") await run( $`container exec --user ${USER} ${CONTAINER_NAME} bash -c ${"curl -fsSL https://github.com/neovim/neovim/releases/latest/download/nvim-linux-arm64.tar.gz -o /tmp/nvim.tar.gz && tar xzf /tmp/nvim.tar.gz -C ~/.local --strip-components=1"}`, "Neovim installation") // Cache binaries for next time await $`container exec --user ${USER} ${CONTAINER_NAME} bash -c ${"cp ~/.local/bin/bun ~/.local/bin/claude ~/.local/bin/neofetch /sandlot/.cache/ && cp /tmp/nvim.tar.gz /sandlot/.cache/nvim.tar.gz"}`.nothrow().quiet() } /** Configure git identity, API key helper, activity hook, and Claude settings. */ async function configureEnvironment(home: string, log?: (msg: string) => void, apiKey?: string): Promise { const gitName = (await $`git config user.name`.quiet().text()).trim() const gitEmail = (await $`git config user.email`.quiet().text()).trim() if (gitName) await $`container exec --user ${USER} ${CONTAINER_NAME} git config --global user.name ${gitName}`.quiet() if (gitEmail) await $`container exec --user ${USER} ${CONTAINER_NAME} git config --global user.email ${gitEmail}`.quiet() if (!apiKey) { log?.("Warning: ANTHROPIC_API_KEY not found in ~/.env — claude will require manual login") } const activityBin = `/home/${USER}/.local/bin/sandlot-activity` const hooks = { UserPromptSubmit: [{ hooks: [{ type: "command", command: `${activityBin} active` }] }], Stop: [{ hooks: [{ type: "command", command: `${activityBin} idle` }] }], } const settingsJson = JSON.stringify(apiKey ? { apiKeyHelper: "~/.claude/api-key-helper.sh", skipDangerousModePermissionPrompt: true, hooks } : { skipDangerousModePermissionPrompt: true, hooks }) 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() } // Install activity tracking hook script const activityTmp = `${home}/.sandlot/.sandlot-activity.tmp` await Bun.write(activityTmp, [ '#!/bin/bash', 'P="${CLAUDE_PROJECT_DIR%/}"', 'echo "$1" > "$(dirname "$P")/.activity-$(basename "$P")"', '', ].join('\n')) await $`chmod +x ${activityTmp}`.quiet() await $`container exec --user ${USER} ${CONTAINER_NAME} bash -c ${"cp /sandlot/.sandlot-activity.tmp ~/.local/bin/sandlot-activity"}`.quiet() await Bun.file(activityTmp).unlink() await $`container exec --user ${USER} ${CONTAINER_NAME} bash -c ${` mkdir -p ~/.claude echo '${settingsJson}' > ~/.claude/settings.json echo '${claudeJson}' > ~/.claude.json `}`.quiet() } // ── create() ──────────────────────────────────────────────────────── /** Create and provision the container from scratch. Fails if it already exists. */ export async function create(log?: (msg: string) => void): Promise { requireContainer() const s = await status() if (s !== "missing") { throw new Error("Container already exists. Use 'sandlot vm destroy' first to recreate it.") } const home = homedir() const cached = await hasCachedTooling() log?.("Pulling image & creating container") await createContainer(home) log?.("Installing packages") await installPackages(cached) await createHostSymlinks(home) await installTooling(cached, log) log?.("Configuring environment") const apiKey = await getApiKey() await configureEnvironment(home, log, apiKey) } /** Start a stopped container. */ export async function start(): Promise { requireContainer() const s = await status() if (s === "running") return if (s === "missing") throw new Error("Container does not exist. Use 'sandlot vm create' first.") await run($`container start ${CONTAINER_NAME}`, "Container start") } /** 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 start() return } await create(log) } /** 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; continue?: boolean }): Promise<{ exitCode: number; output?: string }> { const cwd = containerPath(workdir) const mounts = await hostMounts(homedir()) const systemPromptLines = [ "You are running inside a sandlot container (Apple Container, ubuntu:24.04).", `Your working directory is ${cwd}, a git worktree managed by sandlot.`, ] if (mounts.dev) systemPromptLines.push("The host's ~/dev is mounted read-only at /host/dev.") if (mounts.code) systemPromptLines.push("The host's ~/Code is mounted read-only at /host/Code.") systemPromptLines.push( "The host's ~/.sandlot is mounted at /sandlot.", "Bun is installed at ~/.local/bin/bun. Use bun instead of node/npm.", ) if (opts?.print) { systemPromptLines.push("IMPORTANT: Do not use plan mode. Do not call the EnterPlanMode tool. Proceed directly with the task.") } const systemPrompt = systemPromptLines.join("\n") const term = process.env.TERM || "xterm-256color" const args = ["container", "exec", "-it", "--user", USER, "--workdir", cwd, CONTAINER_NAME, "env", `TERM=${term}`, `PATH=${CONTAINER_PATH}`, CLAUDE_BIN, "--dangerously-skip-permissions", "--model", "claude-opus-4-6", "--append-system-prompt", systemPrompt] if (opts?.continue) args.push("--continue") 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() const exitCode = await proc.exited if (exitCode !== 0 && opts?.continue) { info("Retrying without --continue") return claude(workdir, { ...opts, continue: false }) } return { exitCode, output } } const proc = Bun.spawn(args, { stdin: "inherit", stdout: "inherit", stderr: "inherit" }) const exitCode = await proc.exited if (exitCode !== 0 && opts?.continue) { info("Retrying without --continue") return claude(workdir, { ...opts, continue: false }) } return { exitCode } } /** Open an interactive fish shell in the container, optionally in a specific directory. */ export async function shell(workdir?: string): Promise { const args = ["container", "exec", "-it", "--user", USER] if (workdir) args.push("--workdir", containerPath(workdir)) args.push(CONTAINER_NAME, "env", "TERM=xterm-256color", `PATH=${CONTAINER_PATH}`, "fish", "--login") const proc = Bun.spawn(args, { stdin: "inherit", stdout: "inherit", stderr: "inherit" }) await proc.exited } /** Run neofetch in the container. */ export async function neofetch(): Promise { const proc = Bun.spawn( ["container", "exec", "--user", USER, CONTAINER_NAME, "env", `PATH=${CONTAINER_PATH}`, "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(), } } /** Pipe input text to Claude in the container with a prompt, returning the output. */ export async function claudePipe(input: string, prompt: string): Promise<{ exitCode: number; stdout: string; stderr: string }> { const tmpPath = join(homedir(), '.sandlot', '.claude-pipe-tmp') try { await Bun.write(tmpPath, input) return await exec( join(homedir(), '.sandlot'), `cat /sandlot/.claude-pipe-tmp | claude -p "${prompt.replace(/"/g, '\\"')}"`, ) } finally { await Bun.file(tmpPath).unlink().catch(() => {}) } } /** Check if Claude is actively working in the given worktree (based on activity hook). */ export async function isClaudeActive(worktree: string, branch: string): Promise { const file = `${dirname(worktree)}/.activity-${branch}` try { const content = await Bun.file(file).text() return content.trim() === "active" } catch { return false } } /** Remove the activity marker file for a worktree. */ export async function clearActivity(worktree: string, branch: string): Promise { const file = `${dirname(worktree)}/.activity-${branch}` await Bun.file(file).unlink().catch(() => {}) } /** 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() } /** Clear the package cache so the next create re-downloads everything. */ export async function clearCache(): Promise { const exists = await Bun.file(join(CACHE_DIR, 'bun')).exists() await $`rm -rf ${CACHE_DIR}`.nothrow().quiet() return exists }