@hellix-ai/cubes 0.2.1 → 0.3.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,7 +1,10 @@
1
1
  # cubes
2
2
 
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.
3
+ The [Cubes](https://cubes.hellix.ai) command line. Work in your Cubes sessions from your own terminal
4
+ (Warp, iTerm2, Ghostty, Terminal.app, anything) with your own font, colors and keyboard shortcuts: attach
5
+ to one, or keep all of them in a split view, with image paste, your clipboard, and a tab that tells you
6
+ when a session needs you. And move Claude Code conversations between your computer and Cubes: import
7
+ one into a new session, or export a session's conversation to continue it here with `claude --resume`.
5
8
 
6
9
  ```sh
7
10
  npm install -g @hellix-ai/cubes # needs Node.js 22.4+
@@ -10,6 +13,8 @@ cubes login --host cubes.hellix.ai # prints a code; approve it in the browser
10
13
  cubes ls # sessions, under a heading per group (--mine, --live)
11
14
  cubes attach # pick a running one
12
15
  cubes attach 7ff4 # or name one: an id prefix, or part of its title
16
+ cubes import # continue one of your local Claude Code conversations in Cubes
17
+ cubes export 7ff4 # bring a session's conversation here (then claude --resume)
13
18
  ```
14
19
 
15
20
  No install: `npx @hellix-ai/cubes attach <session>`. Later, `cubes upgrade` updates it in place.
@@ -23,8 +28,15 @@ use is never touched), so close the terminal and run `cubes` again to land right
23
28
  - **The list** is grouped like the web's Sessions page, with a session's spawned sessions nested under it
24
29
  and a coloured dot for its state: working, idle, **needs you**, starting, ended.
25
30
  - **Above the session**, a header says which one it is: name · group · runner · state.
26
- - **Keys:** `⏎` (or a click) opens a session · `Ctrl-]` in a session jumps back to the list · `l` live only
27
- · `m` mine only · `/` find · `←` `→` fold a session's children · `q` quits (sessions keep running).
31
+ - **Browsing:** `⏎` (or a click) shows a session on the right and leaves you in the list, so `↑` `↓` and
32
+ `⏎` again show the next one.
33
+ - **`F1` switches** between the list and the session, from either side. It is the one single key Claude
34
+ Code does not use (in a session every other key, `Ctrl-]` included, goes to Claude). On a MacBook the
35
+ top row sends it with `fn` held, unless *Use F1, F2, etc. keys as standard function keys* is on. From
36
+ the list, `⏎` on the session already showing (or `Tab`) goes in too, and a click on either side moves
37
+ you there.
38
+ - **Other keys:** `l` live only · `m` mine only · `/` find · `←` `→` fold a session's children · `q` quits
39
+ (sessions keep running).
28
40
 
29
41
  ## While attached
30
42
 
@@ -61,6 +73,49 @@ viewers) and `r` reconnects when you choose to.
61
73
  Scrollback lives in the session's tmux: scroll with the mouse wheel or trackpad (tmux copy-mode), not
62
74
  your terminal's own scrollback.
63
75
 
76
+ ## Moving a conversation between your computer and Cubes
77
+
78
+ **`cubes import`** starts a Cubes session that continues a Claude Code conversation from this computer.
79
+
80
+ ```sh
81
+ cubes import # pick one of your recent conversations (this directory's first)
82
+ cubes import 0f3c1d52 # or name it: its id, the start of it, or the path to its .jsonl
83
+ ```
84
+
85
+ It asks which org (if you are in more than one), then everything the web's New session dialog asks:
86
+ title, group, repositories, a cube to start from, agents, model, runner, Remote Control and extra
87
+ `claude` arguments, with the same defaults and the same rules (a group pre-selects its repositories, an
88
+ agent that declares a model decides it). The repository your conversation's checkout points at is
89
+ pre-selected. Then it uploads the conversation, starts the session continuing it, and offers to attach.
90
+
91
+ Any question can be answered up front, so it also works in a script:
92
+
93
+ ```sh
94
+ cubes import 0f3c1d52 --yes --title "importer, continued" --repo acme/api --runner k3s --no-rc
95
+ ```
96
+
97
+ `--title`, `--group`, `--repo` (repeatable), `--cube` and `--input name=value` (repeatable), `--agent`
98
+ (repeatable), `--model` (or `default`), `--runner`, `--rc` / `--no-rc`, `--cli-args="..."`, plus
99
+ `--package ID`, `--mcp-config FILE` and `--hooks FILE`, which the dialog does not offer. `--yes` takes
100
+ the default for anything not given.
101
+
102
+ **`cubes export`** copies a session's conversation onto this computer, where `claude --resume` finds it.
103
+
104
+ ```sh
105
+ cd ~/code/api
106
+ cubes export 7ff4 # then: claude --resume <id>, as printed
107
+ cubes export 7ff4 --dir ~/code/api
108
+ ```
109
+
110
+ It is filed under `~/.claude/projects` (or `$CLAUDE_CONFIG_DIR`) for the directory you run it in, or
111
+ `--dir`. Exporting again brings your copy up to date; if you have continued it locally since, it is not
112
+ replaced unless you pass `--force`. A stopped session on a cluster runner keeps its conversation on its
113
+ own machine, so it is resumed first (you are asked).
114
+
115
+ Only the **conversation** travels, in both directions. An imported session checks its repositories out
116
+ fresh on its runner, and an export does not bring the session's files: push them from the session and
117
+ pull them here.
118
+
64
119
  ## Commands
