@claudecollab/cli 0.1.3 → 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,7 +16,7 @@
16
16
 
17
17
  import ssh2 from 'ssh2';
18
18
  import { createHash } from 'node:crypto';
19
- import { TYPES, encode, validate, Decoder } from '../../shared/protocol.js';
19
+ import { TYPES, encode, validate, Decoder, PROTOCOL_V } from '../../shared/protocol.js';
20
20
 
21
21
  const { Client, utils } = ssh2;
22
22
 
@@ -70,6 +70,8 @@ export function openingMove(heldRoom) {
70
70
  * @param {string} [opts.roomPass] optional join password for the room this host
71
71
  * creates — the relay challenges every guest
72
72
  * with it before their knock is delivered
73
+ * @param {number} [opts.cap] requested max guests for this room; rides HELLO
74
+ * as `cap` and the relay clamps it to [1, relayCap]
73
75
  * @param {(fp:string)=>boolean} [opts.verifyHostKey] called with the relay's SHA256:…
74
76
  * key fingerprint during the handshake; return
75
77
  * false to abort (impersonation defense).
@@ -84,7 +86,7 @@ export function openingMove(heldRoom) {
84
86
  * }}
85
87
  */
86
88
  export function connectRelay(opts = {}) {
87
- const { url, keepaliveInterval = 20000, secret, roomPass, verifyHostKey } = opts;
89
+ const { url, keepaliveInterval = 20000, secret, roomPass, cap, verifyHostKey } = opts;
88
90
  const { host, port } = parseRelayUrl(url);
89
91
  // A stable key lets the host reclaim its room after a drop; if the caller has
90
92
  // none we still connect (relay rejects `none` auth) with a throwaway key —
@@ -243,7 +245,15 @@ export function connectRelay(opts = {}) {
243
245
  onClose: on(sets.close), // () the control channel closed
244
246
  onError: on(sets.error), // (err) ssh transport error
245
247
 
246
- hello: () => send({ t: TYPES.HELLO, want: 'room', ...(secret ? { secret } : {}), ...(roomPass ? { pass: roomPass } : {}) }),
248
+ hello: () =>
249
+ send({
250
+ t: TYPES.HELLO,
251
+ want: 'room',
252
+ v: PROTOCOL_V, // announce our wire version; the relay refuses a newer one
253
+ ...(Number.isFinite(cap) ? { cap } : {}), // requested room size (relay clamps)
254
+ ...(secret ? { secret } : {}),
255
+ ...(roomPass ? { pass: roomPass } : {}),
256
+ }),
247
257
  reclaim: (code) => send({ t: TYPES.RECLAIM, code }),
248
258
  admit: (id) => send({ t: TYPES.ADMIT, id }),
249
259
  deny: (id) => send({ t: TYPES.DENY, id }),
@@ -0,0 +1,22 @@
1
+ // The room file — how the Claude INSIDE the session learns its own invite link
2
+ // (env CLAUDE_SHARE_ROOM_FILE names it). Whitelist, not caller discipline: the
3
+ // host URL carries the seat token and must never reach the child-readable file.
4
+ import fs from 'node:fs';
5
+ const FIELDS = ['room', 'inviteUrl', 'webUrl'];
6
+ export function writeRoomFile(file, info = {}) {
7
+ const out = {};
8
+ for (const k of FIELDS) if (typeof info[k] === 'string' && info[k]) out[k] = info[k];
9
+ try {
10
+ fs.writeFileSync(file, JSON.stringify(out) + '\n', { mode: 0o600 });
11
+ return true;
12
+ } catch {
13
+ return false; // a tmpfile problem must never take the session down
14
+ }
15
+ }
16
+ export function clearRoomFile(file) {
17
+ try {
18
+ fs.unlinkSync(file);
19
+ } catch {
20
+ /* already gone / never written */
21
+ }
22
+ }
@@ -0,0 +1,163 @@
1
+ // The first-run side effects, split out from the bin so they're execFile-injectable
2
+ // and unit-testable without a real HOME or a real `claude` on PATH.
3
+ //
4
+ // • installPlugin — `claude plugin marketplace add` + `install` (best-effort)
5
+ // • installShimAction — write the `claude` shim + rc edit (see shim.js)
6
+ // • connectorInstructions — honest per-connector guidance (see the docs note below)
7
+ // • runActions/runSetup — the orchestrated first-run: screen → actions → marker
8
+ // • undoSetup — reverse the shim + best-effort plugin uninstall
9
+ // • shouldRunSetup + marker helpers — the trigger gate
10
+ //
11
+ // CONNECTOR MECHANICS (verified against code.claude.com/docs/en/mcp, 2026-07-14):
12
+ // `claude mcp add --transport http <name> <url>` is the documented non-interactive
13
+ // add, but it needs a KNOWN server URL, and the docs publish NO official server URLs
14
+ // for the user-facing Slack / Gmail / Discord connectors — those are claude.ai
15
+ // connectors, added in the browser at claude.ai/customize/connectors, after which they
16
+ // appear in Claude Code automatically. There is no documented CLI one-liner for them,
17
+ // so we print the exact instruction instead of inventing a server URL (plan's graceful
18
+ // degradation). Even remote servers added via the CLI still need interactive OAuth.
19
+
20
+ import { execFile as nodeExecFile } from 'node:child_process';
21
+ import { promisify } from 'node:util';
22
+ import fs from 'node:fs';
23
+ import path from 'node:path';
24
+ import os from 'node:os';
25
+ import { installShim, removeShim } from './shim.js';
26
+ import { runFirstRun } from './first-run.js';
27
+
28
+ const pexec = promisify(nodeExecFile);
29
+ const CONNECTOR_LABELS = { slack: 'Slack', gmail: 'Gmail', discord: 'Discord' };
30
+
31
+ /** The shown-once marker: `<home>/.claude-share/setup-done`. Brand stays out of paths. */
32
+ export function markerPath(home = os.homedir()) {
33
+ return path.join(home, '.claude-share', 'setup-done');
34
+ }
35
+
36
+ /** True once the first-run screen has completed at least once. */
37
+ export function setupDone(home = os.homedir()) {
38
+ return fs.existsSync(markerPath(home));
39
+ }
40
+
41
+ /** Record that setup ran (best-effort; a write failure just means we may re-show it). */
42
+ export function writeMarker(home = os.homedir()) {
43
+ const p = markerPath(home);
44
+ try {
45
+ fs.mkdirSync(path.dirname(p), { recursive: true });
46
+ fs.writeFileSync(p, new Date().toISOString() + '\n');
47
+ } catch {
48
+ /* home not writable — the screen may show again next run, which is harmless */
49
+ }
50
+ }
51
+
52
+ /**
53
+ * The trigger gate. Show the first-run screen iff we're interactive on both ends, no
54
+ * skip env, no --yes, and the marker isn't set. Pure — so it's unit-tested directly.
55
+ */
56
+ export function shouldRunSetup({ stdinTTY, stdoutTTY, home = os.homedir(), skipEnv, yes } = {}) {
57
+ return !!stdinTTY && !!stdoutTTY && skipEnv !== '1' && !yes && !setupDone(home);
58
+ }
59
+
60
+ /**
61
+ * Install the /collab plugin (the required core). Best-effort — a private repo or an
62
+ * offline machine fails here, which is EXPECTED pre-launch, so the failure copy says so.
63
+ * @returns {Promise<string>} one honest report line
64
+ */
65
+ export async function installPlugin({ execFile = pexec } = {}) {
66
+ try {
67
+ await execFile('claude', ['plugin', 'marketplace', 'add', 'ir272/claudecollab'], { timeout: 30000 });
68
+ await execFile('claude', ['plugin', 'install', 'collab@claudecollab'], { timeout: 30000 });
69
+ return '✓ /collab is installed — type /collab in Claude Code when you want company';
70
+ } catch {
71
+ return "couldn't install the /collab plugin (offline or repo not public yet) — rerun anytime: collab setup";
72
+ }
73
+ }
74
+
75
+ /**
76
+ * Write the `claude` shim + rc edit. Best-effort — a read-only home fails here.
77
+ * @returns {string} one honest report line
78
+ */
79
+ export function installShimAction({ home = os.homedir(), rcFiles, installShimFn = installShim } = {}) {
80
+ try {
81
+ installShimFn({ home, rcFiles });
82
+ return '✓ `claude` is now shareable (new terminals; or run: rehash)';
83
+ } catch {
84
+ return "couldn't shim the `claude` command — undo any partial changes with: collab setup --undo";
85
+ }
86
+ }
87
+
88
+ /**
89
+ * One honest instruction line per checked connector. No CLI add exists for the hosted
90
+ * connectors (see the docs note at the top), so we point the user at claude.ai — never
91
+ * a fabricated server URL.
92
+ * @param {string[]} connectors checked connector keys
93
+ * @returns {string[]}
94
+ */
95
+ export function connectorInstructions(connectors = []) {
96
+ return connectors.map((key) => {
97
+ const label = CONNECTOR_LABELS[key] ?? key;
98
+ return `• ${label}: add it at claude.ai/customize/connectors — it then shows up in Claude Code automatically (check /mcp).`;
99
+ });
100
+ }
101
+
102
+ /**
103
+ * Run all first-run side effects and report each on its own line via `out`. Each is
104
+ * best-effort and reports honestly; this never throws.
105
+ */
106
+ export async function runActions({ connectors = [], home = os.homedir(), rcFiles, execFile, installShimFn, out = () => {} } = {}) {
107
+ out('');
108
+ out(await installPlugin({ execFile }));
109
+ out(installShimAction({ home, rcFiles, installShimFn }));
110
+ const connLines = connectorInstructions(connectors);
111
+ if (connLines.length) {
112
+ out('');
113
+ out('link delivery — turn on the connectors you picked (they live in your claude.ai account):');
114
+ for (const l of connLines) out(l);
115
+ }
116
+ }
117
+
118
+ /**
119
+ * The orchestrated first run: render the screen, run the actions, write the marker.
120
+ * The marker is written AFTER the screen even if an action failed (failures print
121
+ * their own retry line) so the screen never nags on every subsequent run.
122
+ */
123
+ export async function runSetup({ input, output, home = os.homedir(), rcFiles, execFile, installShimFn, connectors } = {}) {
124
+ const picked = await runFirstRun({ input, output, connectors });
125
+ await runActions({
126
+ connectors: picked.connectors,
127
+ home,
128
+ rcFiles,
129
+ execFile,
130
+ installShimFn,
131
+ out: (s) => output.write(s + '\r\n'),
132
+ });
133
+ writeMarker(home);
134
+ return picked;
135
+ }
136
+
137
+ /**
138
+ * Reverse setup: remove the shim + rc edit, best-effort uninstall the plugin. The
139
+ * marker is RETAINED — setup stays "done", so a later plain `collab` won't re-prompt.
140
+ */
141
+ export async function undoSetup({ home = os.homedir(), rcFiles, execFile = pexec, removeShimFn = removeShim, out = () => {} } = {}) {
142
+ removeShimFn({ home, rcFiles });
143
+ out('✓ removed the `claude` shim (restart your terminal, or run: rehash)');
144
+ try {
145
+ await execFile('claude', ['plugin', 'uninstall', 'collab@claudecollab'], { timeout: 30000 });
146
+ out('✓ uninstalled the /collab plugin');
147
+ } catch {
148
+ out("left the /collab plugin in place (couldn't uninstall it — remove it with /plugin inside Claude Code)");
149
+ }
150
+ }
151
+
152
+ /**
153
+ * The `collab setup [--undo]` bin entry point. Re-runs the screen + actions ignoring
154
+ * the marker, or reverses everything with --undo. Owns its own exit.
155
+ */
156
+ export async function setupMain(args = []) {
157
+ if (args.includes('--undo')) {
158
+ await undoSetup({ out: (s) => process.stdout.write(s + '\n') });
159
+ } else {
160
+ await runSetup({ input: process.stdin, output: process.stdout });
161
+ }
162
+ process.exit(0);
163
+ }
@@ -0,0 +1,125 @@
1
+ // The `claude` shim — the mechanism that makes `claude` itself go multiplayer.
2
+ //
3
+ // A tiny `claude` script in <home>/.claude-share/bin execs `collab "$@"`; a
4
+ // marker-delimited line in the user's rc prepends that dir to PATH, so a fresh
5
+ // terminal's `claude` IS collab wrapping the real claude. The recursion guard lives
6
+ // in claude-share.js: when collab spawns its child `claude`, it strips THIS dir from
7
+ // the child's PATH (stripShimDir), so the wrapped command resolves to the real
8
+ // binary, never back to the shim. Spike-proven (2026-07-14): no recursion at depth 3.
9
+ //
10
+ // Everything here is pure/injectable — install/undo take an explicit `home` and
11
+ // `rcFiles`, so tests run entirely under a tmp HOME and never touch the real one.
12
+
13
+ import fs from 'node:fs';
14
+ import path from 'node:path';
15
+ import os from 'node:os';
16
+
17
+ const MARK_START = '# >>> claude-share shim >>>';
18
+ const MARK_END = '# <<< claude-share shim <<<';
19
+ // The one PATH line the block manages. $HOME (not the expanded path) so the rc line
20
+ // stays portable across machines/home dirs.
21
+ const PATH_LINE = 'export PATH="$HOME/.claude-share/bin:$PATH"';
22
+ // Default rc candidates (macOS/Linux stance — no Windows rc handling by design).
23
+ const RC_CANDIDATES = ['.zshrc', '.bashrc'];
24
+
25
+ /** The dir the shim script lives in: `<home>/.claude-share/bin`. */
26
+ export function shimDir(home = os.homedir()) {
27
+ return path.join(home, '.claude-share', 'bin');
28
+ }
29
+
30
+ /** The full managed block, markers + the PATH line, each on its own line. */
31
+ function block() {
32
+ return `${MARK_START}\n${PATH_LINE}\n${MARK_END}\n`;
33
+ }
34
+
35
+ // Which rc files to write to. An explicit list wins (tests, power users). Otherwise
36
+ // every existing default candidate; if NONE exists, create a .zshrc.
37
+ function resolveRcFiles(home, rcFiles) {
38
+ if (Array.isArray(rcFiles)) return rcFiles;
39
+ const existing = RC_CANDIDATES.map((n) => path.join(home, n)).filter((p) => fs.existsSync(p));
40
+ return existing.length ? existing : [path.join(home, '.zshrc')];
41
+ }
42
+
43
+ /**
44
+ * Install the shim: write the exec script (0755) and append the managed PATH block
45
+ * to each rc (idempotent — a present marker skips it). Returns which rc files carry
46
+ * the block.
47
+ * @returns {{ installed: true, rcFiles: string[] }}
48
+ */
49
+ export function installShim({ home = os.homedir(), rcFiles } = {}) {
50
+ const dir = shimDir(home);
51
+ fs.mkdirSync(dir, { recursive: true });
52
+ const script = path.join(dir, 'claude');
53
+ fs.writeFileSync(script, '#!/bin/sh\nexec collab "$@"\n', { mode: 0o755 });
54
+ fs.chmodSync(script, 0o755); // writeFileSync doesn't re-chmod an existing file
55
+
56
+ const targets = resolveRcFiles(home, rcFiles);
57
+ for (const rc of targets) {
58
+ let content = '';
59
+ try {
60
+ content = fs.readFileSync(rc, 'utf8');
61
+ } catch {
62
+ /* rc doesn't exist yet — we create it below */
63
+ }
64
+ if (content.includes(MARK_START)) continue; // idempotent: block already present
65
+ // Keep the block on its own line: separate from prior content that lacks a
66
+ // trailing newline. A normal rc (ends in \n) needs no separator, so removeShim
67
+ // restores it byte-for-byte.
68
+ const sep = content.length && !content.endsWith('\n') ? '\n' : '';
69
+ fs.appendFileSync(rc, sep + block());
70
+ }
71
+ return { installed: true, rcFiles: targets };
72
+ }
73
+
74
+ // Strip the managed block from rc text. Matches the whole block through its trailing
75
+ // newline, so an rc that ended in \n before install is restored byte-identical.
76
+ function stripBlock(content) {
77
+ const re = new RegExp(`${escapeRe(MARK_START)}\\n[\\s\\S]*?${escapeRe(MARK_END)}\\n?`);
78
+ return content.replace(re, '');
79
+ }
80
+
81
+ function escapeRe(s) {
82
+ return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
83
+ }
84
+
85
+ /**
86
+ * Remove the shim: delete the exec script and strip the managed block from each rc.
87
+ * Idempotent — missing script/block is a no-op.
88
+ * @returns {{ removed: true }}
89
+ */
90
+ export function removeShim({ home = os.homedir(), rcFiles } = {}) {
91
+ try {
92
+ fs.unlinkSync(path.join(shimDir(home), 'claude'));
93
+ } catch {
94
+ /* already gone */
95
+ }
96
+ const targets = resolveRcFiles(home, rcFiles);
97
+ for (const rc of targets) {
98
+ let content;
99
+ try {
100
+ content = fs.readFileSync(rc, 'utf8');
101
+ } catch {
102
+ continue; // no such rc — nothing to strip
103
+ }
104
+ const stripped = stripBlock(content);
105
+ if (stripped !== content) fs.writeFileSync(rc, stripped);
106
+ }
107
+ return { removed: true };
108
+ }
109
+
110
+ /**
111
+ * The recursion guard: return `pathStr` with the shim dir entry removed. Matches the
112
+ * expanded dir AND the unexpanded $HOME/~ forms an rc might have introduced. A PATH
113
+ * without the shim dir is returned unchanged.
114
+ * @param {string} pathStr a PATH-style, delimiter-joined string
115
+ * @param {string} home
116
+ */
117
+ export function stripShimDir(pathStr, home = os.homedir()) {
118
+ if (!pathStr) return pathStr;
119
+ const dir = shimDir(home);
120
+ const variants = new Set([dir, '$HOME/.claude-share/bin', '${HOME}/.claude-share/bin', '~/.claude-share/bin']);
121
+ return pathStr
122
+ .split(path.delimiter)
123
+ .filter((entry) => !variants.has(entry))
124
+ .join(path.delimiter);
125
+ }
@@ -55,6 +55,8 @@ const relay = await startRelay({
55
55
  hostName,
56
56
  maxRooms: Number(process.env.MAX_ROOMS || 50),
57
57
  roomSecret,
58
+ // Only enable behind Fly's proxy: `fly secrets set CLAUDE_SHARE_TRUST_PROXY=1`.
59
+ trustProxy: process.env.CLAUDE_SHARE_TRUST_PROXY === '1',
58
60
  });
59
61
 
60
62
  // The public-half fingerprint of our ssh identity. Safe to publish — hosts pin