craftboxes
Version:
box: Claude Code on your laptop, the work on a cloud dev machine you reach as <name>.box, with your team databases through MCP.
1,387 lines • 71.1 kB
JavaScript
#!/usr/bin/env node
// src/index.ts
import { styleText as styleText5 } from "node:util";
import { Command } from "commander";
import { input as input2, select as select4 } from "@inquirer/prompts";
// src/api.ts
import { spawn } from "node:child_process";
import { chmodSync, existsSync as existsSync2, mkdirSync as mkdirSync2, readFileSync as readFileSync2, writeFileSync as writeFileSync2 } from "node:fs";
import { join as join2 } from "node:path";
import { styleText as styleText2 } from "node:util";
// src/laptop.ts
import { execFile, execFileSync, spawnSync } from "node:child_process";
import { gzip } from "node:zlib";
import { existsSync, mkdirSync, readdirSync, readFileSync, renameSync, rmdirSync, statSync, writeFileSync } from "node:fs";
import { homedir as homedir2, userInfo } from "node:os";
import { join, sep } from "node:path";
import ssh2 from "ssh2";
// src/ui.ts
import { homedir } from "node:os";
import { styleText } from "node:util";
var dim = (s) => styleText("dim", s);
var ok = (s) => console.log(`${styleText("green", "\u2713")} ${s}`);
var miss = (s) => console.log(`${styleText("yellow", "!")} ${s}`);
var fail = (s) => {
active?.();
console.error(`${styleText("red", "\u2717")} ${s}`);
process.exit(1);
};
var active;
var stopProgress = () => active?.();
function progress(steps, { pace = 5e3 } = {}) {
const tty = process.stdout.isTTY;
if (!tty) {
console.log(dim(`${steps[0][1]}\u2026`));
return Object.assign((done) => {
if (done) ok(done);
}, { say: (label2) => console.log(dim(`${label2}\u2026`)) });
}
const spin = "\u280B\u2819\u2839\u2838\u283C\u2834\u2826\u2827\u2807\u280F", t0 = Date.now(), width = 16;
let i = 0, said;
process.stdout.write("\x1B[?25l");
const restore = () => process.stdout.write("\x1B[?25h");
process.once("exit", restore);
const draw = () => {
const ms = Date.now() - t0;
const label2 = said ?? steps.filter(([at2]) => at2 <= ms).at(-1)[1];
const at = Math.min(width - 1, Math.floor((1 - Math.exp(-ms / pace)) * width));
const track = [...Array(width)].map((_, k) => k < at ? styleText("cyan", "\u2501") : k === at ? "\u{1F4E6}" : styleText("dim", "\u2500")).join("");
const secs = ms / 1e3, clock = secs < 60 ? `${secs.toFixed(1)}s` : `${Math.floor(secs / 60)}m ${String(Math.floor(secs % 60)).padStart(2, "0")}s`;
process.stdout.write(`\r\x1B[2K${styleText("cyan", spin[i++ % spin.length])} ${track} ${label2.slice(0, Math.max(20, (process.stdout.columns ?? 100) - width - 16))}\u2026 ${dim(clock)}`);
};
draw();
const timer = setInterval(draw, 80);
const stop = (done) => {
clearInterval(timer);
process.stdout.write("\r\x1B[2K");
restore();
active = void 0;
if (done) ok(done);
};
active = stop;
return Object.assign(stop, { say: (label2) => {
said = label2;
} });
}
var span = (ms) => {
const m = Math.max(0, Math.round(ms / 6e4));
return m < 60 ? `${m}m` : m < 1440 ? `${Math.floor(m / 60)}h ${m % 60}m` : `${Math.round(m / 1440)} days`;
};
var tilde = (p) => p.startsWith(homedir()) ? `~${p.slice(homedir().length)}` : p;
function label(b) {
if (b.state === "paused") return styleText("blue", b.asleepSince ? `asleep ${span(Date.now() - new Date(b.asleepSince).getTime())}` : "asleep");
if (b.state !== "running") return styleText("yellow", b.state);
if (b.busy) return styleText("green", "\u25CF working");
if (!b.idleMinutes) return styleText("green", "awake");
const left = b.idleMinutes * 6e4 - (Date.now() - new Date(b.lastActiveAt ?? Date.now()).getTime());
return styleText("yellow", `\u263E sleeps in ${span(Math.max(6e4, left))}`);
}
// src/laptop.ts
var OLD_CONFIG = join(homedir2(), ".config", "craftboxes");
var OLD_BOXES = join(homedir2(), ".craftboxes");
var NEW_CONFIG = join(homedir2(), ".config", "box");
var NEW_BOXES = join(homedir2(), ".box");
var CONFIG_DIR = existsSync(OLD_CONFIG) ? OLD_CONFIG : NEW_CONFIG;
var BOXES = existsSync(OLD_BOXES) ? OLD_BOXES : NEW_BOXES;
var SERVICES = { 5432: "Postgres", 6379: "Redis", 3306: "MySQL", 27017: "MongoDB", 8123: "ClickHouse" };
function moveHome() {
const moves = [[OLD_CONFIG, NEW_CONFIG], [OLD_BOXES, NEW_BOXES]].filter(([from]) => existsSync(from));
if (!moves.length) return;
if (otherBoxRunning()) return miss(`Close your other box windows so box can move ~/.craftboxes to ~/.box`);
const clash = moves.flatMap(([from, to]) => existsSync(to) ? readdirSync(from).filter((f) => existsSync(join(to, f))).map((f) => join(to, f)) : []);
if (clash.length) return miss(`box can't move ~/.craftboxes to ~/.box while ${clash.join(", ")} exists`);
try {
for (const [from, to] of moves) {
if (!existsSync(to)) {
renameSync(from, to);
continue;
}
for (const f of readdirSync(from)) renameSync(join(from, f), join(to, f));
rmdirSync(from);
}
} catch (e) {
return miss(`Couldn't move ~/.craftboxes to ~/.box yet: ${e.message}`);
}
if (moves.some(([from]) => from === OLD_BOXES)) {
repairWorktrees();
moveClaudeProjects();
}
ok(`Moved ~/.craftboxes to ~/.box ${dim("\xB7 reopen VS Code windows with box code")}`);
const again = spawnSync(process.execPath, process.argv.slice(1), { stdio: "inherit", env: { ...process.env, CBOX_UPDATED: "1" } });
process.exit(again.status ?? 0);
}
function otherBoxRunning() {
try {
return execFileSync("ps", ["-A", "-o", "pid=,args="], { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"], timeout: 3e3 }).split("\n").some((line) => {
const [pid, ...args] = line.trim().split(/\s+/);
const command = args.join(" ");
return ![process.pid, process.ppid].includes(Number(pid)) && /(^|[\s/])c?box(\.mjs)?(\s|$)/.test(command) && !/\s(proxy|sync)(\s|$)/.test(command);
});
} catch {
return false;
}
}
function repairWorktrees() {
for (const env of readdirSync(NEW_BOXES)) {
const clone = join(NEW_BOXES, env, ".clone");
if (env.startsWith(".") || !existsSync(join(clone, ".git"))) continue;
const worktrees = readdirSync(join(NEW_BOXES, env)).map((d) => join(NEW_BOXES, env, d, "repo")).filter((r) => existsSync(join(r, ".git")) && statSync(join(r, ".git")).isFile());
try {
if (worktrees.length) execFileSync("git", ["-C", clone, "worktree", "repair", ...worktrees], { stdio: "ignore", timeout: 3e4 });
} catch {
miss(`Couldn't repair the ${env} worktrees. Run: git -C ${clone} worktree repair`);
}
}
}
function moveClaudeProjects() {
const projects = join(homedir2(), ".claude", "projects");
const [from, to] = [OLD_BOXES, NEW_BOXES].map((p) => p.replace(/[^a-zA-Z0-9]/g, "-"));
for (const dir of existsSync(projects) ? readdirSync(projects) : []) {
const target = join(projects, to + dir.slice(from.length));
if ((dir === from || dir.startsWith(`${from}-`)) && !existsSync(target)) renameSync(join(projects, dir), target);
}
const file2 = join(homedir2(), ".claude.json");
try {
const config = JSON.parse(readFileSync(file2, "utf8"));
const keys = Object.keys(config.projects ?? {});
if (!keys.some((k) => k.startsWith(OLD_BOXES + sep))) return;
config.projects = Object.fromEntries(Object.entries(config.projects).map(([k, v]) => [k.startsWith(OLD_BOXES + sep) ? NEW_BOXES + k.slice(OLD_BOXES.length) : k, v]));
writeFileSync(file2, JSON.stringify(config, null, 2));
} catch {
}
}
function localMcp() {
const claudeJson = join(homedir2(), ".claude.json");
const servers = existsSync(claudeJson) ? JSON.parse(readFileSync(claudeJson, "utf8")).mcpServers ?? {} : {};
const skipped = [];
const mcpServers = {};
for (const [name, s] of Object.entries(servers)) {
if (s.command && /^(\/|\.|~)/.test(s.command)) skipped.push(name);
else mcpServers[name] = s;
}
const mcpOAuth = Object.fromEntries(Object.entries(localCreds().mcpOAuth ?? {}).map(([k, v]) => {
const { refreshToken, ...rest } = v;
void refreshToken;
return [k, rest];
}));
return { mcpServers, mcpOAuth, skipped };
}
function localClaudeHome() {
const root = join(homedir2(), ".claude");
const json = (p) => {
try {
return JSON.parse(readFileSync(join(root, p), "utf8"));
} catch {
return {};
}
};
const settings = json("settings.json");
const enabled = Object.keys(settings.enabledPlugins ?? {}).filter((k) => settings.enabledPlugins[k]);
const installed = json("plugins/installed_plugins.json").plugins ?? {};
const known = json("plugins/known_marketplaces.json");
const plugins = {}, marketplaces = {};
for (const id of enabled) {
const mine = (installed[id] ?? []).filter((e) => e.scope === "user" && e.installPath?.startsWith(root));
const m = known[id.split("@")[1]];
if (!mine.length || !m?.installLocation?.startsWith(root)) continue;
plugins[id] = mine;
marketplaces[id.split("@")[1]] = m;
}
const dirs = ["skills", "agents", "commands", "output-styles"].filter((d) => existsSync(join(root, d)));
const paths = [...dirs, ...Object.values(plugins).flat().map((e) => e.installPath), ...Object.values(marketplaces).map((m) => m.installLocation)].map((p) => p.startsWith(root) ? p.slice(root.length + 1) : p);
const tar = !paths.length ? Promise.resolve(void 0) : new Promise((done) => execFile("tar", ["-chf", "-", "--no-xattrs", "--exclude=.git", "--exclude=node_modules", "--exclude=.DS_Store", "-C", root, ...new Set(paths)], { encoding: "buffer", maxBuffer: 256 << 20, env: { ...process.env, COPYFILE_DISABLE: "1" } }, (err, out) => err ? done(void 0) : gzip(out, (e, gz) => done(e ? void 0 : gz))));
const memory = existsSync(join(root, "CLAUDE.md")) ? readFileSync(join(root, "CLAUDE.md"), "utf8") : "";
return { from: root, pending: paths.length > 0, tar, plugins, marketplaces, enabled: Object.keys(plugins), memory, skills: existsSync(join(root, "skills")) ? readdirSync(join(root, "skills")).filter((f) => !f.startsWith(".")).length : 0 };
}
function laptopKey() {
const priv = join(CONFIG_DIR, "id_ed25519");
if (!existsSync(priv)) {
const k = ssh2.utils.generateKeyPairSync("ed25519", { comment: "box" });
mkdirSync(CONFIG_DIR, { recursive: true });
writeFileSync(priv, k.private, { mode: 384 });
writeFileSync(priv + ".pub", k.public.trim() + "\n");
}
return { privateKey: readFileSync(priv), publicKey: readFileSync(priv + ".pub", "utf8").trim() };
}
function sshKeys(boxKey) {
const home2 = join(homedir2(), ".ssh");
const mine = existsSync(home2) ? readdirSync(home2).filter((f) => f.endsWith(".pub")).map((f) => readFileSync(join(home2, f), "utf8").trim().split(/\s+/).slice(0, 2).join(" ")) : [];
return [boxKey, ...mine.filter((k) => /^(ssh-ed25519|ssh-rsa|ecdsa-sha2-nistp(256|384|521)|sk-ssh-ed25519@openssh\.com|sk-ecdsa-sha2-nistp256@openssh\.com) [A-Za-z0-9+/=]+$/.test(k))].join("\n");
}
function localCreds() {
const file2 = join(homedir2(), ".claude", ".credentials.json");
if (process.platform !== "darwin") return existsSync(file2) ? JSON.parse(readFileSync(file2, "utf8")) : {};
for (const account of [["-a", userInfo().username], []]) {
try {
return JSON.parse(execFileSync("security", ["find-generic-password", "-s", "Claude Code-credentials", ...account, "-w"], { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }));
} catch {
}
}
return {};
}
function localGitHubToken() {
const out = (cmd, args, input3) => {
try {
return execFileSync(cmd, args, { encoding: "utf8", input: input3, stdio: [input3 ? "pipe" : "ignore", "pipe", "ignore"], env: { ...process.env, GIT_TERMINAL_PROMPT: "0" } }).trim() || void 0;
} catch {
return void 0;
}
};
return out("gh", ["auth", "token", "--hostname", "github.com"]) ?? out("git", ["credential", "fill"], "protocol=https\nhost=github.com\n\n")?.match(/^password=(.+)$/m)?.[1];
}
function gitRepo() {
const git2 = (...args) => {
try {
return execFileSync("git", args, { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"], timeout: 3e3 }).trim();
} catch {
return "";
}
};
const dir = git2("rev-parse", "--show-toplevel");
if (!dir) return void 0;
const url = git2("remote", "get-url", "origin") || git2("remote", "get-url", "upstream");
const m = /github\.com[:/]+([\w.-]+)\/([\w.-]+?)(?:\.git)?\/?$/.exec(url);
if (!m) return void 0;
const branch = git2("branch", "--show-current");
const pushed = branch && git2("rev-parse", "--abbrev-ref", `${branch}@{upstream}`);
const defaultBranch = git2("symbolic-ref", "--short", "refs/remotes/origin/HEAD").replace(/^origin\//, "");
return { dir, repo: `${m[1]}/${m[2]}`, branch: pushed && branch !== defaultBranch ? branch : void 0 };
}
function localCopy() {
const git2 = (...args) => {
try {
return execFileSync("git", args, { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"], timeout: 5e3, maxBuffer: 16 << 20 }).trim();
} catch {
return "";
}
};
const top = git2("rev-parse", "--show-toplevel");
if (!top) return void 0;
const repo = /github\.com[:/]+([\w.-]+\/[\w.-]+?)(?:\.git)?\/?$/.exec(git2("-C", top, "remote", "get-url", "origin"))?.[1];
const files = git2("-C", top, "ls-files", "--others", "--ignored", "--exclude-standard", "--directory").split("\n").filter((f) => /(^|\/)(\.env[^/]*|[^/]+\.local|docker-compose\.override\.ya?ml)$/.test(f) && !/(^|\/)(node_modules|\.claude)\//.test(f)).slice(0, 8);
let listening = "";
try {
listening = execFileSync("lsof", ["-nP", "-iTCP", "-sTCP:LISTEN"], { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"], timeout: 3e3 });
} catch {
}
const ports = new Set([...listening.matchAll(/:(\d+) \(LISTEN\)/g)].map((m) => Number(m[1])));
const services = Object.entries(SERVICES).filter(([port]) => ports.has(Number(port))).map(([port, name]) => `${name} :${port}`);
return { dir: top, repo, branch: git2("-C", top, "branch", "--show-current") || void 0, files, services };
}
// src/api.ts
var file = join2(CONFIG_DIR, "config.json");
var load = () => {
if (!existsSync2(file)) return { server: process.env.CB_SERVER ?? "https://box.abuaboud.me" };
const c = JSON.parse(readFileSync2(file, "utf8"));
return { ...c, server: c.server.replace("://craftboxes.abuaboud.me", "://box.abuaboud.me") };
};
var save = (c) => {
mkdirSync2(CONFIG_DIR, { recursive: true });
writeFileSync2(file, JSON.stringify(c, null, 2));
chmodSync(file, 384);
};
async function api(path, init = {}, retried = false) {
if (!load().token) await login();
const c = load();
const res = await fetch(new URL(path, c.server), { ...init, headers: { authorization: `Bearer ${c.token}`, ...init.body ? { "content-type": "application/json" } : {} } });
const body = await res.json().catch(() => ({}));
if (res.status === 401 && !retried) {
save({ ...c, token: void 0 });
return api(path, init, true);
}
if (!res.ok) fail(body.error ?? `Request failed (${res.status})`);
return body;
}
async function login(server) {
stopProgress();
const c = { ...load(), ...server && { server } };
const post = (path, body) => fetch(new URL(path, c.server), { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(body) }).then((r) => r.json());
const code2 = await post("/api/auth/device/code", { client_id: "cb" });
if (!code2.device_code) fail(`The server refused: ${code2.error_description ?? code2.message ?? "no device code"}`);
console.log(`Open ${styleText2("underline", code2.verification_uri_complete)} and approve ${styleText2("bold", code2.user_code)}`);
if (process.stdout.isTTY) spawn(process.platform === "darwin" ? "open" : "xdg-open", [code2.verification_uri_complete], { stdio: "ignore", detached: true }).on("error", () => {
}).unref();
for (let wait = code2.interval ?? 5; ; ) {
await new Promise((r) => setTimeout(r, wait * 1e3));
const t = await post("/api/auth/device/token", { grant_type: "urn:ietf:params:oauth:grant-type:device_code", device_code: code2.device_code, client_id: "cb" });
if (t.access_token) {
c.token = t.access_token;
break;
}
if (t.error === "slow_down") wait += 5;
else if (t.error !== "authorization_pending") fail(t.error_description ?? t.error);
}
save(c);
const me = await fetch(new URL("/api/me", c.server), { headers: { authorization: `Bearer ${c.token}` } }).then((r) => r.json());
save({ ...load(), email: me.user.email });
ok(`Signed in as ${me.user.email} ${dim(`\xB7 ${me.org.name}`)}`);
console.log();
}
async function findBox(name) {
const box = (await api("/api/boxes")).find((b) => b.name === name || b.id === name);
return box ?? fail(`No box named ${name}. box ls lists them.`);
}
// src/environments.ts
import { execFileSync as execFileSync2, spawn as spawn2 } from "node:child_process";
import { existsSync as existsSync3, readFileSync as readFileSync3 } from "node:fs";
import { join as join3 } from "node:path";
import { styleText as styleText3 } from "node:util";
import { input, select } from "@inquirer/prompts";
async function chooseEnvironment(envName, here) {
const envs = await api("/api/environments");
if (envName) return waitReady(envs.find((e) => e.name === envName) ?? fail(`No environment named ${envName}.`));
const mine = here ? envs.filter((e) => e.repo.toLowerCase() === here.repo.toLowerCase()) : [];
const setUp = async () => setUpEnvironment(here?.repo ?? await askRepo(), here?.dir, envs);
if (!envs.length) return waitReady(await setUp());
if (!process.stdin.isTTY && mine.length === 1) return waitReady(mine[0]);
const label2 = (e) => `${e.name.padEnd(16)}${dim(`${mine.includes(e) ? "this repo" : e.repo} \xB7 ${e.size ?? "small"}${e.status === "ready" ? "" : ` \xB7 ${e.status}`}`)}`;
const create = { name: styleText3("cyan", here ? `+ Set up a new environment for ${here.repo}` : "+ Set up a new environment"), value: "" };
const pick = await select({
message: here ? `Environment for ${here.repo}${here.branch ? dim(` \xB7 branch ${here.branch}`) : ""}` : `Environment ${dim("(you're not in a repo)")}`,
choices: [...mine.map((e) => ({ name: label2(e), value: e.name })), ...here && !mine.length ? [create] : [], ...envs.filter((e) => !mine.includes(e)).map((e) => ({ name: label2(e), value: e.name })), ...here && !mine.length ? [] : [create]]
});
return waitReady(pick ? envs.find((e) => e.name === pick) : await setUp());
}
var askRepo = () => input({ message: "GitHub repo (owner/name)", validate: (v) => /^[\w.-]+\/[\w.-]+$/.test(v.trim()) || "Write it as owner/name" }).then((v) => v.trim());
async function waitReady(env) {
if (env.status === "ready") return env;
if (env.status === "failed") fail(`The ${env.name} environment failed to build: ${(env.error ?? "").split("\n")[0]}. Rebuild it from the dashboard.`);
const t0 = Date.now();
const bar = progress([[0, env.progress ?? "Building the snapshot"]], { pace: 9e4 });
for (; ; ) {
await new Promise((r) => setTimeout(r, 2e3));
const now = await api(`/api/environments/${env.id}`);
if (now.progress) bar.say(now.progress);
if (now.status === "ready") {
const secs = Math.round((Date.now() - t0) / 1e3);
bar(`Snapshot ready ${dim(`${secs >= 60 ? `${Math.floor(secs / 60)}m ${String(secs % 60).padStart(2, "0")}s` : `${secs}s`} \xB7 next boxes start in about 2s`)}`);
return now;
}
if (now.status === "failed") {
bar();
fail(`The build failed: ${(now.error ?? "").trim().split("\n").slice(-3).join(" ").slice(0, 300)}`);
}
}
}
function detectSetup(dir) {
if (!dir) return void 0;
const has = (f) => existsSync3(join3(dir, f));
let scripts = {};
try {
scripts = JSON.parse(readFileSync3(join3(dir, "package.json"), "utf8")).scripts ?? {};
} catch {
}
const pm = has("bun.lock") || has("bun.lockb") ? "bun" : has("pnpm-lock.yaml") ? "pnpm" : has("yarn.lock") ? "yarn" : has("package-lock.json") ? "npm" : void 0;
const own = ["setup", "bootstrap"].find((k) => scripts[k]);
if (pm && own) return { setup: `${pm} run ${own}`, from: `the ${own} script in package.json` };
const byLock = [
["bun.lock", "bun install"],
["bun.lockb", "bun install"],
["pnpm-lock.yaml", "pnpm install --frozen-lockfile"],
["yarn.lock", "yarn install --immutable"],
["package-lock.json", "npm ci"],
["uv.lock", "uv sync"],
["requirements.txt", "pip install -r requirements.txt"],
["go.mod", "go mod download"],
["Cargo.lock", "cargo fetch"],
["Gemfile.lock", "bundle install"]
];
const hit = byLock.find(([f]) => has(f));
return hit && { setup: hit[1], from: hit[0] };
}
function detectSize(dir) {
if (!dir) return { size: "small", why: "the default" };
let files = 0, kb = 0;
try {
files = Number(execFileSync2("sh", ["-c", "git ls-files | wc -l"], { cwd: dir, encoding: "utf8", timeout: 5e3 }).trim());
} catch {
}
try {
kb = Number(execFileSync2("du", ["-sk", ".git"], { cwd: dir, encoding: "utf8", timeout: 5e3 }).split(" ")[0]);
} catch {
}
const why = `${files.toLocaleString()} files, ${(kb / 1024 / 1024).toFixed(1)} GB of history`;
return files > 2e4 || kb > 1024 * 1024 ? { size: "medium", why } : { size: "small", why };
}
function secretNames(dir) {
for (const f of [".env.example", ".env.sample", ".env.template"]) {
try {
return readFileSync3(join3(dir ?? "", f), "utf8").split("\n").map((l) => /^\s*(?:export\s+)?([A-Z][A-Z0-9_]*)\s*=/.exec(l)?.[1]).filter((k) => !!k);
} catch {
}
}
return [];
}
async function setUpEnvironment(repo, dir, envs) {
miss(`${repo} ${dim("has no environment yet. Setting one up:")}`);
const detected = detectSetup(dir), sized = detectSize(dir), names = secretNames(dir);
let name = repo.split("/")[1].toLowerCase().replace(/[^a-z0-9-]/g, "-").replace(/^-+/, "").slice(0, 28) || "env";
for (let i = 2, base = name; envs.some((e) => e.name === name); i++) name = `${base}-${i}`;
let setup = detected?.setup ?? "", size = sized.size, secrets = "";
const sizes = { small: "2 vCPU, 4 GB", medium: "4 vCPU, 8 GB", large: "8 vCPU, 16 GB" };
for (; ; ) {
console.log();
console.log(` ${dim("Name ")} ${name}`);
console.log(` ${dim("Repo ")} ${repo}`);
console.log(` ${dim("Machine ")} ${size[0].toUpperCase() + size.slice(1)} ${dim(`\xB7 ${sizes[size]}${size === sized.size ? ` (${sized.why})` : ""}`)}`);
console.log(` ${dim("Setup ")} ${setup ? styleText3("cyan", setup) : dim("none")} ${dim(detected && setup === detected.setup ? `\xB7 from ${detected.from}` : setup ? "" : "\xB7 boxes start from a plain clone")}`);
if (names.length) console.log(` ${dim("Secrets ")} ${names.join(", ")} ${dim(secrets ? "\xB7 values copied from .env" : "\xB7 names from .env.example, values empty")}`);
console.log();
const choice = await select({ message: "Create it?", choices: [
{ name: "Create and start a box", value: "go" },
...names.length && !secrets && dir && existsSync3(join3(dir, ".env")) ? [{ name: "Copy the secret values from my .env first", value: "secrets" }] : [],
{ name: "Change something", value: "edit" },
{ name: "Cancel", value: "cancel" }
] });
if (choice === "cancel") process.exit(0);
if (choice === "secrets") {
const values = Object.fromEntries(readFileSync3(join3(dir, ".env"), "utf8").split("\n").map((l) => /^\s*(?:export\s+)?([A-Z][A-Z0-9_]*)\s*=\s*(.*)$/.exec(l)).filter((m) => !!m).map((m) => [m[1], m[2].replace(/^(['"])(.*)\1$/, "$2")]));
secrets = names.filter((n) => values[n] !== void 0).map((n) => `${n}=${values[n]}`).join("\n");
ok(`${secrets.split("\n").filter(Boolean).length} of ${names.length} secret values copied`);
continue;
}
if (choice === "edit") {
name = await input({ message: "Name", default: name, validate: (v) => /^[a-z0-9][a-z0-9-]{0,30}$/.test(v) || "Lowercase letters, digits and dashes" });
size = await select({ message: "Machine", default: size, choices: Object.entries(sizes).map(([value, spec]) => ({ name: `${value.padEnd(8)}${dim(spec)}`, value })) });
setup = (await input({ message: "Setup script (runs once in the snapshot; empty for none)", default: setup })).trim();
continue;
}
break;
}
await ensureGitHub(repo);
const { id } = await api("/api/environments", { method: "POST", body: JSON.stringify({ name, repo, size, setup, secrets }) });
ok(`Environment ${styleText3("cyan", name)} created`);
return { id, name, repo, status: "building", size, progress: "Starting a build machine" };
}
async function ensureGitHub(repo) {
const open = (url) => {
if (process.stdout.isTTY) spawn2(process.platform === "darwin" ? "open" : "xdg-open", [url], { stdio: "ignore", detached: true }).on("error", () => {
}).unref();
};
const status = () => api("/api/github/repos");
let r = await status();
if (!r.linked) {
const url = new URL("/connect/github", load().server).toString();
console.log(`
${styleText3("yellow", "!")} Connect GitHub so Box can clone ${repo} and your boxes can push as you.`);
console.log(dim(` Opening ${url}`));
open(url);
const bar = progress([[0, "Waiting for you to connect GitHub in the browser"]], { pace: 6e4 });
for (const end = Date.now() + 10 * 6e4; !r.linked; r = await status()) {
if (Date.now() > end) {
bar();
fail("Gave up waiting after 10 minutes. Run box again when it's done.");
}
await new Promise((resolve) => setTimeout(resolve, 2500));
}
bar();
ok("GitHub connected");
}
const isPublic = await fetch(`https://api.github.com/repos/${repo}`, { signal: AbortSignal.timeout(5e3) }).then((res) => res.ok ? res.json() : null).then((j) => j && !j.private).catch(() => false);
if (!isPublic && !r.repos.some((p) => p.fullName.toLowerCase() === repo.toLowerCase())) fail(`Your GitHub login can't reach ${repo}.`);
}
// src/local.ts
import { execFileSync as execFileSync3, spawn as spawn3 } from "node:child_process";
import { existsSync as existsSync5, mkdirSync as mkdirSync4, readdirSync as readdirSync2, readFileSync as readFileSync5, renameSync as renameSync2, rmSync as rmSync2, statSync as statSync2, watch, writeFileSync as writeFileSync4 } from "node:fs";
import { homedir as homedir4 } from "node:os";
import { basename, dirname, join as join5 } from "node:path";
import { select as select2 } from "@inquirer/prompts";
// src/ssh.ts
import { existsSync as existsSync4, mkdirSync as mkdirSync3, readFileSync as readFileSync4, rmSync, writeFileSync as writeFileSync3 } from "node:fs";
import { connect } from "node:net";
import { homedir as homedir3 } from "node:os";
import { join as join4 } from "node:path";
var SELF = [process.execPath, process.argv[1]];
var selfCommand = (...args) => [...SELF, ...args].map((a) => `"${a}"`).join(" ");
function ensureSshConfig() {
const sshDir = join4(homedir3(), ".ssh");
mkdirSync3(sshDir, { recursive: true, mode: 448 });
laptopKey();
writeFileSync3(join4(sshDir, "box"), [
"Host *.box",
" User user",
` IdentityFile "${join4(CONFIG_DIR, "id_ed25519")}"`,
` ProxyCommand ${selfCommand("proxy", "%h")}`,
" StrictHostKeyChecking no",
" UserKnownHostsFile /dev/null",
" LogLevel ERROR",
" ServerAliveInterval 30",
""
].join("\n"));
const config = join4(sshDir, "config");
const now = existsSync4(config) ? readFileSync4(config, "utf8") : "";
const renamed = now.replace(/^Include ~\/\.ssh\/craftboxes$/m, "Include ~/.ssh/box");
const next = /^Include ~\/\.ssh\/box$/m.test(renamed) ? renamed : `Include ~/.ssh/box
${renamed}`;
if (next !== now) writeFileSync3(config, next, { mode: 384 });
rmSync(join4(sshDir, "craftboxes"), { force: true });
}
async function proxy(host) {
const name = host.replace(/\.box$/, "");
const got = await api(`/api/boxes/${name}/connect`, { method: "POST", body: JSON.stringify({ sshKey: sshKeys(laptopKey().publicKey) }) });
const targets = [...got.ssh.hosts].reverse();
for (let attempt = 0; attempt < 20; attempt++) {
for (const [h, port] of targets) {
const sock = await new Promise((done) => {
const s = connect({ host: h, port, timeout: 5e3 });
s.once("connect", () => {
s.setTimeout(0);
done(s);
}).once("error", () => done(void 0)).once("timeout", () => {
s.destroy();
done(void 0);
});
});
if (!sock) continue;
process.stdin.pipe(sock);
sock.pipe(process.stdout);
sock.on("close", () => process.exit(0));
return;
}
await new Promise((r) => setTimeout(r, 1e3));
}
fail(`Couldn't reach ${name} over SSH.`);
}
// src/local.ts
async function newLocalBox(env, branch, name, o) {
const stop = progress([[0, "Finding a machine"], [900, `Unpacking the ${env} snapshot`], [2500, "Connecting GitHub"], [9e3, "Bigger machines take a few extra seconds"]]);
const box = await api("/api/boxes", { method: "POST", body: JSON.stringify({ env, branch, name, agent: "local", size: o.size, idleMinutes: o.alwaysOn ? 0 : void 0, sshKey: sshKeys(laptopKey().publicKey) }) }).finally(() => stop());
ok(`${box.name} is ready ${dim(`\xB7 ssh ${box.name}.box`)}`);
await openLocal({ name: box.name, env, here: localCopy(), branch, fresh: true });
}
var CLONE = ".clone";
var MIRROR_SKIP = [".git", "node_modules", "dist", "build", ".turbo", ".nx", ".next", ".cache", "coverage", ".DS_Store"];
function claudeMd(name, here) {
const lines = [
`# Machine: ${name}.box`,
`Run every command there with \`ssh ${name}.box '<command>'\`, never on this laptop. ./repo is a git worktree on this laptop and its files are mirrored to ~/repo on the machine: read and edit files in ./repo.`,
`Run git here, never on the machine: \`git -C repo status\`, commit and push from ./repo.`
];
if (here) {
const where = tilde(here.dir);
lines.push("", `# The user's local copy: ${where}${here.repo ? ` (${here.repo}${here.branch ? `, ${here.branch}` : ""})` : ""}`);
const mine = here.files.map((f) => `${where}/${f}`);
if (mine.length) lines.push(`"My .env", "my data" or "my setup" mean their laptop: ${mine.join(", ")}.`);
if (here.services.length) lines.push(`Running on their laptop: ${here.services.join(", ")}.`);
}
lines.push("", "How to do anything else: @../../llms.txt");
return lines.join("\n") + "\n";
}
function boxFolder(env, name) {
return join5(BOXES, env, name.startsWith(`${env}-`) ? name.slice(env.length + 1) : name);
}
function localBoxes() {
if (!existsSync5(BOXES)) return [];
const found = [];
for (const top of readdirSync2(BOXES, { withFileTypes: true })) {
if (!top.isDirectory() || top.name.startsWith(".")) continue;
const env = top.name, envDir = join5(BOXES, env);
for (const entry of readdirSync2(envDir, { withFileTypes: true })) {
const file2 = join5(envDir, entry.name, ".box.json");
if (!entry.isDirectory() || !existsSync5(file2)) continue;
const state = JSON.parse(readFileSync5(file2, "utf8"));
if (state.name) found.push({ name: state.name, env, folder: join5(envDir, entry.name), owner: state.owner });
}
}
return found;
}
function prepareFolder(name, env, here, mcp) {
const folder = boxFolder(env, name);
const legacy = join5(BOXES, name);
if (!existsSync5(folder) && existsSync5(join5(legacy, ".box.json"))) {
mkdirSync4(dirname(folder), { recursive: true });
renameSync2(legacy, folder);
}
mkdirSync4(join5(folder, ".claude"), { recursive: true });
const stateFile = join5(folder, ".box.json");
const state = existsSync5(stateFile) ? JSON.parse(readFileSync5(stateFile, "utf8")) : {};
const local = here ?? state.local;
const next = { ...state, name, env, owner: load().email, local };
writeFileSync4(join5(folder, "CLAUDE.md"), claudeMd(name, local));
const hook = (direction) => [{ matcher: "Bash", hooks: [{ type: "command", command: selfCommand("sync", name, direction, folder), timeout: 600 }] }];
writeFileSync4(join5(folder, ".claude", "settings.json"), JSON.stringify({ enableAllProjectMcpServers: true, permissions: { allow: [`Bash(ssh ${name}.box:*)`, `Bash(scp:*)`, `Bash(rsync:*)`, "mcp__box"] }, hooks: { PreToolUse: hook("push"), PostToolUse: hook("pull") } }, null, 2));
if (mcp) writeFileSync4(join5(folder, ".mcp.json"), JSON.stringify({ mcpServers: { box: { type: "http", url: mcp.url, headers: { authorization: `Box ${mcp.token}` } } } }, null, 2), { mode: 384 });
writeFileSync4(stateFile, JSON.stringify(next, null, 2));
return folder;
}
async function ensureClone(env) {
const clone = join5(BOXES, env.name, CLONE);
const token = localGitHubToken();
const auth = token ? ["-c", `http.https://github.com/.extraheader=AUTHORIZATION: basic ${Buffer.from(`x-access-token:${token}`).toString("base64")}`] : [];
if (existsSync5(join5(clone, ".git"))) {
await git([...auth, "-C", clone, "fetch", "-q", "--prune", "origin"]);
return clone;
}
const partial = `${clone}.partial`;
rmSync2(partial, { recursive: true, force: true });
mkdirSync4(dirname(clone), { recursive: true });
const bar = progress([[0, `Cloning ${env.repo} once for every ${env.name} box`]], { pace: 6e4 });
const cloned = await git([...auth, "clone", "-q", "--no-checkout", "--filter=blob:none", token ? `https://github.com/${env.repo}.git` : `git@github.com:${env.repo}.git`, partial]);
if (!cloned.ok) {
bar();
fail(`Couldn't clone ${env.repo}: ${cloned.err.split("\n").at(-1)}. Run gh auth login and try again.`);
}
await git(["-C", partial, "update-ref", "--no-deref", "HEAD", "HEAD"]);
renameSync2(partial, clone);
bar(`Cloned ${env.repo} into ${tilde(clone)}`);
return clone;
}
async function ensureWorktree({ clone, folder, name, branch }) {
const repo = join5(folder, "repo");
if (existsSync5(join5(repo, ".git")) && statSync2(join5(repo, ".git")).isFile()) return false;
if (existsSync5(repo)) renameSync2(repo, `${repo}.mirror`);
await git(["-C", clone, "worktree", "prune"]);
const onOrigin = branch && (await git(["-C", clone, "rev-parse", "-q", "--verify", `refs/remotes/origin/${branch}`])).ok;
const tries = onOrigin ? [["worktree", "add", "-q", repo, branch], ["worktree", "add", "-q", "--detach", repo, `origin/${branch}`]] : [["worktree", "add", "-q", "--no-track", "-b", `cb/${name}`, repo, "origin/HEAD"], ["worktree", "add", "-q", repo, `cb/${name}`]];
for (const args of tries) {
const r = await git(["-C", clone, ...args]);
if (r.ok) return true;
rmSync2(repo, { recursive: true, force: true });
}
if (existsSync5(`${repo}.mirror`)) renameSync2(`${repo}.mirror`, repo);
return fail(`Couldn't make a worktree for ${name} in ${tilde(clone)}.`);
}
async function setUpWorktree({ clone, folder, name, branch, fresh }) {
if (!await ensureWorktree({ clone, folder, name, branch })) return false;
const repo = join5(folder, "repo");
const bar = progress([[0, fresh ? `Sending ${tilde(repo)} to ${name}` : `Copying ${name}'s work into ${tilde(repo)}`]], { pace: 6e4 });
let copied = false;
for (let attempt = 0; attempt < 4 && !copied; attempt++) copied = await (fresh ? push(name, folder, false, ["--delete"]) : pull(name, folder)) || (await new Promise((r) => setTimeout(r, 3e3)), false);
if (copied && fresh) await alignBox(name, repo);
bar(copied ? `${tilde(repo)} is a worktree of ${tilde(clone)}` : void 0);
if (!copied) fail(`Couldn't sync ${tilde(repo)} with ${name}. Check that ssh ${name}.box works.`);
rmSync2(`${repo}.mirror`, { recursive: true, force: true });
return true;
}
async function alignBox(name, repo) {
const sha = (await git(["-C", repo, "rev-parse", "HEAD"])).out;
const branch = (await git(["-C", repo, "branch", "--show-current"])).out;
if (!/^[0-9a-f]{40}$/.test(sha)) return;
const point = branch && /^[\w./-]+$/.test(branch) ? `git symbolic-ref HEAD refs/heads/${branch}` : "git checkout -q --detach";
await new Promise((done) => spawn3("ssh", [`${name}.box`, `cd repo && { git cat-file -e ${sha}^{commit} 2>/dev/null || git fetch -q origin ${sha} 2>/dev/null; } && git cat-file -e ${sha}^{commit} && ${point} && git reset -q ${sha}`], { stdio: "ignore" }).on("close", done).on("error", done));
}
function newestTranscript(folder) {
const sessions = join5(homedir4(), ".claude", "projects", folder.replace(/[^a-zA-Z0-9]/g, "-"));
return existsSync5(sessions) ? readdirSync2(sessions).filter((f) => f.endsWith(".jsonl")).map((f) => join5(sessions, f)).sort((a, b) => statSync2(b).mtimeMs - statSync2(a).mtimeMs)[0] : void 0;
}
function backgroundSession(folder) {
try {
const sessions = JSON.parse(execFileSync3("claude", ["agents", "--json", "--cwd", folder], { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"], timeout: 1e4 }));
return sessions.filter((s) => s.kind === "background").sort((a, b) => b.startedAt - a.startedAt)[0]?.id;
} catch {
return void 0;
}
}
function laptopState(folder) {
const branch = (() => {
try {
return execFileSync3("git", ["-C", join5(folder, "repo"), "branch", "--show-current"], { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"], timeout: 3e3 }).trim();
} catch {
return "";
}
})();
const newest = newestTranscript(folder);
const titles = newest ? [...readFileSync5(newest, "utf8").matchAll(/"aiTitle":"((?:[^"\\]|\\.)*)"/g)] : [];
const task = titles.length ? (() => {
try {
return JSON.parse(`"${titles.at(-1)[1]}"`);
} catch {
return void 0;
}
})() : void 0;
return { branch: branch || void 0, task, working: !!newest && Date.now() - statSync2(newest).mtimeMs < 9e4 };
}
var git = (args) => new Promise((done) => {
const p = spawn3("git", args, { stdio: ["ignore", "pipe", "pipe"], env: { ...process.env, GIT_TERMINAL_PROMPT: "0" } });
let out = "", err = "";
p.stdout.on("data", (d) => {
out += d;
});
p.stderr.on("data", (d) => {
err += d;
});
p.on("close", (code2) => done({ ok: code2 === 0, out: out.trim(), err: err.trim() }));
p.on("error", (e) => done({ ok: false, out: "", err: e.message }));
});
function watchRepo(name, folder) {
const repo = join5(folder, "repo");
let timer, poll, running = false, again = false;
const run = async () => {
if (running) {
again = true;
return;
}
running = true;
do {
again = false;
await push(name, folder, true);
} while (again);
running = false;
};
const schedule = () => {
clearTimeout(timer);
timer = setTimeout(() => void run(), 800);
};
const pollInstead = () => {
poll ??= setInterval(() => void run(), 5e3);
};
const close = process.platform === "linux" ? watchEachDir(repo, schedule, pollInstead) : watchTree(repo, schedule, pollInstead);
return async () => {
clearTimeout(timer);
clearInterval(poll);
close();
while (running) await new Promise((r) => setTimeout(r, 200));
};
}
function watchTree(repo, changed, failed) {
try {
const watcher = watch(repo, { recursive: true }, (_event, file2) => {
if (file2 && !String(file2).split(/[\\/]/).some((part) => MIRROR_SKIP.includes(part))) changed();
}).on("error", () => {
watcher.close();
failed();
});
return () => watcher.close();
} catch {
failed();
return () => void 0;
}
}
function watchEachDir(repo, changed, failed) {
const watchers = /* @__PURE__ */ new Map();
let broken = false;
const closeAll = () => {
for (const w of watchers.values()) w.close();
watchers.clear();
};
const fail2 = () => {
if (broken) return;
broken = true;
closeAll();
failed();
};
const add = (dir) => {
if (broken || watchers.has(dir)) return;
try {
watchers.set(dir, watch(dir, (_event, file2) => {
if (file2) track(join5(dir, String(file2)));
changed();
}).on("error", fail2));
} catch {
fail2();
}
};
const track = (path) => {
const known = watchers.get(path);
if (known && !existsSync5(path)) {
known.close();
watchers.delete(path);
} else if (!known && basename(path) !== ".git" && isDirectory(path) && !ignored(repo, path)) add(path);
};
const dirs = trackedDirs(repo);
if (dirs) for (const d of dirs) add(d);
else fail2();
return closeAll;
}
function trackedDirs(repo) {
try {
const files = execFileSync3("git", ["-C", repo, "ls-files", "-z", "--cached", "--others", "--exclude-standard"], { encoding: "utf8", maxBuffer: 256 << 20, stdio: ["ignore", "pipe", "ignore"] }).split("\0");
const dirs = /* @__PURE__ */ new Set([repo]);
for (const f of files) for (let d = dirname(f); d !== "." && !dirs.has(join5(repo, d)); d = dirname(d)) dirs.add(join5(repo, d));
return [...dirs].filter(isDirectory);
} catch {
return void 0;
}
}
function ignored(repo, path) {
try {
execFileSync3("git", ["-C", repo, "check-ignore", "-q", path], { stdio: "ignore" });
return true;
} catch {
return false;
}
}
function isDirectory(path) {
try {
return statSync2(path).isDirectory();
} catch {
return false;
}
}
async function prune(boxes) {
const email = load().email;
const mine = localBoxes().filter((b) => b.owner && b.owner === email);
if (!mine.length) return;
const alive = new Set((boxes ?? await api("/api/boxes")).filter((b) => b.state !== "gone").map((b) => b.name));
for (const b of mine) {
if (alive.has(b.name)) continue;
await dropFolder(b);
console.log(dim(`Removed ${tilde(b.folder)}: ${b.name} no longer exists`));
}
}
async function dropFolder(b) {
rmSync2(b.folder, { recursive: true, force: true });
const clone = join5(BOXES, b.env, CLONE);
if (existsSync5(clone)) await git(["-C", clone, "worktree", "prune"]);
}
var rsync = (from, to, extra = [], quiet2 = false) => new Promise((done) => {
const args = ["-az", ...extra, ...MIRROR_SKIP.flatMap((x) => ["--exclude", x]), from, to];
const p = spawn3("rsync", args, { stdio: ["ignore", "ignore", "pipe"] });
let err = "";
p.stderr.on("data", (d) => {
err += d;
});
p.on("close", (code2) => {
if (code2 && !quiet2) console.error(dim(`rsync: ${err.trim().split("\n").slice(-2).join(" ")}`));
done(code2 === 0);
});
});
var pull = (name, folder, quiet2 = false) => rsync(`${name}.box:repo/`, join5(folder, "repo") + "/", ["--delete"], quiet2);
var push = (name, folder, quiet2 = false, extra = []) => rsync(join5(folder, "repo") + "/", `${name}.box:repo/`, extra, quiet2);
async function openLocal({ name, env, here, branch, fresh = false }) {
ensureSshConfig();
const stop = progress([[0, `Opening ${name}`], [1500, "Waking the box up"]]);
const [got, envs] = await Promise.all([
api(`/api/boxes/${name}/connect`, { method: "POST", body: JSON.stringify({ sshKey: sshKeys(laptopKey().publicKey) }) }),
api("/api/environments")
]).finally(() => stop());
const environment = envs.find((e) => e.name === env) ?? fail(`No environment named ${env}.`);
const folder = prepareFolder(name, env, here, got.mcp);
try {
writeFileSync4(join5(BOXES, "llms.txt"), await fetch(new URL("/llms.txt", load().server)).then((r) => r.ok ? r.text() : Promise.reject()));
} catch {
}
trustFolder(folder);
if (!await setUpWorktree({ clone: await ensureClone(environment), folder, name, branch, fresh })) await pull(name, folder);
if (here) ok(`Your local copy: ${tilde(here.dir)}${here.files.length || here.services.length ? dim(` \xB7 ${[...here.files, ...here.services].join(", ")}`) : ""}`);
console.log(dim(`\u25CF Keeping ${name} awake and ${tilde(folder)}/repo in sync while Claude is open`));
const stopWatching = watchRepo(name, folder);
const heartbeat = () => void api(`/api/boxes/${name}/heartbeat`, { method: "POST", body: JSON.stringify({ typed: true, ...laptopState(folder) }) }).catch(() => void 0);
const beat = setInterval(heartbeat, 6e4);
heartbeat();
const background = backgroundSession(folder);
const code2 = background && await claude(folder, ["attach", background]) === 0 ? 0 : await claude(folder, ["--dangerously-skip-permissions", ...newestTranscript(folder) ? ["--continue"] : []]);
clearInterval(beat);
await stopWatching();
await push(name, folder);
ok(`Claude closed \xB7 repo synced \xB7 ${name} sleeps once it's quiet ${dim(`\xB7 come back with: box ${name}`)}`);
process.exit(code2);
}
var claude = (folder, args) => new Promise((done) => spawn3("claude", args, { cwd: folder, stdio: "inherit", env: { ...process.env, ...process.getuid?.() === 0 && { IS_SANDBOX: "1" } } }).on("error", () => fail("Claude Code is not installed on this laptop. npm i -g @anthropic-ai/claude-code, or use box --remote.")).on("close", (c) => done(c ?? 0)));
function trustFolder(folder) {
const file2 = join5(homedir4(), ".claude.json");
try {
const config = JSON.parse(readFileSync5(file2, "utf8"));
if (config.projects?.[folder]?.hasTrustDialogAccepted) return;
config.projects = { ...config.projects, [folder]: { ...config.projects?.[folder], hasTrustDialogAccepted: true } };
writeFileSync4(file2, JSON.stringify(config, null, 2));
} catch {
}
}
async function sync(name, direction, folder) {
const chunks = [];
for await (const c of process.stdin) chunks.push(c);
let command = "";
try {
command = JSON.parse(Buffer.concat(chunks).toString("utf8")).tool_input?.command ?? "";
} catch {
}
if (!command.includes(`${name}.box`) || !existsSync5(join5(folder, "repo"))) return;
await (direction === "push" ? push(name, folder) : pull(name, folder));
}
async function code(envArg) {
const boxes = await api("/api/boxes");
await prune(boxes);
const here = localBoxes().filter((b) => existsSync5(join5(b.folder, "repo")));
const envs = [...new Set(here.map((b) => b.env))].sort();
if (!envs.length) return console.log(dim("No boxes on this laptop yet. Run box first."));
const env = envArg ?? (envs.length === 1 ? envs[0] : await select2({ message: "Open the boxes of which environment?", choices: envs.map((e) => ({ name: `${e.padEnd(18)}${dim(`${here.filter((b) => b.env === e).length} boxes`)}`, value: e })) }));
const group = here.filter((b) => b.env === env);
if (!group.length) fail(`No boxes from ${env} on this laptop.`);
const awake = new Set(boxes.filter((b) => b.state === "running").map((b) => b.name));
const environment = (await api("/api/environments")).find((e) => e.name === env) ?? fail(`No environment named ${env}.`);
const clone = await ensureClone(environment);
const migrated = [];
for (const b of group.filter((g) => awake.has(g.name))) {
if (await setUpWorktree({ clone, folder: b.folder, name: b.name, branch: boxes.find((x) => x.name === b.name)?.branch ?? void 0, fresh: false })) migrated.push(b.name);
}
const workspace = join5(BOXES, env, `${env}.code-workspace`);
writeFileSync4(workspace, JSON.stringify({ folders: group.map((b) => ({ name: basename(b.folder), path: `${basename(b.folder)}/repo` })), settings: { "files.exclude": { "**/node_modules": true } } }, null, 2));
spawn3("code", [workspace], { stdio: "ignore", detached: true }).on("error", () => miss(`VS Code's code command isn't installed. In VS Code run: Shell Command: Install 'code' command in PATH, or open ${tilde(workspace)}.`)).unref();
ok(`${group.length} ${env} box${group.length === 1 ? "" : "es"} in one window ${dim(`\xB7 ${tilde(workspace)}`)}`);
await Promise.all(group.filter((b) => awake.has(b.name) && !migrated.includes(b.name)).map((b) => pull(b.name, b.folder, true)));
const stops = group.map((b) => watchRepo(b.name, b.folder));
console.log(dim("Syncing your saves to the boxes. Leave this open; ctrl+c stops syncing."));
await new Promise((resolve) => process.once("SIGINT", resolve));
await Promise.all(stops.map((s) => s()));
ok("Stopped syncing");
process.exit(0);
}
async function rm(name, opts = {}) {
const local = localBoxes().find((b) => b.name === name);
if (local && existsSync5(join5(local.folder, "repo"))) await push(name, local.folder, true);
const stop = progress([[0, `Saving unpushed work to cb/${name}`], [2500, `Deleting ${name}`], [6e3, "Cleaning up its preview URLs"]]);
await api(`/api/boxes/${name}${opts.force ? "?force=1" : ""}`, { method: "DELETE" }).finally(() => stop());
if (local) await dropFolder(local);
ok(`Deleted ${name}${local ? dim(` \xB7 removed ${tilde(local.folder)}`) : ""}`);
}
// src/remote.ts
import { createHash } from "node:crypto";
import { styleText as styleText4 } from "node:util";
import { select as select3 } from "@inquirer/prompts";
import ssh22 from "ssh2";
// src/predict.ts
import xterm from "@xterm/headless";
function predictor(out, cols, rows) {
const screen = new xterm.Terminal({ cols, rows, scrollback: 0, allowProposedApi: true });
let pending = [];
let visible = false;
let parsing = 0;
let boxCursor;
let quietUntil = 0;
const inputStart = /* @__PURE__ */ new Map();
const buf = () => screen.buffer.active;
const cursor = () => ({ x: buf().cursorX, y: buf().cursorY });
const chars = (x, y) => buf().getLine(buf().viewportY + y)?.getCell(x)?.getChars() || " ";
const paint = (x, y) => {
const c = buf().getLine(buf().viewportY + y)?.getCell(x);
if (!c) return "";
const color = (rgb, pal, v, base) => rgb ? `;${base + 8};2;${v >> 16 & 255};${v >> 8 & 255};${v & 255}` : pal ? `;${base + 8};5;${v}` : "";
const sgr = `0${c.isBold() ? ";1" : ""}${c.isDim() ? ";2" : ""}${c.isItalic() ? ";3" : ""}${c.isUnderline() ? ";4" : ""}${c.isInverse() ? ";7" : ""}${c.isStrikethrough() ? ";9" : ""}` + color(c.isFgRGB(), c.isFgPalette(), c.getFgColor(), 30) + color(c.isBgRGB(), c.isBgPalette(), c.getBgColor(), 40);
return `\x1B[${y + 1};${x + 1}H\x1B[${sgr}m${c.getChars() || " "}`;
};
const at = (x, y) => `\x1B[${y + 1};${x + 1}H`;
const caret = () => pending.length ? { x: pending.at(-1).x + (pending.at(-1).ch === "\b" ? 0 : 1), y: pending.at(-1).y } : boxCursor ?? cursor();
function giveUp(quiet2 = 0) {
if (!pending.length) return;
const { x, y } = cursor();
out(`\x1B7${pending.map((g) => paint(g.x, g.y)).join("")}\x1B8${at(x, y)}`);
pending = [];
quietUntil = Date.now() + quiet2;
}
function settle() {
if (parsing) return;
const now = Date.now(), { x, y } = cursor();
if (pending.some((g) => g.y !== y)) return giveUp(500);
const passed = (g) => g.ch === "\b" ? x <= g.x : x > g.x;
if (pending.some((g) => passed(g) && chars(g.x, g.y) !== (g.ch === "\b" ? " " : g.ch))) return giveUp(300);
pending = pending.filter((g) => {
if (!passed(g)) return true;
if (g.ch !== "\b") inputStart.set(g.y, Math.min(inputStart.get(g.y) ?? g.x, g.x));
return false;
});
if (pending.some((g) => now - g.at > 1500)) return giveUp(1e3);
if (!pending.length) return;
const c = caret();
out(`\x1B7\x1B[0m${pending.map((g) => at(g.x, g.y) + (g.ch === "\b" ? " " : g.ch)).join("")}\x1B8${at(c.x, c.y)}`);
}
const timer = setInterval(settle, 200);
timer.unref();
return {
fromBox(d) {
out(d);
const text = Buffer.from(d).toString("latin1");
const show = text.lastIndexOf("\x1B[?25h"), hide = text.lastIndexOf("\x1B[?25l");
if (show !== hide) visible = show > hide;
const end = /\x1b\[(\d+);(\d+)H(?:\x1b\[\?25[hl])*$/.exec(text);
boxCursor = end ? { x: Number(end[2]) - 1, y: Number(end[1]) - 1 } : void 0;
parsing++;
screen.write(d, () => {
parsing--;
settle();
});
},
fromKeys(d) {
if (!visible || parsing && !boxCursor && !pending.length || Date.now() < quietUntil || pending.length > 40) return;
for (const b of d) {
const { x, y } = caret();
if (b >= 32 && b < 127 && x < cols - 2 && d.length === 1) {
const ch = String.fromCharCode(b);
pending.push({ x, y, ch, at: Date.now() });
out(`\x1B7\x1B[0m${at(x, y)}${ch}\x1B8${at(x + 1, y)}`);
} else if ((b === 127 || b === 8) && d.length === 1) {
const last = pending.at(-1);
if (last && last.ch !== "\b" && last.x === x - 1) {
pending.pop();
out(`\x1B7${paint(x - 1, y)}\x1B8${at(x - 1, y)}`);
} else if (x - 1 >= (inputStart.get(y) ?? Infinity) && (chars(x, y) === " " || pending.some((g) => g.ch === "\b" && g.x === x && g.y === y))) {
pending.push({ x: x - 1, y, ch: "\b", at: Date.now() });
out(`\x1B7\x1B[0m${at(x - 1, y)} \x1B8${at(x - 1, y)}`);
}
}
}
},
resize(c, r) {
cols = c;
giveUp();
screen.resize(c, r);
}
};
}
// src/remote.ts
var mcpCount = (s) => {
const n = Object.keys(s).length;
return `${n} MCP server${n === 1 ? "" : "s"}`;
};
async function newRemoteBox(env, branch, name, o) {
const agent = o.agent;
const idle = o.alwaysOn ? "0" : void 0;
const { mcpServers, mcpOAuth, skipped } = localMcp();
ok(`${mcpCount(mcpServers)} will be copied into the box ${dim(skipped.length ? `(skipping local-only: ${skipped.join(", ")})` : "")}`);
const claudeHome = localClaudeHome();
const stop = progress([
[0, "Finding a machine"],
[900, `Unpacking the ${env} snapshot`],
[2e3, "Signing Claude Code in"],
[2900, `Copying ${mcpCount(mcpServers)}`],
[3300, `Copying ${claudeHome.skills} skill${claudeHome.skills === 1 ? "" : "s"} and ${claudeHome.enabled.length} plugin${claudeHome.enabled.length === 1 ? "" : "s"}`],
[3700, "Connecting GitHub"],
[4600, "Waking Claude up"],
[9e3, "Bigger machines take a few extra seconds"]
]);
const { tar, ...home2 } = claudeHome;
const box = await api("/api/boxes", { method: "POST", body: JSON.stringify({ env, branch, name, size: o.size, idleMinutes: idle === void 0 ? void 0 : Number(idle), mcpServers, mcpOAuth, claudeHome: home2, sshKey: sshKeys(laptopKey().publicKey), agent }) }).finally(() => stop());
if (home2.pending) void sendHome(box.name, tar);
await attach(box.name, { name: box.name, ssh: box.ssh });
}
async function sendHome(name, tar) {
const buf = await tar;
if (!buf || buf.length > 30 << 20) return api(`/api/boxes/${name}/home`, { method: "POST", body: "{}" }).catch(() => void 0);
const sha = createHash("sha256").update(buf).digest("hex");
const post = (body) => api(`/api/boxes/${name}/home`, { method: "POST", body: JSON.stringify(body) });
if ((await post({ sha }).catch(() => ({ need: false }))).need) await post({ sha, tgz: buf.toString("base64") }).catch(() => void 0);
}
async function dial({ user, hosts }, privateKey, attempts) {
const once = () => new Promise((resolve, reject) => {
let left = hosts.length, won = false;
if (!left) return reject(new Error("The box has no address yet."));
for (const [host, port] of hosts) {
const c = new ssh22.Client();
c.on("ready", () => {
if (won) return c.end();
won = true;
resolve(c);
}).on("error", (e) => {
if (!won && --left === 0) reject(e);
}).connect({ host, port, username: user, privateKey, readyTimeout: 6e3, keepaliveInterval: 15e3, hostVerifier: () => true });
}
});
for (let attempt = 1; ; attempt++) {
try {
return await once();
} catch (err) {
if (attempt >= attempts) throw err;
await new Promise((r) => setTimeout(r, 1500));
}
}
}
async function attach(name, ready) {
if (!name) {
const boxes = await api("/api/boxes");
if (!boxes.length) return console.log(dim("No boxes yet. Run box."));
name = await select3({ message: "Attach to", choices: boxes.map((b) => ({ name: `${b.name.padEnd(28)}${label(b)}`, value: b.name })) });
}
const boxName = name;
const key = laptopKey();
const keys = [];
let session, conn, echo, typed = false, pressed = () => {
};
if (process.stdin.isTTY) process.stdin.setRawMode(true);
process.stdin.resume();
process.stdin.on("data", (d) => {
if (d.includes(29) || !session && d.includes(3)) return leave();
typed = true;
if (session) {
echo?.fromKeys(d);
session.write(d);
} else {
keys.push(d);
pressed();
}
});
setInterval(() => {
if (session) void api(`/api/boxes/${boxName}/heartbeat`, { method: "POST", body: JSON.stringify({ typed }) }).catch(() => void 0);
typed = false;
}, 3e4).unref();
function leave() {
conn?.end();
process.stdout.write(TERMINAL_RESET);
if (process.stdin.isTTY) process.stdin.setRawMode(false);
console.log(dim(`
Detached. ${boxName} sleeps once Claude is done. box ${boxName} to return.`));
process.exit(0);
}
let ssh = ready?.ssh, attempts = 4, announced = false;
for (; ; ) {
if (!ssh) {
attempts = 4;
const box = await findBox(boxName);
if (box.state === "gone") fail(`${boxName} is gone. Make a new one with box.`);
if (box.state === "paused" || box.state === "pausing") {
sleepScreen(box);
if (!keys.length) await new Promise((r) => {
pressed = r;
});
wakingScreen(box, keys);
pressed = () => wakingScreen(box, keys);
}
const stop = keys.length ? () => void 0 : progress([[0, `Opening ${boxName}`], [1200, "Waking the box up"], [4e3, "Starting Claude"]]);
ssh = (await api(`/api/boxes/${boxName}/attach`, { method: "POST", body: JSON.stringify({ sshKey: sshKeys(key.publicKey) }) }).finally(() => stop())).ssh;
}
conn = await dial(ssh, key.privateKey, attempts).catch(() => void 0);
attempts = 1;
if (conn && !announced && !keys.length) console.log(dim(`Attaching to ${boxName}. Detach with ctrl-], come back with box ${boxName}.`));
announced = true;
if (!conn) {
ssh = void 0;
for (let i = 0; i < 20 && (await findBox(boxName)).state === "running"; i++) await new Promise((r) => setTimeout(r, 1500));
continue;
}
const { columns: cols = 100, rows = 30 } = process.stdout;
echo = process.env.CBOX_PREDICT === "0" ? void 0 : predictor((s) => process.stdout.write(s), cols, rows);
const stream = await new Promise((resolve, reject) => conn.exec(
"dtach -A ~/.cb/claude.sock -E -r winch bash -l ~/.cb/start.sh",
{ pty: { term: process.env.TERM || "xterm-256color", cols, rows } },
(err, s) => err ? reject(err) : resolve(s)
));
let exited = false;
stream.on("exit", (code2, signal) => {
exited = code2 === 0 && !signal;
});
const closed = new Promise((r) => stream.on("close", r));
let settle;
stream.on("data", (d) => {
echo ? echo.fromBox(d) : process.stdout.write(d);
if (!keys.length) return;
clearTimeout(settle);
settle = setTimeout(() => {
session?.write(Buffer.concat(keys.splice(0)));
}, 800);
});
stream.setWindow(rows, cols - 1, 0, 0);
setTimeout(() => stream.setWindow(rows, cols, 0, 0), 100);
const resize = () => {
echo?.resize(process.stdout.columns, process.stdout.rows);
stream.setWindow(process.stdout.rows, process.stdout.columns, 0, 0);
};
process.stdout.on("resize", resize);
session = stream;
typed = false;
void api(`/api/boxes/${boxName}/heartbeat`, { method: "POST", body: JSON.stringify({ typed: false }) }).catch(() => void 0);
await closed;
session = void 0;
process.stdout.off("resize", resize);
if (exited) leave();
conn.end();
process.stdout.write(TERMINAL_RESET);
}
}
var RATES = { small: 0.018, medium: 0.036, large: 0.072 };
function centered(lines) {
const { columns = 100, rows = 30 } = process.stdout;
const visible = (t) => t.replace(/\x1b\[[0-9;]*m/g, "").length;
const top = Math.max(0, Math.floor((rows - lines.length) / 2));
process.stdout.write("\x1B[?1049h\x1B[2J\x1B[?25l" + lines.map((l, i) => `\x1B[${top + i + 1};${Math.max(1, Math.floor((columns - visible(l)) / 2) + 1)}H${l}`).join(""));
}
function sleepScreen(box) {
const since = box.asleepSince ? Date.now() - new Date(box.asleepSince).getTime() : 0;
const saved = since / 36e5 * (RATES[box.size ?? "small"] ?? RATES.small);
centered([
styleText4("blue", "\u263E"),
"",
styleText4("bold", `${box.name} is asleep`),
dim(`Last used ${span(Date.now() - new Date(box.lastActiveAt ?? Date.now()).getTime())} ago. Your files and the conversation are saved.`),
"",
`Press any key to wake it ${dim("\xB7")} ctrl+] to leave`,
"",
dim(since ? `asleep ${span(since)} \xB7 $${saved.toFixed(2)} not spent` : "asleep")
]);
}
function wakingScreen(box, keys) {
const text = Buffer.concat(keys).toString("utf8").replace(/[\x00-\x1f\x7f]/g, "");
centered([
styleText4("yellow", "\u2600"),
"",
styleText4("bold", `Waking ${box.name}`),
dim("a few seconds"),
"",
text ? `${dim("you typed:")} ${text}` : "",
dim("what you type now goes to Claude once it is up")
]);
}
var TERMINAL_RESET = "\x1B[?1000l\x1B[?1002l\x1B[?1003l\x1B[?1005l\x1B[?1006l\x1B[?1015l\x1B[?1004l\x1B[?2004l\x1B[<u\x1B[?1049l\x1B[?25h\x1B[0m";
// src/update.ts
import { execFileSync as execFileSync4, spawn as spawn4, spawnSync as spawnSync2 } from "node:child_process";
import { mkdirSync as mkdirSync5, readFileSync as readFileSync6, writeFileSync as writeFileSync5 } from "node:fs";
import { basename as basename2, dirname as dirname2, join as join6 } from "node:path";
import { fileURLToPath } from "node:url";
var PACKAGE = "@abuaboud/box";
var LATEST = `https://registry.npmjs.org/${PACKAGE.replace("/", "%2f")}/latest`;
var INSTALLED = JSON.parse(readFileSync6(new URL("../package.json", import.meta.url), "utf8"));
var VERSION = INSTALLED.version;
function npmPrefix() {
const pkgDir = dirname2(dirname2(fileURLToPath(import.meta.url)));
const modules = basename2(dirname2(pkgDir)).startsWith("@") ? dirname2(dirname2(pkgDir)) : dirname2(pkgDir);
if (basename2(modules) !== "node_modules") return void 0;
return basename2(dirname2(modules)) === "lib" ? dirname2(dirname2(modules)) : dirname2(modules);
}
var newer = (a, b) => {
const x = a.split(".").map(Number), y = b.split(".").map(Number);
for (let i = 0; i < 3; i++) if ((x[i] ?? 0) !== (y[i] ?? 0)) return (x[i] ?? 0) > (y[i] ?? 0);
return false;
};
var due = (latest) => INSTALLED.name !== PACKAGE || newer(latest, VERSION);
var NPM = process.platform === "win32" ? "npm.cmd" : "npm";
function installSteps(version, prefix) {
const at = prefix ? ["--prefix", prefix] : [];
const box = `${PACKAGE}@${version}`;
return INSTALLED.name === PACKAGE ? [["i", "-g", "--prefer-online", ...at, box]] : [["i", "-g", "--prefer-online", "--force", ...at, box], ["rm", "-g", ...at, INSTALLED.name], ["i", "-g", "--prefer-offline", "--force", ...at, box]];
}
async function updateFirst() {
const prefix = npmPrefix();
if (!prefix || process.env.CBOX_NO_UPDATE || process.env.CBOX_UPDATED) return;
const latest = await fetch(LATEST, { signal: AbortSignal.timeout(2500) }).then((r) => r.json()).then((j) => j.version, () => void 0);
if (!latest || !due(latest)) return;
const stop = progress([[0, `Updating box ${VERSION} \u2192 ${latest}`]]);
const done = installSteps(latest, prefix).every((args) => spawnSync2(NPM, args, { stdio: "ignore" }).status === 0);
stop();
if (!done) return miss(`Couldn't update box to ${latest}, so carrying on with ${VERSION}`);
ok(`Updated box ${dim(VERSION)} \u2192 ${latest}`);
try {
writeFileSync5(join6(CONFIG_DIR, "update.json"), JSON.stringify({ version: latest, checkedAt: Date.now() }));
} catch {
}
const again = spawnSync2(process.execPath, process.argv.slice(1), { stdio: "inherit", env: { ...process.env, CBOX_UPDATED: "1" } });
process.exit(again.status ?? 0);
}
function autoUpdate() {
const prefix = npmPrefix();
if (process.env.CBOX_NO_UPDATE || process.env.CBOX_UPDATED || !prefix) return;
const file2 = join6(CONFIG_DIR, "update.json");
let state = {};
try {
state = JSON.parse(readFileSync6(file2, "utf8"));
} catch {
}
if (state.version && state.version !== VERSION) ok(`Updated box ${dim(state.version)} \u2192 ${VERSION}`);
const check = INSTALLED.name !== PACKAGE || !state.checkedAt || Date.now() - state.checkedAt > 12 * 36e5;
try {
mkdirSync5(CONFIG_DIR, { recursive: true });
writeFileSync5(file2, JSON.stringify({ version: VERSION, checkedAt: check ? Date.now() : state.checkedAt }));
} catch {
return;
}
if (!check) return;
const job = `fetch(${JSON.stringify(LATEST)}).then(r => r.json()).then(({ version }) => {
const newer = (a, b) => { const x = a.split('.').map(Number), y = b.split('.').map(Number); for (let i = 0; i < 3; i++) if ((x[i] ?? 0) !== (y[i] ?? 0)) return (x[i] ?? 0) > (y[i] ?? 0); return false }
if (${JSON.stringify(INSTALLED.name === PACKAGE)} && !newer(version, ${JSON.stringify(VERSION)})) return
for (const args of ${JSON.stringify(installSteps("{v}", prefix))}) require('node:child_process').execFileSync(process.platform === 'win32' ? 'npm.cmd' : 'npm', args.map((a) => a.replace('{v}', version)), { stdio: 'ignore' })
}).catch(() => {})`;
spawn4(process.execPath, ["-e", job], { detached: true, stdio: "ignore" }).on("error", () => {
}).unref();
}
async function upgrade() {
const latest = await fetch(LATEST).then((r) => r.json()).then((j) => j.version).catch(() => fail("Could not reach the npm registry."));
if (!due(latest)) return ok(`box ${VERSION} is the latest`);
console.log(dim(`Updating box ${VERSION} \u2192 ${latest}\u2026`));
try {
for (const args of installSteps(latest, npmPrefix())) execFileSync4(NPM, args, { stdio: "inherit" });
} catch {
fail(`npm could not update box. Try: npm i -g ${PACKAGE}@latest`);
}
ok(`Updated box to ${latest}`);
}
// src/index.ts
var ANIMALS = ["otter", "heron", "lynx", "moth", "yak", "orca", "wren", "newt", "ibis", "mole", "kiwi", "crab", "swan", "toad", "hare", "seal"];
var NAME = /^[a-z0-9][a-z0-9-]{0,30}$/;
async function newBox(o = {}) {
await updateFirst();
if (o.size && !["small", "medium", "large"].includes(o.size)) fail("Pick --size small, medium or large.");
if (o.name !== void 0 && !NAME.test(o.name)) fail("Name the box with lowercase letters, digits and dashes, like webhook-retry.");
const here = gitRepo();
const picked = await chooseEnvironment(o.env, here);
const branch = here && picked.repo.toLowerCase() === here.repo.toLowerCase() ? here.branch : void 0;
const name = o.name ?? await askName(branch);
return o.remote || o.agent === "codex" ? newRemoteBox(picked.name, branch, name, o) : newLocalBox(picked.name, branch, name, o);
}
async function askName(branch) {
const fromBranch = branch?.split("/").at(-1)?.toLowerCase().replace(/[^a-z0-9-]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 30);
const suggestion = fromBranch || `${ANIMALS[Math.floor(Math.random() * ANIMALS.length)]}-${Math.random().toString(16).slice(2, 5)}`;
if (!process.stdin.isTTY) return void 0;
const picked = await input2({
message: `Name this box ${dim("(optional \xB7 you will see it in its folder, ssh name and the dashboard)")}`,
default: suggestion,
validate: (v) => !v.trim() || NAME.test(v.trim()) || "Lowercase letters, digits and dashes"
});
return picked.trim() || suggestion;
}
async function home(name, o) {
const making = o.env || o.remote || o.agent || o.size || o.alwaysOn;
if (name && making) fail(`--env, --remote, --agent, --size and --always-on only shape a new box. Open ${name} with box ${name}, or make a new one with box new --name <name> and the options.`);
if (name) return openBox(name);
const email = load().email;
const all = await api("/api/boxes");
await prune(all);
const mine = all.filter((b) => b.state !== "gone" && (!email || b.owner === email));
if (!mine.length || !process.stdin.isTTY || making) return newBox(o);
const pick = await select4({ message: "Open a box", choices: [
...mine.map((b) => ({ name: `${b.name.padEnd(28)}${label(b)}${dim(b.agent === "local" ? "" : " \xB7 remote")}`, value: b.name })),
{ name: styleText5("cyan", "+ new box"), value: "" }
] });
return pick ? openBox(pick) : newBox(o);
}
async function openBox(name) {
const box = await findBox(name);
if (box.state === "gone") fail(`${name} is gone. Make a new one with box new.`);
return box.agent === "local" ? openLocal({ name: box.name, env: box.env ?? "boxes", here: localCopy(), branch: box.branch ?? void 0 }) : attach(box.name);
}
async function ls() {
const email = load().email;
const all = await api("/api/boxes");
await prune(all);
const mine = all.filter((b) => !email || b.owner === email);
if (!mine.length) return console.log(dim("No boxes of yours. Run box."));
const width = Math.max(...mine.map((b) => b.name.length)) + 2;
console.log(dim(`${"BOX".padEnd(width)}${"STATE".padEnd(20)}WHERE`));
for (const b of mine) {
const l = label(b);
console.log(`${b.name.padEnd(width)}${l}${" ".repeat(Math.max(1, 20 - l.replace(/\x1b\[[0-9;]*m/g, "").length))}${dim(b.agent === "local" ? "laptop" : "remote")}`);
}
}
var quiet = ["proxy", "sync"].includes(process.argv[2] ?? "");
if (quiet) console.log = console.error;
if (!quiet && !process.env.CBOX_UPDATED && !process.argv.slice(2).some((a) => ["-V", "--version", "-h", "--help", "help"].includes(a))) console.log(dim(`box ${VERSION}`));
if (!quiet) {
moveHome();
autoUpdate();
ensureSshConfig();
}
var boxOptions = (c) => c.option("-e, --env <name>", "environment").option("-n, --name <name>", "name the box, like webhook-retry (default: asked, or generated)").option("--remote", "run Claude on the machine instead of this laptop").option("--agent <name>", "claude (default) or codex; codex implies --remote").option("--size <size>", "small, medium or large (default: the environment's)").option("--always-on", "never sleep, for example for a demo");
var cli = boxOptions(new Command("box").enablePositionalOptions().description("Dev machines for Claude: Claude on your laptop, the work on a box").version(VERSION, "-v, --version").argument("[box]", "open this box")).action((name, o) => home(name, o));
boxOptions(cli.command("new").description("always make a fresh box")).action((o) => newBox(o));
cli.command("ls").description("list your boxes").action(ls);
cli.command("code [env]").description("open an environment's boxes in one VS Code window, each a git worktree, and sync your saves").action((env) => code(env));
cli.command("rm <box>").option("-f, --force", "delete even while someone is attached or Claude is working").description("delete a box, pushing unpushed work to cb/<box>").action(rm);
cli.command("login").option("--server <url>", "box server").description("sign in from the browser").action((o) => login(o.server));
cli.command("upgrade").description("update box to the latest version now").action(upgrade);
cli.command("attach [box]", { hidden: true }).action((name) => name ? openBox(name) : home(void 0, {}));
cli.command("proxy <host>", { hidden: true }).action(proxy);
cli.command("sync <box> <direction> <folder>", { hidden: true }).action(sync);
await cli.parseAsync();