agentlip 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.
@@ -0,0 +1,2255 @@
1
+ #!/usr/bin/env bun
2
+ /**
3
+ * agentlip CLI - stateless read-only queries and hub mutations
4
+ *
5
+ * Commands:
6
+ * - doctor: run diagnostics (DB integrity, schema version, etc.)
7
+ * - channel list: list all channels
8
+ * - topic list: list topics in a channel
9
+ * - msg tail: get latest messages from a topic
10
+ * - msg page: paginate messages with cursor
11
+ * - attachment list: list topic attachments
12
+ * - search: full-text search (if FTS available)
13
+ * - listen: stream events via WebSocket
14
+ */
15
+
16
+ // Runtime guard: require Bun
17
+ if (typeof Bun === "undefined") {
18
+ console.error("Error: agentlip requires Bun runtime (https://bun.sh)");
19
+ process.exit(1);
20
+ }
21
+
22
+ import { openWorkspaceDbReadonly, isQueryOnly, WorkspaceNotFoundError, DatabaseNotFoundError, discoverWorkspaceRoot } from "./index.js";
23
+ import {
24
+ listChannels,
25
+ getChannelByName,
26
+ listTopicsByChannel,
27
+ tailMessages,
28
+ listMessages,
29
+ listTopicAttachments,
30
+ isFtsAvailable,
31
+ type Channel,
32
+ type Topic,
33
+ type Message,
34
+ type TopicAttachment,
35
+ type ListResult,
36
+ } from "@agentlip/kernel";
37
+ import type { Database } from "bun:sqlite";
38
+ import { readFile } from "node:fs/promises";
39
+ import { join } from "node:path";
40
+
41
+ // ─────────────────────────────────────────────────────────────────────────────
42
+ // Types
43
+ // ─────────────────────────────────────────────────────────────────────────────
44
+
45
+ interface GlobalOptions {
46
+ workspace?: string;
47
+ json?: boolean;
48
+ }
49
+
50
+ interface DoctorResult {
51
+ status: "ok" | "error";
52
+ workspace_root?: string;
53
+ db_path?: string;
54
+ db_id?: string;
55
+ schema_version?: number;
56
+ query_only?: boolean;
57
+ error?: string;
58
+ }
59
+
60
+ interface ChannelListResult {
61
+ status: "ok" | "error";
62
+ channels?: Channel[];
63
+ count?: number;
64
+ error?: string;
65
+ }
66
+
67
+ interface TopicListResult {
68
+ status: "ok" | "error";
69
+ topics?: Topic[];
70
+ count?: number;
71
+ hasMore?: boolean;
72
+ error?: string;
73
+ }
74
+
75
+ interface MessageListResult {
76
+ status: "ok" | "error";
77
+ messages?: Message[];
78
+ count?: number;
79
+ hasMore?: boolean;
80
+ error?: string;
81
+ }
82
+
83
+ interface AttachmentListResult {
84
+ status: "ok" | "error";
85
+ attachments?: TopicAttachment[];
86
+ count?: number;
87
+ error?: string;
88
+ }
89
+
90
+ interface SearchResult {
91
+ status: "ok" | "error";
92
+ messages?: Message[];
93
+ count?: number;
94
+ error?: string;
95
+ }
96
+
97
+ // ─────────────────────────────────────────────────────────────────────────────
98
+ // Listen Types (WS streaming)
99
+ // ─────────────────────────────────────────────────────────────────────────────
100
+
101
+ interface ListenOptions extends GlobalOptions {
102
+ since: number;
103
+ channels: string[];
104
+ topicIds: string[];
105
+ }
106
+
107
+ interface ServerJsonData {
108
+ host: string;
109
+ port: number;
110
+ auth_token: string;
111
+ }
112
+
113
+ interface HelloMessage {
114
+ type: "hello";
115
+ after_event_id: number;
116
+ subscriptions?: {
117
+ channels?: string[];
118
+ topics?: string[];
119
+ };
120
+ }
121
+
122
+ interface HelloOkMessage {
123
+ type: "hello_ok";
124
+ replay_until: number;
125
+ instance_id: string;
126
+ }
127
+
128
+ interface EventEnvelope {
129
+ type: "event";
130
+ event_id: number;
131
+ ts: string;
132
+ name: string;
133
+ scope: {
134
+ channel_id?: string | null;
135
+ topic_id?: string | null;
136
+ topic_id2?: string | null;
137
+ };
138
+ data: Record<string, unknown>;
139
+ }
140
+
141
+ // ─────────────────────────────────────────────────────────────────────────────
142
+ // Helper functions
143
+ // ─────────────────────────────────────────────────────────────────────────────
144
+
145
+ /**
146
+ * Parse arguments and extract global options
147
+ */
148
+ function parseGlobalOptions(args: string[]): { globalOpts: GlobalOptions; remainingArgs: string[] } {
149
+ const globalOpts: GlobalOptions = {};
150
+ const remainingArgs: string[] = [];
151
+
152
+ for (let i = 0; i < args.length; i++) {
153
+ const arg = args[i];
154
+ if (arg === "--workspace" || arg === "-w") {
155
+ const value = args[++i];
156
+ if (!value) {
157
+ throw new Error("--workspace requires a value");
158
+ }
159
+ globalOpts.workspace = value;
160
+ } else if (arg === "--json") {
161
+ globalOpts.json = true;
162
+ } else {
163
+ remainingArgs.push(arg);
164
+ }
165
+ }
166
+
167
+ return { globalOpts, remainingArgs };
168
+ }
169
+
170
+ /**
171
+ * Open workspace DB with standardized error handling
172
+ */
173
+ async function withWorkspaceDb<T>(
174
+ workspace: string | undefined,
175
+ fn: (db: Database) => T
176
+ ): Promise<T> {
177
+ const { db } = await openWorkspaceDbReadonly({ workspace });
178
+ try {
179
+ return fn(db);
180
+ } finally {
181
+ db.close();
182
+ }
183
+ }
184
+
185
+ /**
186
+ * Output result as JSON or human-readable format
187
+ */
188
+ function output(result: object, json: boolean, humanFormatter: () => void): void {
189
+ if (json) {
190
+ console.log(JSON.stringify(result, null, 2));
191
+ } else {
192
+ humanFormatter();
193
+ }
194
+ }
195
+
196
+ // ─────────────────────────────────────────────────────────────────────────────
197
+ // doctor command
198
+ // ─────────────────────────────────────────────────────────────────────────────
199
+
200
+ async function runDoctor(options: GlobalOptions): Promise<DoctorResult> {
201
+ try {
202
+ const { db, workspaceRoot, dbPath } = await openWorkspaceDbReadonly({
203
+ workspace: options.workspace,
204
+ });
205
+
206
+ try {
207
+ const queryOnly = isQueryOnly(db);
208
+ let dbId: string | undefined;
209
+ let schemaVersion: number | undefined;
210
+
211
+ try {
212
+ const metaRow = db
213
+ .query<{ key: string; value: string }, []>("SELECT key, value FROM meta WHERE key IN ('db_id', 'schema_version')")
214
+ .all();
215
+
216
+ for (const row of metaRow) {
217
+ if (row.key === "db_id") dbId = row.value;
218
+ if (row.key === "schema_version") schemaVersion = parseInt(row.value, 10);
219
+ }
220
+ } catch {
221
+ // meta table may not exist yet
222
+ }
223
+
224
+ return {
225
+ status: "ok",
226
+ workspace_root: workspaceRoot,
227
+ db_path: dbPath,
228
+ db_id: dbId,
229
+ schema_version: schemaVersion,
230
+ query_only: queryOnly,
231
+ };
232
+ } finally {
233
+ db.close();
234
+ }
235
+ } catch (err) {
236
+ if (err instanceof WorkspaceNotFoundError || err instanceof DatabaseNotFoundError) {
237
+ return { status: "error", error: err.message };
238
+ }
239
+ return { status: "error", error: err instanceof Error ? err.message : String(err) };
240
+ }
241
+ }
242
+
243
+ function printHumanDoctor(result: DoctorResult): void {
244
+ if (result.status === "ok") {
245
+ console.log("✓ Workspace found");
246
+ console.log(` Workspace Root: ${result.workspace_root}`);
247
+ console.log(` Database Path: ${result.db_path}`);
248
+ console.log(` Database ID: ${result.db_id ?? "(not initialized)"}`);
249
+ console.log(` Schema Version: ${result.schema_version ?? "(not initialized)"}`);
250
+ console.log(` Query Only: ${result.query_only ? "yes" : "no"}`);
251
+ } else {
252
+ console.log("✗ Workspace check failed");
253
+ if (result.error) {
254
+ console.log(` Error: ${result.error}`);
255
+ }
256
+ }
257
+ }
258
+
259
+ // ─────────────────────────────────────────────────────────────────────────────
260
+ // channel list command
261
+ // ─────────────────────────────────────────────────────────────────────────────
262
+
263
+ async function runChannelList(options: GlobalOptions): Promise<ChannelListResult> {
264
+ try {
265
+ return await withWorkspaceDb(options.workspace, (db) => {
266
+ const channels = listChannels(db);
267
+ return {
268
+ status: "ok",
269
+ channels,
270
+ count: channels.length,
271
+ };
272
+ });
273
+ } catch (err) {
274
+ if (err instanceof WorkspaceNotFoundError || err instanceof DatabaseNotFoundError) {
275
+ return { status: "error", error: err.message };
276
+ }
277
+ return { status: "error", error: err instanceof Error ? err.message : String(err) };
278
+ }
279
+ }
280
+
281
+ function printHumanChannelList(result: ChannelListResult): void {
282
+ if (result.status === "ok") {
283
+ if (result.channels && result.channels.length > 0) {
284
+ console.log(`Channels (${result.count}):`);
285
+ for (const ch of result.channels) {
286
+ const desc = ch.description ? ` - ${ch.description}` : "";
287
+ console.log(` ${ch.id} ${ch.name}${desc}`);
288
+ }
289
+ } else {
290
+ console.log("No channels found.");
291
+ }
292
+ } else {
293
+ console.error(`Error: ${result.error}`);
294
+ }
295
+ }
296
+
297
+ // ─────────────────────────────────────────────────────────────────────────────
298
+ // topic list command
299
+ // ─────────────────────────────────────────────────────────────────────────────
300
+
301
+ interface TopicListOptions extends GlobalOptions {
302
+ channelId: string;
303
+ limit?: number;
304
+ offset?: number;
305
+ }
306
+
307
+ async function runTopicList(options: TopicListOptions): Promise<TopicListResult> {
308
+ try {
309
+ return await withWorkspaceDb(options.workspace, (db) => {
310
+ const result = listTopicsByChannel(db, options.channelId, {
311
+ limit: options.limit,
312
+ offset: options.offset,
313
+ });
314
+ return {
315
+ status: "ok",
316
+ topics: result.items,
317
+ count: result.items.length,
318
+ hasMore: result.hasMore,
319
+ };
320
+ });
321
+ } catch (err) {
322
+ if (err instanceof WorkspaceNotFoundError || err instanceof DatabaseNotFoundError) {
323
+ return { status: "error", error: err.message };
324
+ }
325
+ return { status: "error", error: err instanceof Error ? err.message : String(err) };
326
+ }
327
+ }
328
+
329
+ function printHumanTopicList(result: TopicListResult): void {
330
+ if (result.status === "ok") {
331
+ if (result.topics && result.topics.length > 0) {
332
+ console.log(`Topics (${result.count}${result.hasMore ? "+": ""}):`);
333
+ for (const t of result.topics) {
334
+ console.log(` ${t.id} ${t.title}`);
335
+ console.log(` updated: ${t.updated_at}`);
336
+ }
337
+ if (result.hasMore) {
338
+ console.log(" (more topics available, use --offset to paginate)");
339
+ }
340
+ } else {
341
+ console.log("No topics found.");
342
+ }
343
+ } else {
344
+ console.error(`Error: ${result.error}`);
345
+ }
346
+ }
347
+
348
+ // ─────────────────────────────────────────────────────────────────────────────
349
+ // msg tail command
350
+ // ─────────────────────────────────────────────────────────────────────────────
351
+
352
+ interface MsgTailOptions extends GlobalOptions {
353
+ topicId: string;
354
+ limit?: number;
355
+ }
356
+
357
+ async function runMsgTail(options: MsgTailOptions): Promise<MessageListResult> {
358
+ try {
359
+ return await withWorkspaceDb(options.workspace, (db) => {
360
+ const messages = tailMessages(db, options.topicId, options.limit ?? 50);
361
+ return {
362
+ status: "ok",
363
+ messages,
364
+ count: messages.length,
365
+ };
366
+ });
367
+ } catch (err) {
368
+ if (err instanceof WorkspaceNotFoundError || err instanceof DatabaseNotFoundError) {
369
+ return { status: "error", error: err.message };
370
+ }
371
+ return { status: "error", error: err instanceof Error ? err.message : String(err) };
372
+ }
373
+ }
374
+
375
+ function printHumanMsgList(result: MessageListResult): void {
376
+ if (result.status === "ok") {
377
+ if (result.messages && result.messages.length > 0) {
378
+ console.log(`Messages (${result.count}${result.hasMore ? "+": ""}):`);
379
+ for (const m of result.messages) {
380
+ const deleted = m.deleted_at ? " [DELETED]" : "";
381
+ const edited = m.edited_at ? " [edited]" : "";
382
+ console.log(` [${m.id}] ${m.sender}${deleted}${edited}`);
383
+ console.log(` ${m.created_at}`);
384
+ if (!m.deleted_at) {
385
+ // Truncate long content
386
+ const content = m.content_raw.length > 200
387
+ ? m.content_raw.slice(0, 200) + "..."
388
+ : m.content_raw;
389
+ console.log(` ${content}`);
390
+ }
391
+ console.log();
392
+ }
393
+ if (result.hasMore) {
394
+ console.log(" (more messages available)");
395
+ }
396
+ } else {
397
+ console.log("No messages found.");
398
+ }
399
+ } else {
400
+ console.error(`Error: ${result.error}`);
401
+ }
402
+ }
403
+
404
+ // ─────────────────────────────────────────────────────────────────────────────
405
+ // msg page command
406
+ // ─────────────────────────────────────────────────────────────────────────────
407
+
408
+ interface MsgPageOptions extends GlobalOptions {
409
+ topicId: string;
410
+ beforeId?: string;
411
+ afterId?: string;
412
+ limit?: number;
413
+ }
414
+
415
+ async function runMsgPage(options: MsgPageOptions): Promise<MessageListResult> {
416
+ try {
417
+ return await withWorkspaceDb(options.workspace, (db) => {
418
+ const result = listMessages(db, {
419
+ topicId: options.topicId,
420
+ beforeId: options.beforeId,
421
+ afterId: options.afterId,
422
+ limit: options.limit ?? 50,
423
+ });
424
+ return {
425
+ status: "ok",
426
+ messages: result.items,
427
+ count: result.items.length,
428
+ hasMore: result.hasMore,
429
+ };
430
+ });
431
+ } catch (err) {
432
+ if (err instanceof WorkspaceNotFoundError || err instanceof DatabaseNotFoundError) {
433
+ return { status: "error", error: err.message };
434
+ }
435
+ return { status: "error", error: err instanceof Error ? err.message : String(err) };
436
+ }
437
+ }
438
+
439
+ // ─────────────────────────────────────────────────────────────────────────────
440
+ // attachment list command
441
+ // ─────────────────────────────────────────────────────────────────────────────
442
+
443
+ interface AttachmentListOptions extends GlobalOptions {
444
+ topicId: string;
445
+ kind?: string;
446
+ }
447
+
448
+ async function runAttachmentList(options: AttachmentListOptions): Promise<AttachmentListResult> {
449
+ try {
450
+ return await withWorkspaceDb(options.workspace, (db) => {
451
+ const attachments = listTopicAttachments(db, options.topicId, options.kind);
452
+ return {
453
+ status: "ok",
454
+ attachments,
455
+ count: attachments.length,
456
+ };
457
+ });
458
+ } catch (err) {
459
+ if (err instanceof WorkspaceNotFoundError || err instanceof DatabaseNotFoundError) {
460
+ return { status: "error", error: err.message };
461
+ }
462
+ return { status: "error", error: err instanceof Error ? err.message : String(err) };
463
+ }
464
+ }
465
+
466
+ function printHumanAttachmentList(result: AttachmentListResult): void {
467
+ if (result.status === "ok") {
468
+ if (result.attachments && result.attachments.length > 0) {
469
+ console.log(`Attachments (${result.count}):`);
470
+ for (const a of result.attachments) {
471
+ console.log(` [${a.id}] ${a.kind}${a.key ? `:${a.key}` : ""}`);
472
+ console.log(` created: ${a.created_at}`);
473
+ console.log(` value: ${JSON.stringify(a.value_json)}`);
474
+ console.log();
475
+ }
476
+ } else {
477
+ console.log("No attachments found.");
478
+ }
479
+ } else {
480
+ console.error(`Error: ${result.error}`);
481
+ }
482
+ }
483
+
484
+ // ─────────────────────────────────────────────────────────────────────────────
485
+ // search command
486
+ // ─────────────────────────────────────────────────────────────────────────────
487
+
488
+ interface SearchOptions extends GlobalOptions {
489
+ query: string;
490
+ limit?: number;
491
+ }
492
+
493
+ async function runSearch(options: SearchOptions): Promise<SearchResult> {
494
+ try {
495
+ return await withWorkspaceDb(options.workspace, (db) => {
496
+ // Check if FTS is available
497
+ if (!isFtsAvailable(db)) {
498
+ return {
499
+ status: "error",
500
+ error: "Full-text search not available: messages_fts table does not exist. Enable FTS by running migrations with enableFts=true.",
501
+ };
502
+ }
503
+
504
+ const limit = options.limit ?? 50;
505
+ // Use FTS5 MATCH syntax
506
+ const rows = db.query<Message, [string, number]>(`
507
+ SELECT m.id, m.topic_id, m.channel_id, m.sender, m.content_raw, m.version,
508
+ m.created_at, m.edited_at, m.deleted_at, m.deleted_by
509
+ FROM messages m
510
+ JOIN messages_fts fts ON m.rowid = fts.rowid
511
+ WHERE messages_fts MATCH ?
512
+ ORDER BY m.created_at DESC
513
+ LIMIT ?
514
+ `).all(options.query, limit);
515
+
516
+ return {
517
+ status: "ok",
518
+ messages: rows,
519
+ count: rows.length,
520
+ };
521
+ });
522
+ } catch (err) {
523
+ if (err instanceof WorkspaceNotFoundError || err instanceof DatabaseNotFoundError) {
524
+ return { status: "error", error: err.message };
525
+ }
526
+ return { status: "error", error: err instanceof Error ? err.message : String(err) };
527
+ }
528
+ }
529
+
530
+ function printHumanSearch(result: SearchResult): void {
531
+ if (result.status === "ok") {
532
+ if (result.messages && result.messages.length > 0) {
533
+ console.log(`Search results (${result.count}):`);
534
+ for (const m of result.messages) {
535
+ const deleted = m.deleted_at ? " [DELETED]" : "";
536
+ console.log(` [${m.id}] ${m.sender} in topic:${m.topic_id}${deleted}`);
537
+ console.log(` ${m.created_at}`);
538
+ if (!m.deleted_at) {
539
+ const content = m.content_raw.length > 200
540
+ ? m.content_raw.slice(0, 200) + "..."
541
+ : m.content_raw;
542
+ console.log(` ${content}`);
543
+ }
544
+ console.log();
545
+ }
546
+ } else {
547
+ console.log("No results found.");
548
+ }
549
+ } else {
550
+ console.error(`Error: ${result.error}`);
551
+ }
552
+ }
553
+
554
+ // ─────────────────────────────────────────────────────────────────────────────
555
+ // msg send command (HTTP mutation)
556
+ // ─────────────────────────────────────────────────────────────────────────────
557
+
558
+ interface MsgSendOptions extends GlobalOptions {
559
+ topicId: string;
560
+ sender: string;
561
+ content?: string;
562
+ stdin?: boolean;
563
+ }
564
+
565
+ interface MsgSendResult {
566
+ status: "ok" | "error";
567
+ message_id?: string;
568
+ event_id?: number;
569
+ error?: string;
570
+ code?: string;
571
+ }
572
+
573
+ async function runMsgSend(options: MsgSendOptions): Promise<MsgSendResult> {
574
+ try {
575
+ // Get content from stdin or --content
576
+ let content: string;
577
+ if (options.stdin) {
578
+ // Read from stdin
579
+ const chunks: Buffer[] = [];
580
+ for await (const chunk of process.stdin) {
581
+ chunks.push(chunk);
582
+ }
583
+ content = Buffer.concat(chunks).toString("utf-8");
584
+ } else if (options.content !== undefined) {
585
+ content = options.content;
586
+ } else {
587
+ return {
588
+ status: "error",
589
+ error: "Either --content or --stdin is required",
590
+ code: "INVALID_INPUT",
591
+ };
592
+ }
593
+
594
+ // Get hub context
595
+ const ctx = await getHubContext(options.workspace);
596
+ if (!ctx) {
597
+ return {
598
+ status: "error",
599
+ error: "Hub not running (server.json not found)",
600
+ code: "HUB_NOT_RUNNING",
601
+ };
602
+ }
603
+
604
+ // Make request
605
+ const result = await hubRequest<{ message: any; event_id: number }>(
606
+ ctx,
607
+ "POST",
608
+ "/api/v1/messages",
609
+ {
610
+ topic_id: options.topicId,
611
+ sender: options.sender,
612
+ content_raw: content,
613
+ }
614
+ );
615
+
616
+ if (!result.ok) {
617
+ return {
618
+ status: "error",
619
+ error: result.error,
620
+ code: result.code,
621
+ };
622
+ }
623
+
624
+ return {
625
+ status: "ok",
626
+ message_id: result.data.message.id,
627
+ event_id: result.data.event_id,
628
+ };
629
+ } catch (err) {
630
+ return {
631
+ status: "error",
632
+ error: err instanceof Error ? err.message : String(err),
633
+ code: "INTERNAL_ERROR",
634
+ };
635
+ }
636
+ }
637
+
638
+ function printHumanMsgSend(result: MsgSendResult): void {
639
+ if (result.status === "ok") {
640
+ console.log(`Message sent: ${result.message_id}`);
641
+ if (result.event_id) {
642
+ console.log(`Event ID: ${result.event_id}`);
643
+ }
644
+ } else {
645
+ console.error(`Error: ${result.error}`);
646
+ }
647
+ }
648
+
649
+ // ─────────────────────────────────────────────────────────────────────────────
650
+ // msg edit command (HTTP mutation)
651
+ // ─────────────────────────────────────────────────────────────────────────────
652
+
653
+ interface MsgEditOptions extends GlobalOptions {
654
+ messageId: string;
655
+ content: string;
656
+ expectedVersion?: number;
657
+ }
658
+
659
+ interface MsgEditResult {
660
+ status: "ok" | "error";
661
+ message?: any;
662
+ event_id?: number;
663
+ error?: string;
664
+ code?: string;
665
+ details?: Record<string, unknown>;
666
+ }
667
+
668
+ async function runMsgEdit(options: MsgEditOptions): Promise<MsgEditResult> {
669
+ try {
670
+ const ctx = await getHubContext(options.workspace);
671
+ if (!ctx) {
672
+ return {
673
+ status: "error",
674
+ error: "Hub not running (server.json not found)",
675
+ code: "HUB_NOT_RUNNING",
676
+ };
677
+ }
678
+
679
+ const result = await hubRequest<{ message: any; event_id: number }>(
680
+ ctx,
681
+ "PATCH",
682
+ `/api/v1/messages/${options.messageId}`,
683
+ {
684
+ op: "edit",
685
+ content_raw: options.content,
686
+ expected_version: options.expectedVersion,
687
+ }
688
+ );
689
+
690
+ if (!result.ok) {
691
+ return {
692
+ status: "error",
693
+ error: result.error,
694
+ code: result.code,
695
+ details: result.details,
696
+ };
697
+ }
698
+
699
+ return {
700
+ status: "ok",
701
+ message: result.data.message,
702
+ event_id: result.data.event_id,
703
+ };
704
+ } catch (err) {
705
+ return {
706
+ status: "error",
707
+ error: err instanceof Error ? err.message : String(err),
708
+ code: "INTERNAL_ERROR",
709
+ };
710
+ }
711
+ }
712
+
713
+ function printHumanMsgEdit(result: MsgEditResult): void {
714
+ if (result.status === "ok") {
715
+ console.log(`Message edited: ${result.message?.id}`);
716
+ if (result.event_id) {
717
+ console.log(`Event ID: ${result.event_id}`);
718
+ }
719
+ } else {
720
+ console.error(`Error: ${result.error}`);
721
+ if (result.code === "VERSION_CONFLICT" && result.details?.current) {
722
+ console.error(` Current version: ${result.details.current}`);
723
+ }
724
+ }
725
+ }
726
+
727
+ // ─────────────────────────────────────────────────────────────────────────────
728
+ // msg delete command (HTTP mutation)
729
+ // ─────────────────────────────────────────────────────────────────────────────
730
+
731
+ interface MsgDeleteOptions extends GlobalOptions {
732
+ messageId: string;
733
+ actor: string;
734
+ expectedVersion?: number;
735
+ }
736
+
737
+ interface MsgDeleteResult {
738
+ status: "ok" | "error";
739
+ deleted?: boolean;
740
+ event_id?: number | null;
741
+ error?: string;
742
+ code?: string;
743
+ details?: Record<string, unknown>;
744
+ }
745
+
746
+ async function runMsgDelete(options: MsgDeleteOptions): Promise<MsgDeleteResult> {
747
+ try {
748
+ const ctx = await getHubContext(options.workspace);
749
+ if (!ctx) {
750
+ return {
751
+ status: "error",
752
+ error: "Hub not running (server.json not found)",
753
+ code: "HUB_NOT_RUNNING",
754
+ };
755
+ }
756
+
757
+ const result = await hubRequest<{ message: any; event_id: number | null }>(
758
+ ctx,
759
+ "PATCH",
760
+ `/api/v1/messages/${options.messageId}`,
761
+ {
762
+ op: "delete",
763
+ actor: options.actor,
764
+ expected_version: options.expectedVersion,
765
+ }
766
+ );
767
+
768
+ if (!result.ok) {
769
+ return {
770
+ status: "error",
771
+ error: result.error,
772
+ code: result.code,
773
+ details: result.details,
774
+ };
775
+ }
776
+
777
+ return {
778
+ status: "ok",
779
+ deleted: true,
780
+ event_id: result.data.event_id,
781
+ };
782
+ } catch (err) {
783
+ return {
784
+ status: "error",
785
+ error: err instanceof Error ? err.message : String(err),
786
+ code: "INTERNAL_ERROR",
787
+ };
788
+ }
789
+ }
790
+
791
+ function printHumanMsgDelete(result: MsgDeleteResult): void {
792
+ if (result.status === "ok") {
793
+ console.log("Message deleted");
794
+ if (result.event_id !== null && result.event_id !== undefined) {
795
+ console.log(`Event ID: ${result.event_id}`);
796
+ }
797
+ } else {
798
+ console.error(`Error: ${result.error}`);
799
+ if (result.code === "VERSION_CONFLICT" && result.details?.current) {
800
+ console.error(` Current version: ${result.details.current}`);
801
+ }
802
+ }
803
+ }
804
+
805
+ // ─────────────────────────────────────────────────────────────────────────────
806
+ // msg retopic command (HTTP mutation)
807
+ // ─────────────────────────────────────────────────────────────────────────────
808
+
809
+ interface MsgRetopicOptions extends GlobalOptions {
810
+ messageId: string;
811
+ toTopicId: string;
812
+ mode: "one" | "later" | "all";
813
+ force?: boolean;
814
+ expectedVersion?: number;
815
+ }
816
+
817
+ interface MsgRetopicResult {
818
+ status: "ok" | "error";
819
+ affected_count?: number;
820
+ event_ids?: number[];
821
+ error?: string;
822
+ code?: string;
823
+ details?: Record<string, unknown>;
824
+ }
825
+
826
+ async function runMsgRetopic(options: MsgRetopicOptions): Promise<MsgRetopicResult> {
827
+ try {
828
+ // Safety check: mode=all requires --force
829
+ if (options.mode === "all" && !options.force) {
830
+ return {
831
+ status: "error",
832
+ error: "Mode 'all' requires --force flag",
833
+ code: "INVALID_INPUT",
834
+ };
835
+ }
836
+
837
+ const ctx = await getHubContext(options.workspace);
838
+ if (!ctx) {
839
+ return {
840
+ status: "error",
841
+ error: "Hub not running (server.json not found)",
842
+ code: "HUB_NOT_RUNNING",
843
+ };
844
+ }
845
+
846
+ const result = await hubRequest<{ affected_count: number; event_ids: number[] }>(
847
+ ctx,
848
+ "PATCH",
849
+ `/api/v1/messages/${options.messageId}`,
850
+ {
851
+ op: "move_topic",
852
+ to_topic_id: options.toTopicId,
853
+ mode: options.mode,
854
+ expected_version: options.expectedVersion,
855
+ }
856
+ );
857
+
858
+ if (!result.ok) {
859
+ return {
860
+ status: "error",
861
+ error: result.error,
862
+ code: result.code,
863
+ details: result.details,
864
+ };
865
+ }
866
+
867
+ return {
868
+ status: "ok",
869
+ affected_count: result.data.affected_count,
870
+ event_ids: result.data.event_ids,
871
+ };
872
+ } catch (err) {
873
+ return {
874
+ status: "error",
875
+ error: err instanceof Error ? err.message : String(err),
876
+ code: "INTERNAL_ERROR",
877
+ };
878
+ }
879
+ }
880
+
881
+ function printHumanMsgRetopic(result: MsgRetopicResult): void {
882
+ if (result.status === "ok") {
883
+ console.log(`Moved ${result.affected_count} message(s)`);
884
+ if (result.event_ids && result.event_ids.length > 0) {
885
+ console.log(`Event IDs: ${result.event_ids.join(", ")}`);
886
+ }
887
+ } else {
888
+ console.error(`Error: ${result.error}`);
889
+ if (result.code === "CROSS_CHANNEL_MOVE") {
890
+ console.error(" Cross-channel moves are not allowed");
891
+ }
892
+ }
893
+ }
894
+
895
+ // ─────────────────────────────────────────────────────────────────────────────
896
+ // topic rename command (HTTP mutation)
897
+ // ─────────────────────────────────────────────────────────────────────────────
898
+
899
+ interface TopicRenameOptions extends GlobalOptions {
900
+ topicId: string;
901
+ title: string;
902
+ }
903
+
904
+ interface TopicRenameResult {
905
+ status: "ok" | "error";
906
+ topic?: any;
907
+ event_id?: number;
908
+ error?: string;
909
+ code?: string;
910
+ }
911
+
912
+ async function runTopicRename(options: TopicRenameOptions): Promise<TopicRenameResult> {
913
+ try {
914
+ const ctx = await getHubContext(options.workspace);
915
+ if (!ctx) {
916
+ return {
917
+ status: "error",
918
+ error: "Hub not running (server.json not found)",
919
+ code: "HUB_NOT_RUNNING",
920
+ };
921
+ }
922
+
923
+ const result = await hubRequest<{ topic: any; event_id: number }>(
924
+ ctx,
925
+ "PATCH",
926
+ `/api/v1/topics/${options.topicId}`,
927
+ {
928
+ title: options.title,
929
+ }
930
+ );
931
+
932
+ if (!result.ok) {
933
+ return {
934
+ status: "error",
935
+ error: result.error,
936
+ code: result.code,
937
+ };
938
+ }
939
+
940
+ return {
941
+ status: "ok",
942
+ topic: result.data.topic,
943
+ event_id: result.data.event_id,
944
+ };
945
+ } catch (err) {
946
+ return {
947
+ status: "error",
948
+ error: err instanceof Error ? err.message : String(err),
949
+ code: "INTERNAL_ERROR",
950
+ };
951
+ }
952
+ }
953
+
954
+ function printHumanTopicRename(result: TopicRenameResult): void {
955
+ if (result.status === "ok") {
956
+ console.log(`Topic renamed: ${result.topic?.id}`);
957
+ console.log(`New title: ${result.topic?.title}`);
958
+ if (result.event_id) {
959
+ console.log(`Event ID: ${result.event_id}`);
960
+ }
961
+ } else {
962
+ console.error(`Error: ${result.error}`);
963
+ }
964
+ }
965
+
966
+ // ─────────────────────────────────────────────────────────────────────────────
967
+ // attachment add command (HTTP mutation)
968
+ // ─────────────────────────────────────────────────────────────────────────────
969
+
970
+ interface AttachmentAddOptions extends GlobalOptions {
971
+ topicId: string;
972
+ kind: string;
973
+ valueJson: string;
974
+ key?: string;
975
+ sourceMessageId?: string;
976
+ dedupeKey?: string;
977
+ }
978
+
979
+ interface AttachmentAddResult {
980
+ status: "ok" | "error";
981
+ attachment?: any;
982
+ event_id?: number | null;
983
+ deduplicated?: boolean;
984
+ error?: string;
985
+ code?: string;
986
+ }
987
+
988
+ async function runAttachmentAdd(options: AttachmentAddOptions): Promise<AttachmentAddResult> {
989
+ try {
990
+ // Parse value_json
991
+ let valueJson: Record<string, unknown>;
992
+ try {
993
+ valueJson = JSON.parse(options.valueJson);
994
+ if (typeof valueJson !== "object" || Array.isArray(valueJson)) {
995
+ return {
996
+ status: "error",
997
+ error: "value_json must be a JSON object",
998
+ code: "INVALID_INPUT",
999
+ };
1000
+ }
1001
+ } catch {
1002
+ return {
1003
+ status: "error",
1004
+ error: "value_json is not valid JSON",
1005
+ code: "INVALID_INPUT",
1006
+ };
1007
+ }
1008
+
1009
+ const ctx = await getHubContext(options.workspace);
1010
+ if (!ctx) {
1011
+ return {
1012
+ status: "error",
1013
+ error: "Hub not running (server.json not found)",
1014
+ code: "HUB_NOT_RUNNING",
1015
+ };
1016
+ }
1017
+
1018
+ const body: Record<string, unknown> = {
1019
+ kind: options.kind,
1020
+ value_json: valueJson,
1021
+ };
1022
+ if (options.key) body.key = options.key;
1023
+ if (options.sourceMessageId) body.source_message_id = options.sourceMessageId;
1024
+ if (options.dedupeKey) body.dedupe_key = options.dedupeKey;
1025
+
1026
+ const result = await hubRequest<{ attachment: any; event_id: number | null }>(
1027
+ ctx,
1028
+ "POST",
1029
+ `/api/v1/topics/${options.topicId}/attachments`,
1030
+ body
1031
+ );
1032
+
1033
+ if (!result.ok) {
1034
+ return {
1035
+ status: "error",
1036
+ error: result.error,
1037
+ code: result.code,
1038
+ };
1039
+ }
1040
+
1041
+ return {
1042
+ status: "ok",
1043
+ attachment: result.data.attachment,
1044
+ event_id: result.data.event_id,
1045
+ deduplicated: result.data.event_id === null,
1046
+ };
1047
+ } catch (err) {
1048
+ return {
1049
+ status: "error",
1050
+ error: err instanceof Error ? err.message : String(err),
1051
+ code: "INTERNAL_ERROR",
1052
+ };
1053
+ }
1054
+ }
1055
+
1056
+ function printHumanAttachmentAdd(result: AttachmentAddResult): void {
1057
+ if (result.status === "ok") {
1058
+ if (result.deduplicated) {
1059
+ console.log(`Attachment already exists: ${result.attachment?.id}`);
1060
+ console.log("(deduplicated, no new event)");
1061
+ } else {
1062
+ console.log(`Attachment added: ${result.attachment?.id}`);
1063
+ if (result.event_id) {
1064
+ console.log(`Event ID: ${result.event_id}`);
1065
+ }
1066
+ }
1067
+ } else {
1068
+ console.error(`Error: ${result.error}`);
1069
+ }
1070
+ }
1071
+
1072
+ // ─────────────────────────────────────────────────────────────────────────────
1073
+ // listen command (WebSocket event stream)
1074
+ // ─────────────────────────────────────────────────────────────────────────────
1075
+
1076
+ /**
1077
+ * Read server.json from workspace .agentlip directory.
1078
+ */
1079
+ async function readServerJson(workspaceRoot: string): Promise<ServerJsonData | null> {
1080
+ try {
1081
+ const serverJsonPath = join(workspaceRoot, ".agentlip", "server.json");
1082
+ const content = await readFile(serverJsonPath, "utf-8");
1083
+ return JSON.parse(content) as ServerJsonData;
1084
+ } catch (err: unknown) {
1085
+ if ((err as NodeJS.ErrnoException)?.code === "ENOENT") {
1086
+ return null;
1087
+ }
1088
+ throw err;
1089
+ }
1090
+ }
1091
+
1092
+ // ─────────────────────────────────────────────────────────────────────────────
1093
+ // HTTP API helpers (for mutations)
1094
+ // ─────────────────────────────────────────────────────────────────────────────
1095
+
1096
+ interface HubContext {
1097
+ baseUrl: string;
1098
+ authToken: string;
1099
+ }
1100
+
1101
+ /**
1102
+ * Get hub context (baseUrl + authToken) by reading server.json.
1103
+ * Returns null if hub is not running.
1104
+ */
1105
+ async function getHubContext(workspace?: string): Promise<HubContext | null> {
1106
+ const startPath = workspace ?? process.cwd();
1107
+ const discovered = await discoverWorkspaceRoot(startPath);
1108
+ if (!discovered) {
1109
+ return null;
1110
+ }
1111
+
1112
+ const serverJson = await readServerJson(discovered.root);
1113
+ if (!serverJson) {
1114
+ return null;
1115
+ }
1116
+
1117
+ const baseUrl = `http://${serverJson.host}:${serverJson.port}`;
1118
+ return {
1119
+ baseUrl,
1120
+ authToken: serverJson.auth_token,
1121
+ };
1122
+ }
1123
+
1124
+ /**
1125
+ * Make authenticated HTTP request to hub.
1126
+ * Returns parsed JSON response.
1127
+ */
1128
+ async function hubRequest<T = unknown>(
1129
+ ctx: HubContext,
1130
+ method: string,
1131
+ path: string,
1132
+ body?: unknown
1133
+ ): Promise<{ ok: true; data: T; status: number } | { ok: false; error: string; code: string; status: number; details?: Record<string, unknown> }> {
1134
+ try {
1135
+ const url = `${ctx.baseUrl}${path}`;
1136
+ const headers: Record<string, string> = {
1137
+ "Authorization": `Bearer ${ctx.authToken}`,
1138
+ };
1139
+
1140
+ let requestInit: RequestInit = { method, headers };
1141
+
1142
+ if (body !== undefined) {
1143
+ headers["Content-Type"] = "application/json";
1144
+ requestInit.body = JSON.stringify(body);
1145
+ }
1146
+
1147
+ const response = await fetch(url, requestInit);
1148
+ const text = await response.text();
1149
+
1150
+ // Try to parse as JSON
1151
+ let json: any;
1152
+ try {
1153
+ json = text ? JSON.parse(text) : {};
1154
+ } catch {
1155
+ // Not JSON - treat as plain error
1156
+ return {
1157
+ ok: false,
1158
+ error: text || "Unknown error",
1159
+ code: "INTERNAL_ERROR",
1160
+ status: response.status,
1161
+ };
1162
+ }
1163
+
1164
+ if (response.ok) {
1165
+ return { ok: true, data: json as T, status: response.status };
1166
+ }
1167
+
1168
+ // Error response - extract error/code/details
1169
+ const errorMsg = json.error || json.message || "Unknown error";
1170
+ const errorCode = json.code || "INTERNAL_ERROR";
1171
+ const details = json.details;
1172
+
1173
+ return {
1174
+ ok: false,
1175
+ error: errorMsg,
1176
+ code: errorCode,
1177
+ status: response.status,
1178
+ details,
1179
+ };
1180
+ } catch (err) {
1181
+ // Network error
1182
+ return {
1183
+ ok: false,
1184
+ error: err instanceof Error ? err.message : String(err),
1185
+ code: "CONNECTION_FAILED",
1186
+ status: 0,
1187
+ };
1188
+ }
1189
+ }
1190
+
1191
+ /**
1192
+ * Handle error response and exit with appropriate code.
1193
+ */
1194
+ function handleMutationError(error: {
1195
+ error: string;
1196
+ code: string;
1197
+ status: number;
1198
+ details?: Record<string, unknown>;
1199
+ }, json: boolean): never {
1200
+ if (json) {
1201
+ // Output as JSON error
1202
+ console.log(JSON.stringify({
1203
+ status: "error",
1204
+ error: error.error,
1205
+ code: error.code,
1206
+ details: error.details,
1207
+ }));
1208
+ } else {
1209
+ // Human-readable error to stderr
1210
+ console.error(`Error: ${error.error}`);
1211
+ if (error.code === "VERSION_CONFLICT" && error.details?.current) {
1212
+ console.error(` Current version: ${error.details.current}`);
1213
+ }
1214
+ }
1215
+
1216
+ // Exit with appropriate code per plan
1217
+ if (error.code === "VERSION_CONFLICT") {
1218
+ process.exit(2); // Conflict
1219
+ } else if (error.code === "CONNECTION_FAILED" || error.code === "HUB_NOT_RUNNING") {
1220
+ process.exit(3); // Hub not running
1221
+ } else if (error.code === "UNAUTHORIZED") {
1222
+ process.exit(4); // Auth failed
1223
+ } else {
1224
+ process.exit(1); // General error
1225
+ }
1226
+ }
1227
+
1228
+ /**
1229
+ * Map channel names to IDs using local DB.
1230
+ * If a value looks like a channel name (exists in DB), map to ID; otherwise treat as ID.
1231
+ */
1232
+ async function resolveChannelIds(
1233
+ workspaceRoot: string,
1234
+ channelInputs: string[]
1235
+ ): Promise<string[]> {
1236
+ if (channelInputs.length === 0) return [];
1237
+
1238
+ const { db } = await openWorkspaceDbReadonly({ workspace: workspaceRoot });
1239
+ try {
1240
+ const resolved: string[] = [];
1241
+ for (const input of channelInputs) {
1242
+ // Try to find by name first
1243
+ const byName = getChannelByName(db, input);
1244
+ if (byName) {
1245
+ resolved.push(byName.id);
1246
+ } else {
1247
+ // Treat as ID
1248
+ resolved.push(input);
1249
+ }
1250
+ }
1251
+ return resolved;
1252
+ } finally {
1253
+ db.close();
1254
+ }
1255
+ }
1256
+
1257
+ /**
1258
+ * Run the listen command - connects to hub WS and streams events as JSONL.
1259
+ */
1260
+ async function runListen(options: ListenOptions): Promise<void> {
1261
+ // 1. Discover workspace root
1262
+ const startPath = options.workspace ?? process.cwd();
1263
+ const discovered = await discoverWorkspaceRoot(startPath);
1264
+ if (!discovered) {
1265
+ console.error(`Error: No workspace found (no .agentlip/db.sqlite3 in directory tree starting from ${startPath})`);
1266
+ process.exit(1);
1267
+ }
1268
+ const workspaceRoot = discovered.root;
1269
+
1270
+ // 2. Read server.json
1271
+ const serverJson = await readServerJson(workspaceRoot);
1272
+ if (!serverJson) {
1273
+ console.error("Error: Hub not running (server.json not found). Start hub with: agentlipd up");
1274
+ process.exit(3);
1275
+ }
1276
+
1277
+ // 3. Map channel names to IDs
1278
+ let channelIds: string[] = [];
1279
+ if (options.channels.length > 0) {
1280
+ try {
1281
+ channelIds = await resolveChannelIds(workspaceRoot, options.channels);
1282
+ } catch (err) {
1283
+ console.error(`Error resolving channels: ${err instanceof Error ? err.message : String(err)}`);
1284
+ process.exit(1);
1285
+ }
1286
+ }
1287
+ const topicIds = options.topicIds;
1288
+
1289
+ // 4. Build WS URL
1290
+ const wsUrl = `ws://${serverJson.host}:${serverJson.port}/ws?token=${encodeURIComponent(serverJson.auth_token)}`;
1291
+
1292
+ // 5. Connection state
1293
+ let lastSeenEventId = options.since;
1294
+ const seenEventIds = new Set<number>();
1295
+ let reconnectDelay = 1000;
1296
+ const maxReconnectDelay = 30000;
1297
+ let shouldRun = true;
1298
+ let currentWs: WebSocket | null = null;
1299
+
1300
+ // Handle Ctrl+C
1301
+ const cleanup = () => {
1302
+ shouldRun = false;
1303
+ if (currentWs) {
1304
+ try {
1305
+ currentWs.close(1000, "Client shutdown");
1306
+ } catch {
1307
+ // Ignore close errors
1308
+ }
1309
+ }
1310
+ process.exit(0);
1311
+ };
1312
+ process.on("SIGINT", cleanup);
1313
+ process.on("SIGTERM", cleanup);
1314
+
1315
+ // 6. Connection loop
1316
+ while (shouldRun) {
1317
+ try {
1318
+ await connectAndStream(
1319
+ wsUrl,
1320
+ lastSeenEventId,
1321
+ channelIds,
1322
+ topicIds,
1323
+ seenEventIds,
1324
+ (eventId) => { lastSeenEventId = eventId; },
1325
+ () => shouldRun,
1326
+ (ws) => { currentWs = ws; }
1327
+ );
1328
+
1329
+ // If we reach here, connection closed cleanly (code 1000)
1330
+ // Check if we should reconnect
1331
+ if (!shouldRun) break;
1332
+
1333
+ } catch (err) {
1334
+ if (!shouldRun) break;
1335
+
1336
+ const errorMsg = err instanceof Error ? err.message : String(err);
1337
+
1338
+ // Check for auth failure (don't retry)
1339
+ if (errorMsg.includes("4401") || errorMsg.includes("Unauthorized")) {
1340
+ console.error("Error: Authentication failed. Check hub auth token.");
1341
+ process.exit(4);
1342
+ }
1343
+
1344
+ // Log reconnection attempt
1345
+ console.error(`Connection error: ${errorMsg}. Reconnecting in ${reconnectDelay / 1000}s...`);
1346
+
1347
+ // Wait before reconnecting
1348
+ await sleep(reconnectDelay);
1349
+
1350
+ // Exponential backoff
1351
+ reconnectDelay = Math.min(reconnectDelay * 2, maxReconnectDelay);
1352
+ }
1353
+
1354
+ // Reset reconnect delay on successful connection cycle
1355
+ reconnectDelay = 1000;
1356
+ }
1357
+ }
1358
+
1359
+ function sleep(ms: number): Promise<void> {
1360
+ return new Promise((resolve) => setTimeout(resolve, ms));
1361
+ }
1362
+
1363
+ /**
1364
+ * Connect to WebSocket and stream events until disconnect.
1365
+ */
1366
+ async function connectAndStream(
1367
+ wsUrl: string,
1368
+ afterEventId: number,
1369
+ channelIds: string[],
1370
+ topicIds: string[],
1371
+ seenEventIds: Set<number>,
1372
+ updateLastSeen: (eventId: number) => void,
1373
+ shouldContinue: () => boolean,
1374
+ setCurrentWs: (ws: WebSocket | null) => void
1375
+ ): Promise<void> {
1376
+ return new Promise((resolve, reject) => {
1377
+ const ws = new WebSocket(wsUrl);
1378
+ setCurrentWs(ws);
1379
+
1380
+ let handshakeComplete = false;
1381
+ let resolved = false;
1382
+
1383
+ const finish = (error?: Error) => {
1384
+ if (resolved) return;
1385
+ resolved = true;
1386
+ setCurrentWs(null);
1387
+ if (error) {
1388
+ reject(error);
1389
+ } else {
1390
+ resolve();
1391
+ }
1392
+ };
1393
+
1394
+ const openTimeout = setTimeout(() => {
1395
+ if (!resolved) {
1396
+ ws.close();
1397
+ finish(new Error("WebSocket open timeout after 10s"));
1398
+ }
1399
+ }, 10000);
1400
+
1401
+ ws.onopen = () => {
1402
+ clearTimeout(openTimeout);
1403
+
1404
+ // Build hello message
1405
+ const hello: HelloMessage = {
1406
+ type: "hello",
1407
+ after_event_id: afterEventId,
1408
+ };
1409
+
1410
+ // Only add subscriptions if filters are specified
1411
+ // Per plan: omit subscriptions field entirely for ALL events
1412
+ if (channelIds.length > 0 || topicIds.length > 0) {
1413
+ hello.subscriptions = {};
1414
+ if (channelIds.length > 0) {
1415
+ hello.subscriptions.channels = channelIds;
1416
+ }
1417
+ if (topicIds.length > 0) {
1418
+ hello.subscriptions.topics = topicIds;
1419
+ }
1420
+ }
1421
+
1422
+ ws.send(JSON.stringify(hello));
1423
+ };
1424
+
1425
+ ws.onmessage = (event) => {
1426
+ if (!shouldContinue()) {
1427
+ ws.close(1000, "Client shutdown");
1428
+ return;
1429
+ }
1430
+
1431
+ try {
1432
+ const data = JSON.parse(String(event.data));
1433
+
1434
+ if (data.type === "hello_ok") {
1435
+ handshakeComplete = true;
1436
+ // Log hello_ok to stderr for debugging (not stdout which is for JSONL)
1437
+ // console.error(`Connected. replay_until=${(data as HelloOkMessage).replay_until}`);
1438
+ return;
1439
+ }
1440
+
1441
+ if (data.type === "event") {
1442
+ const envelope = data as EventEnvelope;
1443
+
1444
+ // Deduplicate
1445
+ if (seenEventIds.has(envelope.event_id)) {
1446
+ return;
1447
+ }
1448
+ seenEventIds.add(envelope.event_id);
1449
+
1450
+ // Track for resume
1451
+ updateLastSeen(envelope.event_id);
1452
+
1453
+ // Output as JSONL to stdout
1454
+ console.log(JSON.stringify(envelope));
1455
+ }
1456
+ } catch (err) {
1457
+ // Log parse errors to stderr
1458
+ console.error(`Error parsing message: ${err instanceof Error ? err.message : String(err)}`);
1459
+ }
1460
+ };
1461
+
1462
+ ws.onerror = (err) => {
1463
+ clearTimeout(openTimeout);
1464
+ if (!handshakeComplete) {
1465
+ finish(new Error(`WebSocket error: ${String(err)}`));
1466
+ }
1467
+ };
1468
+
1469
+ ws.onclose = (event) => {
1470
+ clearTimeout(openTimeout);
1471
+
1472
+ const code = (event as CloseEvent).code;
1473
+ const reason = (event as CloseEvent).reason || "unknown";
1474
+
1475
+ // Check close codes per plan:
1476
+ // 1000: Normal closure - don't reconnect
1477
+ // 1001: Going away (server shutdown) - reconnect after delay
1478
+ // 1008: Policy violation (backpressure) - reconnect immediately
1479
+ // 1011: Internal error - reconnect with backoff
1480
+ // 4401: Unauthorized - don't reconnect
1481
+
1482
+ if (code === 1000) {
1483
+ // Normal close - don't reconnect
1484
+ finish();
1485
+ return;
1486
+ }
1487
+
1488
+ if (code === 4401) {
1489
+ finish(new Error("4401: Unauthorized"));
1490
+ return;
1491
+ }
1492
+
1493
+ // All other codes: trigger reconnect via error
1494
+ finish(new Error(`WebSocket closed: code=${code}, reason=${reason}`));
1495
+ };
1496
+ });
1497
+ }
1498
+
1499
+ // ─────────────────────────────────────────────────────────────────────────────
1500
+ // Help messages
1501
+ // ─────────────────────────────────────────────────────────────────────────────
1502
+
1503
+ function printHelp(): void {
1504
+ console.log("Usage: agentlip <command> [options]");
1505
+ console.log();
1506
+ console.log("Commands:");
1507
+ console.log(" doctor Run diagnostics on workspace DB");
1508
+ console.log(" channel list List all channels");
1509
+ console.log(" topic list List topics in a channel");
1510
+ console.log(" msg tail Get latest messages from a topic");
1511
+ console.log(" msg page Paginate messages with cursor");
1512
+ console.log(" attachment list List topic attachments");
1513
+ console.log(" search Full-text search messages");
1514
+ console.log(" listen Stream events via WebSocket");
1515
+ console.log();
1516
+ console.log("Global options:");
1517
+ console.log(" --workspace <path> Explicit workspace root (default: auto-discover)");
1518
+ console.log(" --json Output as JSON");
1519
+ console.log(" --help, -h Show this help");
1520
+ console.log();
1521
+ console.log("Use '<command> --help' for more information on a command.");
1522
+ }
1523
+
1524
+ function printDoctorHelp(): void {
1525
+ console.log("Usage: agentlip doctor [--workspace <path>] [--json]");
1526
+ console.log();
1527
+ console.log("Run diagnostics on workspace database.");
1528
+ console.log();
1529
+ console.log("Options:");
1530
+ console.log(" --workspace <path> Explicit workspace root (default: auto-discover)");
1531
+ console.log(" --json Output as JSON");
1532
+ console.log(" --help, -h Show this help");
1533
+ console.log();
1534
+ console.log("Exit codes:");
1535
+ console.log(" 0 OK");
1536
+ console.log(" 1 Error (workspace not found, DB issues, etc.)");
1537
+ }
1538
+
1539
+ function printChannelHelp(): void {
1540
+ console.log("Usage: agentlip channel <subcommand> [options]");
1541
+ console.log();
1542
+ console.log("Subcommands:");
1543
+ console.log(" list List all channels");
1544
+ console.log();
1545
+ console.log("Options:");
1546
+ console.log(" --workspace <path> Explicit workspace root (default: auto-discover)");
1547
+ console.log(" --json Output as JSON");
1548
+ console.log(" --help, -h Show this help");
1549
+ }
1550
+
1551
+ function printTopicHelp(): void {
1552
+ console.log("Usage: agentlip topic <subcommand> [options]");
1553
+ console.log();
1554
+ console.log("Subcommands (read-only):");
1555
+ console.log(" list List topics in a channel");
1556
+ console.log();
1557
+ console.log("Subcommands (mutations, require running hub):");
1558
+ console.log(" rename Rename a topic");
1559
+ console.log();
1560
+ console.log("Usage:");
1561
+ console.log(" agentlip topic list --channel-id <id> [--limit N] [--offset N]");
1562
+ console.log(" agentlip topic rename <topic_id> --title <new_title>");
1563
+ console.log();
1564
+ console.log("Options:");
1565
+ console.log(" --channel-id <id> Channel ID (required for list)");
1566
+ console.log(" --title <title> New topic title (required for rename)");
1567
+ console.log(" --limit <n> Max topics to return (default: 50)");
1568
+ console.log(" --offset <n> Offset for pagination (default: 0)");
1569
+ console.log(" --workspace <path> Explicit workspace root");
1570
+ console.log(" --json Output as JSON");
1571
+ console.log(" --help, -h Show this help");
1572
+ }
1573
+
1574
+ function printMsgHelp(): void {
1575
+ console.log("Usage: agentlip msg <subcommand> [options]");
1576
+ console.log();
1577
+ console.log("Subcommands (read-only):");
1578
+ console.log(" tail Get latest messages from a topic");
1579
+ console.log(" page Paginate messages with cursor");
1580
+ console.log();
1581
+ console.log("Subcommands (mutations, require running hub):");
1582
+ console.log(" send Send a message");
1583
+ console.log(" edit Edit a message");
1584
+ console.log(" delete Delete a message (tombstone)");
1585
+ console.log(" retopic Move message(s) to different topic");
1586
+ console.log();
1587
+ console.log("Read-only usage:");
1588
+ console.log(" agentlip msg tail --topic-id <id> [--limit N]");
1589
+ console.log(" agentlip msg page --topic-id <id> [--before-id <id>] [--after-id <id>] [--limit N]");
1590
+ console.log();
1591
+ console.log("Mutation usage:");
1592
+ console.log(" agentlip msg send --topic-id <id> --sender <name> [--content <text>] [--stdin]");
1593
+ console.log(" agentlip msg edit <message_id> --content <text> [--expected-version <n>]");
1594
+ console.log(" agentlip msg delete <message_id> --actor <name> [--expected-version <n>]");
1595
+ console.log(" agentlip msg retopic <message_id> --to-topic-id <id> --mode <one|later|all> [--force]");
1596
+ console.log();
1597
+ console.log("Options:");
1598
+ console.log(" --topic-id <id> Topic ID (required for tail/page/send)");
1599
+ console.log(" --sender <name> Sender name (required for send)");
1600
+ console.log(" --content <text> Message content (send/edit)");
1601
+ console.log(" --stdin Read content from stdin (send only)");
1602
+ console.log(" --actor <name> Actor performing delete (required for delete)");
1603
+ console.log(" --expected-version <n> Expected version for optimistic locking");
1604
+ console.log(" --to-topic-id <id> Target topic for retopic");
1605
+ console.log(" --mode <mode> Retopic mode: one, later, or all");
1606
+ console.log(" --force Required for retopic mode=all");
1607
+ console.log(" --before-id <id> Get messages before this ID (page command)");
1608
+ console.log(" --after-id <id> Get messages after this ID (page command)");
1609
+ console.log(" --limit <n> Max messages to return (default: 50)");
1610
+ console.log(" --workspace <path> Explicit workspace root");
1611
+ console.log(" --json Output as JSON");
1612
+ console.log(" --help, -h Show this help");
1613
+ console.log();
1614
+ console.log("Exit codes:");
1615
+ console.log(" 0 Success");
1616
+ console.log(" 1 General error");
1617
+ console.log(" 2 Version conflict (optimistic lock failed)");
1618
+ console.log(" 3 Hub not running");
1619
+ console.log(" 4 Authentication failed");
1620
+ }
1621
+
1622
+ function printAttachmentHelp(): void {
1623
+ console.log("Usage: agentlip attachment <subcommand> [options]");
1624
+ console.log();
1625
+ console.log("Subcommands (read-only):");
1626
+ console.log(" list List attachments for a topic");
1627
+ console.log();
1628
+ console.log("Subcommands (mutations, require running hub):");
1629
+ console.log(" add Add an attachment to a topic");
1630
+ console.log();
1631
+ console.log("Usage:");
1632
+ console.log(" agentlip attachment list --topic-id <id> [--kind <kind>]");
1633
+ console.log(" agentlip attachment add --topic-id <id> --kind <kind> --value-json <json>");
1634
+ console.log(" [--key <key>] [--source-message-id <id>] [--dedupe-key <key>]");
1635
+ console.log();
1636
+ console.log("Options:");
1637
+ console.log(" --topic-id <id> Topic ID (required)");
1638
+ console.log(" --kind <kind> Attachment kind (required for add, filter for list)");
1639
+ console.log(" --value-json <json> JSON value (required for add)");
1640
+ console.log(" --key <key> Optional key for namespacing");
1641
+ console.log(" --source-message-id <id> Source message ID");
1642
+ console.log(" --dedupe-key <key> Custom deduplication key");
1643
+ console.log(" --workspace <path> Explicit workspace root");
1644
+ console.log(" --json Output as JSON");
1645
+ console.log(" --help, -h Show this help");
1646
+ }
1647
+
1648
+ function printSearchHelp(): void {
1649
+ console.log("Usage: agentlip search --query <text> [--limit N] [--workspace <path>] [--json]");
1650
+ console.log();
1651
+ console.log("Full-text search messages (requires FTS to be enabled).");
1652
+ console.log();
1653
+ console.log("Options:");
1654
+ console.log(" --query <text> Search query (required, FTS5 syntax)");
1655
+ console.log(" --limit <n> Max results to return (default: 50)");
1656
+ console.log(" --workspace <path> Explicit workspace root");
1657
+ console.log(" --json Output as JSON");
1658
+ console.log(" --help, -h Show this help");
1659
+ console.log();
1660
+ console.log("Exit codes:");
1661
+ console.log(" 0 OK");
1662
+ console.log(" 1 Error (FTS not available, workspace not found, etc.)");
1663
+ }
1664
+
1665
+ function printListenHelp(): void {
1666
+ console.log("Usage: agentlip listen [--since <event_id>] [--channel <name|id>...] [--topic-id <id>...] [--workspace <path>]");
1667
+ console.log();
1668
+ console.log("Stream events from hub via WebSocket (JSONL output to stdout).");
1669
+ console.log();
1670
+ console.log("Options:");
1671
+ console.log(" --since <event_id> Start from this event ID (default: 0, all history)");
1672
+ console.log(" --channel <name|id> Filter by channel (can specify multiple times)");
1673
+ console.log(" --topic-id <id> Filter by topic ID (can specify multiple times)");
1674
+ console.log(" --workspace <path> Explicit workspace root (default: auto-discover)");
1675
+ console.log(" --help, -h Show this help");
1676
+ console.log();
1677
+ console.log("If no --channel or --topic-id filters are specified, subscribes to ALL events.");
1678
+ console.log();
1679
+ console.log("Auto-reconnects on disconnect with exponential backoff (1s to 30s).");
1680
+ console.log("Deduplicates events on reconnect. Press Ctrl+C to exit.");
1681
+ console.log();
1682
+ console.log("Exit codes:");
1683
+ console.log(" 0 Normal exit (Ctrl+C)");
1684
+ console.log(" 1 Error (workspace not found, etc.)");
1685
+ console.log(" 3 Hub not running (server.json not found)");
1686
+ console.log(" 4 Authentication failed");
1687
+ }
1688
+
1689
+ // ─────────────────────────────────────────────────────────────────────────────
1690
+ // Main CLI entry point
1691
+ // ─────────────────────────────────────────────────────────────────────────────
1692
+
1693
+ export async function main(argv: string[] = process.argv.slice(2)) {
1694
+ // Handle global help
1695
+ if (argv.length === 0 || argv[0] === "--help" || argv[0] === "-h") {
1696
+ printHelp();
1697
+ process.exit(0);
1698
+ }
1699
+
1700
+ // Parse global options from entire argv first, then extract command
1701
+ const { globalOpts, remainingArgs: argsAfterGlobal } = parseGlobalOptions(argv);
1702
+ const [command, ...remainingArgs] = argsAfterGlobal;
1703
+
1704
+ // ─── doctor ───
1705
+ if (command === "doctor") {
1706
+ if (remainingArgs.includes("--help") || remainingArgs.includes("-h")) {
1707
+ printDoctorHelp();
1708
+ process.exit(0);
1709
+ }
1710
+ const result = await runDoctor(globalOpts);
1711
+ output(result, globalOpts.json ?? false, () => printHumanDoctor(result));
1712
+ process.exit(result.status === "ok" ? 0 : 1);
1713
+ }
1714
+
1715
+ // ─── channel ───
1716
+ if (command === "channel") {
1717
+ const [subcommand, ...subArgs] = remainingArgs;
1718
+
1719
+ if (!subcommand || subcommand === "--help" || subcommand === "-h") {
1720
+ printChannelHelp();
1721
+ process.exit(0);
1722
+ }
1723
+
1724
+ if (subcommand === "list") {
1725
+ if (subArgs.includes("--help") || subArgs.includes("-h")) {
1726
+ printChannelHelp();
1727
+ process.exit(0);
1728
+ }
1729
+ const result = await runChannelList(globalOpts);
1730
+ output(result, globalOpts.json ?? false, () => printHumanChannelList(result));
1731
+ process.exit(result.status === "ok" ? 0 : 1);
1732
+ }
1733
+
1734
+ console.error(`Unknown channel subcommand: ${subcommand}`);
1735
+ process.exit(1);
1736
+ }
1737
+
1738
+ // ─── topic ───
1739
+ if (command === "topic") {
1740
+ const [subcommand, ...subArgs] = remainingArgs;
1741
+
1742
+ if (!subcommand || subcommand === "--help" || subcommand === "-h") {
1743
+ printTopicHelp();
1744
+ process.exit(0);
1745
+ }
1746
+
1747
+ if (subcommand === "list") {
1748
+ if (subArgs.includes("--help") || subArgs.includes("-h")) {
1749
+ printTopicHelp();
1750
+ process.exit(0);
1751
+ }
1752
+
1753
+ // Parse topic list options
1754
+ let channelId: string | undefined;
1755
+ let limit: number | undefined;
1756
+ let offset: number | undefined;
1757
+
1758
+ for (let i = 0; i < subArgs.length; i++) {
1759
+ const arg = subArgs[i];
1760
+ if (arg === "--channel-id") {
1761
+ channelId = subArgs[++i];
1762
+ } else if (arg === "--limit") {
1763
+ limit = parseInt(subArgs[++i], 10);
1764
+ } else if (arg === "--offset") {
1765
+ offset = parseInt(subArgs[++i], 10);
1766
+ }
1767
+ }
1768
+
1769
+ if (!channelId) {
1770
+ console.error("--channel-id is required");
1771
+ process.exit(1);
1772
+ }
1773
+
1774
+ const result = await runTopicList({ ...globalOpts, channelId, limit, offset });
1775
+ output(result, globalOpts.json ?? false, () => printHumanTopicList(result));
1776
+ process.exit(result.status === "ok" ? 0 : 1);
1777
+ }
1778
+
1779
+ if (subcommand === "rename") {
1780
+ if (subArgs.includes("--help") || subArgs.includes("-h")) {
1781
+ printTopicHelp();
1782
+ process.exit(0);
1783
+ }
1784
+
1785
+ const [topicId, ...renameArgs] = subArgs;
1786
+ if (!topicId) {
1787
+ console.error("<topic_id> is required");
1788
+ process.exit(1);
1789
+ }
1790
+
1791
+ let title: string | undefined;
1792
+
1793
+ for (let i = 0; i < renameArgs.length; i++) {
1794
+ const arg = renameArgs[i];
1795
+ if (arg === "--title") {
1796
+ title = renameArgs[++i];
1797
+ }
1798
+ }
1799
+
1800
+ if (!title) {
1801
+ console.error("--title is required");
1802
+ process.exit(1);
1803
+ }
1804
+
1805
+ const result = await runTopicRename({ ...globalOpts, topicId, title });
1806
+ if (result.status === "error" && result.code) {
1807
+ handleMutationError({ error: result.error!, code: result.code, status: 400 }, globalOpts.json ?? false);
1808
+ }
1809
+ output(result, globalOpts.json ?? false, () => printHumanTopicRename(result));
1810
+ process.exit(result.status === "ok" ? 0 : 1);
1811
+ }
1812
+
1813
+ console.error(`Unknown topic subcommand: ${subcommand}`);
1814
+ process.exit(1);
1815
+ }
1816
+
1817
+ // ─── msg ───
1818
+ if (command === "msg") {
1819
+ const [subcommand, ...subArgs] = remainingArgs;
1820
+
1821
+ if (!subcommand || subcommand === "--help" || subcommand === "-h") {
1822
+ printMsgHelp();
1823
+ process.exit(0);
1824
+ }
1825
+
1826
+ if (subcommand === "tail") {
1827
+ if (subArgs.includes("--help") || subArgs.includes("-h")) {
1828
+ printMsgHelp();
1829
+ process.exit(0);
1830
+ }
1831
+
1832
+ let topicId: string | undefined;
1833
+ let limit: number | undefined;
1834
+
1835
+ for (let i = 0; i < subArgs.length; i++) {
1836
+ const arg = subArgs[i];
1837
+ if (arg === "--topic-id") {
1838
+ topicId = subArgs[++i];
1839
+ } else if (arg === "--limit") {
1840
+ limit = parseInt(subArgs[++i], 10);
1841
+ }
1842
+ }
1843
+
1844
+ if (!topicId) {
1845
+ console.error("--topic-id is required");
1846
+ process.exit(1);
1847
+ }
1848
+
1849
+ const result = await runMsgTail({ ...globalOpts, topicId, limit });
1850
+ output(result, globalOpts.json ?? false, () => printHumanMsgList(result));
1851
+ process.exit(result.status === "ok" ? 0 : 1);
1852
+ }
1853
+
1854
+ if (subcommand === "page") {
1855
+ if (subArgs.includes("--help") || subArgs.includes("-h")) {
1856
+ printMsgHelp();
1857
+ process.exit(0);
1858
+ }
1859
+
1860
+ let topicId: string | undefined;
1861
+ let beforeId: string | undefined;
1862
+ let afterId: string | undefined;
1863
+ let limit: number | undefined;
1864
+
1865
+ for (let i = 0; i < subArgs.length; i++) {
1866
+ const arg = subArgs[i];
1867
+ if (arg === "--topic-id") {
1868
+ topicId = subArgs[++i];
1869
+ } else if (arg === "--before-id") {
1870
+ beforeId = subArgs[++i];
1871
+ } else if (arg === "--after-id") {
1872
+ afterId = subArgs[++i];
1873
+ } else if (arg === "--limit") {
1874
+ limit = parseInt(subArgs[++i], 10);
1875
+ }
1876
+ }
1877
+
1878
+ if (!topicId) {
1879
+ console.error("--topic-id is required");
1880
+ process.exit(1);
1881
+ }
1882
+
1883
+ const result = await runMsgPage({ ...globalOpts, topicId, beforeId, afterId, limit });
1884
+ output(result, globalOpts.json ?? false, () => printHumanMsgList(result));
1885
+ process.exit(result.status === "ok" ? 0 : 1);
1886
+ }
1887
+
1888
+ if (subcommand === "send") {
1889
+ if (subArgs.includes("--help") || subArgs.includes("-h")) {
1890
+ printMsgHelp();
1891
+ process.exit(0);
1892
+ }
1893
+
1894
+ let topicId: string | undefined;
1895
+ let sender: string | undefined;
1896
+ let content: string | undefined;
1897
+ let stdin = false;
1898
+
1899
+ for (let i = 0; i < subArgs.length; i++) {
1900
+ const arg = subArgs[i];
1901
+ if (arg === "--topic-id") {
1902
+ topicId = subArgs[++i];
1903
+ } else if (arg === "--sender") {
1904
+ sender = subArgs[++i];
1905
+ } else if (arg === "--content") {
1906
+ content = subArgs[++i];
1907
+ } else if (arg === "--stdin") {
1908
+ stdin = true;
1909
+ }
1910
+ }
1911
+
1912
+ if (!topicId) {
1913
+ console.error("--topic-id is required");
1914
+ process.exit(1);
1915
+ }
1916
+ if (!sender) {
1917
+ console.error("--sender is required");
1918
+ process.exit(1);
1919
+ }
1920
+
1921
+ const result = await runMsgSend({ ...globalOpts, topicId, sender, content, stdin });
1922
+ if (result.status === "error" && result.code) {
1923
+ handleMutationError({ error: result.error!, code: result.code, status: 400 }, globalOpts.json ?? false);
1924
+ }
1925
+ output(result, globalOpts.json ?? false, () => printHumanMsgSend(result));
1926
+ process.exit(result.status === "ok" ? 0 : 1);
1927
+ }
1928
+
1929
+ if (subcommand === "edit") {
1930
+ if (subArgs.includes("--help") || subArgs.includes("-h")) {
1931
+ printMsgHelp();
1932
+ process.exit(0);
1933
+ }
1934
+
1935
+ const [messageId, ...editArgs] = subArgs;
1936
+ if (!messageId) {
1937
+ console.error("<message_id> is required");
1938
+ process.exit(1);
1939
+ }
1940
+
1941
+ let content: string | undefined;
1942
+ let expectedVersion: number | undefined;
1943
+
1944
+ for (let i = 0; i < editArgs.length; i++) {
1945
+ const arg = editArgs[i];
1946
+ if (arg === "--content") {
1947
+ content = editArgs[++i];
1948
+ } else if (arg === "--expected-version") {
1949
+ expectedVersion = parseInt(editArgs[++i], 10);
1950
+ }
1951
+ }
1952
+
1953
+ if (!content) {
1954
+ console.error("--content is required");
1955
+ process.exit(1);
1956
+ }
1957
+
1958
+ const result = await runMsgEdit({ ...globalOpts, messageId, content, expectedVersion });
1959
+ if (result.status === "error" && result.code) {
1960
+ handleMutationError({ error: result.error!, code: result.code, status: 400, details: result.details }, globalOpts.json ?? false);
1961
+ }
1962
+ output(result, globalOpts.json ?? false, () => printHumanMsgEdit(result));
1963
+ process.exit(result.status === "ok" ? 0 : 1);
1964
+ }
1965
+
1966
+ if (subcommand === "delete") {
1967
+ if (subArgs.includes("--help") || subArgs.includes("-h")) {
1968
+ printMsgHelp();
1969
+ process.exit(0);
1970
+ }
1971
+
1972
+ const [messageId, ...deleteArgs] = subArgs;
1973
+ if (!messageId) {
1974
+ console.error("<message_id> is required");
1975
+ process.exit(1);
1976
+ }
1977
+
1978
+ let actor: string | undefined;
1979
+ let expectedVersion: number | undefined;
1980
+
1981
+ for (let i = 0; i < deleteArgs.length; i++) {
1982
+ const arg = deleteArgs[i];
1983
+ if (arg === "--actor") {
1984
+ actor = deleteArgs[++i];
1985
+ } else if (arg === "--expected-version") {
1986
+ expectedVersion = parseInt(deleteArgs[++i], 10);
1987
+ }
1988
+ }
1989
+
1990
+ if (!actor) {
1991
+ console.error("--actor is required");
1992
+ process.exit(1);
1993
+ }
1994
+
1995
+ const result = await runMsgDelete({ ...globalOpts, messageId, actor, expectedVersion });
1996
+ if (result.status === "error" && result.code) {
1997
+ handleMutationError({ error: result.error!, code: result.code, status: 400, details: result.details }, globalOpts.json ?? false);
1998
+ }
1999
+ output(result, globalOpts.json ?? false, () => printHumanMsgDelete(result));
2000
+ process.exit(result.status === "ok" ? 0 : 1);
2001
+ }
2002
+
2003
+ if (subcommand === "retopic") {
2004
+ if (subArgs.includes("--help") || subArgs.includes("-h")) {
2005
+ printMsgHelp();
2006
+ process.exit(0);
2007
+ }
2008
+
2009
+ const [messageId, ...retopicArgs] = subArgs;
2010
+ if (!messageId) {
2011
+ console.error("<message_id> is required");
2012
+ process.exit(1);
2013
+ }
2014
+
2015
+ let toTopicId: string | undefined;
2016
+ let mode: "one" | "later" | "all" | undefined;
2017
+ let force = false;
2018
+ let expectedVersion: number | undefined;
2019
+
2020
+ for (let i = 0; i < retopicArgs.length; i++) {
2021
+ const arg = retopicArgs[i];
2022
+ if (arg === "--to-topic-id") {
2023
+ toTopicId = retopicArgs[++i];
2024
+ } else if (arg === "--mode") {
2025
+ const modeStr = retopicArgs[++i];
2026
+ if (!["one", "later", "all"].includes(modeStr)) {
2027
+ console.error("--mode must be one of: one, later, all");
2028
+ process.exit(1);
2029
+ }
2030
+ mode = modeStr as "one" | "later" | "all";
2031
+ } else if (arg === "--force") {
2032
+ force = true;
2033
+ } else if (arg === "--expected-version") {
2034
+ expectedVersion = parseInt(retopicArgs[++i], 10);
2035
+ }
2036
+ }
2037
+
2038
+ if (!toTopicId) {
2039
+ console.error("--to-topic-id is required");
2040
+ process.exit(1);
2041
+ }
2042
+ if (!mode) {
2043
+ console.error("--mode is required");
2044
+ process.exit(1);
2045
+ }
2046
+
2047
+ const result = await runMsgRetopic({ ...globalOpts, messageId, toTopicId, mode, force, expectedVersion });
2048
+ if (result.status === "error" && result.code) {
2049
+ handleMutationError({ error: result.error!, code: result.code, status: 400, details: result.details }, globalOpts.json ?? false);
2050
+ }
2051
+ output(result, globalOpts.json ?? false, () => printHumanMsgRetopic(result));
2052
+ process.exit(result.status === "ok" ? 0 : 1);
2053
+ }
2054
+
2055
+ console.error(`Unknown msg subcommand: ${subcommand}`);
2056
+ process.exit(1);
2057
+ }
2058
+
2059
+ // ─── attachment ───
2060
+ if (command === "attachment") {
2061
+ const [subcommand, ...subArgs] = remainingArgs;
2062
+
2063
+ if (!subcommand || subcommand === "--help" || subcommand === "-h") {
2064
+ printAttachmentHelp();
2065
+ process.exit(0);
2066
+ }
2067
+
2068
+ if (subcommand === "list") {
2069
+ if (subArgs.includes("--help") || subArgs.includes("-h")) {
2070
+ printAttachmentHelp();
2071
+ process.exit(0);
2072
+ }
2073
+
2074
+ let topicId: string | undefined;
2075
+ let kind: string | undefined;
2076
+
2077
+ for (let i = 0; i < subArgs.length; i++) {
2078
+ const arg = subArgs[i];
2079
+ if (arg === "--topic-id") {
2080
+ topicId = subArgs[++i];
2081
+ } else if (arg === "--kind") {
2082
+ kind = subArgs[++i];
2083
+ }
2084
+ }
2085
+
2086
+ if (!topicId) {
2087
+ console.error("--topic-id is required");
2088
+ process.exit(1);
2089
+ }
2090
+
2091
+ const result = await runAttachmentList({ ...globalOpts, topicId, kind });
2092
+ output(result, globalOpts.json ?? false, () => printHumanAttachmentList(result));
2093
+ process.exit(result.status === "ok" ? 0 : 1);
2094
+ }
2095
+
2096
+ if (subcommand === "add") {
2097
+ if (subArgs.includes("--help") || subArgs.includes("-h")) {
2098
+ printAttachmentHelp();
2099
+ process.exit(0);
2100
+ }
2101
+
2102
+ let topicId: string | undefined;
2103
+ let kind: string | undefined;
2104
+ let valueJson: string | undefined;
2105
+ let key: string | undefined;
2106
+ let sourceMessageId: string | undefined;
2107
+ let dedupeKey: string | undefined;
2108
+
2109
+ for (let i = 0; i < subArgs.length; i++) {
2110
+ const arg = subArgs[i];
2111
+ if (arg === "--topic-id") {
2112
+ topicId = subArgs[++i];
2113
+ } else if (arg === "--kind") {
2114
+ kind = subArgs[++i];
2115
+ } else if (arg === "--value-json") {
2116
+ valueJson = subArgs[++i];
2117
+ } else if (arg === "--key") {
2118
+ key = subArgs[++i];
2119
+ } else if (arg === "--source-message-id") {
2120
+ sourceMessageId = subArgs[++i];
2121
+ } else if (arg === "--dedupe-key") {
2122
+ dedupeKey = subArgs[++i];
2123
+ }
2124
+ }
2125
+
2126
+ if (!topicId) {
2127
+ console.error("--topic-id is required");
2128
+ process.exit(1);
2129
+ }
2130
+ if (!kind) {
2131
+ console.error("--kind is required");
2132
+ process.exit(1);
2133
+ }
2134
+ if (!valueJson) {
2135
+ console.error("--value-json is required");
2136
+ process.exit(1);
2137
+ }
2138
+
2139
+ const result = await runAttachmentAdd({
2140
+ ...globalOpts,
2141
+ topicId,
2142
+ kind,
2143
+ valueJson,
2144
+ key,
2145
+ sourceMessageId,
2146
+ dedupeKey
2147
+ });
2148
+ if (result.status === "error" && result.code) {
2149
+ handleMutationError({ error: result.error!, code: result.code, status: 400 }, globalOpts.json ?? false);
2150
+ }
2151
+ output(result, globalOpts.json ?? false, () => printHumanAttachmentAdd(result));
2152
+ process.exit(result.status === "ok" ? 0 : 1);
2153
+ }
2154
+
2155
+ console.error(`Unknown attachment subcommand: ${subcommand}`);
2156
+ process.exit(1);
2157
+ }
2158
+
2159
+ // ─── search ───
2160
+ if (command === "search") {
2161
+ if (remainingArgs.includes("--help") || remainingArgs.includes("-h")) {
2162
+ printSearchHelp();
2163
+ process.exit(0);
2164
+ }
2165
+
2166
+ let query: string | undefined;
2167
+ let limit: number | undefined;
2168
+
2169
+ for (let i = 0; i < remainingArgs.length; i++) {
2170
+ const arg = remainingArgs[i];
2171
+ if (arg === "--query" || arg === "-q") {
2172
+ query = remainingArgs[++i];
2173
+ } else if (arg === "--limit") {
2174
+ limit = parseInt(remainingArgs[++i], 10);
2175
+ }
2176
+ }
2177
+
2178
+ if (!query) {
2179
+ console.error("--query is required");
2180
+ process.exit(1);
2181
+ }
2182
+
2183
+ const result = await runSearch({ ...globalOpts, query, limit });
2184
+ output(result, globalOpts.json ?? false, () => printHumanSearch(result));
2185
+ process.exit(result.status === "ok" ? 0 : 1);
2186
+ }
2187
+
2188
+ // ─── listen ───
2189
+ if (command === "listen") {
2190
+ if (remainingArgs.includes("--help") || remainingArgs.includes("-h")) {
2191
+ printListenHelp();
2192
+ process.exit(0);
2193
+ }
2194
+
2195
+ let since = 0;
2196
+ const channels: string[] = [];
2197
+ const topicIds: string[] = [];
2198
+
2199
+ for (let i = 0; i < remainingArgs.length; i++) {
2200
+ const arg = remainingArgs[i];
2201
+ if (arg === "--since") {
2202
+ since = parseInt(remainingArgs[++i], 10);
2203
+ if (isNaN(since) || since < 0) {
2204
+ console.error("--since must be a non-negative integer");
2205
+ process.exit(1);
2206
+ }
2207
+ } else if (arg === "--channel") {
2208
+ const value = remainingArgs[++i];
2209
+ if (!value) {
2210
+ console.error("--channel requires a value");
2211
+ process.exit(1);
2212
+ }
2213
+ channels.push(value);
2214
+ } else if (arg === "--topic-id") {
2215
+ const value = remainingArgs[++i];
2216
+ if (!value) {
2217
+ console.error("--topic-id requires a value");
2218
+ process.exit(1);
2219
+ }
2220
+ topicIds.push(value);
2221
+ } else if (arg === "--format") {
2222
+ // Only jsonl is supported; accept but ignore
2223
+ const value = remainingArgs[++i];
2224
+ if (value !== "jsonl") {
2225
+ console.error("Only --format jsonl is supported");
2226
+ process.exit(1);
2227
+ }
2228
+ }
2229
+ }
2230
+
2231
+ // Run listen (this is a long-running command)
2232
+ await runListen({
2233
+ ...globalOpts,
2234
+ since,
2235
+ channels,
2236
+ topicIds,
2237
+ });
2238
+
2239
+ // Should not reach here unless cleanly exited
2240
+ process.exit(0);
2241
+ }
2242
+
2243
+ // ─── unknown ───
2244
+ console.error(`Unknown command: ${command}`);
2245
+ console.error("Use --help for usage information");
2246
+ process.exit(1);
2247
+ }
2248
+
2249
+ // Run if executed directly
2250
+ if (import.meta.main) {
2251
+ main().catch((err) => {
2252
+ console.error("Fatal error:", err);
2253
+ process.exit(1);
2254
+ });
2255
+ }