@abuaboud/box 0.7.12 → 0.7.14
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 -65
- 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
|
@@ -63,7 +63,7 @@ function localClaudeHome() {
|
|
|
63
63
|
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);
|
|
64
64
|
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))));
|
|
65
65
|
const memory = existsSync(join(root, "CLAUDE.md")) ? readFileSync(join(root, "CLAUDE.md"), "utf8") : "";
|
|
66
|
-
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("."))
|
|
66
|
+
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(".")) : [] };
|
|
67
67
|
}
|
|
68
68
|
function laptopKey() {
|
|
69
69
|
const priv = join(CONFIG_DIR, "id_ed25519");
|
|
@@ -203,6 +203,13 @@ function label(b) {
|
|
|
203
203
|
return styleText("yellow", `\u263E sleeps in ${span(Math.max(6e4, left))}`);
|
|
204
204
|
}
|
|
205
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
|
+
|
|
206
213
|
// src/api.ts
|
|
207
214
|
var file = join2(CONFIG_DIR, "config.json");
|
|
208
215
|
var load = () => {
|
|
@@ -253,7 +260,7 @@ async function login(server) {
|
|
|
253
260
|
}
|
|
254
261
|
async function findBox(name) {
|
|
255
262
|
const box = (await api("/api/boxes")).find((b) => b.name === name || b.id === name);
|
|
256
|
-
return box ?? fail(`No
|
|
263
|
+
return box ?? fail(`No agent named ${name}. ${brand.command} ls lists them.`);
|
|
257
264
|
}
|
|
258
265
|
|
|
259
266
|
// src/environments.ts
|
|
@@ -289,7 +296,7 @@ async function waitReady(env) {
|
|
|
289
296
|
if (now.progress) bar.say(now.progress);
|
|
290
297
|
if (now.status === "ready") {
|
|
291
298
|
const secs = Math.round((Date.now() - t0) / 1e3);
|
|
292
|
-
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`)}`);
|
|
293
300
|
return now;
|
|
294
301
|
}
|
|
295
302
|
if (now.status === "failed") {
|
|
@@ -359,11 +366,11 @@ async function setUpEnvironment(repo, dir, envs) {
|
|
|
359
366
|
console.log(` ${dim("Name ")} ${name}`);
|
|
360
367
|
console.log(` ${dim("Repo ")} ${repo}`);
|
|
361
368
|
console.log(` ${dim("Machine ")} ${size[0].toUpperCase() + size.slice(1)} ${dim(`\xB7 ${sizes[size]}${size === sized.size ? ` (${sized.why})` : ""}`)}`);
|
|
362
|
-
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")}`);
|
|
363
370
|
if (names.length) console.log(` ${dim("Secrets ")} ${names.join(", ")} ${dim(secrets ? "\xB7 values copied from .env" : "\xB7 names from .env.example, values empty")}`);
|
|
364
371
|
console.log();
|
|
365
372
|
const choice = await select({ message: "Create it?", choices: [
|
|
366
|
-
{ name: "Create and start
|
|
373
|
+
{ name: "Create and start an agent", value: "go" },
|
|
367
374
|
...names.length && !secrets && dir && existsSync3(join3(dir, ".env")) ? [{ name: "Copy the secret values from my .env first", value: "secrets" }] : [],
|
|
368
375
|
{ name: "Change something", value: "edit" },
|
|
369
376
|
{ name: "Cancel", value: "cancel" }
|
|
@@ -398,14 +405,14 @@ async function ensureGitHub(repo) {
|
|
|
398
405
|
if (!r.linked) {
|
|
399
406
|
const url = new URL("/connect/github", load().server).toString();
|
|
400
407
|
console.log(`
|
|
401
|
-
${styleText3("yellow", "!")} Connect GitHub so
|
|
408
|
+
${styleText3("yellow", "!")} Connect GitHub so ${brand.name} can clone ${repo} and your agents can push as you.`);
|
|
402
409
|
console.log(dim(` Opening ${url}`));
|
|
403
410
|
open(url);
|
|
404
411
|
const bar = progress([[0, "Waiting for you to connect GitHub in the browser"]], { pace: 6e4 });
|
|
405
412
|
for (const end = Date.now() + 10 * 6e4; !r.linked; r = await status()) {
|
|
406
413
|
if (Date.now() > end) {
|
|
407
414
|
bar();
|
|
408
|
-
fail(
|
|
415
|
+
fail(`Gave up waiting after 10 minutes. Run ${brand.command} again when it's done.`);
|
|
409
416
|
}
|
|
410
417
|
await new Promise((resolve) => setTimeout(resolve, 2500));
|
|
411
418
|
}
|
|
@@ -436,7 +443,7 @@ function ensureSshConfig() {
|
|
|
436
443
|
mkdirSync3(sshDir, { recursive: true, mode: 448 });
|
|
437
444
|
laptopKey();
|
|
438
445
|
writeFileSync3(join4(sshDir, "box"), [
|
|
439
|
-
"Host *.box",
|
|
446
|
+
"Host *.box *.skyfleet",
|
|
440
447
|
" User user",
|
|
441
448
|
` IdentityFile "${join4(CONFIG_DIR, "id_ed25519")}"`,
|
|
442
449
|
` ProxyCommand ${selfCommand("proxy", "%h")}`,
|
|
@@ -453,7 +460,7 @@ function ensureSshConfig() {
|
|
|
453
460
|
${now}`, { mode: 384 });
|
|
454
461
|
}
|
|
455
462
|
async function proxy(host) {
|
|
456
|
-
const name = host.replace(/\.box$/, "");
|
|
463
|
+
const name = host.replace(/\.(box|skyfleet)$/, "");
|
|
457
464
|
const got = await api(`/api/boxes/${name}/connect`, { method: "POST", body: JSON.stringify({ sshKey: sshKeys(laptopKey().publicKey) }) });
|
|
458
465
|
const targets = [...got.ssh.hosts].reverse();
|
|
459
466
|
for (let attempt = 0; attempt < 20; attempt++) {
|
|
@@ -483,15 +490,15 @@ async function proxy(host) {
|
|
|
483
490
|
async function newLocalBox(env, branch, name, o) {
|
|
484
491
|
const stop = progress([[0, "Finding a machine"], [900, `Unpacking the ${env} snapshot`], [2500, "Connecting GitHub"], [9e3, "Bigger machines take a few extra seconds"]]);
|
|
485
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());
|
|
486
|
-
ok(`${box.name} is ready ${dim(`\xB7 ssh ${box.name}.
|
|
493
|
+
ok(`${box.name} is ready ${dim(`\xB7 ssh ${box.name}.${brand.command}`)}`);
|
|
487
494
|
await openLocal({ name: box.name, env, here: localCopy(), branch, fresh: true });
|
|
488
495
|
}
|
|
489
496
|
var CLONE = ".clone";
|
|
490
497
|
var MIRROR_SKIP = [".git", "node_modules", "dist", "build", ".turbo", ".nx", ".next", ".cache", "coverage", ".DS_Store"];
|
|
491
498
|
function claudeMd(name, here) {
|
|
492
499
|
const lines = [
|
|
493
|
-
`# Machine: ${name}.
|
|
494
|
-
`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.`,
|
|
495
502
|
`Run git here, never on the machine: \`git -C repo status\`, commit and push from ./repo.`
|
|
496
503
|
];
|
|
497
504
|
if (here) {
|
|
@@ -536,8 +543,8 @@ function prepareFolder(name, env, here, mcp) {
|
|
|
536
543
|
const next = { ...state, name, env, owner: load().email, local };
|
|
537
544
|
writeFileSync4(join5(folder, "CLAUDE.md"), claudeMd(name, local));
|
|
538
545
|
const hook = (direction) => [{ matcher: "Bash", hooks: [{ type: "command", command: selfCommand("sync", name, direction, folder), timeout: 600 }] }];
|
|
539
|
-
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));
|
|
540
|
-
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 });
|
|
541
548
|
writeFileSync4(stateFile, JSON.stringify(next, null, 2));
|
|
542
549
|
return folder;
|
|
543
550
|
}
|
|
@@ -552,7 +559,7 @@ async function ensureClone(env) {
|
|
|
552
559
|
const partial = `${clone}.partial`;
|
|
553
560
|
rmSync(partial, { recursive: true, force: true });
|
|
554
561
|
mkdirSync4(dirname(clone), { recursive: true });
|
|
555
|
-
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 });
|
|
556
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]);
|
|
557
564
|
if (!cloned.ok) {
|
|
558
565
|
bar();
|
|
@@ -586,7 +593,7 @@ async function setUpWorktree({ clone, folder, name, branch, fresh }) {
|
|
|
586
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);
|
|
587
594
|
if (copied && fresh) await alignBox(name, repo);
|
|
588
595
|
bar(copied ? `${tilde(repo)} is a worktree of ${tilde(clone)}` : void 0);
|
|
589
|
-
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.`);
|
|
590
597
|
rmSync(`${repo}.mirror`, { recursive: true, force: true });
|
|
591
598
|
return true;
|
|
592
599
|
}
|
|
@@ -777,7 +784,7 @@ var pull = (name, folder, quiet2 = false) => rsync(`${name}.box:repo/`, join5(fo
|
|
|
777
784
|
var push = (name, folder, quiet2 = false, extra = []) => rsync(join5(folder, "repo") + "/", `${name}.box:repo/`, extra, quiet2);
|
|
778
785
|
async function openLocal({ name, env, here, branch, fresh = false }) {
|
|
779
786
|
ensureSshConfig();
|
|
780
|
-
const stop = progress([[0, `Opening ${name}`], [1500, "Waking the
|
|
787
|
+
const stop = progress([[0, `Opening ${name}`], [1500, "Waking the agent up"]]);
|
|
781
788
|
const [got, envs] = await Promise.all([
|
|
782
789
|
api(`/api/boxes/${name}/connect`, { method: "POST", body: JSON.stringify({ sshKey: sshKeys(laptopKey().publicKey) }) }),
|
|
783
790
|
api("/api/environments")
|
|
@@ -802,18 +809,18 @@ async function openLocal({ name, env, here, branch, fresh = false }) {
|
|
|
802
809
|
clearInterval(beat);
|
|
803
810
|
await stopWatching();
|
|
804
811
|
await push(name, folder);
|
|
805
|
-
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}`)}`);
|
|
806
813
|
process.exit(code2);
|
|
807
814
|
}
|
|
808
815
|
async function sendSetup() {
|
|
809
816
|
const { tar, skills, enabled, memory, ...home2 } = localClaudeHome();
|
|
810
817
|
const buf = await tar;
|
|
811
818
|
const sha = buf && buf.length <= MAX_SETUP_MB << 20 ? createHash("sha256").update(buf).digest("hex") : void 0;
|
|
812
|
-
const put = (body) => api("/api/laptop-setup", { method: "PUT", body: JSON.stringify({ home: { ...home2, memory }, sha, ...body }) });
|
|
819
|
+
const put = (body) => api("/api/laptop-setup", { method: "PUT", body: JSON.stringify({ home: { ...home2, memory, skills }, sha, ...body }) });
|
|
813
820
|
if ((await put({})).need && buf) await put({ tgz: buf.toString("base64") });
|
|
814
|
-
return { skills, plugins: enabled.length, memory: !!memory.trim(), tooBig: !!buf && !sha };
|
|
821
|
+
return { skills: skills.length, plugins: enabled.length, memory: !!memory.trim(), tooBig: !!buf && !sha };
|
|
815
822
|
}
|
|
816
|
-
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)));
|
|
817
824
|
function trustFolder(folder) {
|
|
818
825
|
const file2 = join5(homedir4(), ".claude.json");
|
|
819
826
|
try {
|
|
@@ -832,7 +839,7 @@ async function sync(name, direction, folder) {
|
|
|
832
839
|
command = JSON.parse(Buffer.concat(chunks).toString("utf8")).tool_input?.command ?? "";
|
|
833
840
|
} catch {
|
|
834
841
|
}
|
|
835
|
-
if (!
|
|
842
|
+
if (![`${name}.box`, `${name}.skyfleet`].some((host) => command.includes(host)) || !existsSync5(join5(folder, "repo"))) return;
|
|
836
843
|
await (direction === "push" ? push(name, folder) : pull(name, folder));
|
|
837
844
|
}
|
|
838
845
|
async function code(envArg) {
|
|
@@ -840,10 +847,10 @@ async function code(envArg) {
|
|
|
840
847
|
await prune(boxes);
|
|
841
848
|
const here = localBoxes().filter((b) => existsSync5(join5(b.folder, "repo")));
|
|
842
849
|
const envs = [...new Set(here.map((b) => b.env))].sort();
|
|
843
|
-
if (!envs.length) return console.log(dim(
|
|
844
|
-
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 })) }));
|
|
845
852
|
const group = here.filter((b) => b.env === env);
|
|
846
|
-
if (!group.length) fail(`No
|
|
853
|
+
if (!group.length) fail(`No agents from ${env} on this laptop.`);
|
|
847
854
|
const awake = new Set(boxes.filter((b) => b.state === "running").map((b) => b.name));
|
|
848
855
|
const environment = (await api("/api/environments")).find((e) => e.name === env) ?? fail(`No environment named ${env}.`);
|
|
849
856
|
const clone = await ensureClone(environment);
|
|
@@ -854,10 +861,10 @@ async function code(envArg) {
|
|
|
854
861
|
const workspace = join5(BOXES, env, `${env}.code-workspace`);
|
|
855
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));
|
|
856
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();
|
|
857
|
-
ok(`${group.length} ${env}
|
|
864
|
+
ok(`${group.length} ${env} agent${group.length === 1 ? "" : "s"} in one window ${dim(`\xB7 ${tilde(workspace)}`)}`);
|
|
858
865
|
await Promise.all(group.filter((b) => awake.has(b.name) && !migrated.includes(b.name)).map((b) => pull(b.name, b.folder, true)));
|
|
859
866
|
const stops = group.map((b) => watchRepo(b.name, b.folder));
|
|
860
|
-
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."));
|
|
861
868
|
await new Promise((resolve) => process.once("SIGINT", resolve));
|
|
862
869
|
await Promise.all(stops.map((s) => s()));
|
|
863
870
|
ok("Stopped syncing");
|
|
@@ -976,14 +983,14 @@ async function newRemoteBox(env, branch, name, o) {
|
|
|
976
983
|
const agent = o.agent;
|
|
977
984
|
const idle = o.alwaysOn ? "0" : void 0;
|
|
978
985
|
const { mcpServers, mcpOAuth, skipped } = localMcp();
|
|
979
|
-
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(", ")})` : "")}`);
|
|
980
987
|
const claudeHome = localClaudeHome();
|
|
981
988
|
const stop = progress([
|
|
982
989
|
[0, "Finding a machine"],
|
|
983
990
|
[900, `Unpacking the ${env} snapshot`],
|
|
984
991
|
[2e3, "Signing Claude Code in"],
|
|
985
992
|
[2900, `Copying ${mcpCount(mcpServers)}`],
|
|
986
|
-
[3300, `Copying ${claudeHome.skills} skill${claudeHome.skills === 1 ? "" : "s"} and ${claudeHome.enabled.length} plugin${claudeHome.enabled.length === 1 ? "" : "s"}`],
|
|
993
|
+
[3300, `Copying ${claudeHome.skills.length} skill${claudeHome.skills.length === 1 ? "" : "s"} and ${claudeHome.enabled.length} plugin${claudeHome.enabled.length === 1 ? "" : "s"}`],
|
|
987
994
|
[3700, "Connecting GitHub"],
|
|
988
995
|
[4600, "Waking Claude up"],
|
|
989
996
|
[9e3, "Bigger machines take a few extra seconds"]
|
|
@@ -1003,7 +1010,7 @@ async function sendHome(name, tar) {
|
|
|
1003
1010
|
async function dial({ user, hosts }, privateKey, attempts) {
|
|
1004
1011
|
const once = () => new Promise((resolve, reject) => {
|
|
1005
1012
|
let left = hosts.length, won = false;
|
|
1006
|
-
if (!left) return reject(new Error("The
|
|
1013
|
+
if (!left) return reject(new Error("The agent has no address yet."));
|
|
1007
1014
|
for (const [host, port] of hosts) {
|
|
1008
1015
|
const c = new ssh22.Client();
|
|
1009
1016
|
c.on("ready", () => {
|
|
@@ -1027,7 +1034,7 @@ async function dial({ user, hosts }, privateKey, attempts) {
|
|
|
1027
1034
|
async function attach(name, ready) {
|
|
1028
1035
|
if (!name) {
|
|
1029
1036
|
const boxes = await api("/api/boxes");
|
|
1030
|
-
if (!boxes.length) return console.log(dim(
|
|
1037
|
+
if (!boxes.length) return console.log(dim(`No agents yet. Run ${brand.command}.`));
|
|
1031
1038
|
name = await select3({ message: "Attach to", choices: boxes.map((b) => ({ name: `${b.name.padEnd(28)}${label(b)}`, value: b.name })) });
|
|
1032
1039
|
}
|
|
1033
1040
|
const boxName = name;
|
|
@@ -1057,7 +1064,7 @@ async function attach(name, ready) {
|
|
|
1057
1064
|
process.stdout.write(TERMINAL_RESET);
|
|
1058
1065
|
if (process.stdin.isTTY) process.stdin.setRawMode(false);
|
|
1059
1066
|
console.log(dim(`
|
|
1060
|
-
Detached. ${boxName} sleeps once Claude is done.
|
|
1067
|
+
Detached. ${boxName} sleeps once Claude is done. ${brand.command} ${boxName} to return.`));
|
|
1061
1068
|
process.exit(0);
|
|
1062
1069
|
}
|
|
1063
1070
|
let ssh = ready?.ssh, attempts = 4, announced = false;
|
|
@@ -1065,7 +1072,7 @@ Detached. ${boxName} sleeps once Claude is done. box ${boxName} to return.`));
|
|
|
1065
1072
|
if (!ssh) {
|
|
1066
1073
|
attempts = 4;
|
|
1067
1074
|
const box = await findBox(boxName);
|
|
1068
|
-
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}.`);
|
|
1069
1076
|
if (box.state === "paused" || box.state === "pausing") {
|
|
1070
1077
|
sleepScreen(box);
|
|
1071
1078
|
if (!keys.length) await new Promise((r) => {
|
|
@@ -1074,12 +1081,12 @@ Detached. ${boxName} sleeps once Claude is done. box ${boxName} to return.`));
|
|
|
1074
1081
|
wakingScreen(box, keys);
|
|
1075
1082
|
pressed = () => wakingScreen(box, keys);
|
|
1076
1083
|
}
|
|
1077
|
-
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"]]);
|
|
1078
1085
|
ssh = (await api(`/api/boxes/${boxName}/attach`, { method: "POST", body: JSON.stringify({ sshKey: sshKeys(key.publicKey) }) }).finally(() => stop())).ssh;
|
|
1079
1086
|
}
|
|
1080
1087
|
conn = await dial(ssh, key.privateKey, attempts).catch(() => void 0);
|
|
1081
1088
|
attempts = 1;
|
|
1082
|
-
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}.`));
|
|
1083
1090
|
announced = true;
|
|
1084
1091
|
if (!conn) {
|
|
1085
1092
|
ssh = void 0;
|
|
@@ -1186,11 +1193,11 @@ async function updateFirst() {
|
|
|
1186
1193
|
if (!prefix || process.env.CBOX_NO_UPDATE || process.env.CBOX_UPDATED) return;
|
|
1187
1194
|
const latest = await fetch(LATEST, { signal: AbortSignal.timeout(2500) }).then((r) => r.json()).then((j) => j.version, () => void 0);
|
|
1188
1195
|
if (!latest || !newer(latest, VERSION)) return;
|
|
1189
|
-
const stop = progress([[0, `Updating
|
|
1196
|
+
const stop = progress([[0, `Updating ${brand.command} ${VERSION} \u2192 ${latest}`]]);
|
|
1190
1197
|
const done = spawnSync(NPM, installArgs(latest, prefix), { stdio: "ignore" }).status === 0;
|
|
1191
1198
|
stop();
|
|
1192
|
-
if (!done) return miss(`Couldn't update
|
|
1193
|
-
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}`);
|
|
1194
1201
|
try {
|
|
1195
1202
|
writeFileSync5(join6(CONFIG_DIR, "update.json"), JSON.stringify({ version: latest, checkedAt: Date.now() }));
|
|
1196
1203
|
} catch {
|
|
@@ -1207,7 +1214,7 @@ function autoUpdate() {
|
|
|
1207
1214
|
state = JSON.parse(readFileSync6(file2, "utf8"));
|
|
1208
1215
|
} catch {
|
|
1209
1216
|
}
|
|
1210
|
-
if (state.version && state.version !== VERSION) ok(`Updated
|
|
1217
|
+
if (state.version && state.version !== VERSION) ok(`Updated ${brand.command} ${dim(state.version)} \u2192 ${VERSION}`);
|
|
1211
1218
|
const check = !state.checkedAt || Date.now() - state.checkedAt > 12 * 36e5;
|
|
1212
1219
|
try {
|
|
1213
1220
|
mkdirSync5(CONFIG_DIR, { recursive: true });
|
|
@@ -1226,14 +1233,14 @@ function autoUpdate() {
|
|
|
1226
1233
|
}
|
|
1227
1234
|
async function upgrade() {
|
|
1228
1235
|
const latest = await fetch(LATEST).then((r) => r.json()).then((j) => j.version).catch(() => fail("Could not reach the npm registry."));
|
|
1229
|
-
if (!newer(latest, VERSION)) return ok(
|
|
1230
|
-
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`));
|
|
1231
1238
|
try {
|
|
1232
1239
|
execFileSync4(NPM, installArgs(latest, npmPrefix()), { stdio: "inherit" });
|
|
1233
1240
|
} catch {
|
|
1234
|
-
fail(`npm could not update
|
|
1241
|
+
fail(`npm could not update ${brand.command}. Try: npm i -g ${PACKAGE}@latest`);
|
|
1235
1242
|
}
|
|
1236
|
-
ok(`Updated
|
|
1243
|
+
ok(`Updated ${brand.command} to ${latest}`);
|
|
1237
1244
|
}
|
|
1238
1245
|
|
|
1239
1246
|
// src/index.ts
|
|
@@ -1242,7 +1249,7 @@ var NAME = /^[a-z0-9][a-z0-9-]{0,30}$/;
|
|
|
1242
1249
|
async function newBox(o = {}) {
|
|
1243
1250
|
await updateFirst();
|
|
1244
1251
|
if (o.size && !["small", "medium", "large"].includes(o.size)) fail("Pick --size small, medium or large.");
|
|
1245
|
-
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.");
|
|
1246
1253
|
const here = gitRepo();
|
|
1247
1254
|
const picked = await chooseEnvironment(o.env, here);
|
|
1248
1255
|
const branch = here && picked.repo.toLowerCase() === here.repo.toLowerCase() ? here.branch : void 0;
|
|
@@ -1254,7 +1261,7 @@ async function askName(branch) {
|
|
|
1254
1261
|
const suggestion = fromBranch || `${ANIMALS[Math.floor(Math.random() * ANIMALS.length)]}-${Math.random().toString(16).slice(2, 5)}`;
|
|
1255
1262
|
if (!process.stdin.isTTY) return void 0;
|
|
1256
1263
|
const picked = await input2({
|
|
1257
|
-
message: `Name this
|
|
1264
|
+
message: `Name this agent ${dim("(optional \xB7 you will see it in its folder, ssh name and the dashboard)")}`,
|
|
1258
1265
|
default: suggestion,
|
|
1259
1266
|
validate: (v) => !v.trim() || NAME.test(v.trim()) || "Lowercase letters, digits and dashes"
|
|
1260
1267
|
});
|
|
@@ -1262,22 +1269,22 @@ async function askName(branch) {
|
|
|
1262
1269
|
}
|
|
1263
1270
|
async function home(name, o) {
|
|
1264
1271
|
const making = o.env || o.remote || o.agent || o.size || o.alwaysOn;
|
|
1265
|
-
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.`);
|
|
1266
1273
|
if (name) return openBox(name);
|
|
1267
1274
|
const email = load().email;
|
|
1268
1275
|
const all = await api("/api/boxes");
|
|
1269
1276
|
await prune(all);
|
|
1270
1277
|
const mine = all.filter((b) => b.state !== "gone" && (!email || b.owner === email));
|
|
1271
1278
|
if (!mine.length || !process.stdin.isTTY || making) return newBox(o);
|
|
1272
|
-
const pick = await select4({ message: "Open
|
|
1279
|
+
const pick = await select4({ message: "Open an agent", choices: [
|
|
1273
1280
|
...mine.map((b) => ({ name: `${b.name.padEnd(28)}${label(b)}${dim(b.agent === "local" ? "" : " \xB7 remote")}`, value: b.name })),
|
|
1274
|
-
{ name: styleText5("cyan", "+ new
|
|
1281
|
+
{ name: styleText5("cyan", "+ new agent"), value: "" }
|
|
1275
1282
|
] });
|
|
1276
1283
|
return pick ? openBox(pick) : newBox(o);
|
|
1277
1284
|
}
|
|
1278
1285
|
async function openBox(name) {
|
|
1279
1286
|
const box = await findBox(name);
|
|
1280
|
-
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.`);
|
|
1281
1288
|
return box.agent === "local" ? openLocal({ name: box.name, env: box.env ?? "boxes", here: localCopy(), branch: box.branch ?? void 0 }) : attach(box.name);
|
|
1282
1289
|
}
|
|
1283
1290
|
async function ls() {
|
|
@@ -1285,9 +1292,9 @@ async function ls() {
|
|
|
1285
1292
|
const all = await api("/api/boxes");
|
|
1286
1293
|
await prune(all);
|
|
1287
1294
|
const mine = all.filter((b) => !email || b.owner === email);
|
|
1288
|
-
if (!mine.length) return console.log(dim(
|
|
1295
|
+
if (!mine.length) return console.log(dim(`No agents of yours. Run ${brand.command}.`));
|
|
1289
1296
|
const width = Math.max(...mine.map((b) => b.name.length)) + 2;
|
|
1290
|
-
console.log(dim(`${"
|
|
1297
|
+
console.log(dim(`${"AGENT".padEnd(width)}${"STATE".padEnd(20)}WHERE`));
|
|
1291
1298
|
for (const b of mine) {
|
|
1292
1299
|
const l = label(b);
|
|
1293
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")}`);
|
|
@@ -1296,30 +1303,30 @@ async function ls() {
|
|
|
1296
1303
|
async function setup() {
|
|
1297
1304
|
const sent = await sendSetup();
|
|
1298
1305
|
const n = (k, word) => `${k} ${word}${k === 1 ? "" : "s"}`;
|
|
1299
|
-
ok(`Sent ${[n(sent.skills, "skill"), n(sent.plugins, "plugin"), ...sent.memory ? ["CLAUDE.md"] : []].join(", ")} ${dim(
|
|
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`)}`);
|
|
1300
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.`);
|
|
1301
1308
|
const { data } = await api("/api/accounts");
|
|
1302
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");
|
|
1303
|
-
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)}`);
|
|
1304
1311
|
else ok("Claude and GitHub are connected");
|
|
1305
1312
|
}
|
|
1306
1313
|
var quiet = ["proxy", "sync"].includes(process.argv[2] ?? "");
|
|
1307
1314
|
if (quiet) console.log = console.error;
|
|
1308
|
-
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}`));
|
|
1309
1316
|
if (!quiet) {
|
|
1310
1317
|
autoUpdate();
|
|
1311
1318
|
ensureSshConfig();
|
|
1312
1319
|
}
|
|
1313
|
-
var boxOptions = (c) => c.option("-e, --env <name>", "environment").option("-n, --name <name>", "name the
|
|
1314
|
-
var cli = boxOptions(new Command(
|
|
1315
|
-
boxOptions(cli.command("new").description("always make a fresh
|
|
1316
|
-
cli.command("ls").description("list your
|
|
1317
|
-
cli.command("code [env]").description("open an environment's
|
|
1318
|
-
cli.command("rm <
|
|
1319
|
-
cli.command("setup").description(
|
|
1320
|
-
cli.command("login").option("--server <url>",
|
|
1321
|
-
cli.command("upgrade").description(
|
|
1322
|
-
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, {}));
|
|
1323
1330
|
cli.command("proxy <host>", { hidden: true }).action(proxy);
|
|
1324
|
-
cli.command("sync <
|
|
1331
|
+
cli.command("sync <agent> <direction> <folder>", { hidden: true }).action(sync);
|
|
1325
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.14",
|
|
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": {
|