keystream-cli 0.0.1

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 (4) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +457 -0
  3. package/keystream.js +881 -0
  4. package/package.json +38 -0
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Lihao Wang
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,457 @@
1
+ # Keystream
2
+
3
+ Keystream is a real-time terminal chat app built with Bun. It is designed to do two jobs at once:
4
+
5
+ - let multiple users chat live from the CLI
6
+ - turn normal chat usage into Vim-style keyboard practice
7
+
8
+ The app has a standalone Bun WebSocket server, a full-screen terminal client, persistent SQLite history, room-based chat, and a modal UI with `NORMAL`, `INSERT`, `COMMAND`, and `SEARCH` modes.
9
+
10
+ ## Key Features
11
+
12
+ - Real-time multi-user chat over WebSockets
13
+ - Persistent message history stored in SQLite
14
+ - Room-based messaging with live room switching
15
+ - Full-screen terminal UI built for keyboard-first use
16
+ - Vim-style modal interaction
17
+ - Motion counts like `5j`
18
+ - Feed jumps with `gg` and `G`
19
+ - Search mode with `/`
20
+ - Command mode with `:`
21
+ - Strict Vim mode that blocks arrow keys and tracks violations
22
+ - Lightweight training stats for mode time, streaks, and arrow-key usage
23
+ - Auto-reconnect behavior in the CLI client
24
+ - Local multi-client practice launcher with `tmux`
25
+
26
+ ## Current Scope
27
+
28
+ This version is intentionally narrow:
29
+
30
+ - authentication is username-only
31
+ - chat is room-first
32
+ - direct messages are not implemented
33
+ - file upload, reactions, and message editing are not implemented
34
+ - this is best suited for trusted users or local/LAN usage unless you add TLS and stronger access control around it
35
+
36
+ ## Requirements
37
+
38
+ - [Bun](https://bun.sh/) 1.3+
39
+ - macOS, Linux, or another environment where Bun and terminal WebSocket clients work
40
+ - `tmux` only if you want to use `bun run dev:stack`
41
+
42
+ ## Install
43
+
44
+ ```bash
45
+ bun install
46
+ ```
47
+
48
+ ## Quick Start
49
+
50
+ ### Option 1: Run the full local practice stack
51
+
52
+ This is the fastest way to try Keystream as a Vim practice tool.
53
+
54
+ ```bash
55
+ bun run dev:stack
56
+ ```
57
+
58
+ What it does:
59
+
60
+ - starts one server
61
+ - opens three CLI clients in a tiled `tmux` session
62
+ - connects them to the same `general` room
63
+ - enables `--strict-vim` in each client
64
+
65
+ If `tmux` is not installed, use the manual setup below.
66
+
67
+ ### Option 2: Run server and clients manually
68
+
69
+ Start the server:
70
+
71
+ ```bash
72
+ bun --cwd apps/server src/index.ts --host 0.0.0.0 --port 3088 --db ./data/keystream.sqlite
73
+ ```
74
+
75
+ Start a client:
76
+
77
+ ```bash
78
+ bun --cwd apps/cli src/index.ts --url ws://127.0.0.1:3088 --username alice --room general --strict-vim
79
+ ```
80
+
81
+ Start another client in a second terminal:
82
+
83
+ ```bash
84
+ bun --cwd apps/cli src/index.ts --url ws://127.0.0.1:3088 --username bob --room general --strict-vim
85
+ ```
86
+
87
+ You can omit `--strict-vim` if you want arrow keys to translate to motions instead of being counted as violations.
88
+
89
+ ## Public Install And Distribution
90
+
91
+ ### Install the public npm CLI
92
+
93
+ Once published, the CLI can be installed globally with npm:
94
+
95
+ ```bash
96
+ npm install -g keystream-cli
97
+ keystream --help
98
+ ```
99
+
100
+ The installed command is `keystream`, while the package name is `keystream-cli`.
101
+
102
+ ### Run the published CLI
103
+
104
+ ```bash
105
+ keystream --url ws://127.0.0.1:3088 --username alice --room general --strict-vim
106
+ ```
107
+
108
+ ### Pull the backend Docker image
109
+
110
+ Once a tagged GitHub release has published the backend image:
111
+
112
+ ```bash
113
+ docker pull ghcr.io/lihaowang/keystream-server:v0.0.1
114
+ ```
115
+
116
+ ### Run the backend Docker image with persistent storage
117
+
118
+ ```bash
119
+ mkdir -p ./keystream-data
120
+
121
+ docker run --rm \
122
+ -p 3088:3088 \
123
+ -e KEYSTREAM_HOST=0.0.0.0 \
124
+ -e KEYSTREAM_PORT=3088 \
125
+ -e KEYSTREAM_DB=/data/keystream.sqlite \
126
+ -v "$(pwd)/keystream-data:/data" \
127
+ ghcr.io/lihaowang/keystream-server:v0.0.1
128
+ ```
129
+
130
+ This mounted `/data` path is the persistent SQLite store. If you do not mount it, history will not survive container replacement.
131
+
132
+ ## CLI Flags And Env Vars
133
+
134
+ ### Server
135
+
136
+ Command:
137
+
138
+ ```bash
139
+ bun --cwd apps/server src/index.ts --host 0.0.0.0 --port 3088 --db ./data/keystream.sqlite
140
+ ```
141
+
142
+ Flags:
143
+
144
+ - `--host` bind address, default `0.0.0.0`
145
+ - `--port` listening port, default `3088`
146
+ - `--db` SQLite database path, default `./data/keystream.sqlite`
147
+
148
+ Environment variables:
149
+
150
+ - `KEYSTREAM_HOST`
151
+ - `KEYSTREAM_PORT`
152
+ - `KEYSTREAM_DB`
153
+
154
+ ### Client
155
+
156
+ Command:
157
+
158
+ ```bash
159
+ bun --cwd apps/cli src/index.ts --url ws://127.0.0.1:3088 --username alice --room general --strict-vim
160
+ ```
161
+
162
+ Flags:
163
+
164
+ - `--url` WebSocket server URL, default `ws://127.0.0.1:3088`
165
+ - `--username` username to claim on connect
166
+ - `--room` initial room name, default `general`
167
+ - `--strict-vim` block arrow keys and record violations
168
+
169
+ Environment variables:
170
+
171
+ - `KEYSTREAM_URL`
172
+ - `KEYSTREAM_USERNAME`
173
+ - `KEYSTREAM_ROOM`
174
+ - `KEYSTREAM_STRICT_VIM=1`
175
+
176
+ Flag precedence is higher than environment variables.
177
+
178
+ Published npm usage:
179
+
180
+ ```bash
181
+ keystream --url ws://127.0.0.1:3088 --username alice --room general --strict-vim
182
+ ```
183
+
184
+ ## How To Use Keystream
185
+
186
+ ### Screen layout
187
+
188
+ The client screen has four regions:
189
+
190
+ - left pane: room list
191
+ - main pane: message feed for the current room
192
+ - bottom input pane: mode-specific input area
193
+ - status line: mode, username, room, training stats, and status messages
194
+
195
+ ### Modes
196
+
197
+ Keystream is modal. Most interaction starts in `NORMAL` mode.
198
+
199
+ #### `NORMAL`
200
+
201
+ Use this mode to navigate rooms and messages.
202
+
203
+ - `i`, `a`, `o`: enter `INSERT` mode
204
+ - `:`: enter `COMMAND` mode
205
+ - `/`: enter `SEARCH` mode
206
+ - `h` and `l`: move between rooms
207
+ - `j` and `k`: move through messages in the current feed
208
+ - `5j`, `10k`, etc.: counted motions
209
+ - `gg`: jump to the top of the current feed
210
+ - `G`: jump to the bottom of the current feed
211
+ - `Ctrl-u`: move up 10 messages
212
+ - `Ctrl-d`: move down 10 messages
213
+ - `Ctrl-c`: quit immediately
214
+
215
+ #### `INSERT`
216
+
217
+ Use this mode to compose and send a message.
218
+
219
+ - type to edit the current message buffer
220
+ - `Backspace`: delete one character
221
+ - `Enter`: send the message to the active room
222
+ - `Esc`: return to `NORMAL`
223
+
224
+ #### `COMMAND`
225
+
226
+ Use this mode for room and app commands.
227
+
228
+ - starts with `:`
229
+ - type a command, then press `Enter`
230
+ - `Esc` cancels command mode
231
+
232
+ #### `SEARCH`
233
+
234
+ Use this mode to filter the current room feed.
235
+
236
+ - starts with `/`
237
+ - type a search term, then press `Enter`
238
+ - search is case-insensitive
239
+ - only the current room feed is filtered
240
+ - empty search clears the filter
241
+ - `Esc` cancels search mode
242
+
243
+ ### Built-in commands
244
+
245
+ - `:join <room>`: join or create a room and switch to it
246
+ - `:leave`: leave the current room on the server
247
+ - `:rooms`: show known rooms in the status line
248
+ - `:users`: show active occupants in the current room
249
+ - `:history [limit]`: load older messages before the earliest loaded message
250
+ - `:stats`: show mode timing, streak, and arrow-key stats
251
+ - `:help`: print the command list
252
+ - `:commands`: alias for `:help`
253
+ - `:quit`: exit the client
254
+ - `:q`: alias for `:quit`
255
+
256
+ ### Vim training behavior
257
+
258
+ Keystream is not just a terminal chat client with some Vim keys added. The app is intentionally structured to train habits through repetition.
259
+
260
+ - Navigation is concentrated in `NORMAL` mode.
261
+ - Writing happens in `INSERT` mode.
262
+ - Commands happen in `COMMAND` mode.
263
+ - Search happens in `SEARCH` mode.
264
+ - The status line tracks clean-motion streaks and arrow-key violations.
265
+ - In strict mode, arrow keys are blocked and replaced with a hint to use `h`, `j`, `k`, `l`.
266
+
267
+ ### Typical workflow
268
+
269
+ 1. Start the server.
270
+ 2. Open one or more clients.
271
+ 3. Land in `NORMAL` mode.
272
+ 4. Press `i` to enter `INSERT` mode and type a message.
273
+ 5. Press `Enter` to send it.
274
+ 6. Press `Esc` to go back to `NORMAL`.
275
+ 7. Use `j`, `k`, `gg`, `G`, and search to move through the feed.
276
+ 8. Use `:join dev` to switch into another room.
277
+ 9. Use `:stats` to check your mode time and arrow-key violations.
278
+
279
+ ## Development Guide
280
+
281
+ ### Repository layout
282
+
283
+ ```text
284
+ apps/
285
+ cli/ Terminal client
286
+ server/ Bun WebSocket server + SQLite persistence
287
+ packages/
288
+ shared/ Shared protocol, types, and validation
289
+ scripts/
290
+ dev-stack.ts Local tmux-based practice launcher
291
+ test/
292
+ integration/ End-to-end server integration tests
293
+ ```
294
+
295
+ ### Main modules
296
+
297
+ - `packages/shared/src/protocol.ts`: typed wire protocol and runtime validation
298
+ - `apps/server/src/chat-server.ts`: WebSocket lifecycle and room/message coordination
299
+ - `apps/server/src/store.ts`: SQLite schema and persistence logic
300
+ - `apps/cli/src/app.ts`: modal terminal UI and client-side state machine
301
+ - `apps/cli/src/client.ts`: reconnecting WebSocket transport
302
+ - `apps/cli/src/modal-state.ts`: Vim training state and motion logic
303
+ - `apps/cli/src/commands.ts`: command parsing
304
+
305
+ ### Install dependencies
306
+
307
+ ```bash
308
+ bun install
309
+ ```
310
+
311
+ ### Run tests
312
+
313
+ Run all tests:
314
+
315
+ ```bash
316
+ bun test
317
+ ```
318
+
319
+ Run the TypeScript checks:
320
+
321
+ ```bash
322
+ bunx tsc -p packages/shared/tsconfig.json --noEmit
323
+ bunx tsc -p apps/server/tsconfig.json --noEmit
324
+ bunx tsc -p apps/cli/tsconfig.json --noEmit
325
+ ```
326
+
327
+ ### Build release artifacts locally
328
+
329
+ Build the Node-compatible npm CLI bundle:
330
+
331
+ ```bash
332
+ bun run build:cli
333
+ ```
334
+
335
+ Dry-run the npm package tarball:
336
+
337
+ ```bash
338
+ bun run pack:cli --dry-run
339
+ ```
340
+
341
+ Build the backend Docker image:
342
+
343
+ ```bash
344
+ bun run docker:build:server
345
+ ```
346
+
347
+ Run the backend Docker smoke test:
348
+
349
+ ```bash
350
+ bun run docker:smoke:server
351
+ ```
352
+
353
+ ### Recommended development loop
354
+
355
+ 1. Start the server in one terminal.
356
+ 2. Start one or more clients in separate terminals.
357
+ 3. Use `bun test` after behavior changes.
358
+ 4. Re-run the TypeScript checks before finishing a change.
359
+ 5. Use `bun run dev:stack` when you want rapid multi-user keyboard practice.
360
+
361
+ ### Manual smoke test
362
+
363
+ Use this after touching the server-client interaction path:
364
+
365
+ 1. Start the server on a local port.
366
+ 2. Open two clients with different usernames.
367
+ 3. Confirm both receive `welcome` and `room_snapshot` behavior.
368
+ 4. Send messages from one client and confirm the other updates live.
369
+ 5. Join a new room with `:join`.
370
+ 6. Use `/` search and `:history`.
371
+ 7. Run `:stats` and confirm Vim training counters update.
372
+
373
+ ### Persistence notes
374
+
375
+ - The server uses SQLite and creates the database directory automatically.
376
+ - The default database path is `./data/keystream.sqlite`.
377
+ - The local dev stack uses `./data/dev-stack.sqlite`.
378
+ - Message history survives server restarts when you reuse the same database path.
379
+
380
+ ### Networking notes
381
+
382
+ - The server speaks WebSocket only.
383
+ - The client reconnects automatically if the connection drops.
384
+ - Duplicate active usernames are rejected.
385
+ - Room message ordering is assigned by the server per room using monotonically increasing sequence numbers.
386
+
387
+ ## Scripts
388
+
389
+ - `bun run build`: workspace build/typecheck script entrypoint
390
+ - `bun run check`: alias for `bun test`
391
+ - `bun run build:cli`: build the public Node-compatible CLI package into `apps/cli/dist`
392
+ - `bun run pack:cli --dry-run`: generate and inspect the npm tarball from `apps/cli/dist`
393
+ - `bun run docker:build:server`: build the local backend container image
394
+ - `bun run docker:smoke:server`: build, run, restart, and verify the Dockerized backend with persisted SQLite data
395
+ - `bun run release:check`: run the release validation path used before tagging
396
+ - `bun run dev:server`: run the server package script
397
+ - `bun run dev:cli`: run the CLI package script
398
+ - `bun run dev:stack`: launch the tmux multi-client practice environment
399
+
400
+ ## Deployment Guide
401
+
402
+ ### Backend container
403
+
404
+ The Docker image targets the backend only. It does not package the CLI.
405
+
406
+ - Registry: `ghcr.io/lihaowang/keystream-server`
407
+ - Default internal port: `3088`
408
+ - Default container DB path: `/data/keystream.sqlite`
409
+ - Supported runtime configuration: `KEYSTREAM_HOST`, `KEYSTREAM_PORT`, `KEYSTREAM_DB`
410
+
411
+ Recommended deployment pattern:
412
+
413
+ 1. Mount a persistent volume to `/data`
414
+ 2. Expose port `3088` or reverse-proxy it
415
+ 3. Front the service with TLS if it is reachable outside a trusted LAN
416
+ 4. Reuse the same mounted database volume across restarts
417
+
418
+ ### GitHub Container Registry release path
419
+
420
+ The backend image is published by GitHub Actions on version tags:
421
+
422
+ - workflow: `.github/workflows/release-server-image.yml`
423
+ - trigger: `v*` tags
424
+ - image tags:
425
+ - `ghcr.io/lihaowang/keystream-server:<tag>`
426
+ - `ghcr.io/lihaowang/keystream-server:latest`
427
+
428
+ ### npm CLI release path
429
+
430
+ The CLI release path is manual for final publish, but validated in CI:
431
+
432
+ 1. Build the CLI package with `bun run build:cli`
433
+ 2. Validate the package with `bun run pack:cli --dry-run`
434
+ 3. Log in to npm with `npm login`
435
+ 4. Publish from `apps/cli/dist` with:
436
+
437
+ ```bash
438
+ npm publish ./apps/cli/dist --access public
439
+ ```
440
+
441
+ See [docs/releasing.md](/Users/leowang1/Documents/keystream/docs/releasing.md) for the maintainer flow.
442
+
443
+ ## Known Limitations
444
+
445
+ - `:leave` acknowledges on the server but the UI does not implement a separate “roomless” screen state.
446
+ - Presence updates are modeled in the protocol but not yet rendered as live status changes in the UI.
447
+ - The README examples use direct Bun entrypoints because they are the most explicit and reliable during development.
448
+ - Public internet deployment should be fronted by TLS and stronger access control if you plan to expose it beyond a trusted environment.
449
+
450
+ ## Future Improvements
451
+
452
+ - Direct messages
453
+ - Better presence rendering
454
+ - Message editing and reactions
455
+ - Room privacy and invitations
456
+ - Richer training metrics and drills
457
+ - PTY-level end-to-end tests for the full terminal UI
package/keystream.js ADDED
@@ -0,0 +1,881 @@
1
+ #!/usr/bin/env node
2
+
3
+ // apps/cli/src/app.ts
4
+ import blessed from "blessed";
5
+
6
+ // apps/cli/src/commands.ts
7
+ function parseCommand(input) {
8
+ const trimmed = input.trim();
9
+ if (!trimmed.startsWith(":")) {
10
+ return null;
11
+ }
12
+ const tokens = trimmed.slice(1).trim().split(/\s+/).filter(Boolean);
13
+ if (tokens.length === 0) {
14
+ return null;
15
+ }
16
+ return {
17
+ name: tokens[0].toLowerCase(),
18
+ args: tokens.slice(1)
19
+ };
20
+ }
21
+
22
+ // apps/cli/src/modal-state.ts
23
+ var arrowKeys = new Set(["up", "down", "left", "right"]);
24
+ function transitionMode(currentMode, key) {
25
+ if (currentMode === "INSERT" && key === "escape") {
26
+ return "NORMAL";
27
+ }
28
+ if (currentMode === "NORMAL") {
29
+ if (key === "i" || key === "a" || key === "o") {
30
+ return "INSERT";
31
+ }
32
+ if (key === ":") {
33
+ return "COMMAND";
34
+ }
35
+ if (key === "/") {
36
+ return "SEARCH";
37
+ }
38
+ }
39
+ if ((currentMode === "COMMAND" || currentMode === "SEARCH") && key === "escape") {
40
+ return "NORMAL";
41
+ }
42
+ return currentMode;
43
+ }
44
+ function createTrainingStats(overrides = {}) {
45
+ return {
46
+ strictVim: false,
47
+ pendingCount: "",
48
+ pendingOperator: null,
49
+ arrowViolations: 0,
50
+ cleanMotionStreak: 0,
51
+ lastMotion: null,
52
+ lastHint: null,
53
+ ...overrides
54
+ };
55
+ }
56
+ function handleNormalModeKey(state, key) {
57
+ if (arrowKeys.has(key)) {
58
+ return {
59
+ ...state,
60
+ arrowViolations: state.strictVim ? state.arrowViolations + 1 : state.arrowViolations,
61
+ lastHint: state.strictVim ? "Use h j k l instead of arrow keys." : null
62
+ };
63
+ }
64
+ if (/^[1-9]$/.test(key) || state.pendingCount !== "" && /^[0-9]$/.test(key)) {
65
+ return {
66
+ ...state,
67
+ pendingCount: `${state.pendingCount}${key}`,
68
+ lastHint: null
69
+ };
70
+ }
71
+ if (key === "g") {
72
+ if (state.pendingOperator === "g") {
73
+ return finalizeMotion(state, "gg");
74
+ }
75
+ return {
76
+ ...state,
77
+ pendingOperator: "g",
78
+ lastHint: null
79
+ };
80
+ }
81
+ if (["j", "k", "h", "l", "G"].includes(key)) {
82
+ return finalizeMotion(state, key);
83
+ }
84
+ return {
85
+ ...state,
86
+ pendingCount: "",
87
+ pendingOperator: null,
88
+ lastHint: null
89
+ };
90
+ }
91
+ function finalizeMotion(state, key) {
92
+ const count = state.pendingCount === "" ? 1 : Number(state.pendingCount);
93
+ return {
94
+ ...state,
95
+ pendingCount: "",
96
+ pendingOperator: null,
97
+ cleanMotionStreak: state.cleanMotionStreak + 1,
98
+ lastMotion: {
99
+ key,
100
+ count
101
+ },
102
+ lastHint: null
103
+ };
104
+ }
105
+
106
+ // packages/shared/src/protocol.ts
107
+ import { z } from "zod";
108
+ var isoDateString = z.string().datetime({ offset: true });
109
+ var clientPayloadSchemas = {
110
+ connect: z.object({
111
+ username: z.string().min(1),
112
+ room: z.string().min(1),
113
+ sessionId: z.string().min(1).nullable()
114
+ }),
115
+ join_room: z.object({
116
+ room: z.string().min(1)
117
+ }),
118
+ leave_room: z.object({
119
+ roomId: z.string().min(1)
120
+ }),
121
+ send_message: z.object({
122
+ roomId: z.string().min(1),
123
+ body: z.string().min(1)
124
+ }),
125
+ load_history: z.object({
126
+ roomId: z.string().min(1),
127
+ beforeSeq: z.number().int().positive().nullable(),
128
+ limit: z.number().int().positive().max(100).default(50)
129
+ }),
130
+ set_presence: z.object({
131
+ state: z.enum(["active", "idle"])
132
+ }),
133
+ ping: z.object({
134
+ at: isoDateString
135
+ })
136
+ };
137
+ var messageSchema = z.object({
138
+ id: z.string().min(1),
139
+ roomId: z.string().min(1),
140
+ sender: z.string().min(1),
141
+ body: z.string().min(1),
142
+ seq: z.number().int().positive(),
143
+ createdAt: isoDateString
144
+ });
145
+ var roomSchema = z.object({
146
+ id: z.string().min(1),
147
+ name: z.string().min(1),
148
+ topic: z.string().default(""),
149
+ isPrivate: z.boolean().default(false)
150
+ });
151
+ var presenceSchema = z.object({
152
+ username: z.string().min(1),
153
+ state: z.enum(["active", "idle", "offline"]),
154
+ lastSeenAt: isoDateString
155
+ });
156
+ var serverPayloadSchemas = {
157
+ welcome: z.object({
158
+ username: z.string().min(1),
159
+ rooms: z.array(roomSchema),
160
+ joinedRoomId: z.string().min(1),
161
+ sessionId: z.string().min(1)
162
+ }),
163
+ room_snapshot: z.object({
164
+ room: roomSchema,
165
+ occupants: z.array(z.string().min(1)),
166
+ messages: z.array(messageSchema),
167
+ unreadCount: z.number().int().nonnegative()
168
+ }),
169
+ message_created: messageSchema,
170
+ presence_changed: presenceSchema,
171
+ history_page: z.object({
172
+ roomId: z.string().min(1),
173
+ messages: z.array(messageSchema),
174
+ hasMore: z.boolean(),
175
+ nextBeforeSeq: z.number().int().positive().nullable()
176
+ }),
177
+ ack: z.object({
178
+ action: z.string().min(1),
179
+ ok: z.literal(true)
180
+ }),
181
+ error: z.object({
182
+ action: z.string().min(1),
183
+ message: z.string().min(1)
184
+ })
185
+ };
186
+ var clientEventTypes = Object.keys(clientPayloadSchemas);
187
+ var serverEventTypes = Object.keys(serverPayloadSchemas);
188
+ function createProtocolEnvelope(direction, type, payload) {
189
+ return {
190
+ direction,
191
+ type,
192
+ payload
193
+ };
194
+ }
195
+ function makeClientEvent(type, payload) {
196
+ return createProtocolEnvelope("client", type, payload);
197
+ }
198
+ function makeServerEvent(type, payload) {
199
+ return createProtocolEnvelope("server", type, payload);
200
+ }
201
+ function parseProtocolEnvelope(input) {
202
+ const baseEnvelope = z.object({
203
+ direction: z.enum(["client", "server"]),
204
+ type: z.string().min(1),
205
+ payload: z.unknown()
206
+ }).parse(input);
207
+ if (baseEnvelope.direction === "client") {
208
+ const schema2 = clientPayloadSchemas[baseEnvelope.type];
209
+ if (!schema2) {
210
+ throw new Error(`Unsupported client event type: ${baseEnvelope.type}`);
211
+ }
212
+ return makeClientEvent(baseEnvelope.type, schema2.parse(baseEnvelope.payload));
213
+ }
214
+ const schema = serverPayloadSchemas[baseEnvelope.type];
215
+ if (!schema) {
216
+ throw new Error(`Unsupported server event type: ${baseEnvelope.type}`);
217
+ }
218
+ return makeServerEvent(baseEnvelope.type, schema.parse(baseEnvelope.payload));
219
+ }
220
+ // apps/cli/src/client.ts
221
+ class ChatTransport {
222
+ options;
223
+ ws = null;
224
+ closedByClient = false;
225
+ reconnectTimer = null;
226
+ sessionId;
227
+ constructor(options) {
228
+ this.options = options;
229
+ this.sessionId = options.sessionId;
230
+ }
231
+ connect() {
232
+ this.closedByClient = false;
233
+ this.openSocket();
234
+ }
235
+ updateRoom(room) {
236
+ this.options.room = room;
237
+ }
238
+ updateSessionId(sessionId) {
239
+ this.sessionId = sessionId;
240
+ }
241
+ send(type, payload) {
242
+ if (!this.ws || this.ws.readyState !== WebSocket.OPEN) {
243
+ this.options.onStatus("Socket is not connected yet.");
244
+ return;
245
+ }
246
+ this.ws.send(JSON.stringify(makeClientEvent(type, payload)));
247
+ }
248
+ close() {
249
+ this.closedByClient = true;
250
+ if (this.reconnectTimer) {
251
+ clearTimeout(this.reconnectTimer);
252
+ this.reconnectTimer = null;
253
+ }
254
+ this.ws?.close();
255
+ }
256
+ openSocket() {
257
+ this.options.onStatus(`Connecting to ${this.options.url}...`);
258
+ const ws = new WebSocket(this.options.url);
259
+ this.ws = ws;
260
+ ws.addEventListener("open", () => {
261
+ this.options.onStatus(`Connected as ${this.options.username}`);
262
+ this.send("connect", {
263
+ username: this.options.username,
264
+ room: this.options.room,
265
+ sessionId: this.sessionId
266
+ });
267
+ });
268
+ ws.addEventListener("message", (event) => {
269
+ const parsed = parseProtocolEnvelope(JSON.parse(String(event.data)));
270
+ this.options.onEvent(parsed);
271
+ });
272
+ ws.addEventListener("close", () => {
273
+ if (this.closedByClient) {
274
+ return;
275
+ }
276
+ this.options.onStatus("Disconnected. Reconnecting in 1.5s...");
277
+ this.reconnectTimer = setTimeout(() => this.openSocket(), 1500);
278
+ });
279
+ ws.addEventListener("error", () => {
280
+ this.options.onStatus("Socket error.");
281
+ });
282
+ }
283
+ }
284
+
285
+ // apps/cli/src/app.ts
286
+ function startCliApp(options) {
287
+ const screen = blessed.screen({
288
+ smartCSR: true,
289
+ title: "Keystream",
290
+ fullUnicode: true
291
+ });
292
+ const roomsBox = blessed.box({
293
+ parent: screen,
294
+ top: 0,
295
+ left: 0,
296
+ width: "24%",
297
+ height: "100%-4",
298
+ border: "line",
299
+ label: " Rooms ",
300
+ tags: false
301
+ });
302
+ const messagesBox = blessed.box({
303
+ parent: screen,
304
+ top: 0,
305
+ left: "24%",
306
+ width: "76%",
307
+ height: "100%-4",
308
+ border: "line",
309
+ label: " Feed ",
310
+ tags: false,
311
+ scrollable: true,
312
+ alwaysScroll: true
313
+ });
314
+ const inputBox = blessed.box({
315
+ parent: screen,
316
+ bottom: 1,
317
+ left: 0,
318
+ width: "100%",
319
+ height: 3,
320
+ border: "line",
321
+ label: " Input ",
322
+ tags: false
323
+ });
324
+ const statusBox = blessed.box({
325
+ parent: screen,
326
+ bottom: 0,
327
+ left: 0,
328
+ width: "100%",
329
+ height: 1,
330
+ tags: false
331
+ });
332
+ let state = {
333
+ mode: "NORMAL",
334
+ rooms: [],
335
+ currentRoomId: null,
336
+ messagesByRoom: new Map,
337
+ occupantsByRoom: new Map,
338
+ unreadByRoom: new Map,
339
+ selectedMessageIndex: 0,
340
+ composeBuffer: "",
341
+ commandBuffer: "",
342
+ searchBuffer: "",
343
+ searchTerm: "",
344
+ status: "Starting Keystream...",
345
+ sessionId: null,
346
+ training: createTrainingStats({ strictVim: options.strictVim }),
347
+ modeDurations: {
348
+ NORMAL: 0,
349
+ INSERT: 0,
350
+ COMMAND: 0,
351
+ SEARCH: 0
352
+ },
353
+ modeStartedAt: Date.now()
354
+ };
355
+ const transport = new ChatTransport({
356
+ url: options.url,
357
+ username: options.username,
358
+ room: options.room,
359
+ sessionId: state.sessionId,
360
+ onEvent: (event) => {
361
+ state = reduceEvent(state, event);
362
+ if (event.type === "welcome") {
363
+ transport.updateSessionId(event.payload.sessionId);
364
+ }
365
+ render();
366
+ },
367
+ onStatus: (message) => {
368
+ state = {
369
+ ...state,
370
+ status: message
371
+ };
372
+ render();
373
+ }
374
+ });
375
+ const setMode = (nextMode) => {
376
+ if (nextMode === state.mode) {
377
+ return;
378
+ }
379
+ const now = Date.now();
380
+ const elapsed = now - state.modeStartedAt;
381
+ state = {
382
+ ...state,
383
+ mode: nextMode,
384
+ modeStartedAt: now,
385
+ modeDurations: {
386
+ ...state.modeDurations,
387
+ [state.mode]: state.modeDurations[state.mode] + elapsed
388
+ }
389
+ };
390
+ };
391
+ const render = () => {
392
+ const roomLines = state.rooms.map((room) => {
393
+ const currentMarker = room.id === state.currentRoomId ? ">" : " ";
394
+ const unread = state.unreadByRoom.get(room.id) ?? 0;
395
+ const unreadLabel = unread > 0 ? ` (${unread})` : "";
396
+ return `${currentMarker} ${room.name}${unreadLabel}`;
397
+ });
398
+ const visibleMessages = applySearch(getCurrentMessages(state), state.searchTerm);
399
+ const selected = clamp(state.selectedMessageIndex, 0, Math.max(visibleMessages.length - 1, 0));
400
+ state.selectedMessageIndex = selected;
401
+ const messageLines = visibleMessages.map((message, index) => {
402
+ const marker = index === selected ? ">" : " ";
403
+ return `${marker} [${String(message.seq).padStart(3, " ")}] ${message.sender}: ${message.body}`;
404
+ });
405
+ roomsBox.setContent(roomLines.join(`
406
+ `) || "No rooms");
407
+ messagesBox.setLabel(` Feed ${state.searchTerm ? `(search: ${state.searchTerm})` : ""} `);
408
+ messagesBox.setContent(messageLines.join(`
409
+ `) || "No messages yet");
410
+ messagesBox.setScrollPerc(100);
411
+ inputBox.setLabel(` ${state.mode} `);
412
+ inputBox.setContent(currentInputValue(state));
413
+ statusBox.setContent(buildStatusLine(state, options.username));
414
+ screen.render();
415
+ };
416
+ screen.on("keypress", (ch, key) => {
417
+ const rawKey = normalizeKey(ch, key);
418
+ if (rawKey === "C-c") {
419
+ shutdown();
420
+ return;
421
+ }
422
+ if (state.mode === "NORMAL") {
423
+ if (handleNormalBindings(rawKey)) {
424
+ render();
425
+ }
426
+ return;
427
+ }
428
+ if (state.mode === "INSERT") {
429
+ handleInsertMode(rawKey, ch);
430
+ render();
431
+ return;
432
+ }
433
+ if (state.mode === "COMMAND") {
434
+ handleCommandMode(rawKey, ch);
435
+ render();
436
+ return;
437
+ }
438
+ handleSearchMode(rawKey, ch);
439
+ render();
440
+ });
441
+ transport.connect();
442
+ render();
443
+ function handleNormalBindings(rawKey) {
444
+ const nextMode = transitionMode(state.mode, rawKey);
445
+ if (nextMode !== state.mode) {
446
+ if (nextMode === "COMMAND") {
447
+ state.commandBuffer = ":";
448
+ }
449
+ if (nextMode === "SEARCH") {
450
+ state.searchBuffer = "/";
451
+ }
452
+ setMode(nextMode);
453
+ return true;
454
+ }
455
+ const previousTraining = state.training;
456
+ state = {
457
+ ...state,
458
+ training: handleNormalModeKey(state.training, rawKey)
459
+ };
460
+ if (state.training.lastHint) {
461
+ state.status = state.training.lastHint;
462
+ }
463
+ if (state.training.lastMotion !== previousTraining.lastMotion && state.training.lastMotion) {
464
+ applyMotion(state.training.lastMotion.key, state.training.lastMotion.count);
465
+ return true;
466
+ }
467
+ if (rawKey === "C-u") {
468
+ moveSelection(-10);
469
+ return true;
470
+ }
471
+ if (rawKey === "C-d") {
472
+ moveSelection(10);
473
+ return true;
474
+ }
475
+ if (!state.training.strictVim && arrowToMotion(rawKey)) {
476
+ applyMotion(arrowToMotion(rawKey), 1);
477
+ return true;
478
+ }
479
+ return true;
480
+ }
481
+ function handleInsertMode(rawKey, ch) {
482
+ if (rawKey === "escape") {
483
+ setMode("NORMAL");
484
+ state.status = "Returned to NORMAL mode";
485
+ return;
486
+ }
487
+ if (rawKey === "backspace") {
488
+ state.composeBuffer = state.composeBuffer.slice(0, -1);
489
+ return;
490
+ }
491
+ if (rawKey === "enter") {
492
+ const roomId = state.currentRoomId;
493
+ const message = state.composeBuffer.trim();
494
+ if (!roomId || !message) {
495
+ state.status = "Nothing to send";
496
+ return;
497
+ }
498
+ transport.send("send_message", {
499
+ roomId,
500
+ body: message
501
+ });
502
+ state.composeBuffer = "";
503
+ state.status = "Message sent";
504
+ return;
505
+ }
506
+ if (isPrintable(ch)) {
507
+ state.composeBuffer += ch;
508
+ }
509
+ }
510
+ function handleCommandMode(rawKey, ch) {
511
+ if (rawKey === "escape") {
512
+ state.commandBuffer = "";
513
+ setMode("NORMAL");
514
+ state.status = "Command canceled";
515
+ return;
516
+ }
517
+ if (rawKey === "backspace") {
518
+ state.commandBuffer = state.commandBuffer.slice(0, -1);
519
+ return;
520
+ }
521
+ if (rawKey === "enter") {
522
+ executeCommand(state.commandBuffer);
523
+ state.commandBuffer = "";
524
+ setMode("NORMAL");
525
+ return;
526
+ }
527
+ if (isPrintable(ch)) {
528
+ state.commandBuffer += ch;
529
+ }
530
+ }
531
+ function handleSearchMode(rawKey, ch) {
532
+ if (rawKey === "escape") {
533
+ state.searchBuffer = "";
534
+ setMode("NORMAL");
535
+ state.status = "Search canceled";
536
+ return;
537
+ }
538
+ if (rawKey === "backspace") {
539
+ state.searchBuffer = state.searchBuffer.slice(0, -1);
540
+ return;
541
+ }
542
+ if (rawKey === "enter") {
543
+ state.searchTerm = state.searchBuffer.slice(1).trim().toLowerCase();
544
+ state.searchBuffer = "";
545
+ setMode("NORMAL");
546
+ const firstMatch = applySearch(getCurrentMessages(state), state.searchTerm).findIndex(() => true);
547
+ state.selectedMessageIndex = firstMatch >= 0 ? firstMatch : 0;
548
+ state.status = state.searchTerm ? `Search applied for "${state.searchTerm}"` : "Search cleared";
549
+ return;
550
+ }
551
+ if (isPrintable(ch)) {
552
+ state.searchBuffer += ch;
553
+ }
554
+ }
555
+ function executeCommand(input) {
556
+ const parsed = parseCommand(input);
557
+ if (!parsed) {
558
+ state.status = "Not a command";
559
+ return;
560
+ }
561
+ switch (parsed.name) {
562
+ case "join": {
563
+ const roomName = parsed.args[0];
564
+ if (!roomName) {
565
+ state.status = "Usage: :join <room>";
566
+ return;
567
+ }
568
+ transport.updateRoom(roomName);
569
+ transport.send("join_room", { room: roomName });
570
+ state.status = `Joining ${roomName}...`;
571
+ return;
572
+ }
573
+ case "leave":
574
+ if (!state.currentRoomId) {
575
+ state.status = "No active room";
576
+ return;
577
+ }
578
+ transport.send("leave_room", { roomId: state.currentRoomId });
579
+ state.status = "Left current room";
580
+ return;
581
+ case "rooms":
582
+ state.status = state.rooms.map((room) => room.name).join(", ") || "No rooms";
583
+ return;
584
+ case "users":
585
+ state.status = (state.currentRoomId ? state.occupantsByRoom.get(state.currentRoomId)?.join(", ") : "") || "No active occupants";
586
+ return;
587
+ case "history": {
588
+ if (!state.currentRoomId) {
589
+ state.status = "No active room";
590
+ return;
591
+ }
592
+ const currentMessages = getCurrentMessages(state);
593
+ const earliestSeq = currentMessages[0]?.seq ?? null;
594
+ const limit = Number(parsed.args[0] ?? "20");
595
+ transport.send("load_history", {
596
+ roomId: state.currentRoomId,
597
+ beforeSeq: earliestSeq,
598
+ limit: Number.isFinite(limit) && limit > 0 ? limit : 20
599
+ });
600
+ state.status = "Loading older messages...";
601
+ return;
602
+ }
603
+ case "stats":
604
+ state.status = formatStats(state);
605
+ return;
606
+ case "help":
607
+ case "commands":
608
+ state.status = ":join :leave :rooms :users :history :stats :help :quit";
609
+ return;
610
+ case "quit":
611
+ case "q":
612
+ shutdown();
613
+ return;
614
+ default:
615
+ state.status = `Unknown command: ${parsed.name}`;
616
+ }
617
+ }
618
+ function applyMotion(key, count) {
619
+ switch (key) {
620
+ case "j":
621
+ moveSelection(count);
622
+ return;
623
+ case "k":
624
+ moveSelection(-count);
625
+ return;
626
+ case "h":
627
+ moveRoom(-count);
628
+ return;
629
+ case "l":
630
+ moveRoom(count);
631
+ return;
632
+ case "gg":
633
+ state.selectedMessageIndex = 0;
634
+ state.status = "Top of feed";
635
+ return;
636
+ case "G":
637
+ state.selectedMessageIndex = Math.max(applySearch(getCurrentMessages(state), state.searchTerm).length - 1, 0);
638
+ state.status = "Bottom of feed";
639
+ return;
640
+ default:
641
+ return;
642
+ }
643
+ }
644
+ function moveSelection(delta) {
645
+ const visibleMessages = applySearch(getCurrentMessages(state), state.searchTerm);
646
+ const nextIndex = clamp(state.selectedMessageIndex + delta, 0, Math.max(visibleMessages.length - 1, 0));
647
+ state.selectedMessageIndex = nextIndex;
648
+ state.status = `Selected message ${nextIndex + 1}/${Math.max(visibleMessages.length, 1)}`;
649
+ }
650
+ function moveRoom(delta) {
651
+ if (state.rooms.length === 0) {
652
+ return;
653
+ }
654
+ const currentIndex = state.rooms.findIndex((room) => room.id === state.currentRoomId);
655
+ const safeCurrent = currentIndex >= 0 ? currentIndex : 0;
656
+ const nextIndex = clamp(safeCurrent + delta, 0, state.rooms.length - 1);
657
+ const nextRoom = state.rooms[nextIndex];
658
+ if (!nextRoom) {
659
+ return;
660
+ }
661
+ transport.updateRoom(nextRoom.name);
662
+ transport.send("join_room", { room: nextRoom.name });
663
+ state.status = `Switching to #${nextRoom.name}`;
664
+ }
665
+ function shutdown() {
666
+ transport.close();
667
+ screen.destroy();
668
+ process.exit(0);
669
+ }
670
+ }
671
+ function reduceEvent(state, event) {
672
+ if (event.direction !== "server") {
673
+ return state;
674
+ }
675
+ switch (event.type) {
676
+ case "welcome": {
677
+ const unreadByRoom = new Map(state.unreadByRoom);
678
+ const rooms = upsertRooms(state.rooms, event.payload.rooms);
679
+ event.payload.rooms.forEach((room) => unreadByRoom.set(room.id, unreadByRoom.get(room.id) ?? 0));
680
+ return {
681
+ ...state,
682
+ rooms,
683
+ currentRoomId: event.payload.joinedRoomId,
684
+ sessionId: event.payload.sessionId,
685
+ status: `Connected to ${roomNameForId(rooms, event.payload.joinedRoomId)}`
686
+ };
687
+ }
688
+ case "room_snapshot": {
689
+ const rooms = upsertRooms(state.rooms, [event.payload.room]);
690
+ const messagesByRoom = new Map(state.messagesByRoom);
691
+ const occupantsByRoom = new Map(state.occupantsByRoom);
692
+ const unreadByRoom = new Map(state.unreadByRoom);
693
+ messagesByRoom.set(event.payload.room.id, event.payload.messages);
694
+ occupantsByRoom.set(event.payload.room.id, event.payload.occupants);
695
+ unreadByRoom.set(event.payload.room.id, 0);
696
+ return {
697
+ ...state,
698
+ rooms,
699
+ currentRoomId: event.payload.room.id,
700
+ messagesByRoom,
701
+ occupantsByRoom,
702
+ unreadByRoom,
703
+ selectedMessageIndex: Math.max(event.payload.messages.length - 1, 0),
704
+ status: `Loaded #${event.payload.room.name}`
705
+ };
706
+ }
707
+ case "message_created": {
708
+ const messagesByRoom = new Map(state.messagesByRoom);
709
+ const existing = messagesByRoom.get(event.payload.roomId) ?? [];
710
+ if (!existing.some((message) => message.id === event.payload.id)) {
711
+ messagesByRoom.set(event.payload.roomId, [...existing, event.payload]);
712
+ }
713
+ const unreadByRoom = new Map(state.unreadByRoom);
714
+ if (event.payload.roomId !== state.currentRoomId) {
715
+ unreadByRoom.set(event.payload.roomId, (unreadByRoom.get(event.payload.roomId) ?? 0) + 1);
716
+ }
717
+ return {
718
+ ...state,
719
+ messagesByRoom,
720
+ unreadByRoom,
721
+ selectedMessageIndex: event.payload.roomId === state.currentRoomId ? Math.max((messagesByRoom.get(event.payload.roomId) ?? []).length - 1, 0) : state.selectedMessageIndex,
722
+ status: `New message from ${event.payload.sender}`
723
+ };
724
+ }
725
+ case "history_page": {
726
+ const messagesByRoom = new Map(state.messagesByRoom);
727
+ const current = messagesByRoom.get(event.payload.roomId) ?? [];
728
+ const merged = dedupeMessages([...event.payload.messages, ...current]);
729
+ messagesByRoom.set(event.payload.roomId, merged);
730
+ return {
731
+ ...state,
732
+ messagesByRoom,
733
+ status: event.payload.hasMore ? "Loaded older history" : "Reached start of history"
734
+ };
735
+ }
736
+ case "ack":
737
+ return {
738
+ ...state,
739
+ status: `${event.payload.action} ok`
740
+ };
741
+ case "error":
742
+ return {
743
+ ...state,
744
+ status: `Error: ${event.payload.message}`
745
+ };
746
+ case "presence_changed":
747
+ return state;
748
+ }
749
+ }
750
+ function buildStatusLine(state, username) {
751
+ const currentRoomName = state.currentRoomId ? roomNameForId(state.rooms, state.currentRoomId) : "none";
752
+ return `${state.mode} | ${username} | room:${currentRoomName} | streak:${state.training.cleanMotionStreak} | arrows:${state.training.arrowViolations} | ${state.status}`;
753
+ }
754
+ function currentInputValue(state) {
755
+ switch (state.mode) {
756
+ case "INSERT":
757
+ return state.composeBuffer || "Compose a message...";
758
+ case "COMMAND":
759
+ return state.commandBuffer || ":";
760
+ case "SEARCH":
761
+ return state.searchBuffer || "/";
762
+ default:
763
+ return "NORMAL mode: i insert, : command, / search, h/j/k/l move";
764
+ }
765
+ }
766
+ function normalizeKey(ch, key) {
767
+ if (key.full) {
768
+ return key.full;
769
+ }
770
+ if (key.ctrl && key.name) {
771
+ return `C-${key.name}`;
772
+ }
773
+ return key.name ?? ch;
774
+ }
775
+ function isPrintable(ch) {
776
+ return Boolean(ch && ch >= " " && ch !== "");
777
+ }
778
+ function getCurrentMessages(state) {
779
+ return state.currentRoomId ? state.messagesByRoom.get(state.currentRoomId) ?? [] : [];
780
+ }
781
+ function dedupeMessages(messages) {
782
+ return [...new Map(messages.map((message) => [message.id, message])).values()].sort((left, right) => left.seq - right.seq);
783
+ }
784
+ function applySearch(messages, searchTerm) {
785
+ if (!searchTerm) {
786
+ return messages;
787
+ }
788
+ return messages.filter((message) => `${message.sender} ${message.body}`.toLowerCase().includes(searchTerm));
789
+ }
790
+ function upsertRooms(existing, incoming) {
791
+ const merged = new Map(existing.map((room) => [room.id, room]));
792
+ incoming.forEach((room) => merged.set(room.id, room));
793
+ return [...merged.values()].sort((left, right) => left.name.localeCompare(right.name));
794
+ }
795
+ function roomNameForId(rooms, roomId) {
796
+ return rooms.find((room) => room.id === roomId)?.name ?? roomId;
797
+ }
798
+ function formatStats(state) {
799
+ const now = Date.now();
800
+ const liveDurations = {
801
+ ...state.modeDurations,
802
+ [state.mode]: state.modeDurations[state.mode] + (now - state.modeStartedAt)
803
+ };
804
+ const formatMs = (value) => `${Math.round(value / 1000)}s`;
805
+ return `normal:${formatMs(liveDurations.NORMAL)} insert:${formatMs(liveDurations.INSERT)} command:${formatMs(liveDurations.COMMAND)} search:${formatMs(liveDurations.SEARCH)} streak:${state.training.cleanMotionStreak} arrows:${state.training.arrowViolations}`;
806
+ }
807
+ function arrowToMotion(key) {
808
+ switch (key) {
809
+ case "up":
810
+ return "k";
811
+ case "down":
812
+ return "j";
813
+ case "left":
814
+ return "h";
815
+ case "right":
816
+ return "l";
817
+ default:
818
+ return null;
819
+ }
820
+ }
821
+ function clamp(value, min, max) {
822
+ return Math.min(Math.max(value, min), max);
823
+ }
824
+
825
+ // apps/cli/src/args.ts
826
+ function parseCliArgs(argv, env = process.env, randomSuffix = () => Math.floor(Math.random() * 1000)) {
827
+ if (argv.includes("--help") || argv.includes("-h")) {
828
+ return { command: "help" };
829
+ }
830
+ if (argv.includes("--version")) {
831
+ return { command: "version" };
832
+ }
833
+ const url = readFlag(argv, "--url") ?? env.KEYSTREAM_URL ?? "ws://127.0.0.1:3088";
834
+ const username = readFlag(argv, "--username") ?? env.KEYSTREAM_USERNAME ?? `guest-${randomSuffix()}`;
835
+ const room = readFlag(argv, "--room") ?? env.KEYSTREAM_ROOM ?? "general";
836
+ const strictVim = argv.includes("--strict-vim") || env.KEYSTREAM_STRICT_VIM === "1";
837
+ return {
838
+ command: "run",
839
+ url,
840
+ username,
841
+ room,
842
+ strictVim
843
+ };
844
+ }
845
+ function formatCliHelp() {
846
+ return [
847
+ "keystream - real-time terminal chat with Vim-style navigation",
848
+ "",
849
+ "Usage:",
850
+ " keystream [--url ws://host:port] [--username name] [--room room] [--strict-vim]",
851
+ " keystream --help",
852
+ " keystream --version",
853
+ "",
854
+ "Options:",
855
+ " --url WebSocket server URL",
856
+ " --username Username to claim on connect",
857
+ " --room Initial room name",
858
+ " --strict-vim Block arrow keys and track violations",
859
+ " -h, --help Show this help",
860
+ " --version Show package version"
861
+ ].join(`
862
+ `);
863
+ }
864
+ function readFlag(args, name) {
865
+ const index = args.indexOf(name);
866
+ return index >= 0 ? args[index + 1] : undefined;
867
+ }
868
+
869
+ // apps/cli/src/bin.ts
870
+ var options = parseCliArgs(process.argv.slice(2));
871
+ if (options.command === "help") {
872
+ console.log(formatCliHelp());
873
+ process.exit(0);
874
+ }
875
+ if (options.command === "version") {
876
+ console.log("0.0.1");
877
+ process.exit(0);
878
+ }
879
+ if (options.command === "run") {
880
+ startCliApp(options);
881
+ }
package/package.json ADDED
@@ -0,0 +1,38 @@
1
+ {
2
+ "name": "keystream-cli",
3
+ "version": "0.0.1",
4
+ "description": "Real-time terminal chat with Vim-style navigation training.",
5
+ "type": "module",
6
+ "main": "./keystream.js",
7
+ "bin": {
8
+ "keystream": "./keystream.js"
9
+ },
10
+ "files": [
11
+ "keystream.js",
12
+ "README.md",
13
+ "LICENSE"
14
+ ],
15
+ "keywords": [
16
+ "chat",
17
+ "cli",
18
+ "terminal",
19
+ "vim",
20
+ "websocket"
21
+ ],
22
+ "license": "MIT",
23
+ "repository": {
24
+ "type": "git",
25
+ "url": "git+https://github.com/LihaoWang/keystream.git"
26
+ },
27
+ "engines": {
28
+ "node": ">=20"
29
+ },
30
+ "publishConfig": {
31
+ "access": "public"
32
+ },
33
+ "dependencies": {
34
+ "blessed": "^0.1.81",
35
+ "pino": "^9.9.0",
36
+ "zod": "^4.1.8"
37
+ }
38
+ }