65
120
 
66
121
  ```
@@ -71,6 +126,8 @@ cubes whoami
71
126
  cubes org [ALIAS]
72
127
  cubes ls [--mine] [--live] [--all] [--json]
73
128
  cubes attach [SESSION] [--view] [--no-resume] [--no-status]
129
+ cubes import [CONVERSATION] [--yes] [--attach | --no-attach] [form flags]
130
+ cubes export [SESSION] [--dir DIR] [--force]
74
131
  cubes upgrade [--check]
75
132
  ```
76
133
 
package/dist/api.js CHANGED
@@ -1,8 +1,8 @@
1
1
  // The REST half: the same routes the web app calls, with the same Bearer + `x-org` pair.
2
2
  import { CliError } from "./auth.js";
3
3
  export function makeApi(s, fetchImpl = fetch) {
4
- async function call(method, path, body, init) {
5
- const send = async (force) => {
4
+ async function send(method, path, body, init, signal) {
5
+ const once = async (force) => {
6
6
  const headers = { authorization: `Bearer ${await s.token(force)}` };
7
7
  if (s.org)
8
8
  headers["x-org"] = s.org;
@@ -16,43 +16,76 @@ export function makeApi(s, fetchImpl = fetch) {
16
16
  payload = JSON.stringify(body);
17
17
  }
18
18
  try {
19
- return await fetchImpl(`${s.host}/api${path}`, { method, headers, body: payload });
19
+ return await fetchImpl(`${s.host}/api${path}`, { method, headers, body: payload, signal });
20
20
  }
21
21
  catch (e) {
22
+ if (signal?.aborted)
23
+ throw e;
22
24
  throw new CliError(`cannot reach ${s.host}: ${e.message}`);
23
25
  }
24
26
  };
25
- let res = await send(false);
27
+ const res = await once(false);
26
28
  // A token the server refuses may simply be one our clock thinks is still good. One forced
27
29
  // refresh, then the refusal stands.
28
- if (res.status === 401)
29
- res = await send(true);
30
- const text = await res.text();
30
+ if (res.status !== 401)
31
+ return res;
32
+ await res.body?.cancel().catch(() => undefined);
33
+ return once(true);
34
+ }
35
+ async function refusal(res, path) {
36
+ const text = await res.text().catch(() => "");
31
37
  let data = undefined;
32
38
  try {
33
39
  data = text ? JSON.parse(text) : undefined;
34
40
  }
35
41
  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);
42
+ const msg = data?.error;
43
+ const detail = data?.detail;
44
+ const code = data?.code;
45
+ throw new ApiError(res.status, typeof msg === "string" ? (typeof detail === "string" ? `${msg}: ${detail}` : msg) : `HTTP ${res.status}`, path, typeof code === "string" ? code : undefined, data);
46
+ }
47
+ async function call(method, path, body, init) {
48
+ const res = await send(method, path, body, init);
49
+ if (!res.ok)
50
+ return refusal(res, path);
51
+ const text = await res.text();
52
+ let data = undefined;
53
+ try {
54
+ data = text ? JSON.parse(text) : undefined;
39
55
  }
56
+ catch { /* not JSON */ }
40
57
  return data;
41
58
  }
42
59
  return {
43
60
  get: (path) => call("GET", path),
44
61
  post: (path, body, init) => call("POST", path, body, init),
62
+ put: (path, body, init) => call("PUT", path, body, init),
63
+ del: (path) => call("DELETE", path),
64
+ async stream(path, signal) {
65
+ const res = await send("GET", path, undefined, undefined, signal);
66
+ if (!res.ok)
67
+ return refusal(res, path);
68
+ return res;
69
+ },
45
70
  };
46
71
  }
47
72
  export class ApiError extends CliError {
48
73
  status;
49
74
  path;
50
- constructor(status, message, path) {
75
+ code;
76
+ body;
77
+ constructor(status, message, path,
78
+ /** The server's machine-readable `code`, when it sent one. */
79
+ code,
80
+ /** The whole refusal body, for the few callers that need a field beside `error`. */
81
+ body) {
51
82
  super(status === 400 && /organization/.test(message)
52
83
  ? `${message} - pick one with \`cubes org <alias>\``
53
84
  : message);
54
85
  this.status = status;
55
86
  this.path = path;
87
+ this.code = code;
88
+ this.body = body;
56
89
  this.name = "ApiError";
57
90
  }
58
91
  }
package/dist/args.js CHANGED
@@ -1,10 +1,22 @@
1
1
  // Argument parsing, small enough not to need a dependency.
2
2
  import { CliError } from "./auth.js";
3
3
  /** Flags that take a value (`--host X` or `--host=X`); every other flag is a switch. */
4
- const VALUED = new Set(["host", "org"]);
4
+ const VALUED = new Set([
5
+ "host", "org",
6
+ // the session form (cubes import)
7
+ "title", "repo", "group", "cube", "input", "agent", "model", "runner", "cli-args", "package",
8
+ "mcp-config", "hooks",
9
+ // cubes export
10
+ "dir",
11
+ ]);
5
12
  export function parseArgs(argv) {
6
13
  const pos = [];
7
14
  const flags = new Map();
15
+ const lists = new Map();
16
+ const valued = (name, v) => {
17
+ flags.set(name, v);
18
+ lists.set(name, [...(lists.get(name) ?? []), v]);
19
+ };
8
20
  for (let i = 0; i < argv.length; i++) {
9
21
  const a = argv[i];
10
22
  if (a === "-h") {
@@ -19,12 +31,12 @@ export function parseArgs(argv) {
19
31
  const eq = a.indexOf("=");
20
32
  const name = eq > 0 ? a.slice(2, eq) : a.slice(2);
21
33
  if (eq > 0)
22
- flags.set(name, a.slice(eq + 1));
34
+ valued(name, a.slice(eq + 1));
23
35
  else if (VALUED.has(name)) {
24
36
  const v = argv[i + 1];
25
37
  if (v === undefined || v.startsWith("--"))
26
- throw new CliError(`--${name} needs a value`);
27
- flags.set(name, v);
38
+ throw new CliError(`--${name} needs a value${name === "cli-args" ? ` (use --${name}="--flag ...")` : ""}`);
39
+ valued(name, v);
28
40
  i++;
29
41
  }
30
42
  else
@@ -33,5 +45,5 @@ export function parseArgs(argv) {
33
45
  }
34
46
  pos.push(a);
35
47
  }
36
- return { cmd: pos.shift(), pos, flags };
48
+ return { cmd: pos.shift(), pos, flags, lists };
37
49
  }
package/dist/attach.js CHANGED
@@ -46,7 +46,7 @@ export function wsUrlFor(host, id) {
46
46
  }
47
47
  export function attach(deps, session, opts = {}) {
48
48
  const { api, stdin, stdout, stderr } = deps;
49
- const filter = new InputFilter({ pasteImage: !opts.view });
49
+ const filter = new InputFilter({ pasteImage: !opts.view, detach: opts.detachKey !== false });
50
50
  const enc = new TextEncoder();
51
51
  return new Promise((resolve) => {
52
52
  let ws = null;
@@ -218,10 +218,6 @@ export function attach(deps, session, opts = {}) {
218
218
  function onData(chunk) {
219
219
  for (const a of filter.feed(chunk)) {
220
220
  if (a.kind === "detach") {
221
- if (opts.onDetachKey) {
222
- opts.onDetachKey();
223
- continue;
224
- }
225
221
  finish(0, "detached", "detached");
226
222
  return;
227
223
  }
package/dist/cli.js CHANGED
@@ -4,6 +4,8 @@
4
4
  // cubes login --host https://your-cubes sign in (device code, approved in the browser)
5
5
  // cubes ls list sessions
6
6
  // cubes attach [session] attach this terminal to a session (Ctrl-] detaches)
7
+ // cubes import [conversation] continue a Claude Code conversation from here in Cubes
8
+ // cubes export [session] bring a session's conversation here, for claude --resume
7
9
  import { spawn } from "node:child_process";
8
10
  import { createRequire } from "node:module";
9
11
  import { createInterface } from "node:readline/promises";
@@ -20,9 +22,11 @@ import { clearLogin, configPath, loadLogin, saveLogin } from "./config.js";
20
22
  import { parseArgs } from "./args.js";
21
23
  import { formatSections, isUuid, listSessions, LIVE, resolveSession, sectionize, stateOf } from "./sessions.js";
22
24
  import { upgrade } from "./upgrade.js";
25
+ import { exportCommand, importCommand } from "./conversationCmds.js";
26
+ import { defaultsPrompter, terminalPrompter } from "./prompt.js";
23
27
  const PKG = createRequire(import.meta.url)("../package.json");
24
28
  const VERSION = PKG.version;
25
- const HELP = `cubes ${VERSION} - attach to your Cubes sessions from your own terminal
29
+ const HELP = `cubes ${VERSION} - your Cubes sessions in your own terminal, and Claude Code conversations between here and Cubes
26
30
 
27
31
  Usage:
28
32
  cubes the split view: your sessions on the left, one open
@@ -35,6 +39,11 @@ Usage:
35
39
  --live: running now, --all: include spawned ones)
36
40
  cubes attach [SESSION] [--view] [--no-resume] [--no-status]
37
41
  attach to a session
42
+ cubes import [CONVERSATION] [--yes] [--no-attach] [form flags]
43
+ start a Cubes session that continues a Claude Code
44
+ conversation from this computer
45
+ cubes export [SESSION] [--dir DIR] [--force] copy a session's conversation here, so
46
+ \`claude --resume <id>\` continues it in DIR
38
47
  cubes upgrade [--check] install the newest cubes (--check only reports)
39
48
 
40
49
  SESSION is an id, the start of one (4+ characters), or part of its title. Left out, you pick from
@@ -49,8 +58,21 @@ selecting). Claude Code's own copy, like the c on its login URL, lands there too
49
58
 
50
59
  If the connection drops, nothing reconnects on its own: you are told why, and r reconnects.
51
60
 
52
- In the split view: ⏎ or a click opens a session, Ctrl-] in a session jumps back to the list, l / m
53
- show only live / your sessions, / finds, ←→ fold a session's children, q quits (sessions keep running).
61
+ Import asks which org, then everything the New session dialog asks (title, group, repositories,
62
+ cube, agents, model, runner, Remote Control, extra CLI args), uploads the conversation and starts
63
+ the session continuing it. CONVERSATION is its id, the start of it, or the path to its .jsonl; left
64
+ out, you pick from your recent ones (this directory's first). Any question can be answered up front:
65
+ --title T --group G --repo R (repeatable) --cube C --input name=value (repeatable)
66
+ --agent A (repeatable) --model M|default --runner R --rc | --no-rc --cli-args="..."
67
+ --package ID --mcp-config FILE --hooks FILE
68
+ --yes takes the default for everything not given. Only the conversation travels: the session checks
69
+ its repositories out fresh. Export writes the conversation under ~/.claude/projects (or
70
+ $CLAUDE_CONFIG_DIR) for DIR (default: here); it never replaces a copy you have continued locally
71
+ unless --force. A stopped session on a cluster runner is resumed first, if you say so.
72
+
73
+ In the split view: ⏎ or a click shows a session on the right and leaves you in the list, so you can
74
+ keep browsing; F1 switches between the list and the session (a second ⏎ goes in too). l / m show
75
+ only live / your sessions, / finds, ←→ fold a session's children, q quits (sessions keep running).
54
76
 
55
77
  Attaching takes the keyboard from any browser tab on the same session (it becomes read-only) and
56
78
  fits the session to this window. --view watches without taking it.
@@ -256,6 +278,19 @@ async function cmdAttach(a) {
256
278
  row = await api.get(`/sessions/${encodeURIComponent(row.id)}`);
257
279
  }
258
280
  }
281
+ /** What import and export need from this process: who answers questions, and how to attach. */
282
+ function conversationDeps(a) {
283
+ const interactive = isTTY() && !a.flags.has("yes");
284
+ return {
285
+ ask: interactive ? terminalPrompter() : defaultsPrompter(),
286
+ interactive,
287
+ err: process.stderr,
288
+ out: process.stdout,
289
+ attach: (id, org) => cmdAttach({
290
+ cmd: "attach", pos: [id], flags: new Map(org ? [["org", org]] : []), lists: new Map(),
291
+ }),
292
+ };
293
+ }
259
294
  /** What the session copies goes to this machine's clipboard, when it has a way to write one. */
260
295
  export function copier() {
261
296
  const cmd = clipboardWriter();
@@ -288,7 +323,7 @@ async function main(argv) {
288
323
  }
289
324
  // No command at all, in a terminal: the split view. Piped or scripted, still the help.
290
325
  if (!a.cmd && !a.flags.has("help") && isTTY())
291
- return launchTui({ org: str(a.flags, "org") });
326
+ return launchTui({ org: str(a.flags, "org"), version: VERSION });
292
327
  if (!a.cmd || a.cmd === "help" || a.flags.has("help")) {
293
328
  process.stdout.write(HELP);
294
329
  return a.cmd || a.flags.has("help") ? 0 : 1;
@@ -313,6 +348,8 @@ async function main(argv) {
313
348
  return 0;
314
349
  case "attach":
315
350
  case "a": return cmdAttach(a);
351
+ case "import": return importCommand(a, conversationDeps(a));
352
+ case "export": return exportCommand(a, conversationDeps(a));
316
353
  // The split view's two panes (src/tui), started by tmux - not for typing by hand.
317
354
  case "_list": return runList();
318
355
  case "_pane": return runPane(a.pos[0]);
@@ -0,0 +1,273 @@
1
+ // `cubes import` and `cubes export` - a Claude Code conversation from this computer into a new Cubes
2
+ // session, and a Cubes session's conversation onto this computer.
3
+ //
4
+ // cubes import [CONVERSATION] [form flags] [--yes] [--attach | --no-attach]
5
+ // cubes export [SESSION] [--dir DIR] [--force]
6
+ //
7
+ // An import is the New session dialog with a conversation attached: which org, then every question the
8
+ // dialog asks (src/form.ts), then the conversation is uploaded and the session is created through the
9
+ // same route the dialog uses, continuing that conversation. An export files the session's
10
+ // conversation where `claude --resume <id>` finds it when run in DIR (default: here).
11
+ //
12
+ // What travels is the CONVERSATION - the dialogue `--resume` reads. Not the working tree: an import
13
+ // checks the session's repositories out fresh on its runner, and an export leaves the session's files
14
+ // where they are (push them, or pull them from the session's branch).
15
+ import { execFile } from "node:child_process";
16
+ import { existsSync, readFileSync } from "node:fs";
17
+ import { resolve } from "node:path";
18
+ import { promisify } from "node:util";
19
+ import { ApiError, makeApi } from "./api.js";
20
+ import { CliError, currentSession, orgsOf } from "./auth.js";
21
+ import { sessionForm } from "./form.js";
22
+ import { claudeConfigDir, exportTarget, placeExport, recentConversations, resolveConversation, } from "./local.js";
23
+ import { ensureRunning } from "./resume.js";
24
+ import { age, isUuid, listSessions, LIVE, resolveSession, stateOf } from "./sessions.js";
25
+ import { downloadConversation, uploadConversation } from "./transfer.js";
26
+ export function fmtBytes(n) {
27
+ if (n < 1024)
28
+ return `${n} B`;
29
+ if (n < 1024 * 1024)
30
+ return `${(n / 1024).toFixed(1)} KB`;
31
+ if (n < 1024 * 1024 * 1024)
32
+ return `${(n / (1024 * 1024)).toFixed(1)} MB`;
33
+ return `${(n / (1024 * 1024 * 1024)).toFixed(2)} GB`;
34
+ }
35
+ /** A one-line progress readout on a terminal; a line at the start and the end otherwise. */
36
+ export function progressLine(err, label) {
37
+ const tty = !!err.isTTY;
38
+ let last = -1;
39
+ let started = false;
40
+ return {
41
+ update(done, total) {
42
+ if (!tty) {
43
+ if (!started) {
44
+ err.write(`${label}...\n`);
45
+ started = true;
46
+ }
47
+ return;
48
+ }
49
+ const pct = total > 0 ? Math.floor((done / total) * 100) : 0;
50
+ if (pct === last)
51
+ return;
52
+ last = pct;
53
+ const width = 24;
54
+ const fill = Math.round((pct / 100) * width);
55
+ err.write(`\r\x1b[2K${label} [${"#".repeat(fill)}${"-".repeat(width - fill)}] ${pct}% ${fmtBytes(done)} of ${fmtBytes(total)}`);
56
+ },
57
+ done(final) {
58
+ err.write(tty ? `\r\x1b[2K${final}\n` : `${final}\n`);
59
+ },
60
+ };
61
+ }
62
+ /** "owner/name" for the checkout a conversation ran in, from its git origin - so its repository is
63
+ * pre-selected. Nothing when the directory is not on this computer or is not a git checkout. */
64
+ export async function gitOriginOf(cwd) {
65
+ if (!cwd || !existsSync(cwd))
66
+ return [];
67
+ try {
68
+ const { stdout } = await promisify(execFile)("git", ["-C", cwd, "remote", "get-url", "origin"], { timeout: 3000 });
69
+ const m = /[:/]([^/:\s]+\/[^/\s]+?)(?:\.git)?\/?\s*$/.exec(stdout.trim());
70
+ return m ? [m[1]] : [];
71
+ }
72
+ catch {
73
+ return [];
74
+ }
75
+ }
76
+ function readJsonFlag(path, flag) {
77
+ let v;
78
+ try {
79
+ v = JSON.parse(readFileSync(resolve(path), "utf8"));
80
+ }
81
+ catch (e) {
82
+ throw new CliError(`--${flag}: cannot read ${path} as JSON (${e.message})`);
83
+ }
84
+ if (!v || typeof v !== "object" || Array.isArray(v))
85
+ throw new CliError(`--${flag}: ${path} must hold a JSON object`);
86
+ return v;
87
+ }
88
+ /** The form's answers that came on the command line. */
89
+ export function formFlagsOf(a) {
90
+ const str = (n) => { const v = a.flags.get(n); return typeof v === "string" ? v : undefined; };
91
+ const inputs = {};
92
+ for (const kv of a.lists.get("input") ?? []) {
93
+ const eq = kv.indexOf("=");
94
+ if (eq < 1)
95
+ throw new CliError(`--input takes name=value (got "${kv}")`);
96
+ inputs[kv.slice(0, eq)] = kv.slice(eq + 1);
97
+ }
98
+ if (a.flags.has("rc") && a.flags.has("no-rc"))
99
+ throw new CliError("--rc and --no-rc together");
100
+ let hooks;
101
+ const hooksPath = str("hooks");
102
+ if (hooksPath) {
103
+ const h = readJsonFlag(hooksPath, "hooks");
104
+ // A settings.json pasted whole: take its `hooks`.
105
+ hooks = Object.keys(h).length === 1 && h.hooks && typeof h.hooks === "object" ? h.hooks : h;
106
+ }
107
+ const mcpPath = str("mcp-config");
108
+ return {
109
+ title: str("title"),
110
+ repos: (a.lists.get("repo") ?? []).flatMap((r) => r.split(",")).map((r) => r.trim()).filter(Boolean),
111
+ group: str("group"),
112
+ cube: str("cube"),
113
+ inputs: Object.keys(inputs).length ? inputs : undefined,
114
+ agents: (a.lists.get("agent") ?? []).flatMap((r) => r.split(",")).map((r) => r.trim()).filter(Boolean),
115
+ model: str("model"),
116
+ runner: str("runner"),
117
+ rc: a.flags.has("rc") ? true : a.flags.has("no-rc") ? false : undefined,
118
+ cliArgs: str("cli-args"),
119
+ packageId: str("package"),
120
+ mcpServers: mcpPath ? readJsonFlag(mcpPath, "mcp-config") : undefined,
121
+ hooks,
122
+ };
123
+ }
124
+ const orgFlag = (a) => { const v = a.flags.get("org"); return typeof v === "string" ? v : undefined; };
125
+ /** Which org: the flag, the only one, or - asked for - a choice among the person's orgs. */
126
+ async function chooseOrg(a, deps) {
127
+ const flagged = orgFlag(a);
128
+ const base = (deps.session ?? ((org) => currentSession({ org })))(flagged);
129
+ if (flagged)
130
+ return flagged;
131
+ const orgs = orgsOf(await base.token());
132
+ if (orgs.length <= 1 || !deps.interactive)
133
+ return base.org ?? orgs[0];
134
+ const def = Math.max(0, orgs.indexOf(base.org ?? ""));
135
+ return deps.ask.select("Which org should the session be in?", orgs.map((o) => ({
136
+ label: o, value: o, hint: o === base.org ? "your default" : undefined,
137
+ })), def);
138
+ }
139
+ function conversationLabel(c) {
140
+ const name = (c.title ?? c.firstPrompt ?? "(no prompt yet)").slice(0, 60);
141
+ return {
142
+ label: `${c.id.slice(0, 8)} ${age(new Date(c.mtime).toISOString()).padStart(3)} ${name}`,
143
+ hint: [c.cwd ?? "", fmtBytes(c.bytes)].filter(Boolean).join(" "),
144
+ };
145
+ }
146
+ export async function importCommand(a, deps) {
147
+ const env = deps.env ?? process.env;
148
+ const configDir = claudeConfigDir(env);
149
+ const query = a.pos.join(" ").trim();
150
+ let conv;
151
+ if (query)
152
+ conv = await resolveConversation(configDir, query);
153
+ else {
154
+ if (!deps.interactive)
155
+ throw new CliError("which conversation? `cubes import <id, start of the id, or path to its .jsonl>`");
156
+ const recent = await recentConversations(configDir, { cwd: deps.cwd ?? process.cwd(), limit: 15 });
157
+ if (!recent.length)
158
+ throw new CliError(`no Claude Code conversations in ${configDir}/projects`);
159
+ conv = await deps.ask.select("Import which conversation? (this directory's first)", recent.map((c) => ({ ...conversationLabel(c), value: c })), 0);
160
+ }
161
+ deps.ask.note(`Conversation ${conv.id} (${fmtBytes(conv.bytes)}${conv.cwd ? `, from ${conv.cwd}` : ""})`);
162
+ const org = await chooseOrg(a, deps);
163
+ const s = (deps.session ?? ((o) => currentSession({ org: o })))(org);
164
+ const api = makeApi(s);
165
+ const { spec, summary } = await sessionForm(api, deps.ask, formFlagsOf(a), {
166
+ suggestedTitle: conv.title ?? conv.firstPrompt ?? undefined,
167
+ repoHints: await gitOriginOf(conv.cwd),
168
+ });
169
+ deps.err.write(`\nA new session in ${org ?? "your org"}, continuing that conversation:\n${summary.join("\n")}\n\n`);
170
+ if (deps.interactive && !(await deps.ask.confirm("Start it?", true))) {
171
+ deps.err.write("Nothing was started.\n");
172
+ return 1;
173
+ }
174
+ const bar = progressLine(deps.err, "Uploading the conversation");
175
+ let importId;
176
+ try {
177
+ ({ importId } = await uploadConversation(api, conv, (d, t) => bar.update(d, t)));
178
+ }
179
+ catch (e) {
180
+ bar.done("Upload failed.");
181
+ throw e;
182
+ }
183
+ bar.done(`Uploaded ${fmtBytes(conv.bytes)}.`);
184
+ const created = await createImported(api, spec, importId);
185
+ const still = !created.provisioning ? "" : spec.repoIds?.length ? " - its repositories are still being checked out" : " - it is still starting";
186
+ deps.err.write(`Started "${spec.title}" (${created.id.slice(0, 8)})${still}.\n`);
187
+ deps.err.write(`Open it in the browser: ${s.host}/sessions/${created.id}\n`);
188
+ deps.out.write(`${created.id}\n`);
189
+ const wantAttach = a.flags.has("attach") || (!a.flags.has("no-attach") && deps.interactive
190
+ && await deps.ask.confirm("Attach to it now?", true));
191
+ if (wantAttach && deps.attach)
192
+ return deps.attach(created.id, org);
193
+ if (!wantAttach)
194
+ deps.err.write(`Attach any time with: cubes attach ${created.id.slice(0, 8)}${org ? ` --org ${org}` : ""}\n`);
195
+ return 0;
196
+ }
197
+ async function createImported(api, spec, importId) {
198
+ try {
199
+ return await api.post("/sessions", { ...spec, importId });
200
+ }
201
+ catch (e) {
202
+ // The upload is the server's to keep until it expires; a refused create would otherwise leave it
203
+ // counting against the person's open imports for hours.
204
+ await api.del(`/session-imports/${encodeURIComponent(importId)}`).catch(() => undefined);
205
+ throw e;
206
+ }
207
+ }
208
+ export async function exportCommand(a, deps) {
209
+ const env = deps.env ?? process.env;
210
+ const org = orgFlag(a);
211
+ const s = (deps.session ?? ((o) => currentSession({ org: o })))(org);
212
+ const api = makeApi(s);
213
+ const query = a.pos.join(" ").trim();
214
+ let row;
215
+ if (query && isUuid(query))
216
+ row = await api.get(`/sessions/${encodeURIComponent(query)}`);
217
+ else if (query)
218
+ row = resolveSession(await listSessions(api, { all: true }), query);
219
+ else {
220
+ if (!deps.interactive)
221
+ throw new CliError("which session? `cubes export <id or title>`");
222
+ const rows = (await listSessions(api)).slice(0, 20);
223
+ if (!rows.length)
224
+ throw new CliError("no sessions to export");
225
+ row = await deps.ask.select("Export which session?", rows.map((r) => ({
226
+ label: `${r.id.slice(0, 8)} ${stateOf(r).padEnd(8)} ${r.title ?? "(untitled)"}`, value: r,
227
+ })), 0);
228
+ }
229
+ const dirFlag = a.flags.get("dir");
230
+ const dir = resolve(typeof dirFlag === "string" ? dirFlag : deps.cwd ?? process.cwd());
231
+ const configDir = claudeConfigDir(env);
232
+ const name = `"${row.title ?? row.id.slice(0, 8)}"`;
233
+ const fetchIt = async () => {
234
+ const bar = progressLine(deps.err, `Downloading ${name}`);
235
+ try {
236
+ const d = await downloadConversation(api, row.id, (cid) => exportTarget(configDir, dir, cid), (x, t) => bar.update(x, t));
237
+ bar.done(`Downloaded ${fmtBytes(d.bytes)}.`);
238
+ return d;
239
+ }
240
+ catch (e) {
241
+ bar.done("Download failed.");
242
+ throw e;
243
+ }
244
+ };
245
+ let got;
246
+ try {
247
+ got = await fetchIt();
248
+ }
249
+ catch (e) {
250
+ if (e instanceof ApiError && e.code === "no_conversation")
251
+ throw new CliError(`nothing has been said in ${name} yet, so there is no conversation to export`);
252
+ // A stopped session on a cluster runner keeps its conversation on its own volume; only a running
253
+ // box can read it. Offer to bring it back rather than just refusing.
254
+ if (!(e instanceof ApiError && e.code === "conversation_not_running"))
255
+ throw e;
256
+ if (!deps.interactive || !(await deps.ask.confirm(`${name} is not running, and its conversation is on its machine. Resume it to export?`, true))) {
257
+ throw new CliError(`${name} is not running - resume it first (\`cubes attach ${row.id.slice(0, 8)}\`, then detach), or answer yes here`);
258
+ }
259
+ row = await ensureRunning(api, row, true);
260
+ got = await fetchIt();
261
+ }
262
+ const target = exportTarget(configDir, dir, got.conversationId);
263
+ const how = await placeExport(got.file, target, a.flags.has("force"));
264
+ deps.err.write(how === "unchanged"
265
+ ? `Already up to date: ${target}\n`
266
+ : `${how === "created" ? "Exported" : "Updated"} ${name} to ${target}\n`);
267
+ deps.err.write(`Continue it here with:\n\n cd ${JSON.stringify(dir)} && claude --resume ${got.conversationId}\n\n`);
268
+ if (LIVE.has(row.status)) {
269
+ deps.err.write("The session is still running in Cubes; what it says from now on stays there unless you export again.\n");
270
+ }
271
+ deps.out.write(`${target}\n`);
272
+ return 0;
273
+ }