@claudecollab/cli 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (34) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +109 -0
  3. package/package.json +36 -0
  4. package/packages/cli/bin/claude-share.js +1319 -0
  5. package/packages/cli/package.json +15 -0
  6. package/packages/cli/src/brain/card.js +157 -0
  7. package/packages/cli/src/brain/commands.js +122 -0
  8. package/packages/cli/src/brain/drafts.js +557 -0
  9. package/packages/cli/src/brain/gate.js +134 -0
  10. package/packages/cli/src/brain/knocks.js +27 -0
  11. package/packages/cli/src/brain/log.js +123 -0
  12. package/packages/cli/src/brain/queue.js +81 -0
  13. package/packages/cli/src/brain/state.js +245 -0
  14. package/packages/cli/src/hooks.js +243 -0
  15. package/packages/cli/src/invite.js +43 -0
  16. package/packages/cli/src/known-relays.js +66 -0
  17. package/packages/cli/src/pty.js +175 -0
  18. package/packages/cli/src/relay-client.js +271 -0
  19. package/packages/cli/src/renderer.js +230 -0
  20. package/packages/cli/src/screen-snapshot.js +139 -0
  21. package/packages/cli/src/term-chatter.js +89 -0
  22. package/packages/relay/auth.js +18 -0
  23. package/packages/relay/bin/serve.js +73 -0
  24. package/packages/relay/names.js +27 -0
  25. package/packages/relay/package.json +13 -0
  26. package/packages/relay/public/client.js +1553 -0
  27. package/packages/relay/public/index.html +110 -0
  28. package/packages/relay/public/style.css +952 -0
  29. package/packages/relay/rooms.js +149 -0
  30. package/packages/relay/server.js +652 -0
  31. package/packages/relay/web.js +334 -0
  32. package/packages/relay/wordlists.js +38 -0
  33. package/packages/shared/package.json +8 -0
  34. package/packages/shared/protocol.js +189 -0
