@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,15 @@
1
+ {
2
+ "name": "@claude-share/cli",
3
+ "version": "0.0.0",
4
+ "license": "MIT",
5
+ "private": true,
6
+ "type": "module",
7
+ "main": "src/renderer.js",
8
+ "bin": {
9
+ "claude-share": "bin/claude-share.js"
10
+ },
11
+ "dependencies": {
12
+ "node-pty": "^1.1.0",
13
+ "ssh2": "^1.17.0"
14
+ }
15
+ }
@@ -0,0 +1,157 @@
1
+ // The join context card — a joiner's private preamble, printed once into their
2
+ // own scrollback before they attach to the live mirror (spec §join context card).
3
+ // It is assembled instantly from structured brain state (participants, mode) and
4
+ // the attributed log (recent prompts, files touched, Claude's state) — no AI, no
5
+ // latency. The wiring sends it with relay.sendTo(id, card) on admit.
6
+ //
7
+ // Layout (spec frame):
8
+ // ┌ session so far ── 42 min ─────────────────────┐
9
+ // │ people ian★ james✎ · you join as siddh✎ │
10
+ // │ mode accept-edits │
11
+ // │ recent prompts │
12
+ // │ ian → "refactor the navbar" (32m) │
13
+ // │ files src/Nav.tsx · src/auth.test.ts · +3 │
14
+ // │ claude ✻ brewing — on ian's last prompt │
15
+ // └───────────────────────────────────────────────┘
16
+ // ── you're live ──
17
+
18
+ import { ROLE_GLYPH, stringWidth, truncateToWidth } from '../renderer.js';
19
+
20
+ // Keep the box comfortably inside an 80-col floor: content ≤ 74 ⇒ box ≤ 78.
21
+ const MAX_INNER = 74;
22
+
23
+ const CLAUDE_DESC = Object.freeze({
24
+ busy: '✻ brewing',
25
+ idle: '● idle',
26
+ ask: '⚠ permission ask pending',
27
+ });
28
+
29
+ // Relative minutes, coarsened to hours past 60m ("32m", "2h").
30
+ function relAgo(ms) {
31
+ const m = Math.round(ms / 60000);
32
+ return m < 60 ? `${m}m` : `${Math.round(m / 60)}h`;
33
+ }
34
+
35
+ const glyph = (role) => ROLE_GLYPH[role] ?? '';
36
+
37
+ // Pad a plain string to a visible width (truncating first if it's too wide).
38
+ function padTo(str, width) {
39
+ const t = truncateToWidth(str, width);
40
+ return t + ' '.repeat(Math.max(0, width - stringWidth(t)));
41
+ }
42
+
43
+ /**
44
+ * Build the join context card as a multi-line string.
45
+ * @param {import('./state.js').RoomState} state
46
+ * @param {import('./log.js').Log} log
47
+ * @param {object} opts
48
+ * @param {string} opts.joinerId the arriving participant's id
49
+ * @param {() => number} [opts.now] clock (defaults to Date.now)
50
+ * @param {'busy'|'idle'|'ask'} [opts.claudeState] Claude's current state
51
+ * @param {number} [opts.maxPrompts=3] recent prompts to show
52
+ * @param {number} [opts.maxFiles=3] files to show before "+N"
53
+ * @returns {string}
54
+ */
55
+ export function build(state, log, {
56
+ joinerId,
57
+ now = () => Date.now(),
58
+ claudeState = 'idle',
59
+ maxPrompts = 3,
60
+ maxFiles = 3,
61
+ } = {}) {
62
+ const ageMin = Math.round((now() - state.startedAt) / 60000);
63
+ const joiner = state.get(joinerId);
64
+ const others = state.list().filter((p) => p.id !== joinerId);
65
+
66
+ const peopleStr = others.map((p) => `${p.name}${glyph(p.role)}`).join(' ');
67
+ const joinStr = joiner ? `you join as ${joiner.name}${glyph(joiner.role)}` : '';
68
+
69
+ const rows = [];
70
+ rows.push(`people ${peopleStr}${joinStr ? ` · ${joinStr}` : ''}`);
71
+ rows.push(`mode ${state.mode}`);
72
+ rows.push('recent prompts');
73
+ const recent = log.recentPrompts(maxPrompts);
74
+ if (recent.length === 0) {
75
+ rows.push(' (none yet)');
76
+ } else {
77
+ for (const p of recent) rows.push(` ${p.author} → "${p.text}" (${relAgo(now() - p.at)})`);
78
+ }
79
+ const files = log.files;
80
+ const shown = files.slice(0, maxFiles);
81
+ const overflow = files.length - shown.length;
82
+ const filesStr = files.length
83
+ ? shown.join(' · ') + (overflow > 0 ? ` · +${overflow}` : '')
84
+ : '(none)';
85
+ rows.push(`files ${filesStr}`);
86
+ const lastAuthor = log.lastPromptAuthor();
87
+ const cdesc = CLAUDE_DESC[claudeState] ?? claudeState;
88
+ rows.push(`claude ${cdesc}${lastAuthor ? ` — on ${lastAuthor}'s last prompt` : ''}`);
89
+
90
+ const title = `session so far ── ${ageMin} min`;
91
+ const innerWidth = Math.min(MAX_INNER, Math.max(stringWidth(title) + 2, ...rows.map(stringWidth)));
92
+
93
+ const titleTrunc = truncateToWidth(title, innerWidth);
94
+ const dashes = Math.max(0, innerWidth - stringWidth(titleTrunc));
95
+
96
+ const out = [];
97
+ out.push(`┌ ${titleTrunc} ${'─'.repeat(dashes)}┐`);
98
+ for (const r of rows) out.push(`│ ${padTo(r, innerWidth)} │`);
99
+ out.push(`└${'─'.repeat(innerWidth + 2)}┘`);
100
+ out.push("── you're live ──");
101
+ return out.join('\n');
102
+ }
103
+
104
+ // Word-wrap plain prose to `width` cells, honoring the text's own newlines and
105
+ // hard-splitting any single token longer than the width so a box can never
106
+ // overflow. Returns at least one (possibly empty) line.
107
+ function wrapPlain(text, width) {
108
+ const out = [];
109
+ const w = Math.max(1, width);
110
+ for (const para of String(text).split('\n')) {
111
+ let line = '';
112
+ for (let word of para.split(/\s+/).filter(Boolean)) {
113
+ // Break a pathologically long token across rows.
114
+ while (stringWidth(word) > w) {
115
+ const head = truncateToWidth(word, w);
116
+ if (line) {
117
+ out.push(line);
118
+ line = '';
119
+ }
120
+ out.push(head);
121
+ word = word.slice(head.length);
122
+ }
123
+ const cand = line ? `${line} ${word}` : word;
124
+ if (stringWidth(cand) > w && line) {
125
+ out.push(line);
126
+ line = word;
127
+ } else {
128
+ line = cand;
129
+ }
130
+ }
131
+ out.push(line);
132
+ }
133
+ return out.length ? out : [''];
134
+ }
135
+
136
+ /**
137
+ * Frame a /recap summary as a titled box for the SHARED screen (spec §join-context-
138
+ * card: "/recap … posts the summary to the shared screen"). Unlike a band toast,
139
+ * this keeps the FULL prose — wrapped to `cols` so everyone, guests included, can
140
+ * read the whole thing. Pure; the wiring broadcasts the result to host + guests.
141
+ *
142
+ * @param {string} by who ran /recap (display name)
143
+ * @param {string} summary the prose returned by `claude -p`
144
+ * @param {{cols?:number}} [opts] shared-view width (defaults to the 80 floor)
145
+ * @returns {string} multi-line block ('\n'-separated; the caller CRLF-izes)
146
+ */
147
+ export function recapCard(by, summary, { cols = 80 } = {}) {
148
+ const width = Math.max(20, Math.min(MAX_INNER, cols - 2));
149
+ const body = wrapPlain(String(summary ?? '').trim() || '(empty recap)', width);
150
+ const title = `recap · by ${by}`;
151
+ const titleTrunc = truncateToWidth(title, width);
152
+ const dashes = Math.max(0, width - stringWidth(titleTrunc));
153
+ const out = [`┌ ${titleTrunc} ${'─'.repeat(dashes)}┐`];
154
+ for (const l of body) out.push(`│ ${padTo(l, width)} │`);
155
+ out.push(`└${'─'.repeat(width + 2)}┘`);
156
+ return out.join('\n');
157
+ }
@@ -0,0 +1,122 @@
1
+ // claude-share commands — the `/…` verbs the host (and, for /recap, prompters)
2
+ // type into a draft and send (spec §host controls, §per-role input table). This
3
+ // module is pure: it PARSES a sent draft into a command descriptor and answers
4
+ // "is this role allowed to run it?". The wiring in bin/claude-share.js performs
5
+ // the side effects (mutating RoomState, telling the relay to drop/ban a guest,
6
+ // spawning `claude -p` for /recap, running the two-step /end confirmation).
7
+ //
8
+ // It deliberately does NOT own Claude's own slash commands (/clear, /model, …)
9
+ // or the `!` bash prefix — those are Claude's, gated by gate.js and forwarded to
10
+ // the PTY. COMMAND_NAMES is the single list gate.classifySend consults to tell a
11
+ // claude-share command apart from a Claude slash command.
12
+
13
+ import { atLeast } from './state.js';
14
+
15
+ /** The claude-share command verbs (without the leading slash). */
16
+ export const COMMAND_NAMES = new Set(['role', 'kick', 'pause', 'resume', 'recap', 'end', 'queue']);
17
+
18
+ /** Roles /role can assign (never host — the host seat is fixed). */
19
+ const ASSIGNABLE_ROLES = new Set(['prompter', 'viewer']);
20
+ const ROLE_USAGE = 'usage: /role @name prompter|viewer';
21
+ const KICK_USAGE = 'usage: /kick @name';
22
+ const QUEUE_USAGE = 'usage: /queue del <n> | /queue edit <n> <text>';
23
+
24
+ // Pull the bare name out of an @mention token (`@siddh` → `siddh`). A leading @
25
+ // is optional so `/kick bob` also works. Returns null for anything unnameable.
26
+ function parseMention(token) {
27
+ if (!token) return null;
28
+ const m = String(token).match(/^@?([\p{L}\p{N}_-]+)$/u);
29
+ return m ? m[1] : null;
30
+ }
31
+
32
+ /**
33
+ * Parse a sent draft into a claude-share command descriptor.
34
+ * @param {string} text the sent draft text
35
+ * @returns {null | {name:string, mention?:string, role?:string, error?:string}}
36
+ * null if it is not one of our commands; otherwise `{name, …args}` or
37
+ * `{name, error}` when the arguments don't parse.
38
+ */
39
+ export function parse(text) {
40
+ const t = String(text).trim();
41
+ if (!t.startsWith('/')) return null;
42
+ const parts = t.slice(1).split(/\s+/).filter(Boolean);
43
+ const name = (parts[0] ?? '').toLowerCase();
44
+ if (!COMMAND_NAMES.has(name)) return null;
45
+
46
+ switch (name) {
47
+ case 'role': {
48
+ const mention = parseMention(parts[1]);
49
+ const role = (parts[2] ?? '').toLowerCase();
50
+ if (!mention || !ASSIGNABLE_ROLES.has(role)) return { name, error: ROLE_USAGE };
51
+ return { name, mention, role };
52
+ }
53
+ case 'kick': {
54
+ const mention = parseMention(parts[1]);
55
+ if (!mention) return { name, error: KICK_USAGE };
56
+ return { name, mention };
57
+ }
58
+ case 'queue': {
59
+ // /queue del <n> → delete item n (author, or the host on any item)
60
+ // /queue edit <n> <text> → rewrite item n (author only). n is 1-based, matching
61
+ // the queue block the renderer numbers.
62
+ const sub = (parts[1] ?? '').toLowerCase();
63
+ const n = Number(parts[2]);
64
+ if (sub === 'del' || sub === 'delete' || sub === 'rm') {
65
+ if (!Number.isInteger(n) || n < 1) return { name, error: QUEUE_USAGE };
66
+ return { name, sub: 'del', index: n };
67
+ }
68
+ if (sub === 'edit') {
69
+ const text = parts.slice(3).join(' ');
70
+ if (!Number.isInteger(n) || n < 1 || !text) return { name, error: QUEUE_USAGE };
71
+ return { name, sub: 'edit', index: n, text };
72
+ }
73
+ return { name, error: QUEUE_USAGE };
74
+ }
75
+ default:
76
+ // pause | resume | recap | end — no arguments
77
+ return { name };
78
+ }
79
+ }
80
+
81
+ /**
82
+ * Is `role` allowed to run command `name`? (spec §per-role input table)
83
+ * • /recap /queue → prompter and up
84
+ * • /role /kick /pause /resume /end → host only
85
+ * /queue defers per-item permission (author vs host) to the Queue methods;
86
+ * this only gates who may attempt a queue edit/delete at all (spec §queue).
87
+ * @param {string} name
88
+ * @param {string} role
89
+ * @returns {boolean}
90
+ */
91
+ export function permitted(name, role) {
92
+ switch (name) {
93
+ case 'recap':
94
+ case 'queue':
95
+ return atLeast(role, 'prompter');
96
+ case 'role':
97
+ case 'kick':
98
+ case 'pause':
99
+ case 'resume':
100
+ case 'end':
101
+ return atLeast(role, 'host');
102
+ default:
103
+ return false;
104
+ }
105
+ }
106
+
107
+ /**
108
+ * Resolve an @mention to a participant id via room state (case-insensitive name
109
+ * match). The @-autocomplete makes typos unlikely, but names are still claimed by
110
+ * guests, so a miss returns null and the wiring reports "no one called <name>".
111
+ * @param {import('./state.js').RoomState} state
112
+ * @param {string} mention bare name (no @)
113
+ * @returns {string|null} participant id
114
+ */
115
+ export function resolveMention(state, mention) {
116
+ if (!mention) return null;
117
+ const want = String(mention).toLowerCase();
118
+ for (const p of state.list()) {
119
+ if (p.name && p.name.toLowerCase() === want) return p.id;
120
+ }
121
+ return null;
122
+ }