@claudecollab/cli 0.1.2 → 0.2.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.
@@ -16,6 +16,7 @@
16
16
  // ── you're live ──
17
17
 
18
18
  import { ROLE_GLYPH, stringWidth, truncateToWidth } from '../renderer.js';
19
+ import { stripControls } from './log.js';
19
20
 
20
21
  // Keep the box comfortably inside an 80-col floor: content ≤ 74 ⇒ box ≤ 78.
21
22
  const MAX_INNER = 74;
@@ -63,8 +64,11 @@ export function build(state, log, {
63
64
  const joiner = state.get(joinerId);
64
65
  const others = state.list().filter((p) => p.id !== joinerId);
65
66
 
66
- const peopleStr = others.map((p) => `${p.name}${glyph(p.role)}`).join(' ');
67
- const joinStr = joiner ? `you join as ${joiner.name}${glyph(joiner.role)}` : '';
67
+ // Names and prompt snippets are UNTRUSTED (a pasted name/prompt could carry raw
68
+ // ESC/OSC) and this card is printed straight into every joiner's terminal
69
+ // sanitize each display copy before it lands in the box.
70
+ const peopleStr = others.map((p) => `${stripControls(p.name)}${glyph(p.role)}`).join(' ');
71
+ const joinStr = joiner ? `you join as ${stripControls(joiner.name)}${glyph(joiner.role)}` : '';
68
72
 
69
73
  const rows = [];
70
74
  rows.push(`people ${peopleStr}${joinStr ? ` · ${joinStr}` : ''}`);
@@ -74,7 +78,7 @@ export function build(state, log, {
74
78
  if (recent.length === 0) {
75
79
  rows.push(' (none yet)');
76
80
  } else {
77
- for (const p of recent) rows.push(` ${p.author} → "${p.text}" (${relAgo(now() - p.at)})`);
81
+ for (const p of recent) rows.push(` ${stripControls(p.author)} → "${stripControls(p.text)}" (${relAgo(now() - p.at)})`);
78
82
  }
79
83
  const files = log.files;
80
84
  const shown = files.slice(0, maxFiles);
@@ -72,8 +72,12 @@ export function parse(text) {
72
72
  }
73
73
  return { name, error: QUEUE_USAGE };
74
74
  }
75
+ case 'end':
76
+ // /end saves session.md by default; /end nosave honors the browser's
77
+ // "Just end" (and any host who typed it) so nothing is written to disk.
78
+ return { name, save: (parts[1] ?? '').toLowerCase() !== 'nosave' };
75
79
  default:
76
- // pause | resume | recap | end — no arguments
80
+ // pause | resume | recap — no arguments
77
81
  return { name };
78
82
  }
79
83
  }
@@ -0,0 +1,72 @@
1
+ // The room's turn history — every prompt sent to Claude, attributed to the person
2
+ // who sent it, paired with Claude's response. This is what each person's window
3
+ // shows: "who typed what", and what Claude answered.
4
+ //
5
+ // A turn opens when a queued prompt is actually written to Claude (the drain edge)
6
+ // and closes when Claude finishes (the Stop hook), at which point the response text
7
+ // is attached. At most one turn is open at a time — Claude runs one prompt per idle.
8
+ //
9
+ // The log lives only for the session (matches how rooms already work: a relay
10
+ // restart ends them). Only a bounded tail is kept so the state snapshot the browser
11
+ // polls can never grow without limit.
12
+
13
+ const MAX_TURNS = 60;
14
+
15
+ export class History {
16
+ /** @type {{id:number, author:string, prompt:string, response:string, running:boolean, at:number}[]} */
17
+ #turns = [];
18
+ #openId = null; // id of the turn awaiting a response, or null
19
+ #seq = 0;
20
+ #now;
21
+
22
+ /** @param {object} [opts] @param {() => number} [opts.now] injectable clock */
23
+ constructor({ now = () => Date.now() } = {}) {
24
+ this.#now = now;
25
+ }
26
+
27
+ /**
28
+ * Open a turn: a prompt has just gone to Claude. Returns the turn id.
29
+ * @param {string} author participant id who sent it
30
+ * @param {string} prompt the prompt text
31
+ */
32
+ start(author, prompt) {
33
+ const id = ++this.#seq;
34
+ this.#turns.push({ id, author, prompt: String(prompt ?? ''), response: '', running: true, at: this.#now() });
35
+ if (this.#turns.length > MAX_TURNS) this.#turns.shift();
36
+ this.#openId = id;
37
+ return id;
38
+ }
39
+
40
+ /** Is a turn currently awaiting its response? */
41
+ get open() {
42
+ return this.#openId != null;
43
+ }
44
+
45
+ /**
46
+ * Close the open turn with Claude's response text. No-op if nothing is open
47
+ * (e.g. a prompt typed straight into Claude, not from a window).
48
+ * @param {string} response
49
+ */
50
+ finish(response) {
51
+ if (this.#openId == null) return false;
52
+ const turn = this.#turns.find((t) => t.id === this.#openId);
53
+ if (turn) {
54
+ turn.response = String(response ?? '');
55
+ turn.running = false;
56
+ }
57
+ this.#openId = null;
58
+ return true;
59
+ }
60
+
61
+ /** Renderer-facing serialisation: plain objects, oldest→newest. */
62
+ snapshot() {
63
+ return this.#turns.map((t) => ({
64
+ id: t.id,
65
+ author: t.author,
66
+ prompt: t.prompt,
67
+ response: t.response,
68
+ running: t.running,
69
+ at: t.at,
70
+ }));
71
+ }
72
+ }
@@ -11,6 +11,24 @@
11
11
 