@@ -0,0 +1,89 @@
1
+ // Terminal "chatter" vs human typing.
2
+ //
3
+ // Claude Code queries the terminal (device attributes, version) and enables
4
+ // mouse tracking. The terminal ANSWERS on stdin — DA replies, DCS version
5
+ // strings, SGR mouse reports, focus events. Those bytes are machine chatter
6
+ // addressed to Claude, not prompt text; fed into the composer they render as
7
+ // garbage like "[<35;34;4M" (real dogfood bug, 2026-07-09).
8
+ //
9
+ // partitionInput() splits a chunk into { chatter, human, carry }:
10
+ // chatter — forward to Claude's PTY (host) or drop (guests)
11
+ // human — everything else: printable keys + the editing escapes the
12
+ // composer keymap understands (arrows, home/end, alt-word…)
13
+ // carry — an incomplete sequence at the chunk's tail; prepend to the
14
+ // next chunk before partitioning again.
15
+
16
+ const ESC = '\x1b';
17
+
18
+ // Complete chatter sequences, anchored at an ESC.
19
+ // Input-side replies to queries ALWAYS use openers human keys never use:
20
+ // ESC[? (DEC replies: DA1 `c`, DECRQM `$y`, kitty-keyboard `u`, DSR-DEC `n`),
21
+ // ESC[> (DA2/XTMODKEYS replies), ESC[< (SGR mouse). Kitty-protocol KEY events
22
+ // (ESC[97;5u — no `?`) stay human.
23
+ const CHATTER = [
24
+ /^\x1b\[\?[\d;]*(?:\$[a-zA-Z]|[a-zA-Z])/, // any ESC[?…reply (DECRQM $y caught in dogfood)
25
+ /^\x1b\[>[\d;]*[a-zA-Z]/, // any ESC[>…reply (DA2 etc.)
26
+ /^\x1b\[<[\d;]+[Mm]/, // SGR mouse report
27
+ /^\x1b\[[\d;]*R/, // cursor position report
28
+ /^\x1b\[48;[\d;]*t/, // in-band size report (mode 2048, e.g. Ghostty)
29
+ /^\x1b\[[IO]/, // focus in / out
30
+ /^\x1bP[^\x1b\x07]*(?:\x1b\\|\x07)/, // DCS reply (e.g. XTVERSION: ESC P > | ghostty 1.3.1 ESC \)
31
+ /^\x1b\][^\x1b\x07]*(?:\x1b\\|\x07)/, // OSC reply (color queries etc.)
32
+ ];
33
+
34
+ // Could this tail still become a chatter sequence with more bytes?
35
+ // Conservative prefix test: ESC alone, or ESC + opener + params with no final.
36
+ const MAYBE_PREFIX = /^\x1b(?:$|\[(?:$|[<>?]?[\d;$]*$)|P[^\x1b\x07]*$|\][^\x1b\x07]*$|P[^\x1b\x07]*\x1b$|\][^\x1b\x07]*\x1b$)/;
37
+
38
+ export function partitionInput(s) {
39
+ let chatter = '';
40
+ let human = '';
41
+ let carry = '';
42
+ let i = 0;
43
+ while (i < s.length) {
44
+ const esc = s.indexOf(ESC, i);
45
+ if (esc === -1) {
46
+ human += s.slice(i);
47
+ break;
48
+ }
49
+ human += s.slice(i, esc);
50
+ const rest = s.slice(esc);
51
+ let matched = null;
52
+ for (const re of CHATTER) {
53
+ const m = re.exec(rest);
54
+ if (m) {
55
+ matched = m[0];
56
+ break;
57
+ }
58
+ }
59
+ if (matched) {
60
+ chatter += matched;
61
+ i = esc + matched.length;
62
+ } else if (rest === ESC) {
63
+ // A chunk that is exactly ESC is the Escape KEY (interrupt) — deliver it
64
+ // now. Chatter sequences never arrive as a bare trailing ESC chunk.
65
+ human += ESC;
66
+ break;
67
+ } else if (MAYBE_PREFIX.test(rest)) {
68
+ carry = rest; // incomplete — decide when the rest arrives
69
+ break;
70
+ } else {
71
+ // A real escape sequence, but not chatter (arrow key, alt-word, paste
72
+ // guard…) — the composer keymap owns it. Emit the ESC and move on; the
73
+ // rest of the sequence flows through as ordinary bytes.
74
+ human += ESC;
75
+ i = esc + 1;
76
+ }
77
+ }
78
+ return { chatter, human, carry };
79
+ }
80
+
81
+ /** Stateful wrapper: feeds carry back in across chunks. */
82
+ export function createPartitioner() {
83
+ let carry = '';
84
+ return (chunk) => {
85
+ const out = partitionInput(carry + chunk.toString('binary'));
86
+ carry = out.carry;
87
+ return out;
88
+ };
89
+ }
@@ -0,0 +1,18 @@
1
+ // Shared credential comparison for both relay doors (ssh server.js, web web.js).
2
+
3
+ import { createHash, timingSafeEqual } from 'node:crypto';
4
+
5
+ /**
6
+ * Constant-time string comparison via digests (equal-length inputs for
7
+ * timingSafeEqual, and no length leak from the secret itself). Non-strings
8
+ * never match. Used for the relay ROOM_SECRET and per-room join passwords.
9
+ * @param {unknown} a
10
+ * @param {unknown} b
11
+ * @returns {boolean}
12
+ */
13
+ export function secretsMatch(a, b) {
14
+ if (typeof a !== 'string' || typeof b !== 'string') return false;
15
+ const da = createHash('sha256').update(a).digest();
16
+ const db = createHash('sha256').update(b).digest();
17
+ return timingSafeEqual(da, db);
18
+ }
@@ -0,0 +1,73 @@
1
+ #!/usr/bin/env node
2
+ // Production entry for a DEPLOYED relay (Fly.io, a VPS, …) — env-driven config:
3
+ //
4
+ // WEB_PORT browser door (http + ws) default 8787 (the CLI's default)
5
+ // SSH_PORT ssh door (the host CLI connects) default 2222
6
+ // PUBLIC_URL public https origin for links e.g. https://claude-share.fly.dev
7
+ // HOST_KEY ssh host key, PEM contents set as a secret; see below
8
+ // HOST_NAME name shown in the ssh banner default "claude-share"
9
+ // ROOM_SECRET room-creation credential when set, only CLIs that send it
10
+ // (--secret / CLAUDE_SHARE_SECRET)
11
+ // can create rooms; unset = open
12
+ //
13
+ // HOST_KEY should be a PERSISTENT ed25519 private key (e.g. `fly secrets set
14
+ // HOST_KEY="$(node packages/relay/bin/serve.js --make-key)"`). Without one we
15
+ // generate a fresh key per boot — everything works, but ssh guests see a
16
+ // "host identification changed" warning after every restart/redeploy.
17
+
18
+ import process from 'node:process';
19
+ import { createHash } from 'node:crypto';
20
+ import ssh2 from 'ssh2';
21
+ import { startRelay } from '../server.js';
22
+
23
+ const { utils } = ssh2;
24
+
25
+ // `--make-key`: print a fresh ed25519 private key and exit (for the secret).
26
+ if (process.argv.includes('--make-key')) {
27
+ process.stdout.write(utils.generateKeyPairSync('ed25519').private + '\n');
28
+ process.exit(0);
29
+ }
30
+
31
+ const webPort = Number(process.env.WEB_PORT || 8787);
32
+ const sshPort = Number(process.env.SSH_PORT || 2222);
33
+ const publicUrl = process.env.PUBLIC_URL || undefined;
34
+ const hostName = process.env.HOST_NAME || 'claudecollab';
35
+
36
+ let hostKey = process.env.HOST_KEY;
37
+ if (!hostKey) {
38
+ console.warn('serve: no HOST_KEY set — generating an EPHEMERAL key (ssh guests will see');
39
+ console.warn('serve: identity warnings after a restart). Set one: serve.js --make-key');
40
+ hostKey = utils.generateKeyPairSync('ed25519').private;
41
+ }
42
+
43
+ const roomSecret = process.env.ROOM_SECRET || undefined;
44
+ if (!roomSecret) {
45
+ console.warn('serve: no ROOM_SECRET set — ANYONE who finds this relay can create rooms.');
46
+ console.warn('serve: set one (e.g. `fly secrets set ROOM_SECRET=…`) before sharing the address.');
47
+ }
48
+
49
+ const relay = await startRelay({
50
+ host: '0.0.0.0', // containers route external traffic to us; bind everything
51
+ port: sshPort,
52
+ webPort,
53
+ publicUrl,
54
+ hostKey,
55
+ hostName,
56
+ maxRooms: Number(process.env.MAX_ROOMS || 50),
57
+ roomSecret,
58
+ });
59
+
60
+ // The public-half fingerprint of our ssh identity. Safe to publish — hosts pin
61
+ // this (--fingerprint / TOFU) to refuse relay impersonators.
62
+ const pubBlob = utils.parseKey(hostKey).getPublicSSH();
63
+ const fp = 'SHA256:' + createHash('sha256').update(pubBlob).digest('base64').replace(/=+$/, '');
64
+
65
+ console.log(`relay up: ssh :${relay.port} · web :${relay.webPort}${publicUrl ? ` · public ${publicUrl}` : ''}`);
66
+ console.log(`relay ssh fingerprint: ${fp}`);
67
+
68
+ for (const sig of ['SIGINT', 'SIGTERM']) {
69
+ process.on(sig, () => {
70
+ relay.close();
71
+ process.exit(0);
72
+ });
73
+ }
@@ -0,0 +1,27 @@
1
+ // Shared display-name sanitizer for BOTH relay doors (ssh + web). A claimed name is
2
+ // untrusted input that the host CLI later writes verbatim to its terminal (join/leave
3
+ // toasts, the attributed log, session.md, /recap) and mirrors to every ssh guest — so
4
+ // a name carrying raw ESC/OSC/BEL bytes is a terminal-escape injection from a URL
5
+ // parameter or a pasted ssh name. The ssh door already filters name bytes to printable
6
+ // ASCII (0x20–0x7e) as it echoes them; the web door read `?name=` straight off the
7
+ // query string and skipped that filter. Both now route through this one function.
8
+
9
+ const MAX_NAME = 24; // spec: names cap at 24 cells
10
+
11
+ /**
12
+ * Reduce a claimed name to printable ASCII, trim, cap at 24, and fall back when empty.
13
+ * Mirrors the ssh door's per-byte filter (0x20 ≤ c ≤ 0x7e): control bytes (ESC 0x1b,
14
+ * BEL 0x07, CR/LF, …) and every non-ASCII byte are dropped, so the survivor can only
15
+ * ever be a plain one-line label — never an escape sequence.
16
+ * @param {unknown} raw the claimed name (a query param or typed bytes)
17
+ * @param {string} [fallback='guest'] used when nothing printable remains
18
+ * @returns {string}
19
+ */
20
+ export function sanitizeName(raw, fallback = 'guest') {
21
+ let out = '';
22
+ for (const ch of String(raw ?? '')) {
23
+ const c = ch.codePointAt(0);
24
+ if (c >= 0x20 && c <= 0x7e) out += ch;
25
+ }
26
+ return out.trim().slice(0, MAX_NAME) || fallback;
27
+ }
@@ -0,0 +1,13 @@
1
+ {
2
+ "name": "@claude-share/relay",
3
+ "version": "0.0.0",
4
+ "license": "MIT",
5
+ "private": true,
6
+ "type": "module",
7
+ "main": "rooms.js",
8
+ "dependencies": {
9
+ "@xterm/xterm": "^5.5.0",
10
+ "ssh2": "^1.17.0",
11
+ "ws": "^8.18.0"
12
+ }
13
+ }