@xmtp/convos-cli 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (101) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +572 -0
  3. package/bin/dev.js +4 -0
  4. package/bin/run.js +4 -0
  5. package/dist/baseCommand.d.ts +46 -0
  6. package/dist/baseCommand.js +171 -0
  7. package/dist/commands/agent/serve.d.ts +67 -0
  8. package/dist/commands/agent/serve.js +662 -0
  9. package/dist/commands/conversation/add-members.d.ts +19 -0
  10. package/dist/commands/conversation/add-members.js +39 -0
  11. package/dist/commands/conversation/consent-state.d.ts +18 -0
  12. package/dist/commands/conversation/consent-state.js +24 -0
  13. package/dist/commands/conversation/download-attachment.d.ts +28 -0
  14. package/dist/commands/conversation/download-attachment.js +164 -0
  15. package/dist/commands/conversation/explode.d.ts +24 -0
  16. package/dist/commands/conversation/explode.js +156 -0
  17. package/dist/commands/conversation/info.d.ts +22 -0
  18. package/dist/commands/conversation/info.js +79 -0
  19. package/dist/commands/conversation/invite.d.ts +26 -0
  20. package/dist/commands/conversation/invite.js +137 -0
  21. package/dist/commands/conversation/lock.d.ts +24 -0
  22. package/dist/commands/conversation/lock.js +98 -0
  23. package/dist/commands/conversation/members.d.ts +22 -0
  24. package/dist/commands/conversation/members.js +39 -0
  25. package/dist/commands/conversation/messages.d.ts +31 -0
  26. package/dist/commands/conversation/messages.js +141 -0
  27. package/dist/commands/conversation/permissions.d.ts +18 -0
  28. package/dist/commands/conversation/permissions.js +33 -0
  29. package/dist/commands/conversation/profiles.d.ts +22 -0
  30. package/dist/commands/conversation/profiles.js +80 -0
  31. package/dist/commands/conversation/remove-members.d.ts +19 -0
  32. package/dist/commands/conversation/remove-members.js +36 -0
  33. package/dist/commands/conversation/send-attachment.d.ts +30 -0
  34. package/dist/commands/conversation/send-attachment.js +187 -0
  35. package/dist/commands/conversation/send-reaction.d.ts +21 -0
  36. package/dist/commands/conversation/send-reaction.js +38 -0
  37. package/dist/commands/conversation/send-remote-attachment.d.ts +30 -0
  38. package/dist/commands/conversation/send-remote-attachment.js +96 -0
  39. package/dist/commands/conversation/send-reply.d.ts +32 -0
  40. package/dist/commands/conversation/send-reply.js +170 -0
  41. package/dist/commands/conversation/send-text.d.ts +24 -0
  42. package/dist/commands/conversation/send-text.js +64 -0
  43. package/dist/commands/conversation/stream.d.ts +24 -0
  44. package/dist/commands/conversation/stream.js +81 -0
  45. package/dist/commands/conversation/sync.d.ts +18 -0
  46. package/dist/commands/conversation/sync.js +25 -0
  47. package/dist/commands/conversation/update-consent.d.ts +19 -0
  48. package/dist/commands/conversation/update-consent.js +35 -0
  49. package/dist/commands/conversation/update-description.d.ts +19 -0
  50. package/dist/commands/conversation/update-description.js +28 -0
  51. package/dist/commands/conversation/update-name.d.ts +19 -0
  52. package/dist/commands/conversation/update-name.js +29 -0
  53. package/dist/commands/conversation/update-profile.d.ts +24 -0
  54. package/dist/commands/conversation/update-profile.js +97 -0
  55. package/dist/commands/conversations/create.d.ts +26 -0
  56. package/dist/commands/conversations/create.js +165 -0
  57. package/dist/commands/conversations/join.d.ts +27 -0
  58. package/dist/commands/conversations/join.js +232 -0
  59. package/dist/commands/conversations/list.d.ts +20 -0
  60. package/dist/commands/conversations/list.js +109 -0
  61. package/dist/commands/conversations/process-join-requests.d.ts +26 -0
  62. package/dist/commands/conversations/process-join-requests.js +261 -0
  63. package/dist/commands/conversations/sync.d.ts +19 -0
  64. package/dist/commands/conversations/sync.js +50 -0
  65. package/dist/commands/identity/create.d.ts +21 -0
  66. package/dist/commands/identity/create.js +56 -0
  67. package/dist/commands/identity/info.d.ts +22 -0
  68. package/dist/commands/identity/info.js +63 -0
  69. package/dist/commands/identity/list.d.ts +19 -0
  70. package/dist/commands/identity/list.js +59 -0
  71. package/dist/commands/identity/remove.d.ts +23 -0
  72. package/dist/commands/identity/remove.js +51 -0
  73. package/dist/commands/init.d.ts +16 -0
  74. package/dist/commands/init.js +91 -0
  75. package/dist/commands/reset.d.ts +17 -0
  76. package/dist/commands/reset.js +93 -0
  77. package/dist/help.d.ts +4 -0
  78. package/dist/help.js +31 -0
  79. package/dist/index.d.ts +9 -0
  80. package/dist/index.js +15 -0
  81. package/dist/utils/client.d.ts +8 -0
  82. package/dist/utils/client.js +58 -0
  83. package/dist/utils/config.d.ts +15 -0
  84. package/dist/utils/config.js +1 -0
  85. package/dist/utils/identities.d.ts +49 -0
  86. package/dist/utils/identities.js +92 -0
  87. package/dist/utils/invite.d.ts +70 -0
  88. package/dist/utils/invite.js +339 -0
  89. package/dist/utils/metadata.d.ts +39 -0
  90. package/dist/utils/metadata.js +180 -0
  91. package/dist/utils/mime.d.ts +2 -0
  92. package/dist/utils/mime.js +42 -0
  93. package/dist/utils/random.d.ts +5 -0
  94. package/dist/utils/random.js +19 -0
  95. package/dist/utils/upload.d.ts +14 -0
  96. package/dist/utils/upload.js +51 -0
  97. package/dist/utils/xmtp.d.ts +45 -0
  98. package/dist/utils/xmtp.js +298 -0
  99. package/oclif.manifest.json +5562 -0
  100. package/package.json +124 -0
  101. package/skills/convos-cli/SKILL.md +588 -0
