@hellix-ai/cubes 0.0.0-stage → 0.1.1
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 +57 -2
- package/dist/api.js +58 -0
- package/dist/args.js +37 -0
- package/dist/attach.js +318 -0
- package/dist/auth.js +206 -0
- package/dist/cli.js +309 -0
- package/dist/clipboard.js +67 -0
- package/dist/config.js +40 -0
- package/dist/keys.js +68 -0
- package/dist/sessions.js +83 -0
- package/dist/upgrade.js +105 -0
- package/package.json +37 -4
package/dist/cli.js
ADDED
|
@@ -0,0 +1,309 @@
|
|
|
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
|
+
import { upgrade } from "./upgrade.js";
|
|
18
|
+
const PKG = createRequire(import.meta.url)("../package.json");
|
|
19
|
+
const VERSION = PKG.version;
|
|
20
|
+
const HELP = `cubes ${VERSION} - attach to your Cubes sessions from your own terminal
|
|
21
|
+
|
|
22
|
+
Usage:
|
|
23
|
+
cubes login [--host URL] [--org ALIAS] [--no-browser] sign in with a code you approve in the browser
|
|
24
|
+
cubes logout sign out and revoke this computer's login
|
|
25
|
+
cubes whoami who, where, and which org
|
|
26
|
+
cubes org [ALIAS] list your orgs, or switch the default
|
|
27
|
+
cubes ls [--all] [--json] list sessions (--all includes spawned ones)
|
|
28
|
+
cubes attach [SESSION] [--view] [--no-resume] attach to a session
|
|
29
|
+
cubes upgrade [--check] install the newest cubes (--check only reports)
|
|
30
|
+
|
|
31
|
+
SESSION is an id, the start of one (4+ characters), or part of its title. Left out, you pick from
|
|
32
|
+
the running sessions. A stopped or hibernated session is resumed first unless --no-resume.
|
|
33
|
+
|
|
34
|
+
While attached, every key goes to the session except:
|
|
35
|
+
Ctrl-] detach (the session keeps running)
|
|
36
|
+
Ctrl-V paste the image on your clipboard (uploaded, then its path is typed in)
|
|
37
|
+
|
|
38
|
+
Attaching takes the keyboard from any browser tab on the same session (it becomes read-only) and
|
|
39
|
+
fits the session to this window. --view watches without taking it.
|
|
40
|
+
|
|
41
|
+
Global: --org ALIAS overrides the default org for one command.
|
|
42
|
+
Env: CUBES_HOST + CUBES_TOKEN (+ CUBES_ORG) use a token instead of the stored login.
|
|
43
|
+
Config: ${configPath()}
|
|
44
|
+
`;
|
|
45
|
+
function str(f, name) {
|
|
46
|
+
const v = f.get(name);
|
|
47
|
+
return typeof v === "string" ? v : undefined;
|
|
48
|
+
}
|
|
49
|
+
const isTTY = () => !!process.stdin.isTTY && !!process.stdout.isTTY;
|
|
50
|
+
async function ask(question) {
|
|
51
|
+
const rl = createInterface({ input: process.stdin, output: process.stderr });
|
|
52
|
+
try {
|
|
53
|
+
return (await rl.question(question)).trim();
|
|
54
|
+
}
|
|
55
|
+
finally {
|
|
56
|
+
rl.close();
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
async function pick(items, label, question) {
|
|
60
|
+
for (let i = 0; i < items.length; i++)
|
|
61
|
+
process.stderr.write(`${label(items[i], i)}\n`);
|
|
62
|
+
const answer = await ask(question);
|
|
63
|
+
const n = Number(answer);
|
|
64
|
+
if (!Number.isInteger(n) || n < 1 || n > items.length)
|
|
65
|
+
throw new CliError("no choice made");
|
|
66
|
+
return items[n - 1];
|
|
67
|
+
}
|
|
68
|
+
function openBrowser(url) {
|
|
69
|
+
const cmd = process.platform === "darwin" ? ["open", url]
|
|
70
|
+
: process.platform === "win32" ? ["cmd", "/c", "start", "", url]
|
|
71
|
+
: (process.env.DISPLAY || process.env.WAYLAND_DISPLAY) ? ["xdg-open", url] : null;
|
|
72
|
+
if (!cmd)
|
|
73
|
+
return;
|
|
74
|
+
try {
|
|
75
|
+
spawn(cmd[0], cmd.slice(1), { stdio: "ignore", detached: true }).on("error", () => undefined).unref();
|
|
76
|
+
}
|
|
77
|
+
catch { /* the URL is printed anyway */ }
|
|
78
|
+
}
|
|
79
|
+
async function cmdLogin(a) {
|
|
80
|
+
const existing = loadLogin();
|
|
81
|
+
let raw = str(a.flags, "host") ?? process.env.CUBES_HOST ?? existing?.host;
|
|
82
|
+
if (!raw) {
|
|
83
|
+
if (!isTTY())
|
|
84
|
+
throw new CliError("which Cubes? `cubes login --host https://…`");
|
|
85
|
+
raw = await ask("Cubes URL (the address you open in the browser): ");
|
|
86
|
+
if (!raw)
|
|
87
|
+
throw new CliError("no URL given");
|
|
88
|
+
}
|
|
89
|
+
const host = normalizeHost(raw);
|
|
90
|
+
const d = await discover(host);
|
|
91
|
+
const start = await startDevice(d);
|
|
92
|
+
const url = start.verification_uri_complete ?? start.verification_uri;
|
|
93
|
+
process.stderr.write(`\nTo sign in to ${host}, open:\n\n ${url}\n\nand confirm the code ${start.user_code}\n\n`);
|
|
94
|
+
if (!a.flags.has("no-browser"))
|
|
95
|
+
openBrowser(url);
|
|
96
|
+
process.stderr.write("Waiting for approval…\n");
|
|
97
|
+
const tokens = await pollDevice(d, start);
|
|
98
|
+
const orgs = orgsOf(tokens.access_token);
|
|
99
|
+
let org = str(a.flags, "org");
|
|
100
|
+
if (org && orgs.length && !orgs.includes(org))
|
|
101
|
+
throw new CliError(`you are not a member of "${org}" (you are in: ${orgs.join(", ")})`);
|
|
102
|
+
if (!org) {
|
|
103
|
+
if (orgs.length === 1)
|
|
104
|
+
org = orgs[0];
|
|
105
|
+
else if (orgs.length > 1) {
|
|
106
|
+
org = isTTY()
|
|
107
|
+
? await pick(orgs, (o, i) => ` ${i + 1}) ${o}`, "Which org should commands use by default? ")
|
|
108
|
+
: orgs[0];
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
saveLogin(loginFromTokens(host, d, tokens, org));
|
|
112
|
+
process.stderr.write(`Signed in to ${host} as ${whoOf(tokens.access_token)}${org ? `, org ${org}` : ""}.\n`);
|
|
113
|
+
if (!orgs.length)
|
|
114
|
+
process.stderr.write("You are not a member of any organization yet, so there is nothing to attach to.\n");
|
|
115
|
+
else if (orgs.length > 1)
|
|
116
|
+
process.stderr.write("Switch orgs any time with `cubes org <alias>`.\n");
|
|
117
|
+
}
|
|
118
|
+
async function cmdLogout() {
|
|
119
|
+
const login = loadLogin();
|
|
120
|
+
if (!login) {
|
|
121
|
+
process.stderr.write("Not signed in.\n");
|
|
122
|
+
return;
|
|
123
|
+
}
|
|
124
|
+
await revoke(login);
|
|
125
|
+
clearLogin();
|
|
126
|
+
process.stderr.write(`Signed out of ${login.host}.\n`);
|
|
127
|
+
}
|
|
128
|
+
async function cmdWhoami(a) {
|
|
129
|
+
const s = currentSession({ org: str(a.flags, "org") });
|
|
130
|
+
await s.token();
|
|
131
|
+
process.stdout.write(`${s.who()} @ ${s.host}${s.org ? ` (org ${s.org})` : ""}\n`);
|
|
132
|
+
}
|
|
133
|
+
async function cmdOrg(a) {
|
|
134
|
+
const s = currentSession();
|
|
135
|
+
const orgs = orgsOf(await s.token());
|
|
136
|
+
const want = a.pos[0];
|
|
137
|
+
if (want && process.env.CUBES_TOKEN)
|
|
138
|
+
throw new CliError("`cubes org <alias>` changes the stored login; with CUBES_TOKEN, set CUBES_ORG instead");
|
|
139
|
+
if (!want) {
|
|
140
|
+
if (!orgs.length) {
|
|
141
|
+
process.stderr.write("You are not a member of any organization.\n");
|
|
142
|
+
return;
|
|
143
|
+
}
|
|
144
|
+
for (const o of orgs)
|
|
145
|
+
process.stdout.write(`${o === s.org ? "*" : " "} ${o}\n`);
|
|
146
|
+
return;
|
|
147
|
+
}
|
|
148
|
+
if (!orgs.includes(want))
|
|
149
|
+
throw new CliError(`you are not a member of "${want}" (you are in: ${orgs.join(", ") || "none"})`);
|
|
150
|
+
const login = loadLogin();
|
|
151
|
+
saveLogin({ ...login, org: want });
|
|
152
|
+
process.stderr.write(`Default org is now ${want}.\n`);
|
|
153
|
+
}
|
|
154
|
+
async function cmdLs(a) {
|
|
155
|
+
const api = makeApi(currentSession({ org: str(a.flags, "org") }));
|
|
156
|
+
const rows = await listSessions(api, { all: a.flags.has("all") });
|
|
157
|
+
if (a.flags.has("json")) {
|
|
158
|
+
process.stdout.write(JSON.stringify(rows, null, 2) + "\n");
|
|
159
|
+
return;
|
|
160
|
+
}
|
|
161
|
+
if (!rows.length) {
|
|
162
|
+
process.stderr.write("No sessions.\n");
|
|
163
|
+
return;
|
|
164
|
+
}
|
|
165
|
+
process.stdout.write(formatTable(rows, process.stdout.columns ?? 120) + "\n");
|
|
166
|
+
}
|
|
167
|
+
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
168
|
+
/** Bring a stopped/hibernated session back and wait until its terminal can be attached. */
|
|
169
|
+
async function ensureRunning(api, row, resume) {
|
|
170
|
+
const id = encodeURIComponent(row.id);
|
|
171
|
+
let s = row;
|
|
172
|
+
if (s.status === "running")
|
|
173
|
+
return s;
|
|
174
|
+
if (!LIVE.has(s.status)) {
|
|
175
|
+
if (!resume)
|
|
176
|
+
throw new CliError(`that session is ${s.status}; leave out --no-resume to bring it back`);
|
|
177
|
+
process.stderr.write(`Resuming "${s.title ?? s.id}"…\n`);
|
|
178
|
+
try {
|
|
179
|
+
await api.post(`/sessions/${id}/resume`);
|
|
180
|
+
}
|
|
181
|
+
catch (e) {
|
|
182
|
+
if (!(e instanceof ApiError && e.status === 409 && /already running/.test(e.message)))
|
|
183
|
+
throw e;
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
let lastNote = "";
|
|
187
|
+
const deadline = Date.now() + 5 * 60_000;
|
|
188
|
+
while (Date.now() < deadline) {
|
|
189
|
+
s = await api.get(`/sessions/${id}`);
|
|
190
|
+
if (s.status === "running")
|
|
191
|
+
return s;
|
|
192
|
+
if (!LIVE.has(s.status))
|
|
193
|
+
throw new CliError(`the session stopped while starting (${s.status})`);
|
|
194
|
+
const note = s.provision_note
|
|
195
|
+
? `${s.provision_note}${s.provision_total ? ` (${s.provision_done ?? 0}/${s.provision_total})` : ""}`
|
|
196
|
+
: "starting";
|
|
197
|
+
if (note !== lastNote) {
|
|
198
|
+
process.stderr.write(` ${note}…\n`);
|
|
199
|
+
lastNote = note;
|
|
200
|
+
}
|
|
201
|
+
await sleep(1000);
|
|
202
|
+
}
|
|
203
|
+
throw new CliError("the session is taking too long to start - check it in the browser");
|
|
204
|
+
}
|
|
205
|
+
async function cmdAttach(a) {
|
|
206
|
+
const s = currentSession({ org: str(a.flags, "org") });
|
|
207
|
+
const api = makeApi(s);
|
|
208
|
+
const query = a.pos.join(" ").trim();
|
|
209
|
+
let row;
|
|
210
|
+
if (query && isUuid(query)) {
|
|
211
|
+
row = await api.get(`/sessions/${encodeURIComponent(query)}`);
|
|
212
|
+
}
|
|
213
|
+
else if (query) {
|
|
214
|
+
row = resolveSession(await listSessions(api, { all: true }), query);
|
|
215
|
+
}
|
|
216
|
+
else {
|
|
217
|
+
if (!isTTY())
|
|
218
|
+
throw new CliError("which session? `cubes attach <id or title>`");
|
|
219
|
+
const live = (await listSessions(api)).filter((r) => LIVE.has(r.status));
|
|
220
|
+
if (!live.length)
|
|
221
|
+
throw new CliError("no running sessions - `cubes ls` shows the rest; attach to one by id to resume it");
|
|
222
|
+
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? ");
|
|
223
|
+
}
|
|
224
|
+
if (!isTTY())
|
|
225
|
+
throw new CliError("attach needs an interactive terminal");
|
|
226
|
+
row = await ensureRunning(api, row, !a.flags.has("no-resume"));
|
|
227
|
+
const view = a.flags.has("view");
|
|
228
|
+
process.stderr.write(`Attaching to "${row.title ?? row.id}" (${row.id.slice(0, 8)})${view ? ", read-only" : ""}. Ctrl-] detaches.\n`);
|
|
229
|
+
const ac = new AbortController();
|
|
230
|
+
for (const sig of ["SIGTERM", "SIGHUP"])
|
|
231
|
+
process.on(sig, () => ac.abort());
|
|
232
|
+
// Whatever happens, never leave the person's terminal in raw mode on the alt screen.
|
|
233
|
+
process.on("uncaughtException", (e) => {
|
|
234
|
+
process.stdout.write(RESTORE_TERMINAL + "\x1b[?1049l");
|
|
235
|
+
try {
|
|
236
|
+
process.stdin.setRawMode?.(false);
|
|
237
|
+
}
|
|
238
|
+
catch { /* not a tty */ }
|
|
239
|
+
process.stderr.write(`cubes: ${e.message}\n`);
|
|
240
|
+
process.exit(1);
|
|
241
|
+
});
|
|
242
|
+
const result = await attach({
|
|
243
|
+
api,
|
|
244
|
+
host: s.host,
|
|
245
|
+
openWs: (url, protocols) => new WebSocket(url, protocols),
|
|
246
|
+
stdin: process.stdin,
|
|
247
|
+
stdout: process.stdout,
|
|
248
|
+
stderr: process.stderr,
|
|
249
|
+
readClipboardImage: () => readClipboardImage(),
|
|
250
|
+
}, row, { view, signal: ac.signal });
|
|
251
|
+
process.stderr.write(`[cubes] ${result.message}\n`);
|
|
252
|
+
return result.code;
|
|
253
|
+
}
|
|
254
|
+
async function main(argv) {
|
|
255
|
+
if (typeof WebSocket === "undefined") {
|
|
256
|
+
throw new CliError(`cubes needs Node.js 22.4 or newer (this is ${process.version}) - it uses Node's built-in WebSocket`);
|
|
257
|
+
}
|
|
258
|
+
const a = parseArgs(argv);
|
|
259
|
+
if (a.flags.has("version")) {
|
|
260
|
+
process.stdout.write(`${VERSION}\n`);
|
|
261
|
+
return 0;
|
|
262
|
+
}
|
|
263
|
+
if (!a.cmd || a.cmd === "help" || a.flags.has("help")) {
|
|
264
|
+
process.stdout.write(HELP);
|
|
265
|
+
return a.cmd || a.flags.has("help") ? 0 : 1;
|
|
266
|
+
}
|
|
267
|
+
switch (a.cmd) {
|
|
268
|
+
case "login":
|
|
269
|
+
await cmdLogin(a);
|
|
270
|
+
return 0;
|
|
271
|
+
case "logout":
|
|
272
|
+
await cmdLogout();
|
|
273
|
+
return 0;
|
|
274
|
+
case "whoami":
|
|
275
|
+
await cmdWhoami(a);
|
|
276
|
+
return 0;
|
|
277
|
+
case "org":
|
|
278
|
+
case "orgs":
|
|
279
|
+
await cmdOrg(a);
|
|
280
|
+
return 0;
|
|
281
|
+
case "ls":
|
|
282
|
+
case "list":
|
|
283
|
+
await cmdLs(a);
|
|
284
|
+
return 0;
|
|
285
|
+
case "attach":
|
|
286
|
+
case "a": return cmdAttach(a);
|
|
287
|
+
case "upgrade":
|
|
288
|
+
case "update":
|
|
289
|
+
return upgrade({
|
|
290
|
+
pkg: PKG.name, current: VERSION, scriptPath: process.argv[1] ?? "",
|
|
291
|
+
log: (line) => process.stderr.write(`${line}\n`),
|
|
292
|
+
}, { check: a.flags.has("check") });
|
|
293
|
+
default: throw new CliError(`unknown command "${a.cmd}" - see \`cubes help\``);
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
/** Exit only once everything written has reached its destination. A write to a PIPE is asynchronous
|
|
297
|
+
* in Node, so a bare process.exit() after `cubes ls --json | jq` cut the JSON off mid-string. An empty
|
|
298
|
+
* write's callback fires after every write queued before it. */
|
|
299
|
+
function exitAfterFlush(code) {
|
|
300
|
+
let pending = 2;
|
|
301
|
+
const done = () => { if (--pending === 0)
|
|
302
|
+
process.exit(code); };
|
|
303
|
+
process.stdout.write("", done);
|
|
304
|
+
process.stderr.write("", done);
|
|
305
|
+
}
|
|
306
|
+
main(process.argv.slice(2)).then((code) => exitAfterFlush(code), (e) => {
|
|
307
|
+
process.stderr.write(`cubes: ${e instanceof Error ? e.message : String(e)}\n`);
|
|
308
|
+
exitAfterFlush(e instanceof CliError ? e.exitCode : 1);
|
|
309
|
+
});
|
|
@@ -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
|
+
}
|
package/dist/sessions.js
ADDED
|
@@ -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", "GROUP"];
|
|
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.group_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 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
|
+
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], groupW)].join(" ").trimEnd();
|
|
82
|
+
return [line(head), ...body.map(line)].join("\n");
|
|
83
|
+
}
|
package/dist/upgrade.js
ADDED
|
@@ -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
|
+
}
|