12
12
  import fs from 'node:fs';
13
13
 
14
+ // Escape-injection guard: display copies only — pty-bound text stays raw (Claude
15
+ // must receive real bytes, including newlines). Pasted prompts land verbatim in
16
+ // join cards, the shared log, and session.md; a raw ESC/OSC/BEL there would inject
17
+ // terminal escapes into every guest's terminal and the saved file. This strips
18
+ // ANSI/OSC sequences wholesale, then any remaining C0 controls + DEL — but keeps
19
+ // \n and \t so multi-line prompts and tabs survive as plain text.
20
+ const ANSI = /\x1b(?:\[[0-?]*[ -\/]*[@-~]|\][^\x07\x1b]*(?:\x07|\x1b\\)?|[@-Z\\-_])/g;
21
+
22
+ /**
23
+ * Remove terminal control/escape sequences from a string, keeping printable text
24
+ * plus newlines and tabs. Safe to run on any untrusted display string.
25
+ * @param {string} s
26
+ * @returns {string}
27
+ */
28
+ export function stripControls(s) {
29
+ return String(s).replace(ANSI, '').replace(/[\x00-\x08\x0b-\x1f\x7f]/g, '');
30
+ }
31
+
14
32
  export class Log {
15
33
  #entries = [];
16
34
  #files = [];
@@ -23,16 +41,16 @@ export class Log {
23
41
  this.#startedAt = now();
24
42
  }
25
43
 
26
- /** Record an attributed prompt. */
44
+ /** Record an attributed prompt (sanitized for display — the raw text still went to the pty). */
27
45
  prompt(author, text, at = this.#now()) {
28
- const e = { kind: 'prompt', author, text: String(text), at };
46
+ const e = { kind: 'prompt', author: stripControls(author), text: stripControls(text), at };
29
47
  this.#entries.push(e);
30
48
  return e;
31
49
  }
32
50
 
33
51
  /** Record a room event (join/leave/role/kick/mode/pause). */
34
52
  event(text, at = this.#now()) {
35
- const e = { kind: 'event', text: String(text), at };
53
+ const e = { kind: 'event', text: stripControls(text), at };
36
54
  this.#entries.push(e);
37
55
  return e;
38
56
  }
@@ -33,6 +33,19 @@ export function atLeast(role, min) {
33
33
  export const FLOOR_COLS = 80;
34
34
  export const FLOOR_ROWS = 24;
35
35
 
36
+ /**
37
+ * A scaled viewer reports the sentinel size 0×0 — "I scale, ignore my size". A
38
+ * browser tab below the 80×24 floor sends this instead of its real (tiny) size so
39
+ * the host renders the shared view zoomed on it rather than shrinking the room to
40
+ * fit or parking it on a "make your terminal bigger" hint (mobile room view). Such
41
+ * a participant is excluded from the clamp and is a mirror target, never a spectator.
42
+ * @param {{cols?:number, rows?:number}} p
43
+ * @returns {boolean}
44
+ */
45
+ export function isScaledViewer(p) {
46
+ return p != null && p.cols === 0 && p.rows === 0;
47
+ }
48
+
36
49
  // Stable participant colors for the browser overlay (spec §roles: "color is a
37
50
  // bonus — colorblind-safe by not being load-bearing"; name tags carry identity).
38
51
  // Eight distinct hues, assigned by the brain per fingerprint so a returning key
@@ -189,7 +202,11 @@ export class RoomState {
189
202
  return true;
190
203
  }
191
204
 
192
- /** Record a participant's terminal size (host or guest). */
205
+ /**
206
+ * Record a participant's terminal size (host or guest). The sentinel 0×0 is legal
207
+ * and means "scaled viewer" (see isScaledViewer): it is excluded from the clamp
208
+ * and never parks — a below-floor browser tab reports it instead of its real size.
209
+ */
193
210
  setSize(id, cols, rows) {
194
211
  const p = this.participants.get(id);
195
212
  if (!p) return false;
@@ -198,10 +215,14 @@ export class RoomState {
198
215
  return true;
199
216
  }
200
217
 
201
- /** True if this participant's terminal is below the 80×24 floor (they spectate). */
218
+ /**
219
+ * True if this participant's terminal is below the 80×24 floor (they spectate). A
220
+ * scaled viewer (sentinel 0×0) is never below the floor — it renders zoomed instead.
221
+ */
202
222
  belowFloor(id) {
203
223
  const p = this.participants.get(id);
204
224
  if (!p) return false;
225
+ if (isScaledViewer(p)) return false;
205
226
  return (p.cols != null && p.cols < FLOOR_COLS) || (p.rows != null && p.rows < FLOOR_ROWS);
206
227
  }
207
228
 
@@ -229,6 +250,7 @@ export class RoomState {
229
250
  let cols = Infinity;
230
251
  let rows = Infinity;
231
252
  for (const p of this.participants.values()) {
253
+ if (isScaledViewer(p)) continue; // "I scale, ignore my size" — never constrains the room
232
254
  if (this.belowFloor(p.id)) continue;
233
255
  if (p.cols != null) cols = Math.min(cols, p.cols);
234
256
  if (p.rows != null) rows = Math.min(rows, p.rows);
@@ -0,0 +1,65 @@
1
+ // Pull Claude's latest response text out of its session transcript.
2
+ //
3
+ // Claude Code writes a JSONL transcript (one message object per line) and hands
4
+ // every hook the path to it (`transcript_path`). We never scrape the repainting
5
+ // TUI for the response — we read the transcript at the Stop edge and take the
6
+ // assistant text produced since the last HUMAN prompt. That's the clean turn.
7
+ //
8
+ // The format has drifted across Claude versions, so every accessor is defensive:
9
+ // content may be a plain string or an array of typed blocks; a "user" line may be
10
+ // a real human prompt OR a tool_result being fed back to the model (which must NOT
11
+ // count as a turn boundary). Anything unparseable is skipped.
12
+
13
+ /** The text of an assistant message, or null if `obj` isn't one. */
14
+ function assistantText(obj) {
15
+ if (!obj || obj.type !== 'assistant') return null;
16
+ const content = obj.message?.content ?? obj.content;
17
+ if (typeof content === 'string') return content;
18
+ if (Array.isArray(content)) {
19
+ const text = content
20
+ .filter((b) => b && b.type === 'text' && typeof b.text === 'string')
21
+ .map((b) => b.text)
22
+ .join('');
23
+ return text || null;
24
+ }
25
+ return null;
26
+ }
27
+
28
+ /** True only for a real human prompt (not a tool_result fed back as a user turn). */
29
+ function isHumanPrompt(obj) {
30
+ if (!obj || obj.type !== 'user') return false;
31
+ const content = obj.message?.content ?? obj.content;
32
+ if (typeof content === 'string') return true;
33
+ if (Array.isArray(content)) {
34
+ if (content.some((b) => b && b.type === 'tool_result')) return false;
35
+ return content.some((b) => b && b.type === 'text' && typeof b.text === 'string');
36
+ }
37
+ return false;
38
+ }
39
+
40
+ /**
41
+ * Extract the assistant text produced after the last human prompt in a transcript.
42
+ * @param {string} jsonl the raw JSONL transcript contents (a tail is fine — a
43
+ * truncated leading line is skipped)
44
+ * @returns {string} the response text (may be ''); never throws
45
+ */
46
+ export function extractLatestResponse(jsonl) {
47
+ const objs = [];
48
+ for (const line of String(jsonl ?? '').split('\n')) {
49
+ const t = line.trim();
50
+ if (!t) continue;
51
+ try {
52
+ objs.push(JSON.parse(t));
53
+ } catch {
54
+ /* a partial/broken line (common when reading a tail) — skip it */
55
+ }
56
+ }
57
+ let lastPrompt = -1;
58
+ for (let i = 0; i < objs.length; i++) if (isHumanPrompt(objs[i])) lastPrompt = i;
59
+ const parts = [];
60
+ for (let i = lastPrompt + 1; i < objs.length; i++) {
61
+ const txt = assistantText(objs[i]);
62
+ if (txt) parts.push(txt);
63
+ }
64
+ return parts.join('\n\n').trim();
65
+ }
@@ -0,0 +1,119 @@
1
+ // `collab go|off|status` — the subcommands the wrapped Claude runs INSIDE a session
2
+ // to drive it (spec phase 5a: the plugin's hands). Each reads CLAUDE_SHARE_CTL from
3
+ // the environment, sends one JSON-line request to the control socket, and prints the
4
+ // single reply as human-readable lines Claude can quote straight back to the user.
5
+ //
6
+ // live — room brave-otter
7
+ // invite: https://claudecollab.org/brave-otter
8
+ //
9
+ // No --json flag: the text IS the interface. Not inside a collab session (no
10
+ // CLAUDE_SHARE_CTL) → a friendly stderr hint + exit 2. A relay/handler failure →
11
+ // stderr the error + exit 1. Success → the lines above + exit 0.
12
+
13
+ import net from 'node:net';
14
+
15
+ /** Map `collab go` flags to request overrides the host validates. */
16
+ function parseGoArgs(args) {
17
+ const req = {};
18
+ for (let i = 0; i < args.length; i++) {
19
+ const a = args[i];
20
+ if (a === '--guests') req.guests = args[++i];
21
+ else if (a === '--max-guests') req.max = Number(args[++i]);
22
+ else if (a === '--room-password') req.pass = args[++i];
23
+ }
24
+ return req;
25
+ }
26
+
27
+ /**
28
+ * Send one request line to the control socket and resolve with the parsed reply
29
+ * line. Rejects on connect error, malformed reply, or timeout.
30
+ * @param {string} sockPath the CLAUDE_SHARE_CTL unix socket
31
+ * @param {object} req the request object (must carry v:1)
32
+ * @param {number} ms reply timeout
33
+ */
34
+ export function sendCtl(sockPath, req, ms = 5000) {
35
+ return new Promise((resolve, reject) => {
36
+ const conn = net.connect(sockPath, () => conn.write(JSON.stringify(req) + '\n'));
37
+ let buf = '';
38
+ const timer = setTimeout(() => {
39
+ conn.destroy();
40
+ reject(new Error('control socket timed out'));
41
+ }, ms);
42
+ timer.unref?.();
43
+ conn.on('data', (d) => {
44
+ buf += d.toString('utf8');
45
+ const nl = buf.indexOf('\n');
46
+ if (nl === -1) return;
47
+ clearTimeout(timer);
48
+ conn.end();
49
+ try {
50
+ resolve(JSON.parse(buf.slice(0, nl)));
51
+ } catch (err) {
52
+ reject(err);
53
+ }
54
+ });
55
+ conn.on('error', (err) => {
56
+ clearTimeout(timer);
57
+ reject(err);
58
+ });
59
+ });
60
+ }
61
+
62
+ /** Human-readable rendering of a successful reply, per subcommand. */
63
+ export function formatReply(sub, reply) {
64
+ if (sub === 'off') return 'sharing stopped';
65
+ if (reply.room) {
66
+ const lines = [`live — room ${reply.room}`];
67
+ if (reply.inviteUrl) lines.push(`invite: ${reply.inviteUrl}`);
68
+ return lines.join('\n');
69
+ }
70
+ return 'not live — run `collab go` to share this session';
71
+ }
72
+
73
+ /**
74
+ * The testable core: returns an exit code and writes via injected out/err so tests
75
+ * never call process.exit. `ctlMain` is the thin process wrapper below.
76
+ * @param {object} opts
77
+ * @param {'go'|'off'|'status'} opts.sub
78
+ * @param {string[]} [opts.args]
79
+ * @param {NodeJS.ProcessEnv} [opts.env]
80
+ * @param {(s:string)=>void} opts.out
81
+ * @param {(s:string)=>void} opts.err
82
+ * @returns {Promise<number>} exit code
83
+ */
84
+ export async function runCtl({ sub, args = [], env = process.env, out, err }) {
85
+ const sockPath = env.CLAUDE_SHARE_CTL;
86
+ if (!sockPath) {
87
+ err('not inside a collab session — start one with: collab\n');
88
+ return 2;
89
+ }
90
+ const req = { v: 1, t: sub, ...(sub === 'go' ? parseGoArgs(args) : {}) };
91
+ // `go` may dial the relay, which the host bounds at 15s — wait past that for the
92
+ // authoritative verdict; the instant ops (off/status) keep the short timeout.
93
+ const timeout = sub === 'go' ? 20000 : 5000;
94
+ let reply;
95
+ try {
96
+ reply = await sendCtl(sockPath, req, timeout);
97
+ } catch (e) {
98
+ err(`collab: ${e.message}\n`);
99
+ return 1;
100
+ }
101
+ if (!reply || reply.ok !== true) {
102
+ err(`collab: ${reply?.error || 'request failed'}\n`);
103
+ return 1;
104
+ }
105
+ out(formatReply(sub, reply) + '\n');
106
+ return 0;
107
+ }
108
+
109
+ /** The process entry point the `collab` bin dispatches `go|off|status` to. */
110
+ export async function ctlMain(sub, args) {
111
+ const code = await runCtl({
112
+ sub,
113
+ args,
114
+ env: process.env,
115
+ out: (s) => process.stdout.write(s),
116
+ err: (s) => process.stderr.write(s),
117
+ });
118
+ process.exit(code);
119
+ }
@@ -0,0 +1,97 @@
1
+ // The control socket — how the wrapped Claude drives its OWN session from the
2
+ // inside (spec phase 5a: `collab go|off|status`). A unix socket at CLAUDE_SHARE_CTL,
3
+ // 0600 (same-user only), speaking JSON-lines. Every request carries `v:1`; a wrong
4
+ // or absent version is refused machine-readably so a future protocol bump can't be
5
+ // silently misread. One reply line per request line.
6
+ //
7
+ // The transport is deliberately dumb: it version-gates, dispatches to a handler by
8
+ // request type, and writes the handler's reply. All product logic (go/off/status
9
+ // against the room lifecycle) lives in the handlers the caller passes — this file
10
+ // never touches the relay or the brain.
11
+
12
+ import net from 'node:net';
13
+ import fs from 'node:fs';
14
+ import { Decoder } from '../../shared/protocol.js';
15
+
16
+ /** The control-socket protocol version. Every request must carry `v` equal to this. */
17
+ export const CTL_V = 1;
18
+
19
+ /**
20
+ * Start the control-socket server.
21
+ *
22
+ * @param {object} opts
23
+ * @param {string} opts.path unix socket path (unlinked on close)
24
+ * @param {Record<string, (req:object)=>any>} opts.handlers
25
+ * map of request type → async handler returning the reply object. Unknown
26
+ * types get a generic error; a handler that throws becomes an error reply.
27
+ * @returns {{ close():void }}
28
+ */
29
+ export function startCtl({ path: sockPath, handlers = {} } = {}) {
30
+ // A stale socket file from a crashed prior run would make listen() fail with
31
+ // EADDRINUSE — clear it first (we own the pid-stamped name).
32
+ try {
33
+ fs.unlinkSync(sockPath);
34
+ } catch {
35
+ /* nothing there — fine */
36
+ }
37
+
38
+ const reply = (conn, obj) => {
39
+ try {
40
+ conn.write(JSON.stringify(obj) + '\n');
41
+ } catch {
42
+ /* client already gone */
43
+ }
44
+ };
45
+
46
+ const handleLine = async (conn, msg) => {
47
+ // Version gate first — the cheapest, most important check. A wrong or absent
48
+ // version is refused before any handler runs (a client speaking a different
49
+ // protocol must not have its fields reinterpreted).
50
+ if (!msg || typeof msg !== 'object' || msg.v !== CTL_V) {
51
+ return reply(conn, { ok: false, error: 'version' });
52
+ }
53
+ const fn = handlers[msg.t];
54
+ if (typeof fn !== 'function') return reply(conn, { ok: false, error: `unknown request: ${msg.t}` });
55
+ let res;
56
+ try {
57
+ res = await fn(msg);
58
+ } catch (err) {
59
+ res = { ok: false, error: String(err?.message || err) };
60
+ }
61
+ reply(conn, res ?? { ok: false, error: 'no reply' });
62
+ };
63
+
64
+ const server = net.createServer((conn) => {
65
+ const dec = new Decoder(); // reused: buffers partial lines, drops malformed JSON
66
+ conn.on('data', (chunk) => {
67
+ for (const msg of dec.push(chunk)) handleLine(conn, msg);
68
+ });
69
+ conn.on('error', () => {}); // a client reset must never crash the host
70
+ });
71
+ server.on('error', () => {}); // post-listen socket errors are non-fatal
72
+
73
+ server.listen(sockPath, () => {
74
+ // Same-user only: the socket carries session control, so lock it to 0600 as
75
+ // soon as it exists (listen created the file with the process umask).
76
+ try {
77
+ fs.chmodSync(sockPath, 0o600);
78
+ } catch {
79
+ /* best-effort — a chmod failure must not take the session down */
80
+ }
81
+ });
82
+
83
+ return {
84
+ close() {
85
+ try {
86
+ server.close();
87
+ } catch {
88
+ /* already closing */
89
+ }
90
+ try {
91
+ fs.unlinkSync(sockPath);
92
+ } catch {
93
+ /* already gone */
94
+ }
95
+ },
96
+ };
97
+ }
@@ -0,0 +1,166 @@
1
+ // The first-run screen — shown once, on the first interactive `collab`. It states the
2
+ // core (the /collab plugin), offers the connector picker for link delivery, and
3
+ // carries the relay footer. Copy is Ian-approved (revised 2026-07-15) — do NOT
4
+ // rewrite it. Pure I/O injection: input/output are passed in, so the whole screen is
5
+ // testable by feeding byte sequences (no real TTY).
6
+ //
7
+ // A hand-rolled raw-mode picker (no dependency): ↑/↓ move the cursor, space toggles
8
+ // the highlighted connector, enter starts claude. No alt-screen — it prints in place,
9
+ // redrawing by moving the cursor up and clearing to the end of the screen.
10
+
11
+ /** The connectors offered for link delivery. Slack only for now (Ian, 2026-07-15). */
12
+ export const DEFAULT_CONNECTORS = [
13
+ { key: 'slack', label: 'Slack', hint: 'DM the join link to a teammate', checked: true },
14
+ ];
15
+
16
+ // ── styling (the approved mock's visual layer: orange accent = the band's color,
17
+ // green ✓, dim grays, a highlighted selection row, kbd chips). NO_COLOR disables
18
+ // every code; the words themselves never change — tests match on stripped text.
19
+ const useColor = !process.env.NO_COLOR;
20
+ const sgr = (code, s) => (useColor ? `\x1b[${code}m${s}\x1b[0m` : s);
21
+ const o = (s) => sgr('1;38;5;214', s); // orange, bold — the band accent
22
+ const g = (s) => sgr('1;32', s); // green — the "already done" checkmark
23
+ const d = (s) => sgr('38;5;245', s); // dim gray — secondary copy
24
+ const b = (s) => sgr('1', s); // bold
25
+ const chip = (s) => (useColor ? `\x1b[48;5;236m\x1b[38;5;252m${s}\x1b[0m` : s); // kbd key chip (no padding — stripped text stays byte-equal to the approved copy)
26
+ const selRow = (s) => (useColor ? `\x1b[48;5;236m${s}\x1b[0m` : s); // highlighted picker row
27
+
28
+ // ── the CLAUDE COLLAB wordmark (the skills-installer look: chunky block glyphs with
29
+ // a light→dark gray gradient per row). 5-row bitmap font, only the letters we need.
30
+ // Width: "CLAUDE COLLAB" renders 75 cols incl. indent — under the 80-col floor; a
31
+ // narrower terminal gets the plain one-line title instead of wrapped soup.
32
+ const FONT = {
33
+ A: ['█████', '█ █', '█████', '█ █', '█ █'],
34
+ B: ['████ ', '█ █', '████ ', '█ █', '████ '],
35
+ C: ['█████', '█ ', '█ ', '█ ', '█████'],
36
+ D: ['████ ', '█ █', '█ █', '█ █', '████ '],
37
+ E: ['█████', '█ ', '███ ', '█ ', '█████'],
38
+ L: ['█ ', '█ ', '█ ', '█ ', '█████'],
39
+ O: ['█████', '█ █', '█ █', '█ █', '█████'],
40
+ U: ['█ █', '█ █', '█ █', '█ █', '█████'],
41
+ ' ': [' ', ' ', ' ', ' ', ' '],
42
+ };
43
+ const GRADIENT = ['223', '216', '215', '214', '208']; // light → deep orange (the band accent family)
44
+ function wordmark(text) {
45
+ const rows = [];
46
+ for (let r = 0; r < 5; r++) {
47
+ const line = [...text].map((ch) => FONT[ch]?.[r] ?? ' ').join(' ');
48
+ rows.push(useColor ? `\x1b[38;5;${GRADIENT[r]}m${line}\x1b[0m` : line);
49
+ }
50
+ return rows;
51
+ }
52
+
53
+ /**
54
+ * Render the first-run screen and drive the picker.
55
+ * @param {object} opts
56
+ * @param {NodeJS.ReadableStream} [opts.input] raw key source (default process.stdin)
57
+ * @param {{write:(s:string)=>void}} [opts.output] screen sink (default process.stdout)
58
+ * @param {Array} [opts.connectors] connector rows (default DEFAULT_CONNECTORS)
59
+ * @returns {Promise<{connectors:string[]}>} the checked connector keys, in order
60
+ */
61
+ export function runFirstRun({ input = process.stdin, output = process.stdout, connectors = DEFAULT_CONNECTORS } = {}) {
62
+ const items = connectors.map((c) => ({ ...c }));
63
+ let cursor = 0;
64
+ let lastLines = 0;
65
+ let pending = ''; // a partial escape sequence straddling two chunks
66
+
67
+ const frame = () => {
68
+ const rows = items.map((it, i) => {
69
+ const sel = i === cursor;
70
+ const arrow = sel ? o('▸') : ' ';
71
+ const box = it.checked ? o('[x]') : '[ ]';
72
+ const body = ` ${box} ${b(it.label.padEnd(10))}${d(it.hint)} `;
73
+ return `${arrow}${sel ? selRow(body) : body}`;
74
+ });
75
+ // The wordmark needs ~75 cols; a narrower terminal gets the plain title so the
76
+ // blocks never wrap into soup. Injected test outputs have no .columns → wordmark.
77
+ const wide = output.columns === undefined || output.columns >= 76;
78
+ const mark = wordmark('CLAUDE COLLAB');
79
+ const title = wide ? mark.map((l) => ` ${l}`) : [` ${o('✦ claudecollab')}`];
80
+ // The rule spans exactly the wordmark's width (strip the color codes to measure).
81
+ const ruleWidth = wide ? mark[0].replace(/\x1b\[[0-9;]*m/g, '').length : 58;
82
+ return [
83
+ '',
84
+ ...title,
85
+ ` ${sgr('38;5;208', '─'.repeat(ruleWidth))}`,
86
+ '',
87
+ ` ${g('✓')} /collab will be added to Claude Code`,
88
+ ` ${b('claude')} ${d('← start like you always do')}`,
89
+ ` ${b('/collab')} ${d('← run /collab to turn it multiplayer!')}`,
90
+ '',
91
+ ` ${d("Select Claude's connectors:")}`,
92
+ '',
93
+ ...rows,
94
+ '',
95
+ ` ${d('want your own server? `collab relay` (guide in the README).')} ${o('♥')}`,
96
+ '',
97
+ ` ${chip('↑↓')} ${d('move ·')} ${chip('space')} ${d('toggle ·')} ${chip('enter')} ${d('start claude')}`,
98
+ ];
99
+ };
100
+
101
+ const paint = () => {
102
+ const lines = frame();
103
+ let out = '';
104
+ if (lastLines > 0) out += `\x1b[${lastLines}A`; // back up over the previous frame
105
+ out += '\x1b[0J'; // clear from the cursor to the end of the screen
106
+ out += lines.join('\r\n') + '\r\n';
107
+ lastLines = lines.length;
108
+ output.write(out);
109
+ };
110
+
111
+ return new Promise((resolve) => {
112
+ let finished = false;
113
+ const finish = () => {
114
+ if (finished) return;
115
+ finished = true;
116
+ input.removeListener('data', onData);
117
+ try {
118
+ input.setRawMode?.(false);
119
+ } catch {}
120
+ resolve({ connectors: items.filter((it) => it.checked).map((it) => it.key) });
121
+ };
122
+
123
+ const onData = (chunk) => {
124
+ let buf = pending + (typeof chunk === 'string' ? chunk : chunk.toString('utf8'));
125
+ pending = '';
126
+ while (buf.length) {
127
+ if (buf[0] === '\x1b') {
128
+ if (buf.startsWith('\x1b[A') || buf.startsWith('\x1bOA')) {
129
+ cursor = (cursor - 1 + items.length) % items.length;
130
+ buf = buf.slice(3);
131
+ paint();
132
+ continue;
133
+ }
134
+ if (buf.startsWith('\x1b[B') || buf.startsWith('\x1bOB')) {
135
+ cursor = (cursor + 1) % items.length;
136
+ buf = buf.slice(3);
137
+ paint();
138
+ continue;
139
+ }
140
+ if (buf.length < 3) {
141
+ pending = buf; // incomplete escape — wait for the rest of the sequence
142
+ return;
143
+ }
144
+ buf = buf.slice(1); // an escape we don't handle — drop the ESC, keep going
145
+ continue;
146
+ }
147
+ const ch = buf[0];
148
+ buf = buf.slice(1);
149
+ if (ch === ' ') {
150
+ items[cursor].checked = !items[cursor].checked;
151
+ paint();
152
+ } else if (ch === '\r' || ch === '\n' || ch === '\x03') {
153
+ // Enter starts claude; Ctrl-C also proceeds (never leaves the user stuck).
154
+ return finish();
155
+ }
156
+ }
157
+ };
158
+
159
+ try {
160
+ input.setRawMode?.(true);
161
+ } catch {}
162
+ input.resume?.();
163
+ input.on('data', onData);
164
+ paint();
165
+ });
166
+ }