@@ -0,0 +1,662 @@
1
+ import { readFile } from "node:fs/promises";
2
+ import { basename } from "node:path";
3
+ import { createInterface } from "node:readline";
4
+ import { Args, Flags } from "@oclif/core";
5
+ import { encryptAttachment, } from "@xmtp/node-sdk";
6
+ import { tmpdir } from "node:os";
7
+ import { join } from "node:path";
8
+ import QRCode from "qrcode";
9
+ import { ConvosBaseCommand } from "../../baseCommand.js";
10
+ import { createClientForIdentity } from "../../utils/client.js";
11
+ import { createIdentityStore } from "../../utils/identities.js";
12
+ import { createInviteSlug, decryptConversationToken, parseInvite, verifyInvite, verifyInviteSignature, } from "../../utils/invite.js";
13
+ import { getMimeType } from "../../utils/mime.js";
14
+ import { parseAppData, serializeAppData, upsertProfile } from "../../utils/metadata.js";
15
+ import { randomAlphanumeric } from "../../utils/random.js";
16
+ import { getUploadProvider, INLINE_ATTACHMENT_MAX_BYTES, } from "../../utils/upload.js";
17
+ import { buildProfileMap, getAccountAddress, isDisplayableMessage, jsonStringify, normalizeMessageContent, requireGroup, } from "../../utils/xmtp.js";
18
+ export default class AgentServe extends ConvosBaseCommand {
19
+ static description = `Run an agent server for a conversation.
20
+
21
+ Starts a long-running process that combines message streaming,
22
+ join request processing, and stdin command handling into a single
23
+ session — ideal for AI agents and bots.
24
+
25
+ If no conversation ID is provided, creates a new conversation.
26
+ Displays the QR code invite on stderr so agents can share it.
27
+
28
+ Uses an ndjson (newline-delimited JSON) protocol:
29
+
30
+ STDIN commands (one JSON object per line):
31
+ {"type":"send","text":"Hello"} Send a text message
32
+ {"type":"send","text":"Re","replyTo":"<id>"} Reply to a message
33
+ {"type":"react","messageId":"<id>","emoji":"👍"} React to a message
34
+ {"type":"react","messageId":"<id>","emoji":"👍","action":"remove"}
35
+ {"type":"attach","file":"./photo.jpg"} Send a file attachment
36
+ {"type":"attach","file":"./img.jpg","replyTo":"<id>"} Reply with attachment
37
+ {"type":"remote-attach","url":"https://...","contentDigest":"...","secret":"...","salt":"...","nonce":"...","contentLength":123}
38
+ {"type":"stop"} Graceful shutdown
39
+
40
+ STDOUT events (one JSON object per line):
41
+ {"event":"ready",...} Session started, includes invite URL
42
+ {"event":"message",...} New message received
43
+ {"event":"member_joined",...} A member joined via invite
44
+ {"event":"sent",...} Message sent confirmation
45
+ {"event":"error",...} Error occurred
46
+
47
+ STDERR: QR code, diagnostic logs (does not interfere with protocol)`;
48
+ static examples = [
49
+ {
50
+ command: '<%= config.bin %> <%= command.id %> --name "My Bot"',
51
+ description: "Create a new conversation and start serving",
52
+ },
53
+ {
54
+ command: "<%= config.bin %> <%= command.id %> <conversation-id>",
55
+ description: "Attach to an existing conversation",
56
+ },
57
+ {
58
+ command: '<%= config.bin %> <%= command.id %> --name "Agent" --profile-name "Assistant" --permissions admin-only',
59
+ description: "Create an admin-only conversation with a profile name",
60
+ },
61
+ ];
62
+ static args = {
63
+ id: Args.string({
64
+ description: "Existing conversation ID to attach to (omit to create new)",
65
+ required: false,
66
+ }),
67
+ };
68
+ static flags = {
69
+ ...ConvosBaseCommand.baseFlags,
70
+ name: Flags.string({
71
+ description: "Conversation name (when creating new)",
72
+ helpValue: "<name>",
73
+ }),
74
+ description: Flags.string({
75
+ description: "Conversation description (when creating new)",
76
+ helpValue: "<description>",
77
+ }),
78
+ permissions: Flags.option({
79
+ options: ["all-members", "admin-only"],
80
+ description: "Permission preset (when creating new)",
81
+ default: "all-members",
82
+ })(),
83
+ "profile-name": Flags.string({
84
+ description: "Profile display name for this conversation",
85
+ helpValue: "<name>",
86
+ }),
87
+ identity: Flags.string({
88
+ description: "Use an existing unlinked identity ID (when creating new)",
89
+ helpValue: "<id>",
90
+ }),
91
+ label: Flags.string({
92
+ description: "Local label for the identity",
93
+ helpValue: "<label>",
94
+ }),
95
+ "no-invite": Flags.boolean({
96
+ description: "Skip generating an invite (attach mode only)",
97
+ default: false,
98
+ }),
99
+ };
100
+ streams = [];
101
+ shutdownResolve;
102
+ /**
103
+ * Emit a JSON event to stdout (one line).
104
+ */
105
+ emit(event) {
106
+ process.stdout.write(jsonStringify(event) + "\n");
107
+ }
108
+ /**
109
+ * Emit an error event.
110
+ */
111
+ emitError(message, details) {
112
+ this.emit({ event: "error", message, ...details });
113
+ }
114
+ /**
115
+ * Process a single DM message as a potential join request.
116
+ */
117
+ async processJoinMessage(message, client, identity) {
118
+ if (message.senderInboxId === client.inboxId)
119
+ return;
120
+ const text = typeof message.content === "string" ? message.content : null;
121
+ if (!text)
122
+ return;
123
+ let invite;
124
+ try {
125
+ invite = parseInvite(text);
126
+ }
127
+ catch {
128
+ return;
129
+ }
130
+ const dmConversation = (await client.conversations.getConversationById(message.conversationId));
131
+ if (!(await verifyInvite(invite))) {
132
+ if (dmConversation)
133
+ dmConversation.updateConsentState(2 /* ConsentState.Denied */);
134
+ return;
135
+ }
136
+ if (!(await verifyInviteSignature(invite, identity.walletKey))) {
137
+ if (dmConversation)
138
+ dmConversation.updateConsentState(2 /* ConsentState.Denied */);
139
+ return;
140
+ }
141
+ if (invite.creatorInboxId !== client.inboxId) {
142
+ if (dmConversation)
143
+ dmConversation.updateConsentState(2 /* ConsentState.Denied */);
144
+ return;
145
+ }
146
+ if (invite.expiresAt && invite.expiresAt < new Date())
147
+ return;
148
+ let conversationId;
149
+ try {
150
+ conversationId = decryptConversationToken(invite.conversationToken, client.inboxId, Buffer.from(identity.walletKey.replace("0x", ""), "hex"));
151
+ }
152
+ catch {
153
+ if (dmConversation)
154
+ dmConversation.updateConsentState(2 /* ConsentState.Denied */);
155
+ return;
156
+ }
157
+ const conversation = await client.conversations.getConversationById(conversationId);
158
+ if (!conversation)
159
+ return;
160
+ const group = requireGroup(conversation);
161
+ try {
162
+ const appData = group.appData ?? "";
163
+ const metadata = parseAppData(appData);
164
+ if (metadata.tag && invite.tag !== metadata.tag)
165
+ return;
166
+ }
167
+ catch {
168
+ // skip tag check
169
+ }
170
+ await group.addMembers([message.senderInboxId]);
171
+ if (dmConversation)
172
+ dmConversation.updateConsentState(1 /* ConsentState.Allowed */);
173
+ return { conversationId, joinerInboxId: message.senderInboxId };
174
+ }
175
+ /**
176
+ * Process any pending DM join requests (batch, on startup).
177
+ */
178
+ async processPendingJoinRequests(client, identity) {
179
+ try {
180
+ await client.conversations.sync();
181
+ const dms = await client.conversations.list({
182
+ conversationType: 0 /* ConversationType.Dm */,
183
+ consentStates: [0 /* ConsentState.Unknown */],
184
+ });
185
+ for (const dm of dms) {
186
+ try {
187
+ await dm.sync();
188
+ const messages = await dm.messages({ limit: 10 });
189
+ for (const message of messages) {
190
+ try {
191
+ const result = await this.processJoinMessage(message, client, identity);
192
+ if (result) {
193
+ this.emit({
194
+ event: "member_joined",
195
+ inboxId: result.joinerInboxId,
196
+ conversationId: result.conversationId,
197
+ timestamp: new Date().toISOString(),
198
+ });
199
+ break;
200
+ }
201
+ }
202
+ catch {
203
+ // skip individual message errors
204
+ }
205
+ }
206
+ }
207
+ catch {
208
+ // skip individual DM errors
209
+ }
210
+ }
211
+ }
212
+ catch (error) {
213
+ this.emitError(`Failed to process pending join requests: ${error instanceof Error ? error.message : "unknown"}`);
214
+ }
215
+ }
216
+ /**
217
+ * Start streaming DM messages for join request processing.
218
+ */
219
+ async startJoinRequestStream(client, identity) {
220
+ try {
221
+ const stream = await client.conversations.streamAllDmMessages();
222
+ this.streams.push(stream);
223
+ (async () => {
224
+ try {
225
+ for await (const message of stream) {
226
+ try {
227
+ const result = await this.processJoinMessage(message, client, identity);
228
+ if (result) {
229
+ this.emit({
230
+ event: "member_joined",
231
+ inboxId: result.joinerInboxId,
232
+ conversationId: result.conversationId,
233
+ timestamp: new Date().toISOString(),
234
+ });
235
+ }
236
+ }
237
+ catch {
238
+ // skip individual message errors
239
+ }
240
+ }
241
+ }
242
+ catch {
243
+ // stream ended
244
+ }
245
+ })();
246
+ }
247
+ catch (error) {
248
+ this.emitError(`Failed to start join request stream: ${error instanceof Error ? error.message : "unknown"}`);
249
+ }
250
+ }
251
+ /**
252
+ * Start streaming conversation messages.
253
+ */
254
+ async startMessageStream(conversation, client) {
255
+ try {
256
+ const stream = await conversation.stream();
257
+ this.streams.push(stream);
258
+ (async () => {
259
+ try {
260
+ for await (const message of stream) {
261
+ // Skip our own messages (they get a "sent" event instead)
262
+ if (message.senderInboxId === client.inboxId)
263
+ continue;
264
+ // Skip content types we can't display cleanly
265
+ if (!isDisplayableMessage(message))
266
+ continue;
267
+ // Rebuild profiles each time so newly-joined members are resolved
268
+ const profiles = buildProfileMap(conversation.appData ?? "");
269
+ this.emit({
270
+ event: "message",
271
+ id: message.id,
272
+ senderInboxId: message.senderInboxId,
273
+ contentType: message.contentType,
274
+ content: normalizeMessageContent(message, profiles),
275
+ sentAt: message.sentAt.toISOString(),
276
+ deliveryStatus: message.deliveryStatus,
277
+ });
278
+ }
279
+ }
280
+ catch {
281
+ // stream ended
282
+ }
283
+ })();
284
+ }
285
+ catch (error) {
286
+ this.emitError(`Failed to start message stream: ${error instanceof Error ? error.message : "unknown"}`);
287
+ }
288
+ }
289
+ /**
290
+ * Read and process stdin commands.
291
+ */
292
+ async startStdinReader(conversation) {
293
+ // If stdin is a TTY, skip the reader (no piped input)
294
+ if (process.stdin.isTTY)
295
+ return;
296
+ const rl = createInterface({
297
+ input: process.stdin,
298
+ terminal: false,
299
+ });
300
+ rl.on("line", (line) => {
301
+ const trimmed = line.trim();
302
+ if (!trimmed)
303
+ return;
304
+ let cmd;
305
+ try {
306
+ cmd = JSON.parse(trimmed);
307
+ }
308
+ catch {
309
+ this.emitError("Invalid JSON on stdin", { input: trimmed });
310
+ return;
311
+ }
312
+ void this.handleCommand(cmd, conversation);
313
+ });
314
+ rl.on("close", () => {
315
+ // stdin closed — trigger shutdown
316
+ this.shutdown();
317
+ });
318
+ }
319
+ /**
320
+ * Handle a parsed stdin command.
321
+ */
322
+ async handleCommand(cmd, conversation) {
323
+ try {
324
+ switch (cmd.type) {
325
+ case "send": {
326
+ if (!cmd.text) {
327
+ this.emitError("send command requires 'text' field");
328
+ return;
329
+ }
330
+ let messageId;
331
+ if (cmd.replyTo) {
332
+ const { encodeText } = await import("@xmtp/node-sdk");
333
+ messageId = await conversation.sendReply({
334
+ reference: cmd.replyTo,
335
+ content: encodeText(cmd.text),
336
+ });
337
+ }
338
+ else {
339
+ messageId = await conversation.sendText(cmd.text);
340
+ }
341
+ this.emit({
342
+ event: "sent",
343
+ id: messageId,
344
+ text: cmd.text,
345
+ ...(cmd.replyTo && { replyTo: cmd.replyTo }),
346
+ timestamp: new Date().toISOString(),
347
+ });
348
+ break;
349
+ }
350
+ case "react": {
351
+ if (!cmd.messageId || !cmd.emoji) {
352
+ this.emitError("react command requires 'messageId' and 'emoji' fields");
353
+ return;
354
+ }
355
+ const action = cmd.action === "remove" ? 2 /* ReactionAction.Removed */ : 1 /* ReactionAction.Added */;
356
+ const reaction = {
357
+ reference: cmd.messageId,
358
+ referenceInboxId: "",
359
+ action,
360
+ content: cmd.emoji,
361
+ schema: 1 /* ReactionSchema.Unicode */,
362
+ };
363
+ const reactionMessageId = await conversation.sendReaction(reaction);
364
+ this.emit({
365
+ event: "sent",
366
+ id: reactionMessageId,
367
+ type: "reaction",
368
+ messageId: cmd.messageId,
369
+ emoji: cmd.emoji,
370
+ action: cmd.action ?? "add",
371
+ timestamp: new Date().toISOString(),
372
+ });
373
+ break;
374
+ }
375
+ case "attach": {
376
+ if (!cmd.file) {
377
+ this.emitError("attach command requires 'file' field");
378
+ return;
379
+ }
380
+ const content = await readFile(cmd.file);
381
+ const filename = basename(cmd.file);
382
+ const mimeType = cmd.mimeType ?? getMimeType(cmd.file);
383
+ const attachment = { mimeType, content, filename };
384
+ const needsRemote = content.length > INLINE_ATTACHMENT_MAX_BYTES;
385
+ let messageId;
386
+ let sendType;
387
+ let url;
388
+ if (needsRemote) {
389
+ const config = this.getConvosConfig();
390
+ const provider = getUploadProvider(config);
391
+ if (!provider) {
392
+ this.emitError(`File is ${content.length} bytes (>${INLINE_ATTACHMENT_MAX_BYTES}). ` +
393
+ `Configure an upload provider (CONVOS_UPLOAD_PROVIDER) to send large files.`);
394
+ return;
395
+ }
396
+ const encrypted = encryptAttachment(attachment);
397
+ url = await provider.upload(encrypted.payload, filename, mimeType);
398
+ if (cmd.replyTo) {
399
+ const { encodeRemoteAttachment } = await import("@xmtp/node-sdk");
400
+ messageId = await conversation.sendReply({
401
+ reference: cmd.replyTo,
402
+ content: encodeRemoteAttachment({
403
+ url,
404
+ contentDigest: encrypted.contentDigest,
405
+ secret: encrypted.secret,
406
+ salt: encrypted.salt,
407
+ nonce: encrypted.nonce,
408
+ scheme: "https",
409
+ contentLength: encrypted.payload.length,
410
+ filename,
411
+ }),
412
+ });
413
+ }
414
+ else {
415
+ messageId = await conversation.sendRemoteAttachment({
416
+ url,
417
+ contentDigest: encrypted.contentDigest,
418
+ secret: encrypted.secret,
419
+ salt: encrypted.salt,
420
+ nonce: encrypted.nonce,
421
+ scheme: "https",
422
+ contentLength: encrypted.payload.length,
423
+ filename,
424
+ }, false);
425
+ }
426
+ sendType = "remote";
427
+ }
428
+ else {
429
+ if (cmd.replyTo) {
430
+ const { encodeAttachment } = await import("@xmtp/node-sdk");
431
+ messageId = await conversation.sendReply({
432
+ reference: cmd.replyTo,
433
+ content: encodeAttachment(attachment),
434
+ });
435
+ }
436
+ else {
437
+ messageId = await conversation.sendAttachment(attachment, false);
438
+ }
439
+ sendType = "inline";
440
+ }
441
+ this.emit({
442
+ event: "sent",
443
+ id: messageId,
444
+ type: "attachment",
445
+ filename,
446
+ mimeType,
447
+ size: content.length,
448
+ sendType,
449
+ ...(url && { url }),
450
+ ...(cmd.replyTo && { replyTo: cmd.replyTo }),
451
+ timestamp: new Date().toISOString(),
452
+ });
453
+ break;
454
+ }
455
+ case "remote-attach": {
456
+ if (!cmd.url || !cmd.contentDigest || !cmd.secret || !cmd.salt || !cmd.nonce || !cmd.contentLength) {
457
+ this.emitError("remote-attach command requires 'url', 'contentDigest', 'secret', 'salt', 'nonce', and 'contentLength' fields");
458
+ return;
459
+ }
460
+ const remoteAttachment = {
461
+ url: cmd.url,
462
+ contentDigest: cmd.contentDigest,
463
+ secret: new Uint8Array(Buffer.from(cmd.secret, "base64")),
464
+ salt: new Uint8Array(Buffer.from(cmd.salt, "base64")),
465
+ nonce: new Uint8Array(Buffer.from(cmd.nonce, "base64")),
466
+ scheme: cmd.scheme ?? "https",
467
+ contentLength: cmd.contentLength,
468
+ filename: cmd.filename,
469
+ };
470
+ const remoteMessageId = await conversation.sendRemoteAttachment(remoteAttachment, false);
471
+ this.emit({
472
+ event: "sent",
473
+ id: remoteMessageId,
474
+ type: "remote-attachment",
475
+ url: cmd.url,
476
+ filename: cmd.filename,
477
+ timestamp: new Date().toISOString(),
478
+ });
479
+ break;
480
+ }
481
+ case "stop":
482
+ this.shutdown();
483
+ break;
484
+ default:
485
+ this.emitError(`Unknown command type: ${cmd.type}`);
486
+ }
487
+ }
488
+ catch (error) {
489
+ this.emitError(`Command failed: ${error instanceof Error ? error.message : "unknown"}`, { command: cmd });
490
+ }
491
+ }
492
+ /**
493
+ * Trigger graceful shutdown.
494
+ */
495
+ shutdown() {
496
+ if (this.shutdownResolve) {
497
+ this.shutdownResolve();
498
+ }
499
+ }
500
+ async run() {
501
+ const { args, flags } = await this.parse(AgentServe);
502
+ const config = this.getConvosConfig();
503
+ const store = createIdentityStore();
504
+ let identity;
505
+ let client;
506
+ let group;
507
+ let conversationId;
508
+ let inviteUrl;
509
+ let inviteSlug;
510
+ let inviteTag;
511
+ if (args.id) {
512
+ // ─── Attach to existing conversation ───
513
+ const existing = store.getByConversationId(args.id);
514
+ if (!existing) {
515
+ this.error(`No identity found for conversation: ${args.id}`);
516
+ }
517
+ identity = existing;
518
+ client = await createClientForIdentity(identity, config);
519
+ const conv = await client.conversations.getConversationById(args.id);
520
+ if (!conv) {
521
+ this.error(`Conversation not found: ${args.id}`);
522
+ }
523
+ group = requireGroup(conv);
524
+ conversationId = args.id;
525
+ // Generate invite unless --no-invite
526
+ if (!flags["no-invite"]) {
527
+ let appData = "";
528
+ try {
529
+ appData = group.appData ?? "";
530
+ }
531
+ catch {
532
+ // no appData
533
+ }
534
+ let metadata = parseAppData(appData);
535
+ inviteTag = metadata.tag;
536
+ if (!inviteTag) {
537
+ inviteTag = randomAlphanumeric(10);
538
+ metadata = { ...metadata, tag: inviteTag };
539
+ await group.updateAppData(serializeAppData(metadata));
540
+ }
541
+ inviteSlug = await createInviteSlug(conversationId, client.inboxId, inviteTag, identity.walletKey, {
542
+ name: group.name || undefined,
543
+ description: group.description || undefined,
544
+ });
545
+ const env = config.env ?? "dev";
546
+ const baseUrl = env === "production"
547
+ ? "https://popup.convos.org/v2"
548
+ : "https://dev.convos.org/v2";
549
+ inviteUrl = `${baseUrl}?i=${encodeURIComponent(inviteSlug)}`;
550
+ }
551
+ }
552
+ else {
553
+ // ─── Create new conversation ───
554
+ if (flags.identity) {
555
+ const existing = store.get(flags.identity);
556
+ if (!existing)
557
+ this.error(`Identity not found: ${flags.identity}`);
558
+ if (existing.conversationId) {
559
+ this.error(`Identity ${flags.identity} is already linked to conversation ${existing.conversationId}`);
560
+ }
561
+ identity = existing;
562
+ }
563
+ else {
564
+ identity = store.create({
565
+ label: flags.label ?? flags.name,
566
+ profileName: flags["profile-name"],
567
+ });
568
+ }
569
+ client = await createClientForIdentity(identity, config);
570
+ const permissionsMap = {
571
+ "all-members": 0 /* GroupPermissionsOptions.Default */,
572
+ "admin-only": 1 /* GroupPermissionsOptions.AdminOnly */,
573
+ };
574
+ const options = {
575
+ groupName: flags.name,
576
+ groupDescription: flags.description,
577
+ permissions: permissionsMap[flags.permissions],
578
+ };
579
+ group = await client.conversations.createGroup([], options);
580
+ conversationId = group.id;
581
+ inviteTag = randomAlphanumeric(10);
582
+ store.update(identity.id, {
583
+ conversationId,
584
+ inboxId: client.inboxId,
585
+ inviteTag,
586
+ label: flags.label ?? flags.name ?? identity.label,
587
+ profileName: flags["profile-name"] ?? identity.profileName,
588
+ });
589
+ // Store invite tag + profile in appData
590
+ let metadata = {
591
+ tag: inviteTag,
592
+ profiles: [],
593
+ };
594
+ const profileName = flags["profile-name"];
595
+ if (profileName) {
596
+ metadata = upsertProfile(metadata, {
597
+ inboxId: client.inboxId,
598
+ name: profileName,
599
+ });
600
+ }
601
+ await group.updateAppData(serializeAppData(metadata));
602
+ // Generate invite
603
+ inviteSlug = await createInviteSlug(conversationId, client.inboxId, inviteTag, identity.walletKey, {
604
+ name: flags.name || undefined,
605
+ description: flags.description || undefined,
606
+ });
607
+ const env = config.env ?? "dev";
608
+ const baseUrl = env === "production"
609
+ ? "https://popup.convos.org/v2"
610
+ : "https://dev.convos.org/v2";
611
+ inviteUrl = `${baseUrl}?i=${encodeURIComponent(inviteSlug)}`;
612
+ }
613
+ // Generate QR code image (PNG) so agents can display it instantly
614
+ let qrCodePath;
615
+ if (inviteUrl) {
616
+ qrCodePath = join(tmpdir(), `convos-invite-${conversationId}.png`);
617
+ await QRCode.toFile(qrCodePath, inviteUrl, {
618
+ type: "png",
619
+ width: 512,
620
+ margin: 2,
621
+ });
622
+ process.stderr.write(`QR code saved to: ${qrCodePath}\n`);
623
+ process.stderr.write(`Invite URL: ${inviteUrl}\n`);
624
+ }
625
+ // Emit ready event
626
+ this.emit({
627
+ event: "ready",
628
+ conversationId,
629
+ identityId: identity.id,
630
+ inboxId: client.inboxId,
631
+ address: getAccountAddress(identity.walletKey),
632
+ name: group.name ?? "",
633
+ ...(inviteUrl && { inviteUrl }),
634
+ ...(inviteSlug && { inviteSlug }),
635
+ ...(inviteTag && { inviteTag }),
636
+ ...(qrCodePath && { qrCodePath }),
637
+ timestamp: new Date().toISOString(),
638
+ });
639
+ // Process any pending join requests from before we started
640
+ await this.processPendingJoinRequests(client, identity);
641
+ // Start all concurrent streams
642
+ await this.startMessageStream(group, client);
643
+ await this.startJoinRequestStream(client, identity);
644
+ this.startStdinReader(group);
645
+ // Keep running until shutdown
646
+ await new Promise((resolve) => {
647
+ this.shutdownResolve = resolve;
648
+ process.on("SIGINT", () => {
649
+ process.stderr.write("\nShutting down...\n");
650
+ resolve();
651
+ });
652
+ process.on("SIGTERM", () => {
653
+ process.stderr.write("\nShutting down...\n");
654
+ resolve();
655
+ });
656
+ });
657
+ // Clean up streams
658
+ for (const stream of this.streams) {
659
+ void stream.return();
660
+ }
661
+ }
662
+ }
@@ -0,0 +1,19 @@
1
+ import { ConvosBaseCommand } from "../../baseCommand.js";
2
+ export default class ConversationAddMembers extends ConvosBaseCommand {
3
+ static description: string;
4
+ static strict: boolean;
5
+ static args: {
6
+ id: import("@oclif/core/interfaces").Arg<string, Record<string, unknown>>;
7
+ };
8
+ static flags: {
9
+ "log-level": import("@oclif/core/interfaces").OptionFlag<"off" | "error" | "warn" | "info" | "debug" | "trace" | undefined, import("@oclif/core/interfaces").CustomOptions>;
10
+ "structured-logging": import("@oclif/core/interfaces").BooleanFlag<boolean>;
11
+ "app-version": import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
12
+ "env-file": import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
13
+ env: import("@oclif/core/interfaces").OptionFlag<"local" | "dev" | "production" | undefined, import("@oclif/core/interfaces").CustomOptions>;
14
+ "gateway-host": import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
15
+ json: import("@oclif/core/interfaces").BooleanFlag<boolean>;
16
+ verbose: import("@oclif/core/interfaces").BooleanFlag<boolean>;
17
+ };
18
+ run(): Promise<void>;
19
+ }