@abuaboud/box 0.7.11 → 0.7.13
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +15 -14
- package/dist/cbox.mjs +72 -64
- package/dist/skyfleet.mjs +3 -0
- package/package.json +4 -3
package/README.md
CHANGED
|
@@ -1,26 +1,27 @@
|
|
|
1
|
-
#
|
|
1
|
+
# skyfleet
|
|
2
2
|
|
|
3
|
-
Compute for your agents: a fresh cloud machine for every task, with Claude Code on it.
|
|
3
|
+
Compute for your agents: a fresh cloud machine, an agent, for every task, with Claude Code on it.
|
|
4
4
|
|
|
5
5
|
```sh
|
|
6
6
|
npm i -g @abuaboud/box
|
|
7
|
-
|
|
7
|
+
skyfleet
|
|
8
8
|
```
|
|
9
9
|
|
|
10
|
-
`
|
|
11
|
-
laptop in a git worktree whose builds, tests and servers run on the
|
|
10
|
+
`skyfleet` signs you in (a browser approval, once), makes an agent from your team's environment, and opens Claude on
|
|
11
|
+
your laptop in a git worktree whose builds, tests and servers run on the agent. Every port you open gets a preview link.
|
|
12
12
|
|
|
13
13
|
| Command | What it does |
|
|
14
14
|
| --- | --- |
|
|
15
|
-
| `
|
|
16
|
-
| `
|
|
17
|
-
| `
|
|
18
|
-
| `
|
|
19
|
-
| `
|
|
20
|
-
| `
|
|
21
|
-
| `
|
|
22
|
-
| `
|
|
15
|
+
| `skyfleet` | Pick one of your agents, or make a new one |
|
|
16
|
+
| `skyfleet new [-e env] [--size small\|medium\|large] [--always-on]` | Always make a fresh agent |
|
|
17
|
+
| `skyfleet <name>` | Back to an agent |
|
|
18
|
+
| `skyfleet ls` | List your agents and where Claude runs |
|
|
19
|
+
| `skyfleet code [env]` | Open every agent of an environment in one VS Code window |
|
|
20
|
+
| `skyfleet rm <name>` | Delete an agent; unpushed work goes to `cb/<name>` first |
|
|
21
|
+
| `skyfleet --remote` | Run Claude on the agent instead, so it keeps going with the laptop shut |
|
|
22
|
+
| `skyfleet upgrade` | Update now (skyfleet also updates itself; `CBOX_NO_UPDATE=1` turns that off) |
|
|
23
23
|
|
|
24
|
-
Every
|
|
24
|
+
Every agent is also an SSH host, `<name>.skyfleet`, for ssh, scp, rsync and VS Code Remote-SSH (`<name>.box` still
|
|
25
|
+
works). Local agents live in `~/.box`. `box` still runs the same command.
|
|
25
26
|
|
|
26
27
|
Dashboard: https://box.abuaboud.me · Node 22.18 or newer.
|
package/dist/cbox.mjs
CHANGED
|
@@ -20,6 +20,7 @@ import { join } from "node:path";
|
|
|
20
20
|
import ssh2 from "ssh2";
|
|
21
21
|
var CONFIG_DIR = join(homedir(), ".config", "box");
|
|
22
22
|
var BOXES = join(homedir(), ".box");
|
|
23
|
+
var MAX_SETUP_MB = 70;
|
|
23
24
|
var SERVICES = { 5432: "Postgres", 6379: "Redis", 3306: "MySQL", 27017: "MongoDB", 8123: "ClickHouse" };
|
|
24
25
|
function localMcp() {
|
|
25
26
|
const claudeJson = join(homedir(), ".claude.json");
|
|
@@ -202,6 +203,13 @@ function label(b) {
|
|
|
202
203
|
return styleText("yellow", `\u263E sleeps in ${span(Math.max(6e4, left))}`);
|
|
203
204
|
}
|
|
204
205
|
|
|
206
|
+
// src/brand.ts
|
|
207
|
+
var BRANDS = {
|
|
208
|
+
box: { name: "Box", command: "box" },
|
|
209
|
+
skyfleet: { name: "Skyfleet", command: "skyfleet" }
|
|
210
|
+
};
|
|
211
|
+
var brand = process.env.CB_BRAND === "skyfleet" ? BRANDS.skyfleet : BRANDS.box;
|
|
212
|
+
|
|
205
213
|
// src/api.ts
|
|
206
214
|
var file = join2(CONFIG_DIR, "config.json");
|
|
207
215
|
var load = () => {
|
|
@@ -252,7 +260,7 @@ async function login(server) {
|
|
|
252
260
|
}
|
|
253
261
|
async function findBox(name) {
|
|
254
262
|
const box = (await api("/api/boxes")).find((b) => b.name === name || b.id === name);
|
|
255
|
-
return box ?? fail(`No
|
|
263
|
+
return box ?? fail(`No agent named ${name}. ${brand.command} ls lists them.`);
|
|
256
264
|
}
|
|
257
265
|
|
|
258
266
|
// src/environments.ts
|
|
@@ -288,7 +296,7 @@ async function waitReady(env) {
|
|
|
288
296
|
if (now.progress) bar.say(now.progress);
|
|
289
297
|
if (now.status === "ready") {
|
|
290
298
|
const secs = Math.round((Date.now() - t0) / 1e3);
|
|
291
|
-
bar(`Snapshot ready ${dim(`${secs >= 60 ? `${Math.floor(secs / 60)}m ${String(secs % 60).padStart(2, "0")}s` : `${secs}s`} \xB7 next
|
|
299
|
+
bar(`Snapshot ready ${dim(`${secs >= 60 ? `${Math.floor(secs / 60)}m ${String(secs % 60).padStart(2, "0")}s` : `${secs}s`} \xB7 next agents start in about 2s`)}`);
|
|
292
300
|
return now;
|
|
293
301
|
}
|
|
294
302
|
if (now.status === "failed") {
|
|
@@ -358,11 +366,11 @@ async function setUpEnvironment(repo, dir, envs) {
|
|
|
358
366
|
console.log(` ${dim("Name ")} ${name}`);
|
|
359
367
|
console.log(` ${dim("Repo ")} ${repo}`);
|
|
360
368
|
console.log(` ${dim("Machine ")} ${size[0].toUpperCase() + size.slice(1)} ${dim(`\xB7 ${sizes[size]}${size === sized.size ? ` (${sized.why})` : ""}`)}`);
|
|
361
|
-
console.log(` ${dim("Setup ")} ${setup2 ? styleText3("cyan", setup2) : dim("none")} ${dim(detected && setup2 === detected.setup ? `\xB7 from ${detected.from}` : setup2 ? "" : "\xB7
|
|
369
|
+
console.log(` ${dim("Setup ")} ${setup2 ? styleText3("cyan", setup2) : dim("none")} ${dim(detected && setup2 === detected.setup ? `\xB7 from ${detected.from}` : setup2 ? "" : "\xB7 agents start from a plain clone")}`);
|
|
362
370
|
if (names.length) console.log(` ${dim("Secrets ")} ${names.join(", ")} ${dim(secrets ? "\xB7 values copied from .env" : "\xB7 names from .env.example, values empty")}`);
|
|
363
371
|
console.log();
|
|
364
372
|
const choice = await select({ message: "Create it?", choices: [
|
|
365
|
-
{ name: "Create and start
|
|
373
|
+
{ name: "Create and start an agent", value: "go" },
|
|
366
374
|
...names.length && !secrets && dir && existsSync3(join3(dir, ".env")) ? [{ name: "Copy the secret values from my .env first", value: "secrets" }] : [],
|
|
367
375
|
{ name: "Change something", value: "edit" },
|
|
368
376
|
{ name: "Cancel", value: "cancel" }
|
|
@@ -397,14 +405,14 @@ async function ensureGitHub(repo) {
|
|
|
397
405
|
if (!r.linked) {
|
|
398
406
|
const url = new URL("/connect/github", load().server).toString();
|
|
399
407
|
console.log(`
|
|
400
|
-
${styleText3("yellow", "!")} Connect GitHub so
|
|
408
|
+
${styleText3("yellow", "!")} Connect GitHub so ${brand.name} can clone ${repo} and your agents can push as you.`);
|
|
401
409
|
console.log(dim(` Opening ${url}`));
|
|
402
410
|
open(url);
|
|
403
411
|
const bar = progress([[0, "Waiting for you to connect GitHub in the browser"]], { pace: 6e4 });
|
|
404
412
|
for (const end = Date.now() + 10 * 6e4; !r.linked; r = await status()) {
|
|
405
413
|
if (Date.now() > end) {
|
|
406
414
|
bar();
|
|
407
|
-
fail(
|
|
415
|
+
fail(`Gave up waiting after 10 minutes. Run ${brand.command} again when it's done.`);
|
|
408
416
|
}
|
|
409
417
|
await new Promise((resolve) => setTimeout(resolve, 2500));
|
|
410
418
|
}
|
|
@@ -435,7 +443,7 @@ function ensureSshConfig() {
|
|
|
435
443
|
mkdirSync3(sshDir, { recursive: true, mode: 448 });
|
|
436
444
|
laptopKey();
|
|
437
445
|
writeFileSync3(join4(sshDir, "box"), [
|
|
438
|
-
"Host *.box",
|
|
446
|
+
"Host *.box *.skyfleet",
|
|
439
447
|
" User user",
|
|
440
448
|
` IdentityFile "${join4(CONFIG_DIR, "id_ed25519")}"`,
|
|
441
449
|
` ProxyCommand ${selfCommand("proxy", "%h")}`,
|
|
@@ -452,7 +460,7 @@ function ensureSshConfig() {
|
|
|
452
460
|
${now}`, { mode: 384 });
|
|
453
461
|
}
|
|
454
462
|
async function proxy(host) {
|
|
455
|
-
const name = host.replace(/\.box$/, "");
|
|
463
|
+
const name = host.replace(/\.(box|skyfleet)$/, "");
|
|
456
464
|
const got = await api(`/api/boxes/${name}/connect`, { method: "POST", body: JSON.stringify({ sshKey: sshKeys(laptopKey().publicKey) }) });
|
|
457
465
|
const targets = [...got.ssh.hosts].reverse();
|
|
458
466
|
for (let attempt = 0; attempt < 20; attempt++) {
|
|
@@ -482,15 +490,15 @@ async function proxy(host) {
|
|
|
482
490
|
async function newLocalBox(env, branch, name, o) {
|
|
483
491
|
const stop = progress([[0, "Finding a machine"], [900, `Unpacking the ${env} snapshot`], [2500, "Connecting GitHub"], [9e3, "Bigger machines take a few extra seconds"]]);
|
|
484
492
|
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());
|
|
485
|
-
ok(`${box.name} is ready ${dim(`\xB7 ssh ${box.name}.
|
|
493
|
+
ok(`${box.name} is ready ${dim(`\xB7 ssh ${box.name}.${brand.command}`)}`);
|
|
486
494
|
await openLocal({ name: box.name, env, here: localCopy(), branch, fresh: true });
|
|
487
495
|
}
|
|
488
496
|
var CLONE = ".clone";
|
|
489
497
|
var MIRROR_SKIP = [".git", "node_modules", "dist", "build", ".turbo", ".nx", ".next", ".cache", "coverage", ".DS_Store"];
|
|
490
498
|
function claudeMd(name, here) {
|
|
491
499
|
const lines = [
|
|
492
|
-
`# Machine: ${name}.
|
|
493
|
-
`Run every command there with \`ssh ${name}.
|
|
500
|
+
`# Machine: ${name}.${brand.command}`,
|
|
501
|
+
`Run every command there with \`ssh ${name}.${brand.command} '<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.`,
|
|
494
502
|
`Run git here, never on the machine: \`git -C repo status\`, commit and push from ./repo.`
|
|
495
503
|
];
|
|
496
504
|
if (here) {
|
|
@@ -535,8 +543,8 @@ function prepareFolder(name, env, here, mcp) {
|
|
|
535
543
|
const next = { ...state, name, env, owner: load().email, local };
|
|
536
544
|
writeFileSync4(join5(folder, "CLAUDE.md"), claudeMd(name, local));
|
|
537
545
|
const hook = (direction) => [{ matcher: "Bash", hooks: [{ type: "command", command: selfCommand("sync", name, direction, folder), timeout: 600 }] }];
|
|
538
|
-
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));
|
|
539
|
-
if (mcp) writeFileSync4(join5(folder, ".mcp.json"), JSON.stringify({ mcpServers: {
|
|
546
|
+
writeFileSync4(join5(folder, ".claude", "settings.json"), JSON.stringify({ enableAllProjectMcpServers: true, permissions: { allow: [`Bash(ssh ${name}.box:*)`, `Bash(ssh ${name}.skyfleet:*)`, `Bash(scp:*)`, `Bash(rsync:*)`, "mcp__box", "mcp__skyfleet"] }, hooks: { PreToolUse: hook("push"), PostToolUse: hook("pull") } }, null, 2));
|
|
547
|
+
if (mcp) writeFileSync4(join5(folder, ".mcp.json"), JSON.stringify({ mcpServers: { [brand.command]: { type: "http", url: mcp.url, headers: { authorization: `Box ${mcp.token}` } } } }, null, 2), { mode: 384 });
|
|
540
548
|
writeFileSync4(stateFile, JSON.stringify(next, null, 2));
|
|
541
549
|
return folder;
|
|
542
550
|
}
|
|
@@ -551,7 +559,7 @@ async function ensureClone(env) {
|
|
|
551
559
|
const partial = `${clone}.partial`;
|
|
552
560
|
rmSync(partial, { recursive: true, force: true });
|
|
553
561
|
mkdirSync4(dirname(clone), { recursive: true });
|
|
554
|
-
const bar = progress([[0, `Cloning ${env.repo} once for every ${env.name}
|
|
562
|
+
const bar = progress([[0, `Cloning ${env.repo} once for every ${env.name} agent`]], { pace: 6e4 });
|
|
555
563
|
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]);
|
|
556
564
|
if (!cloned.ok) {
|
|
557
565
|
bar();
|
|
@@ -585,7 +593,7 @@ async function setUpWorktree({ clone, folder, name, branch, fresh }) {
|
|
|
585
593
|
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);
|
|
586
594
|
if (copied && fresh) await alignBox(name, repo);
|
|
587
595
|
bar(copied ? `${tilde(repo)} is a worktree of ${tilde(clone)}` : void 0);
|
|
588
|
-
if (!copied) fail(`Couldn't sync ${tilde(repo)} with ${name}. Check that ssh ${name}.
|
|
596
|
+
if (!copied) fail(`Couldn't sync ${tilde(repo)} with ${name}. Check that ssh ${name}.${brand.command} works.`);
|
|
589
597
|
rmSync(`${repo}.mirror`, { recursive: true, force: true });
|
|
590
598
|
return true;
|
|
591
599
|
}
|
|
@@ -776,7 +784,7 @@ var pull = (name, folder, quiet2 = false) => rsync(`${name}.box:repo/`, join5(fo
|
|
|
776
784
|
var push = (name, folder, quiet2 = false, extra = []) => rsync(join5(folder, "repo") + "/", `${name}.box:repo/`, extra, quiet2);
|
|
777
785
|
async function openLocal({ name, env, here, branch, fresh = false }) {
|
|
778
786
|
ensureSshConfig();
|
|
779
|
-
const stop = progress([[0, `Opening ${name}`], [1500, "Waking the
|
|
787
|
+
const stop = progress([[0, `Opening ${name}`], [1500, "Waking the agent up"]]);
|
|
780
788
|
const [got, envs] = await Promise.all([
|
|
781
789
|
api(`/api/boxes/${name}/connect`, { method: "POST", body: JSON.stringify({ sshKey: sshKeys(laptopKey().publicKey) }) }),
|
|
782
790
|
api("/api/environments")
|
|
@@ -801,18 +809,18 @@ async function openLocal({ name, env, here, branch, fresh = false }) {
|
|
|
801
809
|
clearInterval(beat);
|
|
802
810
|
await stopWatching();
|
|
803
811
|
await push(name, folder);
|
|
804
|
-
ok(`Claude closed \xB7 repo synced \xB7 ${name} sleeps once it's quiet ${dim(`\xB7 come back with:
|
|
812
|
+
ok(`Claude closed \xB7 repo synced \xB7 ${name} sleeps once it's quiet ${dim(`\xB7 come back with: ${brand.command} ${name}`)}`);
|
|
805
813
|
process.exit(code2);
|
|
806
814
|
}
|
|
807
815
|
async function sendSetup() {
|
|
808
816
|
const { tar, skills, enabled, memory, ...home2 } = localClaudeHome();
|
|
809
817
|
const buf = await tar;
|
|
810
|
-
const sha = buf && buf.length <=
|
|
818
|
+
const sha = buf && buf.length <= MAX_SETUP_MB << 20 ? createHash("sha256").update(buf).digest("hex") : void 0;
|
|
811
819
|
const put = (body) => api("/api/laptop-setup", { method: "PUT", body: JSON.stringify({ home: { ...home2, memory }, sha, ...body }) });
|
|
812
820
|
if ((await put({})).need && buf) await put({ tgz: buf.toString("base64") });
|
|
813
821
|
return { skills, plugins: enabled.length, memory: !!memory.trim(), tooBig: !!buf && !sha };
|
|
814
822
|
}
|
|
815
|
-
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(
|
|
823
|
+
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 ${brand.command} --remote.`)).on("close", (c) => done(c ?? 0)));
|
|
816
824
|
function trustFolder(folder) {
|
|
817
825
|
const file2 = join5(homedir4(), ".claude.json");
|
|
818
826
|
try {
|
|
@@ -831,7 +839,7 @@ async function sync(name, direction, folder) {
|
|
|
831
839
|
command = JSON.parse(Buffer.concat(chunks).toString("utf8")).tool_input?.command ?? "";
|
|
832
840
|
} catch {
|
|
833
841
|
}
|
|
834
|
-
if (!
|
|
842
|
+
if (![`${name}.box`, `${name}.skyfleet`].some((host) => command.includes(host)) || !existsSync5(join5(folder, "repo"))) return;
|
|
835
843
|
await (direction === "push" ? push(name, folder) : pull(name, folder));
|
|
836
844
|
}
|
|
837
845
|
async function code(envArg) {
|
|
@@ -839,10 +847,10 @@ async function code(envArg) {
|
|
|
839
847
|
await prune(boxes);
|
|
840
848
|
const here = localBoxes().filter((b) => existsSync5(join5(b.folder, "repo")));
|
|
841
849
|
const envs = [...new Set(here.map((b) => b.env))].sort();
|
|
842
|
-
if (!envs.length) return console.log(dim(
|
|
843
|
-
const env = envArg ?? (envs.length === 1 ? envs[0] : await select2({ message: "Open the
|
|
850
|
+
if (!envs.length) return console.log(dim(`No agents on this laptop yet. Run ${brand.command} first.`));
|
|
851
|
+
const env = envArg ?? (envs.length === 1 ? envs[0] : await select2({ message: "Open the agents of which environment?", choices: envs.map((e) => ({ name: `${e.padEnd(18)}${dim(`${here.filter((b) => b.env === e).length} agents`)}`, value: e })) }));
|
|
844
852
|
const group = here.filter((b) => b.env === env);
|
|
845
|
-
if (!group.length) fail(`No
|
|
853
|
+
if (!group.length) fail(`No agents from ${env} on this laptop.`);
|
|
846
854
|
const awake = new Set(boxes.filter((b) => b.state === "running").map((b) => b.name));
|
|
847
855
|
const environment = (await api("/api/environments")).find((e) => e.name === env) ?? fail(`No environment named ${env}.`);
|
|
848
856
|
const clone = await ensureClone(environment);
|
|
@@ -853,10 +861,10 @@ async function code(envArg) {
|
|
|
853
861
|
const workspace = join5(BOXES, env, `${env}.code-workspace`);
|
|
854
862
|
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));
|
|
855
863
|
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();
|
|
856
|
-
ok(`${group.length} ${env}
|
|
864
|
+
ok(`${group.length} ${env} agent${group.length === 1 ? "" : "s"} in one window ${dim(`\xB7 ${tilde(workspace)}`)}`);
|
|
857
865
|
await Promise.all(group.filter((b) => awake.has(b.name) && !migrated.includes(b.name)).map((b) => pull(b.name, b.folder, true)));
|
|
858
866
|
const stops = group.map((b) => watchRepo(b.name, b.folder));
|
|
859
|
-
console.log(dim("Syncing your saves to the
|
|
867
|
+
console.log(dim("Syncing your saves to the agents. Leave this open; ctrl+c stops syncing."));
|
|
860
868
|
await new Promise((resolve) => process.once("SIGINT", resolve));
|
|
861
869
|
await Promise.all(stops.map((s) => s()));
|
|
862
870
|
ok("Stopped syncing");
|
|
@@ -975,7 +983,7 @@ async function newRemoteBox(env, branch, name, o) {
|
|
|
975
983
|
const agent = o.agent;
|
|
976
984
|
const idle = o.alwaysOn ? "0" : void 0;
|
|
977
985
|
const { mcpServers, mcpOAuth, skipped } = localMcp();
|
|
978
|
-
ok(`${mcpCount(mcpServers)} will be copied into the
|
|
986
|
+
ok(`${mcpCount(mcpServers)} will be copied into the agent ${dim(skipped.length ? `(skipping local-only: ${skipped.join(", ")})` : "")}`);
|
|
979
987
|
const claudeHome = localClaudeHome();
|
|
980
988
|
const stop = progress([
|
|
981
989
|
[0, "Finding a machine"],
|
|
@@ -994,7 +1002,7 @@ async function newRemoteBox(env, branch, name, o) {
|
|
|
994
1002
|
}
|
|
995
1003
|
async function sendHome(name, tar) {
|
|
996
1004
|
const buf = await tar;
|
|
997
|
-
if (!buf || buf.length >
|
|
1005
|
+
if (!buf || buf.length > MAX_SETUP_MB << 20) return api(`/api/boxes/${name}/home`, { method: "POST", body: "{}" }).catch(() => void 0);
|
|
998
1006
|
const sha = createHash2("sha256").update(buf).digest("hex");
|
|
999
1007
|
const post = (body) => api(`/api/boxes/${name}/home`, { method: "POST", body: JSON.stringify(body) });
|
|
1000
1008
|
if ((await post({ sha }).catch(() => ({ need: false }))).need) await post({ sha, tgz: buf.toString("base64") }).catch(() => void 0);
|
|
@@ -1002,7 +1010,7 @@ async function sendHome(name, tar) {
|
|
|
1002
1010
|
async function dial({ user, hosts }, privateKey, attempts) {
|
|
1003
1011
|
const once = () => new Promise((resolve, reject) => {
|
|
1004
1012
|
let left = hosts.length, won = false;
|
|
1005
|
-
if (!left) return reject(new Error("The
|
|
1013
|
+
if (!left) return reject(new Error("The agent has no address yet."));
|
|
1006
1014
|
for (const [host, port] of hosts) {
|
|
1007
1015
|
const c = new ssh22.Client();
|
|
1008
1016
|
c.on("ready", () => {
|
|
@@ -1026,7 +1034,7 @@ async function dial({ user, hosts }, privateKey, attempts) {
|
|
|
1026
1034
|
async function attach(name, ready) {
|
|
1027
1035
|
if (!name) {
|
|
1028
1036
|
const boxes = await api("/api/boxes");
|
|
1029
|
-
if (!boxes.length) return console.log(dim(
|
|
1037
|
+
if (!boxes.length) return console.log(dim(`No agents yet. Run ${brand.command}.`));
|
|
1030
1038
|
name = await select3({ message: "Attach to", choices: boxes.map((b) => ({ name: `${b.name.padEnd(28)}${label(b)}`, value: b.name })) });
|
|
1031
1039
|
}
|
|
1032
1040
|
const boxName = name;
|
|
@@ -1056,7 +1064,7 @@ async function attach(name, ready) {
|
|
|
1056
1064
|
process.stdout.write(TERMINAL_RESET);
|
|
1057
1065
|
if (process.stdin.isTTY) process.stdin.setRawMode(false);
|
|
1058
1066
|
console.log(dim(`
|
|
1059
|
-
Detached. ${boxName} sleeps once Claude is done.
|
|
1067
|
+
Detached. ${boxName} sleeps once Claude is done. ${brand.command} ${boxName} to return.`));
|
|
1060
1068
|
process.exit(0);
|
|
1061
1069
|
}
|
|
1062
1070
|
let ssh = ready?.ssh, attempts = 4, announced = false;
|
|
@@ -1064,7 +1072,7 @@ Detached. ${boxName} sleeps once Claude is done. box ${boxName} to return.`));
|
|
|
1064
1072
|
if (!ssh) {
|
|
1065
1073
|
attempts = 4;
|
|
1066
1074
|
const box = await findBox(boxName);
|
|
1067
|
-
if (box.state === "gone") fail(`${boxName} is gone. Make a new one with
|
|
1075
|
+
if (box.state === "gone") fail(`${boxName} is gone. Make a new one with ${brand.command}.`);
|
|
1068
1076
|
if (box.state === "paused" || box.state === "pausing") {
|
|
1069
1077
|
sleepScreen(box);
|
|
1070
1078
|
if (!keys.length) await new Promise((r) => {
|
|
@@ -1073,12 +1081,12 @@ Detached. ${boxName} sleeps once Claude is done. box ${boxName} to return.`));
|
|
|
1073
1081
|
wakingScreen(box, keys);
|
|
1074
1082
|
pressed = () => wakingScreen(box, keys);
|
|
1075
1083
|
}
|
|
1076
|
-
const stop = keys.length ? () => void 0 : progress([[0, `Opening ${boxName}`], [1200, "Waking the
|
|
1084
|
+
const stop = keys.length ? () => void 0 : progress([[0, `Opening ${boxName}`], [1200, "Waking the agent up"], [4e3, "Starting Claude"]]);
|
|
1077
1085
|
ssh = (await api(`/api/boxes/${boxName}/attach`, { method: "POST", body: JSON.stringify({ sshKey: sshKeys(key.publicKey) }) }).finally(() => stop())).ssh;
|
|
1078
1086
|
}
|
|
1079
1087
|
conn = await dial(ssh, key.privateKey, attempts).catch(() => void 0);
|
|
1080
1088
|
attempts = 1;
|
|
1081
|
-
if (conn && !announced && !keys.length) console.log(dim(`Attaching to ${boxName}. Detach with ctrl-], come back with
|
|
1089
|
+
if (conn && !announced && !keys.length) console.log(dim(`Attaching to ${boxName}. Detach with ctrl-], come back with ${brand.command} ${boxName}.`));
|
|
1082
1090
|
announced = true;
|
|
1083
1091
|
if (!conn) {
|
|
1084
1092
|
ssh = void 0;
|
|
@@ -1185,11 +1193,11 @@ async function updateFirst() {
|
|
|
1185
1193
|
if (!prefix || process.env.CBOX_NO_UPDATE || process.env.CBOX_UPDATED) return;
|
|
1186
1194
|
const latest = await fetch(LATEST, { signal: AbortSignal.timeout(2500) }).then((r) => r.json()).then((j) => j.version, () => void 0);
|
|
1187
1195
|
if (!latest || !newer(latest, VERSION)) return;
|
|
1188
|
-
const stop = progress([[0, `Updating
|
|
1196
|
+
const stop = progress([[0, `Updating ${brand.command} ${VERSION} \u2192 ${latest}`]]);
|
|
1189
1197
|
const done = spawnSync(NPM, installArgs(latest, prefix), { stdio: "ignore" }).status === 0;
|
|
1190
1198
|
stop();
|
|
1191
|
-
if (!done) return miss(`Couldn't update
|
|
1192
|
-
ok(`Updated
|
|
1199
|
+
if (!done) return miss(`Couldn't update ${brand.command} to ${latest}, so carrying on with ${VERSION}`);
|
|
1200
|
+
ok(`Updated ${brand.command} ${dim(VERSION)} \u2192 ${latest}`);
|
|
1193
1201
|
try {
|
|
1194
1202
|
writeFileSync5(join6(CONFIG_DIR, "update.json"), JSON.stringify({ version: latest, checkedAt: Date.now() }));
|
|
1195
1203
|
} catch {
|
|
@@ -1206,7 +1214,7 @@ function autoUpdate() {
|
|
|
1206
1214
|
state = JSON.parse(readFileSync6(file2, "utf8"));
|
|
1207
1215
|
} catch {
|
|
1208
1216
|
}
|
|
1209
|
-
if (state.version && state.version !== VERSION) ok(`Updated
|
|
1217
|
+
if (state.version && state.version !== VERSION) ok(`Updated ${brand.command} ${dim(state.version)} \u2192 ${VERSION}`);
|
|
1210
1218
|
const check = !state.checkedAt || Date.now() - state.checkedAt > 12 * 36e5;
|
|
1211
1219
|
try {
|
|
1212
1220
|
mkdirSync5(CONFIG_DIR, { recursive: true });
|
|
@@ -1225,14 +1233,14 @@ function autoUpdate() {
|
|
|
1225
1233
|
}
|
|
1226
1234
|
async function upgrade() {
|
|
1227
1235
|
const latest = await fetch(LATEST).then((r) => r.json()).then((j) => j.version).catch(() => fail("Could not reach the npm registry."));
|
|
1228
|
-
if (!newer(latest, VERSION)) return ok(
|
|
1229
|
-
console.log(dim(`Updating
|
|
1236
|
+
if (!newer(latest, VERSION)) return ok(`${brand.command} ${VERSION} is the latest`);
|
|
1237
|
+
console.log(dim(`Updating ${brand.command} ${VERSION} \u2192 ${latest}\u2026`));
|
|
1230
1238
|
try {
|
|
1231
1239
|
execFileSync4(NPM, installArgs(latest, npmPrefix()), { stdio: "inherit" });
|
|
1232
1240
|
} catch {
|
|
1233
|
-
fail(`npm could not update
|
|
1241
|
+
fail(`npm could not update ${brand.command}. Try: npm i -g ${PACKAGE}@latest`);
|
|
1234
1242
|
}
|
|
1235
|
-
ok(`Updated
|
|
1243
|
+
ok(`Updated ${brand.command} to ${latest}`);
|
|
1236
1244
|
}
|
|
1237
1245
|
|
|
1238
1246
|
// src/index.ts
|
|
@@ -1241,7 +1249,7 @@ var NAME = /^[a-z0-9][a-z0-9-]{0,30}$/;
|
|
|
1241
1249
|
async function newBox(o = {}) {
|
|
1242
1250
|
await updateFirst();
|
|
1243
1251
|
if (o.size && !["small", "medium", "large"].includes(o.size)) fail("Pick --size small, medium or large.");
|
|
1244
|
-
if (o.name !== void 0 && !NAME.test(o.name)) fail("Name the
|
|
1252
|
+
if (o.name !== void 0 && !NAME.test(o.name)) fail("Name the agent with lowercase letters, digits and dashes, like webhook-retry.");
|
|
1245
1253
|
const here = gitRepo();
|
|
1246
1254
|
const picked = await chooseEnvironment(o.env, here);
|
|
1247
1255
|
const branch = here && picked.repo.toLowerCase() === here.repo.toLowerCase() ? here.branch : void 0;
|
|
@@ -1253,7 +1261,7 @@ async function askName(branch) {
|
|
|
1253
1261
|
const suggestion = fromBranch || `${ANIMALS[Math.floor(Math.random() * ANIMALS.length)]}-${Math.random().toString(16).slice(2, 5)}`;
|
|
1254
1262
|
if (!process.stdin.isTTY) return void 0;
|
|
1255
1263
|
const picked = await input2({
|
|
1256
|
-
message: `Name this
|
|
1264
|
+
message: `Name this agent ${dim("(optional \xB7 you will see it in its folder, ssh name and the dashboard)")}`,
|
|
1257
1265
|
default: suggestion,
|
|
1258
1266
|
validate: (v) => !v.trim() || NAME.test(v.trim()) || "Lowercase letters, digits and dashes"
|
|
1259
1267
|
});
|
|
@@ -1261,22 +1269,22 @@ async function askName(branch) {
|
|
|
1261
1269
|
}
|
|
1262
1270
|
async function home(name, o) {
|
|
1263
1271
|
const making = o.env || o.remote || o.agent || o.size || o.alwaysOn;
|
|
1264
|
-
if (name && making) fail(`--env, --remote, --agent, --size and --always-on only shape a new
|
|
1272
|
+
if (name && making) fail(`--env, --remote, --agent, --size and --always-on only shape a new agent. Open ${name} with ${brand.command} ${name}, or make a new one with ${brand.command} new --name <name> and the options.`);
|
|
1265
1273
|
if (name) return openBox(name);
|
|
1266
1274
|
const email = load().email;
|
|
1267
1275
|
const all = await api("/api/boxes");
|
|
1268
1276
|
await prune(all);
|
|
1269
1277
|
const mine = all.filter((b) => b.state !== "gone" && (!email || b.owner === email));
|
|
1270
1278
|
if (!mine.length || !process.stdin.isTTY || making) return newBox(o);
|
|
1271
|
-
const pick = await select4({ message: "Open
|
|
1279
|
+
const pick = await select4({ message: "Open an agent", choices: [
|
|
1272
1280
|
...mine.map((b) => ({ name: `${b.name.padEnd(28)}${label(b)}${dim(b.agent === "local" ? "" : " \xB7 remote")}`, value: b.name })),
|
|
1273
|
-
{ name: styleText5("cyan", "+ new
|
|
1281
|
+
{ name: styleText5("cyan", "+ new agent"), value: "" }
|
|
1274
1282
|
] });
|
|
1275
1283
|
return pick ? openBox(pick) : newBox(o);
|
|
1276
1284
|
}
|
|
1277
1285
|
async function openBox(name) {
|
|
1278
1286
|
const box = await findBox(name);
|
|
1279
|
-
if (box.state === "gone") fail(`${name} is gone. Make a new one with
|
|
1287
|
+
if (box.state === "gone") fail(`${name} is gone. Make a new one with ${brand.command} new.`);
|
|
1280
1288
|
return box.agent === "local" ? openLocal({ name: box.name, env: box.env ?? "boxes", here: localCopy(), branch: box.branch ?? void 0 }) : attach(box.name);
|
|
1281
1289
|
}
|
|
1282
1290
|
async function ls() {
|
|
@@ -1284,9 +1292,9 @@ async function ls() {
|
|
|
1284
1292
|
const all = await api("/api/boxes");
|
|
1285
1293
|
await prune(all);
|
|
1286
1294
|
const mine = all.filter((b) => !email || b.owner === email);
|
|
1287
|
-
if (!mine.length) return console.log(dim(
|
|
1295
|
+
if (!mine.length) return console.log(dim(`No agents of yours. Run ${brand.command}.`));
|
|
1288
1296
|
const width = Math.max(...mine.map((b) => b.name.length)) + 2;
|
|
1289
|
-
console.log(dim(`${"
|
|
1297
|
+
console.log(dim(`${"AGENT".padEnd(width)}${"STATE".padEnd(20)}WHERE`));
|
|
1290
1298
|
for (const b of mine) {
|
|
1291
1299
|
const l = label(b);
|
|
1292
1300
|
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")}`);
|
|
@@ -1295,30 +1303,30 @@ async function ls() {
|
|
|
1295
1303
|
async function setup() {
|
|
1296
1304
|
const sent = await sendSetup();
|
|
1297
1305
|
const n = (k, word) => `${k} ${word}${k === 1 ? "" : "s"}`;
|
|
1298
|
-
ok(`Sent ${[n(sent.skills, "skill"), n(sent.plugins, "plugin"), ...sent.memory ? ["CLAUDE.md"] : []].join(", ")} ${dim(
|
|
1299
|
-
if (sent.tooBig) miss(
|
|
1306
|
+
ok(`Sent ${[n(sent.skills, "skill"), n(sent.plugins, "plugin"), ...sent.memory ? ["CLAUDE.md"] : []].join(", ")} ${dim(`\xB7 @${brand.name} and webhooks start agents with these`)}`);
|
|
1307
|
+
if (sent.tooBig) miss(`Your skills and plugins are over ${MAX_SETUP_MB} MB, so only CLAUDE.md and the plugin list were sent.`);
|
|
1300
1308
|
const { data } = await api("/api/accounts");
|
|
1301
1309
|
const missing = ["claude", "github"].filter((id) => data.find((a) => a.id === id)?.status !== "ready").map((id) => id === "claude" ? "sign in to Claude" : "connect GitHub");
|
|
1302
|
-
if (missing.length) miss(`Before
|
|
1310
|
+
if (missing.length) miss(`Before @${brand.name} can start agents as you, ${missing.join(" and ")}: ${new URL("/boxes?new", load().server)}`);
|
|
1303
1311
|
else ok("Claude and GitHub are connected");
|
|
1304
1312
|
}
|
|
1305
1313
|
var quiet = ["proxy", "sync"].includes(process.argv[2] ?? "");
|
|
1306
1314
|
if (quiet) console.log = console.error;
|
|
1307
|
-
if (!quiet && !process.env.CBOX_UPDATED && !process.argv.slice(2).some((a) => ["-V", "--version", "-h", "--help", "help"].includes(a))) console.log(dim(
|
|
1315
|
+
if (!quiet && !process.env.CBOX_UPDATED && !process.argv.slice(2).some((a) => ["-V", "--version", "-h", "--help", "help"].includes(a))) console.log(dim(`${brand.command} ${VERSION}`));
|
|
1308
1316
|
if (!quiet) {
|
|
1309
1317
|
autoUpdate();
|
|
1310
1318
|
ensureSshConfig();
|
|
1311
1319
|
}
|
|
1312
|
-
var boxOptions = (c) => c.option("-e, --env <name>", "environment").option("-n, --name <name>", "name the
|
|
1313
|
-
var cli = boxOptions(new Command(
|
|
1314
|
-
boxOptions(cli.command("new").description("always make a fresh
|
|
1315
|
-
cli.command("ls").description("list your
|
|
1316
|
-
cli.command("code [env]").description("open an environment's
|
|
1317
|
-
cli.command("rm <
|
|
1318
|
-
cli.command("setup").description(
|
|
1319
|
-
cli.command("login").option("--server <url>",
|
|
1320
|
-
cli.command("upgrade").description(
|
|
1321
|
-
cli.command("attach [
|
|
1320
|
+
var boxOptions = (c) => c.option("-e, --env <name>", "environment").option("-n, --name <name>", "name the agent, 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");
|
|
1321
|
+
var cli = boxOptions(new Command(brand.command).enablePositionalOptions().description("Dev machines for Claude: Claude on your laptop, the work in the cloud").version(VERSION, "-v, --version").argument("[agent]", "open this agent")).action((name, o) => home(name, o));
|
|
1322
|
+
boxOptions(cli.command("new").description("always make a fresh agent")).action((o) => newBox(o));
|
|
1323
|
+
cli.command("ls").description("list your agents").action(ls);
|
|
1324
|
+
cli.command("code [env]").description("open an environment's agents in one VS Code window, each a git worktree, and sync your saves").action((env) => code(env));
|
|
1325
|
+
cli.command("rm <agent>").option("-f, --force", "delete even while someone is attached or Claude is working").description("delete an agent, pushing unpushed work to cb/<agent>").action(rm);
|
|
1326
|
+
cli.command("setup").description(`send your skills, plugins and CLAUDE.md for @${brand.name} and webhooks, and check Claude and GitHub`).action(setup);
|
|
1327
|
+
cli.command("login").option("--server <url>", `${brand.name} server`).description("sign in from the browser").action((o) => login(o.server));
|
|
1328
|
+
cli.command("upgrade").description(`update ${brand.command} to the latest version now`).action(upgrade);
|
|
1329
|
+
cli.command("attach [agent]", { hidden: true }).action((name) => name ? openBox(name) : home(void 0, {}));
|
|
1322
1330
|
cli.command("proxy <host>", { hidden: true }).action(proxy);
|
|
1323
|
-
cli.command("sync <
|
|
1331
|
+
cli.command("sync <agent> <direction> <folder>", { hidden: true }).action(sync);
|
|
1324
1332
|
await cli.parseAsync();
|
package/package.json
CHANGED
|
@@ -1,16 +1,17 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@abuaboud/box",
|
|
3
|
-
"version": "0.7.
|
|
4
|
-
"description": "
|
|
3
|
+
"version": "0.7.13",
|
|
4
|
+
"description": "skyfleet: Claude Code on your laptop, the work on a cloud dev machine (a ship) you reach as <name>.skyfleet, with your team databases through MCP.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
7
|
+
"skyfleet": "dist/skyfleet.mjs",
|
|
7
8
|
"box": "dist/box.mjs"
|
|
8
9
|
},
|
|
9
10
|
"files": [
|
|
10
11
|
"dist"
|
|
11
12
|
],
|
|
12
13
|
"scripts": {
|
|
13
|
-
"build": "esbuild src/index.ts --bundle --platform=node --format=esm --target=node22 --packages=external --outfile=dist/cbox.mjs && cp src/box.mjs
|
|
14
|
+
"build": "esbuild src/index.ts --bundle --platform=node --format=esm --target=node22 --packages=external --outfile=dist/cbox.mjs && cp src/box.mjs src/skyfleet.mjs dist/ && chmod +x dist/box.mjs dist/skyfleet.mjs",
|
|
14
15
|
"prepublishOnly": "npm run build"
|
|
15
16
|
},
|
|
16
17
|
"engines": {
|