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.
- package/README.md +74 -0
- package/package.json +37 -0
- package/src/agentlip.ts +2255 -0
- package/src/index.ts +114 -0
package/src/agentlip.ts
ADDED
|
@@ -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
|
+
}
|