@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.
- package/LICENSE +21 -0
- package/README.md +109 -0
- package/package.json +36 -0
- package/packages/cli/bin/claude-share.js +1319 -0
- package/packages/cli/package.json +15 -0
- package/packages/cli/src/brain/card.js +157 -0
- package/packages/cli/src/brain/commands.js +122 -0
- package/packages/cli/src/brain/drafts.js +557 -0
- package/packages/cli/src/brain/gate.js +134 -0
- package/packages/cli/src/brain/knocks.js +27 -0
- package/packages/cli/src/brain/log.js +123 -0
- package/packages/cli/src/brain/queue.js +81 -0
- package/packages/cli/src/brain/state.js +245 -0
- package/packages/cli/src/hooks.js +243 -0
- package/packages/cli/src/invite.js +43 -0
- package/packages/cli/src/known-relays.js +66 -0
- package/packages/cli/src/pty.js +175 -0
- package/packages/cli/src/relay-client.js +271 -0
- package/packages/cli/src/renderer.js +230 -0
- package/packages/cli/src/screen-snapshot.js +139 -0
- package/packages/cli/src/term-chatter.js +89 -0
- package/packages/relay/auth.js +18 -0
- package/packages/relay/bin/serve.js +73 -0
- package/packages/relay/names.js +27 -0
- package/packages/relay/package.json +13 -0
- package/packages/relay/public/client.js +1553 -0
- package/packages/relay/public/index.html +110 -0
- package/packages/relay/public/style.css +952 -0
- package/packages/relay/rooms.js +149 -0
- package/packages/relay/server.js +652 -0
- package/packages/relay/web.js +334 -0
- package/packages/relay/wordlists.js +38 -0
- package/packages/shared/package.json +8 -0
- 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
|
+
}
|