@hellix-ai/cubes 0.0.0-stage → 0.1.0

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
@@ -1,3 +1,58 @@
1
- # Temporary Holding Version
1
+ # cubes
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Attach to your [Cubes](https://cubes.hellix.ai) sessions from your own terminal (Warp, iTerm2, Ghostty,
4
+ Terminal.app, anything) with your own font, colors and keyboard shortcuts.
5
+
6
+ ```sh
7
+ npm install -g @hellix-ai/cubes # needs Node.js 22.4+
8
+
9
+ cubes login --host cubes.hellix.ai # prints a code; approve it in the browser
10
+ cubes ls # your sessions
11
+ cubes attach # pick a running one
12
+ cubes attach 7ff4 # or name one: an id prefix, or part of its title
13
+ ```
14
+
15
+ No install: `npx @hellix-ai/cubes attach <session>`.
16
+
17
+ ## While attached
18
+
19
+ Every key goes to the session, exactly as if Claude Code were running locally, except:
20
+
21
+ | key | does |
22
+ | --- | --- |
23
+ | `Ctrl-]` | detach. The session keeps running. |
24
+ | `Ctrl-V` | paste the image on your clipboard: it is uploaded to the session and its path typed in (macOS, and Linux with `wl-paste` or `xclip`). With no image on the clipboard, Ctrl-V goes through unchanged. |
25
+
26
+ **One keyboard per session.** Attaching takes the keyboard from any browser tab open on the same session
27
+ (that tab becomes read-only, with a Take over button) and fits the session to your window. If someone
28
+ takes it back, the terminal says so and goes read-only; detach and attach again to reclaim it.
29
+ `cubes attach --view` watches without taking it.
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.
33
+
34
+ Scrollback lives in the session's tmux: scroll with the mouse wheel or trackpad (tmux copy-mode), not
35
+ your terminal's own scrollback.
36
+
37
+ ## Commands
38
+
39
+ ```
40
+ cubes login [--host URL] [--org ALIAS] [--no-browser]
41
+ cubes logout
42
+ cubes whoami
43
+ cubes org [ALIAS]
44
+ cubes ls [--all] [--json]
45
+ cubes attach [SESSION] [--view] [--no-resume]
46
+ ```
47
+
48
+ `--org ALIAS` works on any command. If you belong to several organizations, `cubes org` lists them and
49
+ `cubes org <alias>` changes the default.
50
+
51
+ ## Sign-in
52
+
53
+ `cubes login` uses the OAuth device flow against your Cubes install's own sign-in: the CLI never sees your
54
+ password. The login is stored in `~/.config/cubes/config.json` (mode 0600) and lasts until you
55
+ `cubes logout`, which revokes it, or until it goes unused for 30 days.
56
+
57
+ For scripts and CI, skip the stored login: set `CUBES_HOST`, `CUBES_TOKEN` (an access token) and
58
+ optionally `CUBES_ORG`.
package/dist/api.js ADDED
@@ -0,0 +1,58 @@
1
+ // The REST half: the same routes the web app calls, with the same Bearer + `x-org` pair.
2
+ import { CliError } from "./auth.js";
3
+ export function makeApi(s, fetchImpl = fetch) {
4
+ async function call(method, path, body, init) {
5
+ const send = async (force) => {
6
+ const headers = { authorization: `Bearer ${await s.token(force)}` };
7
+ if (s.org)
8
+ headers["x-org"] = s.org;
9
+ let payload;
10
+ if (init?.raw) {
11
+ headers["content-type"] = init.contentType ?? "application/octet-stream";
12
+ payload = init.raw;
13
+ }
14
+ else if (body !== undefined) {
15
+ headers["content-type"] = "application/json";
16
+ payload = JSON.stringify(body);
17
+ }
18
+ try {
19
+ return await fetchImpl(`${s.host}/api${path}`, { method, headers, body: payload });
20
+ }
21
+ catch (e) {
22
+ throw new CliError(`cannot reach ${s.host}: ${e.message}`);
23
+ }
24
+ };
25
+ let res = await send(false);
26
+ // A token the server refuses may simply be one our clock thinks is still good. One forced
27
+ // refresh, then the refusal stands.
28
+ if (res.status === 401)
29
+ res = await send(true);
30
+ const text = await res.text();
31
+ let data = undefined;
32
+ try {
33
+ data = text ? JSON.parse(text) : undefined;
34
+ }
35
+ catch { /* not JSON */ }
36
+ if (!res.ok) {
37
+ const msg = data?.error;
38
+ throw new ApiError(res.status, typeof msg === "string" ? msg : `HTTP ${res.status}`, path);
39
+ }
40
+ return data;
41
+ }
42
+ return {
43
+ get: (path) => call("GET", path),
44
+ post: (path, body, init) => call("POST", path, body, init),
45
+ };
46
+ }
47
+ export class ApiError extends CliError {
48
+ status;
49
+ path;
50
+ constructor(status, message, path) {
51
+ super(status === 400 && /organization/.test(message)
52
+ ? `${message} - pick one with \`cubes org <alias>\``
53
+ : message);
54
+ this.status = status;
55
+ this.path = path;
56
+ this.name = "ApiError";
57
+ }
58
+ }
package/dist/args.js ADDED
@@ -0,0 +1,37 @@
1
+ // Argument parsing, small enough not to need a dependency.
2
+ import { CliError } from "./auth.js";
3
+ /** Flags that take a value (`--host X` or `--host=X`); every other flag is a switch. */
4
+ const VALUED = new Set(["host", "org"]);
5
+ export function parseArgs(argv) {
6
+ const pos = [];
7
+ const flags = new Map();
8
+ for (let i = 0; i < argv.length; i++) {
9
+ const a = argv[i];
10
+ if (a === "-h") {
11
+ flags.set("help", true);
12
+ continue;
13
+ }
14
+ if (a === "-v") {
15
+ flags.set("version", true);
16
+ continue;
17
+ }
18
+ if (a.startsWith("--")) {
19
+ const eq = a.indexOf("=");
20
+ const name = eq > 0 ? a.slice(2, eq) : a.slice(2);
21
+ if (eq > 0)
22
+ flags.set(name, a.slice(eq + 1));
23
+ else if (VALUED.has(name)) {
24
+ const v = argv[i + 1];
25
+ if (v === undefined || v.startsWith("--"))
26
+ throw new CliError(`--${name} needs a value`);
27
+ flags.set(name, v);
28
+ i++;
29
+ }
30
+ else
31
+ flags.set(name, true);
32
+ continue;
33
+ }
34
+ pos.push(a);
35
+ }
36
+ return { cmd: pos.shift(), pos, flags };
37
+ }
package/dist/attach.js ADDED
@@ -0,0 +1,318 @@
1
+ // The bridge: your terminal <-> the session's live TTY, over the same websocket the web terminal uses.
2
+ //
3
+ // The wire (server/src/ws.ts + engine/tty.ts) is already exactly what a terminal needs:
4
+ // • the upgrade carries a single-use ~30s ticket as the SECOND subprotocol, after the constant
5
+ // `cube.tty.v1` (the server echoes only the marker, never the secret);
6
+ // • BINARY frames are raw terminal bytes - our keystrokes in, the screen out;
7
+ // • TEXT frames are JSON control: we send {type:'resize'|'takeover'}, the server sends
8
+ // {type:'role', writer, cols, rows} and {type:'resize', cols, rows}.
9
+ // So there is nothing to emulate here. Raw mode on, bytes both ways, a resize on SIGWINCH.
10
+ //
11
+ // ONE KEYBOARD PER SESSION. The server gives the writer token to the first viewer and makes every
12
+ // later one a read-only mirror. Someone who runs `cubes attach` means to type, so unless `--view` is
13
+ // passed we send `takeover` the moment we are told we are a mirror - the same button the web shows
14
+ // on a read-only tile. The writer also decides the pane's size, so the session re-fits to THIS window.
15
+ import { ApiError } from "./api.js";
16
+ import { InputFilter, PASTE_IMAGE_BYTE } from "./keys.js";
17
+ export const TTY_SUBPROTOCOL = "cube.tty.v1";
18
+ const OPEN = 1;
19
+ /** Close codes (server/src/engine/tty.ts, web/src/cubes/Terminal.tsx). */
20
+ const WS_CLOSE_BACKPRESSURE = 4001;
21
+ const WS_CLOSE_FULL = 4002;
22
+ const WS_CLOSE_PARKED = 4004;
23
+ const WS_CLOSE_UNAUTHORIZED = 4403;
24
+ /** Drops that mean "the session is fine, the socket is not": re-mint a ticket and re-attach; the
25
+ * server replays its scrollback ring. 1001/1012/1013 are a server going away or restarting. */
26
+ const TRANSIENT = new Set([WS_CLOSE_BACKPRESSURE, 1001, 1005, 1006, 1011, 1012, 1013]);
27
+ const DEFAULT_BACKOFF = [300, 600, 1200, 2400, 5000, 5000, 5000, 5000];
28
+ /** Everything an attached session may have switched on in YOUR terminal (mouse reporting, bracketed
29
+ * paste, focus events, app cursor keys, kitty/modifyOtherKeys keyboard modes, a hidden cursor, a
30
+ * scroll region) - switched back off on the way out, so detaching never leaves Warp/iTerm in a
31
+ * state where the wheel prints garbage. */
32
+ export const RESTORE_TERMINAL = "\x1b[0m\x1b[?25h\x1b[0 q\x1b[r" +
33
+ "\x1b[?1000l\x1b[?1002l\x1b[?1003l\x1b[?1005l\x1b[?1006l\x1b[?1015l" +
34
+ "\x1b[?2004l\x1b[?1004l\x1b[?1l\x1b>\x1b[<u\x1b[>4;0m";
35
+ const ENTER_ALT = "\x1b[?1049h\x1b[H\x1b[2J";
36
+ const LEAVE_ALT = "\x1b[?1049l";
37
+ const CLEAR = "\x1b[H\x1b[2J";
38
+ export function wsUrlFor(host, id) {
39
+ return `${host.replace(/^http/i, "ws")}/api/sessions/${encodeURIComponent(id)}/tty`;
40
+ }
41
+ export function attach(deps, session, opts = {}) {
42
+ const { api, stdin, stdout, stderr } = deps;
43
+ const backoff = opts.backoffMs ?? DEFAULT_BACKOFF;
44
+ const filter = new InputFilter({ pasteImage: !opts.view });
45
+ const enc = new TextEncoder();
46
+ return new Promise((resolve) => {
47
+ let ws = null;
48
+ let role = null;
49
+ let askedTakeover = false;
50
+ let takeoverPending = false;
51
+ /** Someone took the keyboard FROM us. Sticky for the whole attach, reconnects included: a person
52
+ * who clicked "take over" in the browser must not have it snatched back by a network blip. */
53
+ let yielded = false;
54
+ let entered = false;
55
+ let done = false;
56
+ let attempts = 0;
57
+ let timer = null;
58
+ // Keystrokes that must wait their turn: until the server says who holds the keyboard, and while
59
+ // an image paste is uploading (so what is typed after Ctrl-V lands after the path, not before).
60
+ let pending = [];
61
+ let pasting = false;
62
+ const note = (msg) => {
63
+ stderr.write(entered ? `\r\n\x1b[2m[cubes] ${msg}\x1b[0m\r\n` : `[cubes] ${msg}\n`);
64
+ };
65
+ const enter = () => {
66
+ if (entered)
67
+ return;
68
+ entered = true;
69
+ if (stdin.isTTY)
70
+ stdin.setRawMode?.(true);
71
+ stdout.write(ENTER_ALT);
72
+ };
73
+ const finish = (code, message) => {
74
+ if (done)
75
+ return;
76
+ done = true;
77
+ if (timer)
78
+ clearTimeout(timer);
79
+ stdin.off("data", onData);
80
+ stdout.off("resize", onResize);
81
+ const sock = ws;
82
+ ws = null;
83
+ if (sock) {
84
+ sock.onclose = null;
85
+ sock.onmessage = null;
86
+ try {
87
+ sock.close(1000, "detached");
88
+ }
89
+ catch { /* already gone */ }
90
+ }
91
+ if (entered) {
92
+ stdout.write(RESTORE_TERMINAL + LEAVE_ALT);
93
+ if (stdin.isTTY)
94
+ stdin.setRawMode?.(false);
95
+ }
96
+ stdin.pause();
97
+ resolve({ code, message });
98
+ };
99
+ const sendRaw = (b) => {
100
+ if (ws && ws.readyState === OPEN)
101
+ ws.send(b);
102
+ };
103
+ // Hold keys while we cannot yet say where they go: before the first role, while our takeover is
104
+ // in flight (a mirror that asked for the keyboard is about to be the writer), and mid-paste.
105
+ const holding = () => role === null || (role === "mirror" && takeoverPending) || pasting;
106
+ const flushPending = () => {
107
+ if (holding())
108
+ return;
109
+ const q = pending;
110
+ pending = [];
111
+ if (role === "writer")
112
+ for (const b of q)
113
+ sendRaw(b);
114
+ };
115
+ const sendInput = (b) => {
116
+ if (opts.view)
117
+ return;
118
+ if (holding()) {
119
+ if (pending.reduce((n, x) => n + x.length, 0) < 64 * 1024)
120
+ pending.push(b.slice());
121
+ return;
122
+ }
123
+ if (role === "writer")
124
+ sendRaw(b);
125
+ };
126
+ const sendResize = () => {
127
+ const cols = stdout.columns ?? 80;
128
+ const rows = stdout.rows ?? 24;
129
+ if (ws && ws.readyState === OPEN)
130
+ ws.send(JSON.stringify({ type: "resize", cols, rows }));
131
+ };
132
+ const pasteImage = async () => {
133
+ pasting = true;
134
+ let typed = Uint8Array.of(PASTE_IMAGE_BYTE);
135
+ try {
136
+ const img = await deps.readClipboardImage();
137
+ if (img) {
138
+ note(`uploading the image on your clipboard (${Math.ceil(img.bytes.length / 1024)} KB)…`);
139
+ const r = await api.post(`/sessions/${encodeURIComponent(session.id)}/paste-image`, undefined, { contentType: img.type, raw: img.bytes });
140
+ // A trailing space so the person can keep typing after the path - the web does the same.
141
+ typed = enc.encode(r.path + " ");
142
+ }
143
+ }
144
+ catch (e) {
145
+ note(`image paste failed: ${e.message}`);
146
+ typed = new Uint8Array(0);
147
+ }
148
+ finally {
149
+ pasting = false;
150
+ }
151
+ if (done)
152
+ return;
153
+ if (typed.length) {
154
+ if (role === "writer")
155
+ sendRaw(typed);
156
+ else
157
+ pending.unshift(typed);
158
+ }
159
+ flushPending();
160
+ };
161
+ function onData(chunk) {
162
+ for (const a of filter.feed(chunk)) {
163
+ if (a.kind === "detach") {
164
+ finish(0, "detached");
165
+ return;
166
+ }
167
+ if (a.kind === "paste-image") {
168
+ if (pasting)
169
+ continue;
170
+ void pasteImage();
171
+ continue;
172
+ }
173
+ sendInput(a.bytes);
174
+ }
175
+ }
176
+ function onResize() { sendResize(); }
177
+ const retry = (ms) => {
178
+ if (done)
179
+ return;
180
+ timer = setTimeout(() => { timer = null; void connect(); }, ms);
181
+ };
182
+ const onClose = (code, reason) => {
183
+ ws = null;
184
+ role = null;
185
+ if (done)
186
+ return;
187
+ if (code === 1000) {
188
+ finish(0, reason ? `session ended (${reason})` : "session ended");
189
+ return;
190
+ }
191
+ if (code === WS_CLOSE_UNAUTHORIZED) {
192
+ finish(1, "the server refused the attach - your sign-in or org membership may have changed");
193
+ return;
194
+ }
195
+ if (code === WS_CLOSE_PARKED) {
196
+ note("this session's terminal is parked (its machine stopped answering). Waiting for it to come back - Ctrl-] to give up.");
197
+ retry(opts.parkedRetryMs ?? 15_000);
198
+ return;
199
+ }
200
+ if (code === WS_CLOSE_FULL) {
201
+ note("this session already has the most viewers it allows - waiting for one to leave. Ctrl-] to give up.");
202
+ retry(opts.fullRetryMs ?? 5_000);
203
+ return;
204
+ }
205
+ if (TRANSIENT.has(code) && attempts < backoff.length) {
206
+ const ms = backoff[attempts];
207
+ attempts += 1;
208
+ note(`connection dropped (${code}) - reconnecting (${attempts}/${backoff.length})…`);
209
+ retry(ms);
210
+ return;
211
+ }
212
+ finish(1, TRANSIENT.has(code)
213
+ ? "lost the connection to the session and could not get it back"
214
+ : `the session closed the connection (${code}${reason ? ` ${reason}` : ""})`);
215
+ };
216
+ const connect = async () => {
217
+ if (done)
218
+ return;
219
+ let ticket;
220
+ try {
221
+ ticket = (await api.post(`/sessions/${encodeURIComponent(session.id)}/terminal/ticket`)).ticket;
222
+ }
223
+ catch (e) {
224
+ if (done)
225
+ return;
226
+ if (e instanceof ApiError && e.status === 409) {
227
+ finish(1, "the session is not running any more");
228
+ return;
229
+ }
230
+ if (e instanceof ApiError && e.status < 500) {
231
+ finish(1, e.message);
232
+ return;
233
+ }
234
+ // The server itself is restarting or unreachable: same ladder as a dropped socket.
235
+ if (attempts < backoff.length) {
236
+ const ms = backoff[attempts];
237
+ attempts += 1;
238
+ note(`${e.message} - retrying (${attempts}/${backoff.length})…`);
239
+ retry(ms);
240
+ return;
241
+ }
242
+ finish(1, e.message);
243
+ return;
244
+ }
245
+ if (done)
246
+ return;
247
+ const sock = deps.openWs(wsUrlFor(deps.host, session.id), [TTY_SUBPROTOCOL, ticket]);
248
+ sock.binaryType = "arraybuffer";
249
+ ws = sock;
250
+ askedTakeover = false;
251
+ takeoverPending = false;
252
+ sock.onopen = () => {
253
+ if (ws !== sock)
254
+ return;
255
+ if (!entered)
256
+ enter();
257
+ else
258
+ stdout.write(CLEAR); // the server replays its ring; paint it on a clean screen
259
+ sendResize();
260
+ };
261
+ sock.onmessage = (ev) => {
262
+ if (ws !== sock)
263
+ return;
264
+ const data = ev.data;
265
+ if (typeof data === "string") {
266
+ let msg;
267
+ try {
268
+ msg = JSON.parse(data);
269
+ }
270
+ catch {
271
+ return;
272
+ }
273
+ if (msg.type === "role") {
274
+ // A role is what proves the attach stuck (the viewer-cap refusal arrives AFTER open),
275
+ // so this - not onopen - is where the reconnect ladder resets.
276
+ attempts = 0;
277
+ const prev = role;
278
+ role = msg.writer ? "writer" : "mirror";
279
+ if (role === "mirror" && prev === "writer") {
280
+ // Demoted: another viewer took the keyboard. Never take it back automatically - two
281
+ // terminals that each did would pass it back and forth forever.
282
+ yielded = true;
283
+ takeoverPending = false;
284
+ note("someone else took the keyboard - this terminal is read-only now. Ctrl-] and attach again to take it back.");
285
+ }
286
+ else if (role === "mirror" && !opts.view && !askedTakeover && !yielded) {
287
+ askedTakeover = true;
288
+ takeoverPending = true;
289
+ sock.send(JSON.stringify({ type: "takeover" }));
290
+ }
291
+ if (role === "writer") {
292
+ takeoverPending = false;
293
+ sendResize(); // the pane now sizes to us
294
+ }
295
+ flushPending();
296
+ }
297
+ return;
298
+ }
299
+ if (data instanceof ArrayBuffer)
300
+ stdout.write(new Uint8Array(data));
301
+ else if (ArrayBuffer.isView(data))
302
+ stdout.write(new Uint8Array(data.buffer, data.byteOffset, data.byteLength));
303
+ };
304
+ sock.onclose = (ev) => { if (ws === sock)
305
+ onClose(ev.code, ev.reason); };
306
+ sock.onerror = () => { };
307
+ };
308
+ stdin.on("data", onData);
309
+ stdout.on("resize", onResize);
310
+ stdin.resume();
311
+ if (opts.signal?.aborted) {
312
+ finish(0, "detached");
313
+ return;
314
+ }
315
+ opts.signal?.addEventListener("abort", () => finish(0, "detached"), { once: true });
316
+ void connect();
317
+ });
318
+ }
package/dist/auth.js ADDED
@@ -0,0 +1,206 @@
1
+ // Signing in: the OAuth 2.0 Device Authorization Grant (RFC 8628) against the app's own Keycloak.
2
+ //
3
+ // The CLI is told nothing but the app's URL. `/api/client-config` names the realm issuer and the
4
+ // public client (`cubes-cli`), the issuer's discovery document names the endpoints, and the person
5
+ // approves a short code in the browser where they are already signed in. So the CLI never sees a
6
+ // password, a Google login never has to happen inside a terminal, and the same binary works against
7
+ // any install.
8
+ //
9
+ // It asks for `offline_access`: a laptop CLI is used in bursts days apart, and an ordinary refresh
10
+ // token dies with the browser-length SSO session. `cubes logout` revokes the offline token.
11
+ import { loadLogin, saveLogin } from "./config.js";
12
+ export const SCOPE = "openid profile email organization offline_access";
13
+ /** Refresh this long before the access token actually expires, so a request never races the edge. */
14
+ const EXP_SKEW_S = 60;
15
+ export class CliError extends Error {
16
+ exitCode;
17
+ constructor(message, exitCode = 1) {
18
+ super(message);
19
+ this.exitCode = exitCode;
20
+ this.name = "CliError";
21
+ }
22
+ }
23
+ export function normalizeHost(raw) {
24
+ let h = raw.trim();
25
+ if (!/^https?:\/\//i.test(h))
26
+ h = `https://${h}`;
27
+ const u = new URL(h);
28
+ return u.origin;
29
+ }
30
+ /** The payload of a JWT, unverified. Only ever used to read OUR OWN token's exp + display claims -
31
+ * the server verifies every token it is handed; nothing here makes an authorization decision. */
32
+ export function decodeJwt(token) {
33
+ const part = token.split(".")[1];
34
+ if (!part)
35
+ return {};
36
+ try {
37
+ return JSON.parse(Buffer.from(part, "base64url").toString("utf8"));
38
+ }
39
+ catch {
40
+ return {};
41
+ }
42
+ }
43
+ /** The org aliases a token is a member of. Keycloak's membership mapper emits a flat string array;
44
+ * it can also be configured to emit an object keyed by alias, so both are read. */
45
+ export function orgsOf(token) {
46
+ const c = decodeJwt(token).organization;
47
+ if (Array.isArray(c))
48
+ return c.filter((x) => typeof x === "string");
49
+ if (c && typeof c === "object")
50
+ return Object.keys(c);
51
+ return [];
52
+ }
53
+ export function whoOf(token) {
54
+ const p = decodeJwt(token);
55
+ const name = p.preferred_username ?? p.email ?? p.sub;
56
+ return typeof name === "string" ? name : "unknown";
57
+ }
58
+ async function json(res, what) {
59
+ const text = await res.text();
60
+ try {
61
+ return JSON.parse(text);
62
+ }
63
+ catch {
64
+ throw new CliError(`${what}: HTTP ${res.status} (not JSON)`);
65
+ }
66
+ }
67
+ export async function discover(host) {
68
+ let res;
69
+ try {
70
+ res = await fetch(`${host}/api/client-config`);
71
+ }
72
+ catch (e) {
73
+ throw new CliError(`cannot reach ${host}: ${e.message}`);
74
+ }
75
+ if (!res.ok)
76
+ throw new CliError(`${host} did not answer /api/client-config (HTTP ${res.status}) - is that a Cubes URL?`);
77
+ const cfg = await json(res, "client-config");
78
+ if (cfg.cli === undefined)
79
+ throw new CliError(`${host} is too old for the CLI (no login settings in /api/client-config)`);
80
+ if (cfg.cli === null)
81
+ throw new CliError(`${host} has no sign-in configured`);
82
+ const wk = await fetch(`${cfg.cli.issuer}/.well-known/openid-configuration`);
83
+ if (!wk.ok)
84
+ throw new CliError(`cannot read the sign-in settings at ${cfg.cli.issuer} (HTTP ${wk.status})`);
85
+ const d = await json(wk, "openid-configuration");
86
+ if (!d.device_authorization_endpoint)
87
+ throw new CliError("this install's sign-in does not support device login");
88
+ return {
89
+ issuer: cfg.cli.issuer,
90
+ clientId: cfg.cli.clientId,
91
+ endpoints: { token: d.token_endpoint, device: d.device_authorization_endpoint, revoke: d.revocation_endpoint },
92
+ };
93
+ }
94
+ export async function startDevice(d) {
95
+ const res = await fetch(d.endpoints.device, {
96
+ method: "POST",
97
+ headers: { "content-type": "application/x-www-form-urlencoded" },
98
+ body: new URLSearchParams({ client_id: d.clientId, scope: SCOPE }),
99
+ });
100
+ const body = await json(res, "device login");
101
+ if (!res.ok || !body.device_code) {
102
+ throw new CliError(`device login refused: ${body.error_description ?? body.error ?? `HTTP ${res.status}`}`);
103
+ }
104
+ return body;
105
+ }
106
+ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
107
+ /** Poll the token endpoint until the person approves (or refuses, or the code expires). */
108
+ export async function pollDevice(d, start) {
109
+ let interval = Math.max(1, start.interval ?? 5) * 1000;
110
+ const deadline = Date.now() + start.expires_in * 1000;
111
+ while (Date.now() < deadline) {
112
+ await sleep(interval);
113
+ const res = await fetch(d.endpoints.token, {
114
+ method: "POST",
115
+ headers: { "content-type": "application/x-www-form-urlencoded" },
116
+ body: new URLSearchParams({
117
+ grant_type: "urn:ietf:params:oauth:grant-type:device_code",
118
+ device_code: start.device_code,
119
+ client_id: d.clientId,
120
+ }),
121
+ });
122
+ const t = await json(res, "token");
123
+ if (res.ok && t.access_token)
124
+ return t;
125
+ if (t.error === "authorization_pending")
126
+ continue;
127
+ if (t.error === "slow_down") {
128
+ interval += 5000;
129
+ continue;
130
+ }
131
+ if (t.error === "access_denied")
132
+ throw new CliError("sign-in was declined in the browser");
133
+ if (t.error === "expired_token")
134
+ break;
135
+ throw new CliError(`sign-in failed: ${t.error_description ?? t.error ?? `HTTP ${res.status}`}`);
136
+ }
137
+ throw new CliError("the code expired before it was approved - run `cubes login` again");
138
+ }
139
+ export function loginFromTokens(host, d, t, org) {
140
+ if (!t.access_token || !t.refresh_token)
141
+ throw new CliError("sign-in returned no tokens");
142
+ const exp = decodeJwt(t.access_token).exp;
143
+ return {
144
+ host, issuer: d.issuer, clientId: d.clientId, endpoints: d.endpoints,
145
+ refreshToken: t.refresh_token, accessToken: t.access_token,
146
+ accessExp: typeof exp === "number" ? exp : Math.floor(Date.now() / 1000) + (t.expires_in ?? 300),
147
+ org,
148
+ };
149
+ }
150
+ export async function refresh(login) {
151
+ const res = await fetch(login.endpoints.token, {
152
+ method: "POST",
153
+ headers: { "content-type": "application/x-www-form-urlencoded" },
154
+ body: new URLSearchParams({ grant_type: "refresh_token", refresh_token: login.refreshToken, client_id: login.clientId }),
155
+ });
156
+ const t = await json(res, "token refresh");
157
+ if (!res.ok || !t.access_token) {
158
+ throw new CliError(`your sign-in to ${login.host} has expired or was revoked - run \`cubes login\``);
159
+ }
160
+ const exp = decodeJwt(t.access_token).exp;
161
+ return {
162
+ ...login,
163
+ accessToken: t.access_token,
164
+ // Keycloak may or may not rotate; keep the old one when it does not send a new one.
165
+ refreshToken: t.refresh_token ?? login.refreshToken,
166
+ accessExp: typeof exp === "number" ? exp : Math.floor(Date.now() / 1000) + (t.expires_in ?? 300),
167
+ };
168
+ }
169
+ export async function revoke(login) {
170
+ if (!login.endpoints.revoke)
171
+ return;
172
+ await fetch(login.endpoints.revoke, {
173
+ method: "POST",
174
+ headers: { "content-type": "application/x-www-form-urlencoded" },
175
+ body: new URLSearchParams({ token: login.refreshToken, token_type_hint: "refresh_token", client_id: login.clientId }),
176
+ }).catch(() => undefined);
177
+ }
178
+ /** The login a command runs under. `CUBES_TOKEN` (+ `CUBES_HOST`, `CUBES_ORG`) bypasses the stored
179
+ * login entirely - for CI and scripts, where nobody is there to approve a device code. */
180
+ export function currentSession(opts = {}, env = process.env) {
181
+ if (env.CUBES_TOKEN) {
182
+ const tok = env.CUBES_TOKEN;
183
+ if (!env.CUBES_HOST)
184
+ throw new CliError("CUBES_TOKEN needs CUBES_HOST too");
185
+ const host = normalizeHost(env.CUBES_HOST);
186
+ const org = opts.org || env.CUBES_ORG || orgsOf(tok)[0];
187
+ return { host, org, token: async () => tok, who: () => whoOf(tok) };
188
+ }
189
+ let login = loadLogin();
190
+ if (!login)
191
+ throw new CliError("not signed in - run `cubes login --host <your Cubes URL>`");
192
+ const org = opts.org || env.CUBES_ORG || login.org;
193
+ return {
194
+ host: login.host,
195
+ org,
196
+ async token(force = false) {
197
+ const now = Math.floor(Date.now() / 1000);
198
+ if (!force && login.accessToken && (login.accessExp ?? 0) - EXP_SKEW_S > now)
199
+ return login.accessToken;
200
+ login = await refresh(login);
201
+ saveLogin(login);
202
+ return login.accessToken;
203
+ },
204
+ who: () => (login.accessToken ? whoOf(login.accessToken) : "unknown"),
205
+ };
206
+ }
package/dist/cli.js ADDED
@@ -0,0 +1,290 @@
1
+ #!/usr/bin/env node
2
+ // `cubes` - your Cubes sessions in your own terminal.
3
+ //
4
+ // cubes login --host https://your-cubes sign in (device code, approved in the browser)
5
+ // cubes ls list sessions
6
+ // cubes attach [session] attach this terminal to a session (Ctrl-] detaches)
7
+ import { spawn } from "node:child_process";
8
+ import { createRequire } from "node:module";
9
+ import { createInterface } from "node:readline/promises";
10
+ import { makeApi, ApiError } from "./api.js";
11
+ import { attach, RESTORE_TERMINAL } from "./attach.js";
12
+ import { CliError, currentSession, discover, loginFromTokens, normalizeHost, orgsOf, pollDevice, revoke, startDevice, whoOf, } from "./auth.js";
13
+ import { readClipboardImage } from "./clipboard.js";
14
+ import { clearLogin, configPath, loadLogin, saveLogin } from "./config.js";
15
+ import { parseArgs } from "./args.js";
16
+ import { formatTable, isUuid, listSessions, LIVE, resolveSession, stateOf } from "./sessions.js";
17
+ const VERSION = createRequire(import.meta.url)("../package.json").version;
18
+ const HELP = `cubes ${VERSION} - attach to your Cubes sessions from your own terminal
19
+
20
+ Usage:
21
+ cubes login [--host URL] [--org ALIAS] [--no-browser] sign in with a code you approve in the browser
22
+ cubes logout sign out and revoke this computer's login
23
+ cubes whoami who, where, and which org
24
+ cubes org [ALIAS] list your orgs, or switch the default
25
+ cubes ls [--all] [--json] list sessions (--all includes spawned ones)
26
+ cubes attach [SESSION] [--view] [--no-resume] attach to a session
27
+
28
+ SESSION is an id, the start of one (4+ characters), or part of its title. Left out, you pick from
29
+ the running sessions. A stopped or hibernated session is resumed first unless --no-resume.
30
+
31
+ While attached, every key goes to the session except:
32
+ Ctrl-] detach (the session keeps running)
33
+ Ctrl-V paste the image on your clipboard (uploaded, then its path is typed in)
34
+
35
+ Attaching takes the keyboard from any browser tab on the same session (it becomes read-only) and
36
+ fits the session to this window. --view watches without taking it.
37
+
38
+ Global: --org ALIAS overrides the default org for one command.
39
+ Env: CUBES_HOST + CUBES_TOKEN (+ CUBES_ORG) use a token instead of the stored login.
40
+ Config: ${configPath()}
41
+ `;
42
+ function str(f, name) {
43
+ const v = f.get(name);
44
+ return typeof v === "string" ? v : undefined;
45
+ }
46
+ const isTTY = () => !!process.stdin.isTTY && !!process.stdout.isTTY;
47
+ async function ask(question) {
48
+ const rl = createInterface({ input: process.stdin, output: process.stderr });
49
+ try {
50
+ return (await rl.question(question)).trim();
51
+ }
52
+ finally {
53
+ rl.close();
54
+ }
55
+ }
56
+ async function pick(items, label, question) {
57
+ for (let i = 0; i < items.length; i++)
58
+ process.stderr.write(`${label(items[i], i)}\n`);
59
+ const answer = await ask(question);
60
+ const n = Number(answer);
61
+ if (!Number.isInteger(n) || n < 1 || n > items.length)
62
+ throw new CliError("no choice made");
63
+ return items[n - 1];
64
+ }
65
+ function openBrowser(url) {
66
+ const cmd = process.platform === "darwin" ? ["open", url]
67
+ : process.platform === "win32" ? ["cmd", "/c", "start", "", url]
68
+ : (process.env.DISPLAY || process.env.WAYLAND_DISPLAY) ? ["xdg-open", url] : null;
69
+ if (!cmd)
70
+ return;
71
+ try {
72
+ spawn(cmd[0], cmd.slice(1), { stdio: "ignore", detached: true }).on("error", () => undefined).unref();
73
+ }
74
+ catch { /* the URL is printed anyway */ }
75
+ }
76
+ async function cmdLogin(a) {
77
+ const existing = loadLogin();
78
+ let raw = str(a.flags, "host") ?? process.env.CUBES_HOST ?? existing?.host;
79
+ if (!raw) {
80
+ if (!isTTY())
81
+ throw new CliError("which Cubes? `cubes login --host https://…`");
82
+ raw = await ask("Cubes URL (the address you open in the browser): ");
83
+ if (!raw)
84
+ throw new CliError("no URL given");
85
+ }
86
+ const host = normalizeHost(raw);
87
+ const d = await discover(host);
88
+ const start = await startDevice(d);
89
+ const url = start.verification_uri_complete ?? start.verification_uri;
90
+ process.stderr.write(`\nTo sign in to ${host}, open:\n\n ${url}\n\nand confirm the code ${start.user_code}\n\n`);
91
+ if (!a.flags.has("no-browser"))
92
+ openBrowser(url);
93
+ process.stderr.write("Waiting for approval…\n");
94
+ const tokens = await pollDevice(d, start);
95
+ const orgs = orgsOf(tokens.access_token);
96
+ let org = str(a.flags, "org");
97
+ if (org && orgs.length && !orgs.includes(org))
98
+ throw new CliError(`you are not a member of "${org}" (you are in: ${orgs.join(", ")})`);
99
+ if (!org) {
100
+ if (orgs.length === 1)
101
+ org = orgs[0];
102
+ else if (orgs.length > 1) {
103
+ org = isTTY()
104
+ ? await pick(orgs, (o, i) => ` ${i + 1}) ${o}`, "Which org should commands use by default? ")
105
+ : orgs[0];
106
+ }
107
+ }
108
+ saveLogin(loginFromTokens(host, d, tokens, org));
109
+ process.stderr.write(`Signed in to ${host} as ${whoOf(tokens.access_token)}${org ? `, org ${org}` : ""}.\n`);
110
+ if (!orgs.length)
111
+ process.stderr.write("You are not a member of any organization yet, so there is nothing to attach to.\n");
112
+ else if (orgs.length > 1)
113
+ process.stderr.write("Switch orgs any time with `cubes org <alias>`.\n");
114
+ }
115
+ async function cmdLogout() {
116
+ const login = loadLogin();
117
+ if (!login) {
118
+ process.stderr.write("Not signed in.\n");
119
+ return;
120
+ }
121
+ await revoke(login);
122
+ clearLogin();
123
+ process.stderr.write(`Signed out of ${login.host}.\n`);
124
+ }
125
+ async function cmdWhoami(a) {
126
+ const s = currentSession({ org: str(a.flags, "org") });
127
+ await s.token();
128
+ process.stdout.write(`${s.who()} @ ${s.host}${s.org ? ` (org ${s.org})` : ""}\n`);
129
+ }
130
+ async function cmdOrg(a) {
131
+ const s = currentSession();
132
+ const orgs = orgsOf(await s.token());
133
+ const want = a.pos[0];
134
+ if (want && process.env.CUBES_TOKEN)
135
+ throw new CliError("`cubes org <alias>` changes the stored login; with CUBES_TOKEN, set CUBES_ORG instead");
136
+ if (!want) {
137
+ if (!orgs.length) {
138
+ process.stderr.write("You are not a member of any organization.\n");
139
+ return;
140
+ }
141
+ for (const o of orgs)
142
+ process.stdout.write(`${o === s.org ? "*" : " "} ${o}\n`);
143
+ return;
144
+ }
145
+ if (!orgs.includes(want))
146
+ throw new CliError(`you are not a member of "${want}" (you are in: ${orgs.join(", ") || "none"})`);
147
+ const login = loadLogin();
148
+ saveLogin({ ...login, org: want });
149
+ process.stderr.write(`Default org is now ${want}.\n`);
150
+ }
151
+ async function cmdLs(a) {
152
+ const api = makeApi(currentSession({ org: str(a.flags, "org") }));
153
+ const rows = await listSessions(api, { all: a.flags.has("all") });
154
+ if (a.flags.has("json")) {
155
+ process.stdout.write(JSON.stringify(rows, null, 2) + "\n");
156
+ return;
157
+ }
158
+ if (!rows.length) {
159
+ process.stderr.write("No sessions.\n");
160
+ return;
161
+ }
162
+ process.stdout.write(formatTable(rows, process.stdout.columns ?? 120) + "\n");
163
+ }
164
+ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
165
+ /** Bring a stopped/hibernated session back and wait until its terminal can be attached. */
166
+ async function ensureRunning(api, row, resume) {
167
+ const id = encodeURIComponent(row.id);
168
+ let s = row;
169
+ if (s.status === "running")
170
+ 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`);
177
+ }
178
+ catch (e) {
179
+ if (!(e instanceof ApiError && e.status === 409 && /already running/.test(e.message)))
180
+ throw e;
181
+ }
182
+ }
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);
199
+ }
200
+ throw new CliError("the session is taking too long to start - check it in the browser");
201
+ }
202
+ async function cmdAttach(a) {
203
+ const s = currentSession({ org: str(a.flags, "org") });
204
+ const api = makeApi(s);
205
+ const query = a.pos.join(" ").trim();
206
+ let row;
207
+ if (query && isUuid(query)) {
208
+ row = await api.get(`/sessions/${encodeURIComponent(query)}`);
209
+ }
210
+ else if (query) {
211
+ row = resolveSession(await listSessions(api, { all: true }), query);
212
+ }
213
+ else {
214
+ if (!isTTY())
215
+ throw new CliError("which session? `cubes attach <id or title>`");
216
+ const live = (await listSessions(api)).filter((r) => LIVE.has(r.status));
217
+ if (!live.length)
218
+ throw new CliError("no running sessions - `cubes ls` shows the rest; attach to one by id to resume it");
219
+ row = await pick(live, (r, i) => ` ${String(i + 1).padStart(2)}) ${r.id.slice(0, 8)} ${stateOf(r).padEnd(8)} ${r.title ?? "(untitled)"}`, "Attach to which session? ");
220
+ }
221
+ if (!isTTY())
222
+ throw new CliError("attach needs an interactive terminal");
223
+ row = await ensureRunning(api, row, !a.flags.has("no-resume"));
224
+ const view = a.flags.has("view");
225
+ process.stderr.write(`Attaching to "${row.title ?? row.id}" (${row.id.slice(0, 8)})${view ? ", read-only" : ""}. Ctrl-] detaches.\n`);
226
+ const ac = new AbortController();
227
+ for (const sig of ["SIGTERM", "SIGHUP"])
228
+ process.on(sig, () => ac.abort());
229
+ // Whatever happens, never leave the person's terminal in raw mode on the alt screen.
230
+ process.on("uncaughtException", (e) => {
231
+ process.stdout.write(RESTORE_TERMINAL + "\x1b[?1049l");
232
+ try {
233
+ process.stdin.setRawMode?.(false);
234
+ }
235
+ catch { /* not a tty */ }
236
+ process.stderr.write(`cubes: ${e.message}\n`);
237
+ process.exit(1);
238
+ });
239
+ const result = await attach({
240
+ api,
241
+ host: s.host,
242
+ openWs: (url, protocols) => new WebSocket(url, protocols),
243
+ stdin: process.stdin,
244
+ stdout: process.stdout,
245
+ stderr: process.stderr,
246
+ readClipboardImage: () => readClipboardImage(),
247
+ }, row, { view, signal: ac.signal });
248
+ process.stderr.write(`[cubes] ${result.message}\n`);
249
+ return result.code;
250
+ }
251
+ async function main(argv) {
252
+ if (typeof WebSocket === "undefined") {
253
+ throw new CliError(`cubes needs Node.js 22.4 or newer (this is ${process.version}) - it uses Node's built-in WebSocket`);
254
+ }
255
+ const a = parseArgs(argv);
256
+ if (a.flags.has("version")) {
257
+ process.stdout.write(`${VERSION}\n`);
258
+ return 0;
259
+ }
260
+ if (!a.cmd || a.cmd === "help" || a.flags.has("help")) {
261
+ process.stdout.write(HELP);
262
+ return a.cmd || a.flags.has("help") ? 0 : 1;
263
+ }
264
+ switch (a.cmd) {
265
+ case "login":
266
+ await cmdLogin(a);
267
+ return 0;
268
+ case "logout":
269
+ await cmdLogout();
270
+ return 0;
271
+ case "whoami":
272
+ await cmdWhoami(a);
273
+ return 0;
274
+ case "org":
275
+ case "orgs":
276
+ await cmdOrg(a);
277
+ return 0;
278
+ case "ls":
279
+ case "list":
280
+ await cmdLs(a);
281
+ return 0;
282
+ case "attach":
283
+ case "a": return cmdAttach(a);
284
+ default: throw new CliError(`unknown command "${a.cmd}" - see \`cubes help\``);
285
+ }
286
+ }
287
+ main(process.argv.slice(2)).then((code) => process.exit(code), (e) => {
288
+ process.stderr.write(`cubes: ${e instanceof Error ? e.message : String(e)}\n`);
289
+ process.exit(e instanceof CliError ? e.exitCode : 1);
290
+ });
@@ -0,0 +1,67 @@
1
+ // Read an IMAGE off the local clipboard, the way Claude Code does when it runs on your machine.
2
+ //
3
+ // macOS: AppleScript coerces whatever the pasteboard holds (a screenshot, an image copied from a
4
+ // browser) to PNG and writes it to a temp file. Linux: wl-paste on Wayland, xclip on X11. Anything
5
+ // else, or no image there, is null - and the caller then sends Ctrl-V through untouched.
6
+ import { execFile } from "node:child_process";
7
+ import { mkdtempSync, readFileSync, rmSync } from "node:fs";
8
+ import { tmpdir } from "node:os";
9
+ import { join } from "node:path";
10
+ function run(cmd, args) {
11
+ return new Promise((resolve) => {
12
+ execFile(cmd, args, { timeout: 5000, maxBuffer: 32 * 1024 * 1024, encoding: "buffer" }, (err, stdout) => {
13
+ resolve(err ? null : stdout);
14
+ });
15
+ });
16
+ }
17
+ async function macos() {
18
+ const info = await run("osascript", ["-e", "clipboard info"]);
19
+ if (!info || !/PNGf|TIFF|JPEG|GIF/i.test(info.toString("utf8")))
20
+ return null;
21
+ const dir = mkdtempSync(join(tmpdir(), "cubes-paste-"));
22
+ const file = join(dir, "clip.png");
23
+ try {
24
+ const ok = await run("osascript", [
25
+ "-e", "set png_data to (the clipboard as «class PNGf»)",
26
+ "-e", `set fp to open for access POSIX file "${file}" with write permission`,
27
+ "-e", "write png_data to fp",
28
+ "-e", "close access fp",
29
+ ]);
30
+ if (!ok)
31
+ return null;
32
+ const bytes = readFileSync(file);
33
+ return bytes.length ? { bytes, type: "image/png" } : null;
34
+ }
35
+ catch {
36
+ return null;
37
+ }
38
+ finally {
39
+ rmSync(dir, { recursive: true, force: true });
40
+ }
41
+ }
42
+ async function linux(env) {
43
+ if (env.WAYLAND_DISPLAY) {
44
+ const types = await run("wl-paste", ["--list-types"]);
45
+ if (types && /^image\/png$/m.test(types.toString("utf8"))) {
46
+ const b = await run("wl-paste", ["--no-newline", "--type", "image/png"]);
47
+ if (b?.length)
48
+ return { bytes: b, type: "image/png" };
49
+ }
50
+ }
51
+ if (env.DISPLAY) {
52
+ const targets = await run("xclip", ["-selection", "clipboard", "-t", "TARGETS", "-o"]);
53
+ if (targets && /^image\/png$/m.test(targets.toString("utf8"))) {
54
+ const b = await run("xclip", ["-selection", "clipboard", "-t", "image/png", "-o"]);
55
+ if (b?.length)
56
+ return { bytes: b, type: "image/png" };
57
+ }
58
+ }
59
+ return null;
60
+ }
61
+ export async function readClipboardImage(platform = process.platform, env = process.env) {
62
+ if (platform === "darwin")
63
+ return macos();
64
+ if (platform === "linux")
65
+ return linux(env);
66
+ return null;
67
+ }
package/dist/config.js ADDED
@@ -0,0 +1,40 @@
1
+ // Where a login lives between runs: one JSON file, mode 0600, in the XDG config dir.
2
+ //
3
+ // It holds a REFRESH token (an offline one - see auth.ts), which is as good as a password for
4
+ // this app until revoked, so the file is created private and re-chmodded on every write: an
5
+ // existing file somebody loosened by hand is tightened again the next time a token rotates.
6
+ import { mkdirSync, readFileSync, writeFileSync, chmodSync, rmSync } from "node:fs";
7
+ import { homedir } from "node:os";
8
+ import { join, dirname } from "node:path";
9
+ export function configPath(env = process.env) {
10
+ if (env.CUBES_CONFIG)
11
+ return env.CUBES_CONFIG;
12
+ const base = env.XDG_CONFIG_HOME || join(homedir(), ".config");
13
+ return join(base, "cubes", "config.json");
14
+ }
15
+ export function loadLogin(path = configPath()) {
16
+ let raw;
17
+ try {
18
+ raw = readFileSync(path, "utf8");
19
+ }
20
+ catch {
21
+ return null;
22
+ }
23
+ try {
24
+ const v = JSON.parse(raw);
25
+ if (!v.host || !v.issuer || !v.clientId || !v.refreshToken || !v.endpoints?.token)
26
+ return null;
27
+ return v;
28
+ }
29
+ catch {
30
+ return null;
31
+ }
32
+ }
33
+ export function saveLogin(login, path = configPath()) {
34
+ mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
35
+ writeFileSync(path, JSON.stringify(login, null, 2) + "\n", { mode: 0o600 });
36
+ chmodSync(path, 0o600);
37
+ }
38
+ export function clearLogin(path = configPath()) {
39
+ rmSync(path, { force: true });
40
+ }
package/dist/keys.js ADDED
@@ -0,0 +1,68 @@
1
+ // What the CLI keeps for itself out of the keystroke stream. Everything else goes to the session
2
+ // byte-for-byte, which is the whole point: your terminal's keybindings, your Option-as-Meta, your
3
+ // Ctrl-C all reach `claude` exactly as they would locally.
4
+ //
5
+ // Two bytes are ours:
6
+ // • Ctrl-] (0x1d) DETACH - the telnet/`docker attach` key, chosen because neither Claude Code nor
7
+ // tmux binds it, so taking it costs the session nothing.
8
+ // • Ctrl-V (0x16) PASTE AN IMAGE - Claude Code's own image-paste key. Locally it reads the
9
+ // clipboard of the machine it runs on; here that is a pod with no clipboard, so the CLI
10
+ // reads YOURS, uploads it, and types the path (what the web terminal does on paste).
11
+ // With no image on the clipboard the 0x16 is sent through untouched.
12
+ //
13
+ // Neither is honoured inside a bracketed paste: pasted text is data, and a stray 0x1d in a paste
14
+ // must not drop the connection.
15
+ export const DETACH_BYTE = 0x1d;
16
+ export const PASTE_IMAGE_BYTE = 0x16;
17
+ const PASTE_START = [0x1b, 0x5b, 0x32, 0x30, 0x30, 0x7e]; // ESC [ 2 0 0 ~
18
+ const PASTE_END = [0x1b, 0x5b, 0x32, 0x30, 0x31, 0x7e]; // ESC [ 2 0 1 ~
19
+ function matchAt(buf, i, seq) {
20
+ if (i + seq.length > buf.length)
21
+ return false;
22
+ for (let k = 0; k < seq.length; k++)
23
+ if (buf[i + k] !== seq[k])
24
+ return false;
25
+ return true;
26
+ }
27
+ export class InputFilter {
28
+ opts;
29
+ inPaste = false;
30
+ constructor(opts = { pasteImage: true }) {
31
+ this.opts = opts;
32
+ }
33
+ feed(chunk) {
34
+ const out = [];
35
+ let start = 0;
36
+ const flush = (end) => {
37
+ if (end > start)
38
+ out.push({ kind: "send", bytes: chunk.subarray(start, end) });
39
+ };
40
+ for (let i = 0; i < chunk.length; i++) {
41
+ if (chunk[i] === 0x1b && matchAt(chunk, i, PASTE_START)) {
42
+ this.inPaste = true;
43
+ i += PASTE_START.length - 1;
44
+ continue;
45
+ }
46
+ if (this.inPaste) {
47
+ if (chunk[i] === 0x1b && matchAt(chunk, i, PASTE_END)) {
48
+ this.inPaste = false;
49
+ i += PASTE_END.length - 1;
50
+ }
51
+ continue;
52
+ }
53
+ const b = chunk[i];
54
+ if (b === DETACH_BYTE) {
55
+ flush(i);
56
+ out.push({ kind: "detach" });
57
+ return out; // whatever was typed after the detach key is not the session's
58
+ }
59
+ if (b === PASTE_IMAGE_BYTE && this.opts.pasteImage) {
60
+ flush(i);
61
+ out.push({ kind: "paste-image" });
62
+ start = i + 1;
63
+ }
64
+ }
65
+ flush(chunk.length);
66
+ return out;
67
+ }
68
+ }
@@ -0,0 +1,83 @@
1
+ // Listing sessions and turning what someone typed (an id, an id prefix, part of a title) into one.
2
+ import { CliError } from "./auth.js";
3
+ export const LIVE = new Set(["starting", "running"]);
4
+ /** An editor row or a headless run has no terminal to attach to. */
5
+ export function attachable(s) {
6
+ return s.origin !== "ide" && s.exec_mode !== "headless";
7
+ }
8
+ export async function listSessions(api, opts = {}) {
9
+ const q = new URLSearchParams({ limit: "200" });
10
+ if (opts.all)
11
+ q.set("scope", "all");
12
+ const page = await api.get(`/sessions?${q}`);
13
+ return (page.sessions ?? []).filter(attachable);
14
+ }
15
+ const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
16
+ export function isUuid(s) {
17
+ return UUID.test(s);
18
+ }
19
+ /** Pick ONE session out of `rows` for `query`, most specific match first. Ambiguity is an error that
20
+ * names the candidates, never a guess: attaching takes the keyboard of whatever it lands on. */
21
+ export function resolveSession(rows, query) {
22
+ const q = query.trim().toLowerCase();
23
+ if (!q)
24
+ throw new CliError("which session? pass an id or part of its title");
25
+ const tiers = [
26
+ (s) => s.id.toLowerCase() === q,
27
+ (s) => q.length >= 4 && s.id.toLowerCase().startsWith(q),
28
+ (s) => (s.title ?? "").toLowerCase() === q,
29
+ (s) => (s.title ?? "").toLowerCase().includes(q),
30
+ ];
31
+ for (const match of tiers) {
32
+ const hits = rows.filter(match);
33
+ if (hits.length === 1)
34
+ return hits[0];
35
+ if (hits.length > 1) {
36
+ // Several, but exactly one is live: that is almost always the one meant.
37
+ const live = hits.filter((s) => LIVE.has(s.status));
38
+ if (live.length === 1)
39
+ return live[0];
40
+ throw new CliError(`"${query}" matches ${hits.length} sessions:\n${formatTable(hits.slice(0, 10), 100)}\nuse more of the id`);
41
+ }
42
+ }
43
+ throw new CliError(`no session matches "${query}" - see \`cubes ls\``);
44
+ }
45
+ export function age(iso, now = Date.now()) {
46
+ if (!iso)
47
+ return "-";
48
+ const s = Math.max(0, Math.floor((now - Date.parse(iso)) / 1000));
49
+ if (s < 60)
50
+ return `${s}s`;
51
+ if (s < 3600)
52
+ return `${Math.floor(s / 60)}m`;
53
+ if (s < 86400)
54
+ return `${Math.floor(s / 3600)}h`;
55
+ return `${Math.floor(s / 86400)}d`;
56
+ }
57
+ export function stateOf(s) {
58
+ if (s.status === "running")
59
+ return s.activity_status || "running";
60
+ return s.status;
61
+ }
62
+ function cut(s, w) {
63
+ if (w <= 1)
64
+ return "";
65
+ return s.length <= w ? s : s.slice(0, w - 1) + "…";
66
+ }
67
+ export function formatTable(rows, width, now = Date.now()) {
68
+ const head = ["ID", "STATE", "AGE", "TITLE", "REPO"];
69
+ const body = rows.map((s) => [
70
+ s.id.slice(0, 8),
71
+ stateOf(s),
72
+ age(s.last_attached_at ?? s.created_at, now),
73
+ s.title || "(untitled)",
74
+ s.repo_full_name ?? "",
75
+ ]);
76
+ const w = (i) => Math.max(head[i].length, ...body.map((r) => r[i].length));
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));
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();
82
+ return [line(head), ...body.map(line)].join("\n");
83
+ }
package/package.json CHANGED
@@ -1,6 +1,39 @@
1
1
  {
2
2
  "name": "@hellix-ai/cubes",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.0",
4
+ "description": "Attach to your Cubes sessions from your own terminal (Warp, iTerm, Ghostty, anything).",
5
+ "type": "module",
6
+ "bin": {
7
+ "cubes": "dist/cli.js"
8
+ },
9
+ "files": [
10
+ "dist",
11
+ "README.md"
12
+ ],
13
+ "engines": {
14
+ "node": ">=22.4.0"
15
+ },
16
+ "scripts": {
17
+ "build": "tsc -p tsconfig.json && chmod +x dist/cli.js",
18
+ "test": "vitest run",
19
+ "typecheck": "tsc -p tsconfig.test.json",
20
+ "prepublishOnly": "npm run build && npm test"
21
+ },
22
+ "keywords": [
23
+ "cubes",
24
+ "claude",
25
+ "terminal",
26
+ "cli"
27
+ ],
28
+ "license": "UNLICENSED",
29
+ "publishConfig": {
30
+ "access": "public"
31
+ },
32
+ "devDependencies": {
33
+ "@types/node": "^22.0.0",
34
+ "@types/ws": "^8.18.1",
35
+ "typescript": "^5.6.0",
36
+ "vitest": "^2.1.0",
37
+ "ws": "^8.18.0"
38
+ }
39
+ }