@hellix-ai/cubes 0.1.0 → 0.1.2

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 CHANGED
@@ -12,7 +12,7 @@ cubes attach # pick a running one
12
12
  cubes attach 7ff4 # or name one: an id prefix, or part of its title
13
13
  ```
14
14
 
15
- No install: `npx @hellix-ai/cubes attach <session>`.
15
+ No install: `npx @hellix-ai/cubes attach <session>`. Later, `cubes upgrade` updates it in place.
16
16
 
17
17
  ## While attached
18
18
 
@@ -28,8 +28,9 @@ Every key goes to the session, exactly as if Claude Code were running locally, e
28
28
  takes it back, the terminal says so and goes read-only; detach and attach again to reclaim it.
29
29
  `cubes attach --view` watches without taking it.
30
30
 
31
- A stopped or hibernated session is resumed before attaching (`--no-resume` to refuse). A dropped
32
- connection reconnects on its own and replays the screen.
31
+ A stopped or hibernated session is resumed before attaching (`--no-resume` to refuse), with the
32
+ spinning Cubes mark on screen until it is up. A dropped connection reconnects on its own and replays
33
+ the screen.
33
34
 
34
35
  Scrollback lives in the session's tmux: scroll with the mouse wheel or trackpad (tmux copy-mode), not
35
36
  your terminal's own scrollback.
@@ -43,6 +44,7 @@ cubes whoami
43
44
  cubes org [ALIAS]
44
45
  cubes ls [--all] [--json]
45
46
  cubes attach [SESSION] [--view] [--no-resume]
47
+ cubes upgrade [--check]
46
48
  ```
47
49
 
48
50
  `--org ALIAS` works on any command. If you belong to several organizations, `cubes org` lists them and
package/dist/cli.js CHANGED
@@ -11,10 +11,13 @@ import { makeApi, ApiError } from "./api.js";
11
11
  import { attach, RESTORE_TERMINAL } from "./attach.js";
12
12
  import { CliError, currentSession, discover, loginFromTokens, normalizeHost, orgsOf, pollDevice, revoke, startDevice, whoOf, } from "./auth.js";
13
13
  import { readClipboardImage } from "./clipboard.js";
14
+ import { CubeSpinner } from "./cube.js";
14
15
  import { clearLogin, configPath, loadLogin, saveLogin } from "./config.js";
15
16
  import { parseArgs } from "./args.js";
16
17
  import { formatTable, isUuid, listSessions, LIVE, resolveSession, stateOf } from "./sessions.js";
