From f7d876776b6f8786c4a7fcb67194f2ed9db57d98 Mon Sep 17 00:00:00 2001 From: Chris Wanstrath Date: Fri, 20 Mar 2026 10:12:50 -0700 Subject: [PATCH] Add per-user config system with `sandlot config` command Allow users to configure container memory limit (default 32G) via `sandlot config memory ` instead of hardcoding it. Co-Authored-By: Claude Opus 4.6 --- CLAUDE.md | 4 +++- src/cli.ts | 7 +++++++ src/commands/config.ts | 38 ++++++++++++++++++++++++++++++++++++++ src/config.ts | 33 +++++++++++++++++++++++++++++++++ src/vm.ts | 4 +++- 5 files changed, 84 insertions(+), 2 deletions(-) create mode 100644 src/commands/config.ts create mode 100644 src/config.ts diff --git a/CLAUDE.md b/CLAUDE.md index d021883..622589d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -36,7 +36,9 @@ src/ fmt.ts # ANSI escape codes, die/success/info helpers, pager markdown.ts # Terminal markdown renderer (headings, bold, links, code blocks) spinner.ts # CLI progress spinner (braille frames) + config.ts # Per-user config (~/.config/sandlot/config.json): memory, etc. commands/ + config.ts # Get/set config values (e.g. `sandlot config memory 16G`) new.ts # Create session, derive branch name from prompt open.ts # Re-enter existing session (always uses --continue) close.ts # Remove worktree and clean up session @@ -79,7 +81,7 @@ Each module has a single responsibility. No classes — only exported async func - Image: `ubuntu:24.04` - User: `ubuntu` -- Memory limit: 4G +- Memory limit: configurable via `sandlot config memory` (default 32G) - 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/…`) diff --git a/src/cli.ts b/src/cli.ts index cc2126f..39dcc36 100755 --- a/src/cli.ts +++ b/src/cli.ts @@ -26,6 +26,7 @@ import { register as registerVmCommands } from "./commands/vm.ts" import { action as completionsAction } from "./commands/completions.ts" import { action as initAction } from "./commands/init.ts" import { action as cdAction } from "./commands/cd.ts" +import { action as configAction } from "./commands/config.ts" const pkg = await Bun.file(new URL("../package.json", import.meta.url)).json() @@ -185,6 +186,12 @@ program program.commandsGroup("Admin Commands:") +program + .command("config") + .argument("[args...]", "key [value]") + .description("Get or set configuration (e.g. sandlot config memory 16G)") + .action(configAction) + program .command("cleanup") .description("Remove stale sessions whose worktrees no longer exist") diff --git a/src/commands/config.ts b/src/commands/config.ts new file mode 100644 index 0000000..24610dc --- /dev/null +++ b/src/commands/config.ts @@ -0,0 +1,38 @@ +import { die } from "../fmt.ts" +import * as config from "../config.ts" + +const KEYS: Record string | null }> = { + memory: { + description: "Container memory limit (e.g. 4G, 16G, 32G, 64G)", + validate: (v) => /^\d+[GMgm]$/.test(v) ? null : "Must be a number followed by G or M (e.g. 16G)", + }, +} + +export async function action(args: string[]) { + if (args.length === 0) { + const cfg = await config.load() + const defaults: Record = { memory: "32G" } + for (const [key, meta] of Object.entries(KEYS)) { + const val = (cfg as any)[key] + const display = val ?? `${defaults[key]} (default)` + console.log(`${key} = ${display}`) + } + return + } + + if (args.length === 1) { + const key = args[0] + if (!(key in KEYS)) die(`Unknown config key: ${key}\nAvailable keys: ${Object.keys(KEYS).join(", ")}`) + const val = await config.get(key as keyof config.Config) + const defaults: Record = { memory: "32G" } + console.log(val ?? `${defaults[key]} (default)`) + return + } + + const [key, value] = args + if (!(key in KEYS)) die(`Unknown config key: ${key}\nAvailable keys: ${Object.keys(KEYS).join(", ")}`) + const error = KEYS[key].validate(value) + if (error) die(error) + await config.set(key as keyof config.Config, value.toUpperCase()) + console.log(`${key} = ${value.toUpperCase()}`) +} diff --git a/src/config.ts b/src/config.ts new file mode 100644 index 0000000..69584b9 --- /dev/null +++ b/src/config.ts @@ -0,0 +1,33 @@ +import { homedir } from "os" +import { dirname, join } from "path" + +const CONFIG_PATH = join(homedir(), ".config", "sandlot", "config.json") + +export interface Config { + memory?: string +} + +export async function load(): Promise { + try { + return await Bun.file(CONFIG_PATH).json() + } catch { + return {} + } +} + +export async function save(config: Config): Promise { + const { mkdir } = await import("fs/promises") + await mkdir(dirname(CONFIG_PATH), { recursive: true }) + await Bun.write(CONFIG_PATH, JSON.stringify(config, null, 2) + "\n") +} + +export async function get(key: K): Promise { + const config = await load() + return config[key] +} + +export async function set(key: K, value: Config[K]): Promise { + const config = await load() + config[key] = value + await save(config) +} diff --git a/src/vm.ts b/src/vm.ts index dedf38c..9b2b6d2 100644 --- a/src/vm.ts +++ b/src/vm.ts @@ -4,6 +4,7 @@ import { homedir } from "os" import { dirname, join } from "path" import { requireApiKey } from "./env.ts" import { info } from "./fmt.ts" +import { get as getConfig } from "./config.ts" const DEBUG = !!process.env.DEBUG const CONTAINER_NAME = "sandlot" @@ -66,7 +67,8 @@ function hostMounts(home: string): { dev: boolean; code: boolean } { /** Pull the image and start the container in detached mode. */ async function createContainer(home: string): Promise { const mounts = hostMounts(home) - const args = ["container", "run", "-d", "--name", CONTAINER_NAME, "-m", "32G"] + const memory = (await getConfig("memory")) ?? "32G" + const args = ["container", "run", "-d", "--name", CONTAINER_NAME, "-m", memory] 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")