@hellix-ai/cubes 0.1.5 → 0.2.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.
@@ -0,0 +1,117 @@
1
+ // What the split view's list shows: the sessions as a tree (a spawned session under the session that
2
+ // spawned it), inside the same sections `cubes ls` and the web use, narrowed by the filters - all pure,
3
+ // so the whole of it is testable without a terminal.
4
+ import { LIVE, sectionize } from "../sessions.js";
5
+ /** One status vocabulary for the list, the header and the tab: the server's lifecycle + activity. */
6
+ export function statusOf(r) {
7
+ switch (r.status) {
8
+ case "running":
9
+ if (r.activity_status === "working")
10
+ return { glyph: "●", color: 75, label: "working", attention: false };
11
+ if (r.activity_status === "waiting")
12
+ return { glyph: "●", color: 214, label: "needs you", attention: true };
13
+ return { glyph: "●", color: 78, label: "idle", attention: false };
14
+ case "starting": return { glyph: "◌", color: 80, label: "starting", attention: false };
15
+ case "stranded": return { glyph: "○", color: 203, label: "stranded", attention: false };
16
+ case "cancelled": return { glyph: "○", color: 242, label: "ended", attention: false };
17
+ default: return { glyph: "○", color: 242, label: r.status, attention: false };
18
+ }
19
+ }
20
+ export function matches(r, f) {
21
+ if (f.live && !LIVE.has(r.status))
22
+ return false;
23
+ if (f.mine && r.created_by_me !== true)
24
+ return false;
25
+ const q = f.text.trim().toLowerCase();
26
+ if (q && !(r.title ?? "").toLowerCase().includes(q) && !r.id.toLowerCase().startsWith(q)
27
+ && !(r.group_name ?? "").toLowerCase().includes(q))
28
+ return false;
29
+ return true;
30
+ }
31
+ const startOf = (r) => r.started_at ?? r.ended_at ?? r.created_at ?? "";
32
+ const order = (a, b) => {
33
+ const la = LIVE.has(a.row.status);
34
+ const lb = LIVE.has(b.row.status);
35
+ if (la !== lb)
36
+ return la ? -1 : 1;
37
+ return startOf(b.row).localeCompare(startOf(a.row));
38
+ };
39
+ /**
40
+ * Build the visible list.
41
+ *
42
+ * ⚠ A SPAWNED SESSION LIVES UNDER ITS PARENT, NOT IN ITS OWN GROUP. The tree is what says who started
43
+ * what; a child filed into a different section would lose its parent and read as a stray root. A
44
+ * child whose parent is not in the page (paged out, deleted) is shown as a root rather than hidden.
45
+ *
46
+ * ⚠ A FILTER KEEPS THE PATH TO A MATCH. A live child under an exited parent is still reachable with
47
+ * `live` on - the parent stays as the way to it, and it is drawn expanded so the match is visible.
48
+ *
49
+ * `expandedOverride` is what the person toggled; without one, a parent starts expanded when anything
50
+ * under it is live (the work in progress is the thing to see) and collapsed otherwise.
51
+ */
52
+ export function buildItems(rows, f, expandedOverride) {
53
+ const byId = new Map(rows.map((r) => [r.id, { row: r, kids: [] }]));
54
+ const roots = [];
55
+ for (const n of byId.values()) {
56
+ const parent = n.row.parent_session_id ? byId.get(n.row.parent_session_id) : undefined;
57
+ if (parent && parent !== n)
58
+ parent.kids.push(n);
59
+ else
60
+ roots.push(n);
61
+ }
62
+ const keep = new Map(); // node matches, or something under it does
63
+ const anyLive = new Map();
64
+ const visit = (n) => {
65
+ n.kids.sort(order);
66
+ let k = matches(n.row, f);
67
+ let live = LIVE.has(n.row.status);
68
+ for (const c of n.kids) {
69
+ visit(c);
70
+ if (keep.get(c.row.id))
71
+ k = true;
72
+ if (anyLive.get(c.row.id))
73
+ live = true;
74
+ }
75
+ keep.set(n.row.id, k);
76
+ anyLive.set(n.row.id, live);
77
+ };
78
+ roots.forEach(visit);
79
+ const filtering = f.live || f.mine || f.text.trim() !== "";
80
+ const visibleRoots = roots.filter((n) => keep.get(n.row.id)).map((n) => n.row);
81
+ const items = [];
82
+ const sections = sectionize(visibleRoots);
83
+ const bare = sections.length === 1 && sections[0].title === "Ungrouped";
84
+ for (const sec of sections) {
85
+ const nodes = sec.rows.map((r) => byId.get(r.id));
86
+ if (!bare)
87
+ items.push({ kind: "section", title: sec.title, count: countVisible(nodes, keep) });
88
+ const walk = (n, depth, lasts) => {
89
+ const kids = n.kids.filter((c) => keep.get(c.row.id));
90
+ const kidMatch = filtering && kids.length > 0;
91
+ const expanded = expandedOverride.get(n.row.id) ?? (kidMatch || n.kids.some((c) => anyLive.get(c.row.id) === true));
92
+ items.push({ kind: "session", row: n.row, depth, lasts, children: kids.length, expanded: expanded && kids.length > 0 });
93
+ if (expanded)
94
+ kids.forEach((c, i) => walk(c, depth + 1, [...lasts, i === kids.length - 1]));
95
+ };
96
+ nodes.forEach((n) => walk(n, 0, []));
97
+ }
98
+ return items;
99
+ }
100
+ function countVisible(nodes, keep) {
101
+ let c = 0;
102
+ const go = (n) => { if (keep.get(n.row.id)) {
103
+ c++;
104
+ n.kids.forEach(go);
105
+ } };
106
+ nodes.forEach(go);
107
+ return c;
108
+ }
109
+ /** The tree guide in front of a child: `│ ` / blank per ancestor level, then `├─`/`└─`. */
110
+ export function treePrefix(item) {
111
+ if (item.depth === 0)
112
+ return "";
113
+ let s = "";
114
+ for (let i = 0; i < item.lasts.length - 1; i++)
115
+ s += item.lasts[i] ? " " : "│ ";
116
+ return s + (item.lasts[item.lasts.length - 1] ? "└─" : "├─");
117
+ }
@@ -0,0 +1,162 @@
1
+ // The right pane of the split view: the session itself, under a header (tmux's pane border) that says
2
+ // which one it is - name, group, runner, state - and stays current while you work in it.
3
+ //
4
+ // The list replaces this process (`respawn-pane -k`) to open another session, so it only ever holds
5
+ // one. When an attach ends it SAYS so and waits: there is no automatic reconnect, by decision ("never
6
+ // keep retrying to reattach a session") - `r` reconnects when the person chooses to.
7
+ import { makeApi } from "../api.js";
8
+ import { attach } from "../attach.js";
9
+ import { currentSession } from "../auth.js";
10
+ import { clipboardWriter, readClipboardImage, writeClipboard } from "../clipboard.js";
11
+ import { CubeSpinner, cubeSize } from "../cube.js";
12
+ import { ensureRunning } from "../resume.js";
13
+ import { statusOf } from "./model.js";
14
+ import { ESC, fit, parseKeys, printable, strWidth } from "./term.js";
15
+ import { fmtText, getSessionOption, setPaneOption, setSessionOption, tmux } from "./tmux.js";
16
+ const out = process.stdout;
17
+ const focusList = () => {
18
+ const left = getSessionOption("@cubes_left");
19
+ if (left)
20
+ tmux(["select-pane", "-t", left]);
21
+ };
22
+ /** The header: `● Title · Group · runner · state`, as a tmux format (styles via #[...]). */
23
+ export function headerFor(r, override) {
24
+ const s = override ?? statusOf({ status: r.status ?? "running", activity_status: r.activity_status ?? null });
25
+ const parts = [
26
+ `#[fg=colour${s.color}]${s.glyph}#[default] #[bold]${fmtText(printable(r.title || r.id.slice(0, 8)))}#[nobold]`,
27
+ fmtText(printable(r.group_name || r.package_name || "no group")),
28
+ fmtText(printable(r.runner_name || "-")),
29
+ `#[fg=colour${s.color}]${s.label}#[default]`,
30
+ ];
31
+ return parts.join(" #[fg=colour240]·#[default] ");
32
+ }
33
+ function setHeader(r, override) {
34
+ setPaneOption("@cubes_header", headerFor(r, override));
35
+ const s = override ?? statusOf({ status: r.status ?? "running", activity_status: r.activity_status ?? null });
36
+ setSessionOption("@cubes_tab", `${s.glyph} ${printable(r.title || "Cubes session")}`);
37
+ }
38
+ /** Read keys until one is accepted. Raw mode on for as long as we wait. */
39
+ function waitKey(accept) {
40
+ return new Promise((resolve) => {
41
+ if (process.stdin.isTTY)
42
+ process.stdin.setRawMode(true);
43
+ const onData = (b) => {
44
+ for (const k of parseKeys(b.toString("utf8"))) {
45
+ if (!accept(k))
46
+ continue;
47
+ process.stdin.off("data", onData);
48
+ process.stdin.pause();
49
+ resolve(k);
50
+ return;
51
+ }
52
+ };
53
+ process.stdin.on("data", onData);
54
+ process.stdin.resume();
55
+ });
56
+ }
57
+ /** A few centred lines, nothing else on screen. */
58
+ function message(lines) {
59
+ const w = out.columns ?? 80;
60
+ const h = out.rows ?? 24;
61
+ const top = Math.max(0, Math.floor((h - lines.length) / 2));
62
+ let s = ESC.hideCursor + ESC.home + ESC.clear;
63
+ lines.forEach((l, i) => {
64
+ const t = fit(l.text, Math.min(w - 2, strWidth(l.text)));
65
+ const col = Math.max(1, Math.floor((w - strWidth(t)) / 2) + 1);
66
+ s += `${ESC.at(top + i + 1, col)}${l.style ?? ""}${t}${ESC.reset}`;
67
+ });
68
+ out.write(s);
69
+ }
70
+ /** Where to put the cube so it sits in the middle of this pane. */
71
+ function cubePlacement() {
72
+ const w = out.columns ?? 80;
73
+ const h = out.rows ?? 24;
74
+ const size = cubeSize(w, h);
75
+ if (!size)
76
+ return { indent: 2, top: 0 };
77
+ return { indent: Math.max(2, Math.floor((w - size.w) / 2)), top: Math.max(0, Math.floor((h - size.h - 2) / 2)) };
78
+ }
79
+ async function welcome() {
80
+ setSessionOption("@cubes_current", "");
81
+ setSessionOption("@cubes_tab", "Cubes");
82
+ setPaneOption("@cubes_header", "#[bold]Cubes#[nobold] #[fg=colour240]·#[default] pick a session on the left");
83
+ let spin = null;
84
+ const draw = () => {
85
+ spin?.stop();
86
+ const { indent, top } = cubePlacement();
87
+ out.write(ESC.hideCursor + ESC.home + ESC.clear + "\n".repeat(top));
88
+ spin = new CubeSpinner(out, "Pick a session on the left", process.env, indent);
89
+ };
90
+ draw();
91
+ out.on("resize", draw);
92
+ for (;;) {
93
+ await waitKey((k) => k.kind === "ctrl-]" || k.kind === "escape" || k.kind === "tab");
94
+ focusList();
95
+ }
96
+ }
97
+ const OUTCOME_WORDS = {
98
+ ended: { head: "Session ended", key: "resume it", color: 242 },
99
+ disconnected: { head: "Disconnected", key: "reconnect", color: 214 },
100
+ refused: { head: "The server refused the attach", key: "try again", color: 203 },
101
+ detached: { head: "Detached", key: "attach again", color: 242 },
102
+ };
103
+ async function sessionPane(id) {
104
+ const api = makeApi(currentSession());
105
+ const copyCmd = clipboardWriter();
106
+ setSessionOption("@cubes_current", id);
107
+ let row = { id, title: null, status: "running", created_at: "" };
108
+ setHeader(row, { glyph: "◌", color: 80, label: "connecting" });
109
+ for (;;) {
110
+ let result;
111
+ try {
112
+ row = await api.get(`/sessions/${encodeURIComponent(id)}`);
113
+ setHeader(row);
114
+ const { indent, top } = cubePlacement();
115
+ out.write(ESC.home + ESC.clear + "\n".repeat(top));
116
+ row = await ensureRunning(api, row, true, out, indent);
117
+ setHeader(row);
118
+ result = await attach({
119
+ api,
120
+ host: currentSession().host,
121
+ openWs: (url, protocols) => new WebSocket(url, protocols),
122
+ stdin: process.stdin,
123
+ stdout: out,
124
+ stderr: out,
125
+ readClipboardImage: () => readClipboardImage(),
126
+ clipboard: copyCmd ? { write: (text) => writeClipboard(copyCmd, text) } : null,
127
+ }, row, {
128
+ onDetachKey: focusList,
129
+ onCopied: (text) => {
130
+ setPaneOption("@cubes_header", `#[fg=colour78]✓ Copied ${[...text].length} characters to your clipboard#[default]`);
131
+ setTimeout(() => setHeader(row), 1500);
132
+ },
133
+ status: { onRow: (r) => { row = { ...row, ...r }; setHeader(row); } },
134
+ });
135
+ }
136
+ catch (e) {
137
+ result = { code: 1, message: e.message, outcome: "disconnected" };
138
+ }
139
+ const words = OUTCOME_WORDS[result.outcome];
140
+ setHeader(row, { glyph: "○", color: words.color, label: words.head.toLowerCase() });
141
+ message([
142
+ { text: words.head, style: ESC.bold + ESC.fg(words.color) },
143
+ { text: result.message, style: ESC.dim },
144
+ { text: "" },
145
+ { text: `r ${words.key} ^] back to the list` },
146
+ ]);
147
+ // Wait for the person: `r` goes round again, ^] / Esc / Tab hand focus back to the list.
148
+ for (;;) {
149
+ const k = await waitKey((k) => (k.kind === "char" && (k.ch === "r" || k.ch === "R")) || k.kind === "enter"
150
+ || k.kind === "ctrl-]" || k.kind === "escape" || k.kind === "tab");
151
+ if (k.kind === "char" || k.kind === "enter")
152
+ break;
153
+ focusList();
154
+ }
155
+ }
156
+ }
157
+ export async function runPane(id) {
158
+ for (const sig of ["SIGTERM", "SIGHUP"])
159
+ process.on(sig, () => process.exit(0));
160
+ process.on("exit", () => out.write(ESC.showCursor));
161
+ return id ? sessionPane(id) : welcome();
162
+ }
@@ -0,0 +1,133 @@
1
+ // The small amount of terminal the split view needs: how wide a string really is, how to cut one to a
2
+ // column budget, and what the bytes a terminal sends for a key or a mouse click mean.
3
+ /** Columns a code point occupies: 0 for combining marks and joiners, 2 for wide (CJK, emoji), else 1.
4
+ * Not the whole of Unicode's East Asian Width table - the ranges that actually show up in session
5
+ * titles and in our own glyphs. Getting this wrong shifts every column after the character. */
6
+ export function cpWidth(cp) {
7
+ if (cp === 0)
8
+ return 0;
9
+ if (cp < 0x20 || (cp >= 0x7f && cp < 0xa0))
10
+ return 0;
11
+ if ((cp >= 0x300 && cp <= 0x36f) || cp === 0x200b || cp === 0x200c || cp === 0x200d ||
12
+ (cp >= 0xfe00 && cp <= 0xfe0f) || (cp >= 0x1f3fb && cp <= 0x1f3ff) || (cp >= 0xe0100 && cp <= 0xe01ef))
13
+ return 0;
14
+ if ((cp >= 0x1100 && cp <= 0x115f) || (cp >= 0x2e80 && cp <= 0xa4cf) || (cp >= 0xac00 && cp <= 0xd7a3) ||
15
+ (cp >= 0xf900 && cp <= 0xfaff) || (cp >= 0xfe30 && cp <= 0xfe4f) || (cp >= 0xff00 && cp <= 0xff60) ||
16
+ (cp >= 0xffe0 && cp <= 0xffe6) || (cp >= 0x1f300 && cp <= 0x1f64f) || (cp >= 0x1f900 && cp <= 0x1f9ff) ||
17
+ (cp >= 0x1f680 && cp <= 0x1f6ff) || (cp >= 0x2600 && cp <= 0x26ff && isEmojiPresentation(cp)) ||
18
+ cp === 0x270b || cp === 0x2705 || cp === 0x274c || (cp >= 0x20000 && cp <= 0x3fffd))
19
+ return 2;
20
+ return 1;
21
+ }
22
+ // The few symbols in U+2600-26FF that terminals draw two cells wide (emoji presentation by default).
23
+ function isEmojiPresentation(cp) {
24
+ return [0x2614, 0x2615, 0x2648, 0x267f, 0x2693, 0x26a1, 0x26aa, 0x26ab, 0x26bd, 0x26be, 0x26c4, 0x26c5,
25
+ 0x26ce, 0x26d4, 0x26ea, 0x26f2, 0x26f3, 0x26f5, 0x26fa, 0x26fd].includes(cp);
26
+ }
27
+ export function strWidth(s) {
28
+ let w = 0;
29
+ for (const ch of s)
30
+ w += cpWidth(ch.codePointAt(0));
31
+ return w;
32
+ }
33
+ /** Exactly `w` columns: cut with an ellipsis when too long, padded with spaces when short. */
34
+ export function fit(s, w) {
35
+ if (w <= 0)
36
+ return "";
37
+ let out = "";
38
+ let used = 0;
39
+ const total = strWidth(s);
40
+ const budget = total > w ? w - 1 : w;
41
+ for (const ch of s) {
42
+ const cw = cpWidth(ch.codePointAt(0));
43
+ if (used + cw > budget)
44
+ break;
45
+ out += ch;
46
+ used += cw;
47
+ }
48
+ if (total > w) {
49
+ out += "…";
50
+ used += 1;
51
+ }
52
+ return out + " ".repeat(Math.max(0, w - used));
53
+ }
54
+ /** Nothing from the server may reach the screen as a control character. */
55
+ export function printable(s) {
56
+ return s.replace(/[\x00-\x1f\x7f-\x9f]/g, " ");
57
+ }
58
+ const CSI_KEYS = {
59
+ A: "up", B: "down", C: "right", D: "left", H: "home", F: "end", Z: "backtab",
60
+ };
61
+ const TILDE_KEYS = {
62
+ "1": "home", "7": "home", "4": "end", "8": "end", "5": "pgup", "6": "pgdn",
63
+ };
64
+ /** Turn one read of raw input into keys. A lone ESC is the Escape key (tmux delivers a real escape
65
+ * sequence in one read; the split view sets tmux's escape-time low so Escape is not held back). */
66
+ export function parseKeys(input) {
67
+ const keys = [];
68
+ let i = 0;
69
+ while (i < input.length) {
70
+ const c = input[i];
71
+ if (c === "\x1b") {
72
+ const rest = input.slice(i);
73
+ const mouse = /^\x1b\[<(\d+);(\d+);(\d+)([Mm])/.exec(rest);
74
+ if (mouse) {
75
+ keys.push({ kind: "mouse", button: Number(mouse[1]), x: Number(mouse[2]), y: Number(mouse[3]), release: mouse[4] === "m" });
76
+ i += mouse[0].length;
77
+ continue;
78
+ }
79
+ const csi = /^\x1b[[O](\d*)(?:;\d+)*([A-Za-z~])/.exec(rest);
80
+ if (csi) {
81
+ const kind = csi[2] === "~" ? TILDE_KEYS[csi[1]] : CSI_KEYS[csi[2]];
82
+ if (kind)
83
+ keys.push({ kind });
84
+ i += csi[0].length;
85
+ continue;
86
+ }
87
+ keys.push({ kind: "escape" });
88
+ i += 1;
89
+ continue;
90
+ }
91
+ i += 1;
92
+ if (c === "\r" || c === "\n")
93
+ keys.push({ kind: "enter" });
94
+ else if (c === "\t")
95
+ keys.push({ kind: "tab" });
96
+ else if (c === "\x7f" || c === "\b")
97
+ keys.push({ kind: "backspace" });
98
+ else if (c === "\x03")
99
+ keys.push({ kind: "ctrl-c" });
100
+ else if (c === "\x1d")
101
+ keys.push({ kind: "ctrl-]" });
102
+ else if (c === "\x0c")
103
+ keys.push({ kind: "ctrl-l" });
104
+ else if (c >= " ") {
105
+ // A surrogate pair is one character.
106
+ const cp = input.codePointAt(i - 1);
107
+ const ch = String.fromCodePoint(cp);
108
+ if (ch.length === 2)
109
+ i += 1;
110
+ keys.push({ kind: "char", ch });
111
+ }
112
+ }
113
+ return keys;
114
+ }
115
+ export const ESC = {
116
+ altOn: "\x1b[?1049h",
117
+ altOff: "\x1b[?1049l",
118
+ hideCursor: "\x1b[?25l",
119
+ showCursor: "\x1b[?25h",
120
+ mouseOn: "\x1b[?1000h\x1b[?1002h\x1b[?1006h",
121
+ mouseOff: "\x1b[?1000l\x1b[?1002l\x1b[?1006l",
122
+ syncOn: "\x1b[?2026h",
123
+ syncOff: "\x1b[?2026l",
124
+ home: "\x1b[H",
125
+ clear: "\x1b[2J",
126
+ reset: "\x1b[0m",
127
+ dim: "\x1b[2m",
128
+ bold: "\x1b[1m",
129
+ inverse: "\x1b[7m",
130
+ fg: (n) => `\x1b[38;5;${n}m`,
131
+ bg: (n) => `\x1b[48;5;${n}m`,
132
+ at: (row, col) => `\x1b[${row};${col}H`,
133
+ };
@@ -0,0 +1,49 @@
1
+ // The local tmux the split view runs in: its own socket, so it never touches a tmux the person uses,
2
+ // and every call synchronous - these are a handful of tiny commands, never a stream.
3
+ import { spawnSync } from "node:child_process";
4
+ export const SOCKET = "cubes-tui";
5
+ export const SESSION = "cubes";
6
+ /** Run `tmux -L cubes-tui <args>`. `TMUX` is dropped from the child's env: inside someone's own tmux
7
+ * it would make every command refuse ("sessions should be nested with care"). */
8
+ export function tmux(args, opts = {}) {
9
+ const env = { ...process.env };
10
+ delete env.TMUX;
11
+ const r = spawnSync("tmux", ["-L", SOCKET, ...args], { encoding: "utf8", env, input: opts.input });
12
+ if (r.error)
13
+ return { code: 127, out: "", err: r.error.message };
14
+ return { code: r.status ?? 1, out: (r.stdout ?? "").trim(), err: (r.stderr ?? "").trim() };
15
+ }
16
+ /** tmux's version as a number (3.3a -> 3.3), or null when there is no tmux at all. */
17
+ export function tmuxVersion() {
18
+ const r = spawnSync("tmux", ["-V"], { encoding: "utf8" });
19
+ if (r.error || r.status !== 0)
20
+ return null;
21
+ const m = /(\d+)\.(\d+)/.exec(r.stdout ?? "");
22
+ return m ? Number(`${m[1]}.${m[2]}`) : null;
23
+ }
24
+ /** Single-quote for the `sh -c` tmux runs a pane's command through. */
25
+ export function shq(s) {
26
+ return `'${s.replace(/'/g, "'\\''")}'`;
27
+ }
28
+ /** A tmux FORMAT is parsed for `#{...}` and `#[...]`; a session's title must never be either. */
29
+ export function fmtText(s) {
30
+ return s.replace(/#/g, "##");
31
+ }
32
+ /** The command line that runs this same CLI (same node, same script) with `args`. */
33
+ export function selfCommand(args) {
34
+ return [process.execPath, process.argv[1] ?? "", ...args].map(shq).join(" ");
35
+ }
36
+ /** Set a pane option on THIS pane (the one the calling process runs in). Best-effort: a header that
37
+ * failed to update must never take the pane down. */
38
+ export function setPaneOption(name, value) {
39
+ const pane = process.env.TMUX_PANE;
40
+ if (!pane)
41
+ return;
42
+ tmux(["set-option", "-p", "-t", pane, name, value]);
43
+ }
44
+ export function setSessionOption(name, value) {
45
+ tmux(["set-option", "-t", SESSION, name, value]);
46
+ }
47
+ export function getSessionOption(name) {
48
+ return tmux(["show-options", "-v", "-q", "-t", SESSION, name]).out;
49
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hellix-ai/cubes",
3
- "version": "0.1.5",
3
+ "version": "0.2.1",
4
4
  "description": "Attach to your Cubes sessions from your own terminal (Warp, iTerm, Ghostty, anything).",
5
5
  "type": "module",
6
6
  "bin": {