17
- const VERSION = createRequire(import.meta.url)("../package.json").version;
18
+ import { upgrade } from "./upgrade.js";
19
+ const PKG = createRequire(import.meta.url)("../package.json");
20
+ const VERSION = PKG.version;
18
21
  const HELP = `cubes ${VERSION} - attach to your Cubes sessions from your own terminal
19
22
 
20
23
  Usage:
@@ -24,6 +27,7 @@ Usage:
24
27
  cubes org [ALIAS] list your orgs, or switch the default
25
28
  cubes ls [--all] [--json] list sessions (--all includes spawned ones)
26
29
  cubes attach [SESSION] [--view] [--no-resume] attach to a session
30
+ cubes upgrade [--check] install the newest cubes (--check only reports)
27
31
 
28
32
  SESSION is an id, the start of one (4+ characters), or part of its title. Left out, you pick from
29
33
  the running sessions. A stopped or hibernated session is resumed first unless --no-resume.
@@ -90,8 +94,14 @@ async function cmdLogin(a) {
90
94
  process.stderr.write(`\nTo sign in to ${host}, open:\n\n ${url}\n\nand confirm the code ${start.user_code}\n\n`);
91
95
  if (!a.flags.has("no-browser"))
92
96
  openBrowser(url);
93
- process.stderr.write("Waiting for approval…\n");
94
- const tokens = await pollDevice(d, start);
97
+ const spin = new CubeSpinner(process.stderr, "Waiting for you to approve it in the browser");
98
+ let tokens;
99
+ try {
100
+ tokens = await pollDevice(d, start);
101
+ }
102
+ finally {
103
+ spin.stop();
104
+ }
95
105
  const orgs = orgsOf(tokens.access_token);
96
106
  let org = str(a.flags, "org");
97
107
  if (org && orgs.length && !orgs.includes(org))
@@ -168,36 +178,38 @@ async function ensureRunning(api, row, resume) {
168
178
  let s = row;
169
179
  if (s.status === "running")
170
180
  return s;
171
- if (!LIVE.has(s.status)) {
172
- if (!resume)
173
- throw new CliError(`that session is ${s.status}; leave out --no-resume to bring it back`);
174
- process.stderr.write(`Resuming "${s.title ?? s.id}"…\n`);
175
- try {
176
- await api.post(`/sessions/${id}/resume`);
181
+ if (!LIVE.has(s.status) && !resume)
182
+ throw new CliError(`that session is ${s.status}; leave out --no-resume to bring it back`);
183
+ const name = `"${s.title ?? s.id.slice(0, 8)}"`;
184
+ // The same cube the session's own screen spins while Claude boots, so the wait reads as one thing.
185
+ const spin = new CubeSpinner(process.stderr, `${LIVE.has(s.status) ? "Starting" : "Resuming"} ${name}`);
186
+ try {
187
+ if (!LIVE.has(s.status)) {
188
+ try {
189
+ await api.post(`/sessions/${id}/resume`);
190
+ }
191
+ catch (e) {
192
+ if (!(e instanceof ApiError && e.status === 409 && /already running/.test(e.message)))
193
+ throw e;
194
+ }
177
195
  }
178
- catch (e) {
179
- if (!(e instanceof ApiError && e.status === 409 && /already running/.test(e.message)))
180
- throw e;
196
+ const deadline = Date.now() + 5 * 60_000;
197
+ while (Date.now() < deadline) {
198
+ s = await api.get(`/sessions/${id}`);
199
+ if (s.status === "running")
200
+ return s;
201
+ if (!LIVE.has(s.status))
202
+ throw new CliError(`the session stopped while starting (${s.status})`);
203
+ if (s.provision_note) {
204
+ spin.update(`${s.provision_note}${s.provision_total ? ` (${s.provision_done ?? 0}/${s.provision_total})` : ""}`);
205
+ }
206
+ await sleep(1000);
181
207
  }
208
+ throw new CliError("the session is taking too long to start - check it in the browser");
182
209
  }
183
- let lastNote = "";
184
- const deadline = Date.now() + 5 * 60_000;
185
- while (Date.now() < deadline) {
186
- s = await api.get(`/sessions/${id}`);
187
- if (s.status === "running")
188
- return s;
189
- if (!LIVE.has(s.status))
190
- throw new CliError(`the session stopped while starting (${s.status})`);
191
- const note = s.provision_note
192
- ? `${s.provision_note}${s.provision_total ? ` (${s.provision_done ?? 0}/${s.provision_total})` : ""}`
193
- : "starting";
194
- if (note !== lastNote) {
195
- process.stderr.write(` ${note}…\n`);
196
- lastNote = note;
197
- }
198
- await sleep(1000);
210
+ finally {
211
+ spin.stop();
199
212
  }
200
- throw new CliError("the session is taking too long to start - check it in the browser");
201
213
  }
202
214
  async function cmdAttach(a) {
203
215
  const s = currentSession({ org: str(a.flags, "org") });
@@ -281,10 +293,26 @@ async function main(argv) {
281
293
  return 0;
282
294
  case "attach":
283
295
  case "a": return cmdAttach(a);
296
+ case "upgrade":
297
+ case "update":
298
+ return upgrade({
299
+ pkg: PKG.name, current: VERSION, scriptPath: process.argv[1] ?? "",
300
+ log: (line) => process.stderr.write(`${line}\n`),
301
+ }, { check: a.flags.has("check") });
284
302
  default: throw new CliError(`unknown command "${a.cmd}" - see \`cubes help\``);
285
303
  }
286
304
  }
287
- main(process.argv.slice(2)).then((code) => process.exit(code), (e) => {
305
+ /** Exit only once everything written has reached its destination. A write to a PIPE is asynchronous
306
+ * in Node, so a bare process.exit() after `cubes ls --json | jq` cut the JSON off mid-string. An empty
307
+ * write's callback fires after every write queued before it. */
308
+ function exitAfterFlush(code) {
309
+ let pending = 2;
310
+ const done = () => { if (--pending === 0)
311
+ process.exit(code); };
312
+ process.stdout.write("", done);
313
+ process.stderr.write("", done);
314
+ }
315
+ main(process.argv.slice(2)).then((code) => exitAfterFlush(code), (e) => {
288
316
  process.stderr.write(`cubes: ${e instanceof Error ? e.message : String(e)}\n`);
289
- process.exit(e instanceof CliError ? e.exitCode : 1);
317
+ exitAfterFlush(e instanceof CliError ? e.exitCode : 1);
290
318
  });
package/dist/cube.js ADDED
@@ -0,0 +1,168 @@
1
+ // The Cubes mark as a spinning ASCII cube, drawn in YOUR terminal while the CLI waits on something.
2
+ //
3
+ // It is the session boot animation (server/src/engine/interactiveAuth.ts, CUBE_SPIN_AWK) ported
4
+ // line for line: the same six faces sampled parametrically, rotated yaw-then-pitch with the pitch on
5
+ // the true isometric angle (so frame 0 is the app icon's pose), the same back-face culling, the same
6
+ // Lambert shading quantised to five steps of one indigo ramp and four ASCII densities, and the same
7
+ // 2π/40 turn so it loops without a stutter. So a resume looks like one continuous thing: this cube
8
+ // while the session comes back, then the session's own cube while Claude boots, then Claude.
9
+ //
10
+ // It is drawn INLINE, not on the alternate screen: what is above it (a sign-in code you have to type)
11
+ // stays where it is, and when the wait ends the region is erased and the cursor is back where it was.
12
+ const NFR = 40;
13
+ const CH = ["", "+", "*", "*", "%", "#"];
14
+ const CO = ["", "\x1b[38;5;61m", "\x1b[38;5;62m", "\x1b[38;5;63m", "\x1b[38;5;105m", "\x1b[38;5;147m"];
15
+ const CAPTION = "\x1b[38;5;147m";
16
+ const FACES = [
17
+ // outward normal, then the two tangents that sweep it
18
+ { n: [0, 1, 0], a: [1, 0, 0], b: [0, 0, 1] },
19
+ { n: [0, -1, 0], a: [1, 0, 0], b: [0, 0, 1] },
20
+ { n: [1, 0, 0], a: [0, 1, 0], b: [0, 0, 1] },
21
+ { n: [-1, 0, 0], a: [0, 1, 0], b: [0, 0, 1] },
22
+ { n: [0, 0, 1], a: [1, 0, 0], b: [0, 1, 0] },
23
+ { n: [0, 0, -1], a: [1, 0, 0], b: [0, 1, 0] },
24
+ ];
25
+ const SP = Math.sin(0.61548);
26
+ const CP = Math.cos(0.61548);
27
+ const L = [-0.5, 0.72, 0.48];
28
+ /** The cube's size for a terminal of `cols` x `rows`: the boot window's caps, a little shorter so
29
+ * an inline cube does not take over the screen. Null when there is no room to draw one. */
30
+ export function cubeSize(cols, rows) {
31
+ let w = Math.min(cols - 4, 46);
32
+ let h = Math.min(rows - 6, 15);
33
+ if (h % 2 === 0)
34
+ h--;
35
+ if (w < 30 || h < 9)
36
+ return null;
37
+ return { w, h };
38
+ }
39
+ /** One frame: `h` rows of shade levels 0-5 (0 = empty). Frame numbers past 39 wrap seamlessly. */
40
+ export function cubeFrame(frame, w, h) {
41
+ const cx = (w - 1) / 2;
42
+ const cy = (h - 1) / 2;
43
+ const ys = cy / 1.634;
44
+ const xs = Math.min(2 * ys, cx / 1.415);
45
+ const th = 0.785398 + frame * (6.283185 / NFR);
46
+ const sq = Math.sin(th);
47
+ const cq = Math.cos(th);
48
+ const sc = Array.from({ length: h }, () => new Array(w).fill(0));
49
+ for (const f of FACES) {
50
+ const [nx, ny, nz] = f.n;
51
+ const mx = nx * cq - nz * sq;
52
+ const mz = nx * sq + nz * cq;
53
+ const qy = ny * CP - mz * SP;
54
+ const qz = ny * SP + mz * CP;
55
+ if (qz <= 0.001)
56
+ continue; // facing away
57
+ const bb = mx * L[0] + qy * L[1] + qz * L[2];
58
+ const lv = Math.min(5, Math.max(1, 1 + Math.floor(((bb + 0.55) / 1.5) * 5)));
59
+ for (let u = -1; u <= 1.0001; u += 0.035) {
60
+ for (let v = -1; v <= 1.0001; v += 0.035) {
61
+ const px = nx + u * f.a[0] + v * f.b[0];
62
+ const py = ny + u * f.a[1] + v * f.b[1];
63
+ const pz = nz + u * f.a[2] + v * f.b[2];
64
+ const rx = px * cq - pz * sq;
65
+ const rz = px * sq + pz * cq;
66
+ const ty = py * CP - rz * SP;
67
+ const sx = Math.floor(cx + xs * rx + 0.5);
68
+ const sy = Math.floor(cy - ys * ty + 0.5);
69
+ if (sx < 0 || sx >= w || sy < 0 || sy >= h)
70
+ continue;
71
+ sc[sy][sx] = lv;
72
+ }
73
+ }
74
+ }
75
+ return sc;
76
+ }
77
+ export function renderRow(levels, color) {
78
+ let out = "";
79
+ let cur = 0;
80
+ for (const s of levels) {
81
+ if (s === 0) {
82
+ if (cur && color)
83
+ out += "\x1b[0m";
84
+ cur = 0;
85
+ out += " ";
86
+ }
87
+ else {
88
+ if (cur !== s && color)
89
+ out += CO[s];
90
+ cur = s;
91
+ out += CH[s];
92
+ }
93
+ }
94
+ return (color ? out + "\x1b[0m" : out).replace(/\s+$/, "");
95
+ }
96
+ /**
97
+ * A wait with the cube on it. Where it cannot draw (not a terminal, TERM=dumb, or too small), each
98
+ * caption is printed as an ordinary line instead, so a pipe or a CI log still says what is happening.
99
+ * NO_COLOR keeps the cube and drops the colour, which the four ASCII densities survive.
100
+ */
101
+ export class CubeSpinner {
102
+ out;
103
+ timer = null;
104
+ frame = 0;
105
+ drawn = 0;
106
+ caption;
107
+ size;
108
+ color;
109
+ restore = () => this.out.write("\x1b[?25h");
110
+ // A Ctrl-C mid-wait: Node's default SIGINT exit runs no "exit" listeners, so it would leave the
111
+ // cube on screen and the cursor hidden. Clean up, then exit the way the default would have.
112
+ onSigint = () => { this.stop(); process.exit(130); };
113
+ constructor(out, caption, env = process.env) {
114
+ this.out = out;
115
+ this.caption = caption;
116
+ this.size = out.isTTY && env.TERM !== "dumb" ? cubeSize(out.columns ?? 80, out.rows ?? 24) : null;
117
+ this.color = !env.NO_COLOR;
118
+ if (!this.size) {
119
+ out.write(`${caption}…\n`);
120
+ return;
121
+ }
122
+ out.write("\x1b[?25l");
123
+ process.once("exit", this.restore);
124
+ process.once("SIGINT", this.onSigint);
125
+ this.draw();
126
+ this.timer = setInterval(() => this.draw(), 60);
127
+ }
128
+ update(caption) {
129
+ if (caption === this.caption)
130
+ return;
131
+ this.caption = caption;
132
+ if (!this.size)
133
+ this.out.write(`${caption}…\n`);
134
+ }
135
+ /** Erase the cube, put the cursor back, and say how it ended (if anything). */
136
+ stop(final) {
137
+ if (this.timer)
138
+ clearInterval(this.timer);
139
+ this.timer = null;
140
+ if (this.size) {
141
+ if (this.drawn)
142
+ this.out.write(`\x1b[${this.drawn}A\r\x1b[J`);
143
+ this.restore();
144
+ process.removeListener("exit", this.restore);
145
+ process.removeListener("SIGINT", this.onSigint);
146
+ this.size = null;
147
+ }
148
+ if (final)
149
+ this.out.write(`${final}\n`);
150
+ }
151
+ draw() {
152
+ const { w, h } = this.size;
153
+ const rows = cubeFrame(this.frame, w, h);
154
+ const dots = ".".repeat((Math.floor(this.frame / 4) % 3) + 1);
155
+ // A caption that wraps would add a row this redraw does not know to move back over.
156
+ const room = Math.max(10, (this.out.columns ?? 80) - 6);
157
+ const text = this.caption.length > room ? this.caption.slice(0, room - 1) + "…" : this.caption;
158
+ const pad = " ".repeat(Math.max(0, Math.floor((w - text.length - 3) / 2) + 2));
159
+ let s = this.drawn ? `\x1b[${this.drawn}A` : "";
160
+ for (const r of rows)
161
+ s += `\r ${renderRow(r, this.color)}\x1b[K\n`;
162
+ const cap = `${text}${dots}`;
163
+ s += `\r\x1b[K\n\r${pad}${this.color ? CAPTION + cap + "\x1b[0m" : cap}\x1b[K\n`;
164
+ this.out.write(s);
165
+ this.drawn = rows.length + 2;
166
+ this.frame = (this.frame + 1) % NFR;
167
+ }
168
+ }
package/dist/sessions.js CHANGED
@@ -65,19 +65,19 @@ function cut(s, w) {
65
65
  return s.length <= w ? s : s.slice(0, w - 1) + "…";
66
66
  }
67
67
  export function formatTable(rows, width, now = Date.now()) {
68
- const head = ["ID", "STATE", "AGE", "TITLE", "REPO"];
68
+ const head = ["ID", "STATE", "AGE", "TITLE", "GROUP"];
69
69
  const body = rows.map((s) => [
70
70
  s.id.slice(0, 8),
71
71
  stateOf(s),
72
72
  age(s.last_attached_at ?? s.created_at, now),
73
73
  s.title || "(untitled)",
74
- s.repo_full_name ?? "",
74
+ s.group_name ?? "",
75
75
  ]);
76
76
  const w = (i) => Math.max(head[i].length, ...body.map((r) => r[i].length));
77
77
  const fixed = [w(0), w(1), w(2)];
78
- const repoW = Math.min(w(4), 32);
79
- const titleW = Math.max(10, Math.min(w(3), width - fixed.reduce((a, b) => a + b, 0) - repoW - 8));
78
+ const groupW = Math.min(w(4), 32);
79
+ const titleW = Math.max(10, Math.min(w(3), width - fixed.reduce((a, b) => a + b, 0) - groupW - 8));
80
80
  const line = (r) => [r[0].padEnd(fixed[0]), r[1].padEnd(fixed[1]), r[2].padStart(fixed[2]),
81
- cut(r[3], titleW).padEnd(titleW), cut(r[4], repoW)].join(" ").trimEnd();
81
+ cut(r[3], titleW).padEnd(titleW), cut(r[4], groupW)].join(" ").trimEnd();
82
82
  return [line(head), ...body.map(line)].join("\n");
83
83
  }
@@ -0,0 +1,105 @@
1
+ // `cubes upgrade`: ask npm for the newest release and reinstall with whatever installed this copy.
2
+ //
3
+ // The install command has to match how the CLI got onto the machine: `npm install -g` over a pnpm or
4
+ // bun global install leaves the old copy first on PATH, and an npx run is a throwaway cache entry
5
+ // that `npx @hellix-ai/cubes@latest` already refreshes. The path of the running script says which.
6
+ import { spawn } from "node:child_process";
7
+ import { CliError } from "./auth.js";
8
+ export const REGISTRY = "https://registry.npmjs.org";
9
+ /** -1 / 0 / 1 for plain x.y.z versions. A prerelease sorts below its release (0.2.0-rc < 0.2.0). */
10
+ export function compareVersions(a, b) {
11
+ const parse = (v) => {
12
+ const [core = "", pre] = v.trim().replace(/^v/, "").split("-", 2);
13
+ return { nums: core.split(".").map((n) => Number.parseInt(n, 10) || 0), pre };
14
+ };
15
+ const x = parse(a);
16
+ const y = parse(b);
17
+ for (let i = 0; i < 3; i++) {
18
+ const d = (x.nums[i] ?? 0) - (y.nums[i] ?? 0);
19
+ if (d)
20
+ return d > 0 ? 1 : -1;
21
+ }
22
+ if (x.pre && !y.pre)
23
+ return -1;
24
+ if (!x.pre && y.pre)
25
+ return 1;
26
+ return 0;
27
+ }
28
+ export function installerFor(scriptPath) {
29
+ const p = scriptPath.replace(/\\/g, "/");
30
+ if (p.includes("/_npx/"))
31
+ return "npx";
32
+ if (p.includes("/.bun/"))
33
+ return "bun";
34
+ if (/\/pnpm\/|\/\.pnpm\//.test(p))
35
+ return "pnpm";
36
+ if (/\/yarn\/global\//.test(p))
37
+ return "yarn";
38
+ return "npm";
39
+ }
40
+ export function installCommand(installer, spec) {
41
+ switch (installer) {
42
+ case "pnpm": return ["pnpm", "add", "-g", spec];
43
+ case "bun": return ["bun", "add", "-g", spec];
44
+ case "yarn": return ["yarn", "global", "add", spec];
45
+ default: return ["npm", "install", "-g", spec];
46
+ }
47
+ }
48
+ export async function latestVersion(pkg, fetchImpl = fetch) {
49
+ let res;
50
+ try {
51
+ res = await fetchImpl(`${REGISTRY}/${pkg.replace("/", "%2f")}/latest`, { signal: AbortSignal.timeout(10_000) });
52
+ }
53
+ catch (e) {
54
+ throw new CliError(`cannot reach the npm registry: ${e.message}`);
55
+ }
56
+ if (!res.ok)
57
+ throw new CliError(`the npm registry answered HTTP ${res.status} for ${pkg}`);
58
+ const body = (await res.json());
59
+ if (typeof body.version !== "string")
60
+ throw new CliError("the npm registry did not say what the latest version is");
61
+ return body.version;
62
+ }
63
+ function runInherited(argv) {
64
+ return new Promise((resolve) => {
65
+ // shell on Windows only, where the package managers are .cmd shims spawn cannot start directly.
66
+ const child = spawn(argv[0], argv.slice(1), { stdio: "inherit", shell: process.platform === "win32" });
67
+ child.on("error", () => resolve(127));
68
+ child.on("exit", (code) => resolve(code ?? 1));
69
+ });
70
+ }
71
+ /** Returns the process exit code. `check` only reports. */
72
+ export async function upgrade(d, opts = {}) {
73
+ const latest = await latestVersion(d.pkg, d.fetchImpl);
74
+ const cmp = compareVersions(d.current, latest);
75
+ if (cmp === 0) {
76
+ d.log(`cubes ${d.current} is the latest version.`);
77
+ return 0;
78
+ }
79
+ if (cmp > 0) {
80
+ d.log(`cubes ${d.current} is newer than the latest published (${latest}); nothing to do.`);
81
+ return 0;
82
+ }
83
+ const installer = installerFor(d.scriptPath);
84
+ const spec = `${d.pkg}@${latest}`;
85
+ if (installer === "npx") {
86
+ d.log(`cubes ${latest} is available (this is ${d.current}). You are running it through npx, so ask for the new one:`);
87
+ d.log(` npx ${d.pkg}@latest <command>`);
88
+ return 0;
89
+ }
90
+ const argv = installCommand(installer, spec);
91
+ if (opts.check) {
92
+ d.log(`cubes ${latest} is available (you have ${d.current}). Run \`cubes upgrade\`, or: ${argv.join(" ")}`);
93
+ return 0;
94
+ }
95
+ d.log(`Upgrading cubes ${d.current} -> ${latest}: ${argv.join(" ")}`);
96
+ const code = await (d.run ?? runInherited)(argv);
97
+ if (code === 127)
98
+ throw new CliError(`could not run \`${argv[0]}\` - upgrade by hand: ${argv.join(" ")}`);
99
+ if (code !== 0) {
100
+ throw new CliError(`the upgrade failed (exit ${code}). If it was a permissions error, your global ${argv[0]} ` +
101
+ `directory is not writable by you - run it with the rights it needs: ${argv.join(" ")}`);
102
+ }
103
+ d.log(`cubes is now ${latest}.`);
104
+ return 0;
105
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hellix-ai/cubes",
3
- "version": "0.1.0",
3
+ "version": "0.1.2",
4
4
  "description": "Attach to your Cubes sessions from your own terminal (Warp, iTerm, Ghostty, anything).",
5
5
  "type": "module",
6
6
  "bin": {