@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,97 @@
1
+ import { Args, Flags } from "@oclif/core";
2
+ import { requireGroup } from "../../utils/xmtp.js";
3
+ import { ConvosBaseCommand } from "../../baseCommand.js";
4
+ import { createClientForIdentity } from "../../utils/client.js";
5
+ import { createIdentityStore } from "../../utils/identities.js";
6
+ import { parseAppData, serializeAppData, upsertProfile, } from "../../utils/metadata.js";
7
+ export default class UpdateProfile extends ConvosBaseCommand {
8
+ static description = `Set your display name and avatar in a conversation.
9
+
10
+ Profiles are stored in the group's metadata (appData) and visible
11
+ to all members. Each conversation has independent profiles — you
12
+ can be a different person in each conversation (ADR 005).
13
+
14
+ Updates are synced to all members via XMTP's group metadata.
15
+ The profile is stored as a compressed protobuf in the group's
16
+ appData field (max 8KB shared across all profiles and metadata).`;
17
+ static examples = [
18
+ {
19
+ command: '<%= config.bin %> <%= command.id %> <conversation-id> --name "Alice"',
20
+ description: "Set your display name",
21
+ },
22
+ {
23
+ command: '<%= config.bin %> <%= command.id %> <conversation-id> --name "Alice" --image "https://example.com/avatar.jpg"',
24
+ description: "Set display name and avatar",
25
+ },
26
+ {
27
+ command: '<%= config.bin %> <%= command.id %> <conversation-id> --name "" --image ""',
28
+ description: "Clear your profile (go anonymous)",
29
+ },
30
+ ];
31
+ static args = {
32
+ id: Args.string({
33
+ description: "The conversation ID",
34
+ required: true,
35
+ }),
36
+ };
37
+ static flags = {
38
+ ...ConvosBaseCommand.baseFlags,
39
+ name: Flags.string({
40
+ description: "Display name (empty string to clear)",
41
+ helpValue: "<name>",
42
+ }),
43
+ image: Flags.string({
44
+ description: "Avatar image URL (empty string to clear)",
45
+ helpValue: "<url>",
46
+ }),
47
+ };
48
+ async run() {
49
+ const { args, flags } = await this.parse(UpdateProfile);
50
+ const config = this.getConvosConfig();
51
+ const store = createIdentityStore();
52
+ if (flags.name === undefined && flags.image === undefined) {
53
+ this.error("At least one of --name or --image must be provided");
54
+ }
55
+ const identity = store.getByConversationId(args.id);
56
+ if (!identity) {
57
+ this.error(`No identity found for conversation ${args.id}`);
58
+ }
59
+ const client = await createClientForIdentity(identity, config);
60
+ await client.conversations.sync();
61
+ const conversation = await client.conversations.getConversationById(args.id);
62
+ if (!conversation) {
63
+ this.error(`Conversation ${args.id} not found`);
64
+ }
65
+ const group = requireGroup(conversation);
66
+ // Parse current metadata
67
+ let appData = "";
68
+ try {
69
+ appData = group.appData ?? "";
70
+ }
71
+ catch {
72
+ // No appData yet
73
+ }
74
+ let metadata = parseAppData(appData);
75
+ // Build profile update
76
+ const profile = {
77
+ inboxId: client.inboxId,
78
+ ...(flags.name !== undefined ? { name: flags.name || undefined } : {}),
79
+ ...(flags.image !== undefined ? { image: flags.image || undefined } : {}),
80
+ };
81
+ metadata = upsertProfile(metadata, profile);
82
+ // Serialize and push
83
+ const newAppData = serializeAppData(metadata);
84
+ await group.updateAppData(newAppData);
85
+ // Also update local identity store
86
+ if (flags.name !== undefined) {
87
+ store.update(identity.id, { profileName: flags.name || undefined });
88
+ }
89
+ this.output({
90
+ conversationId: args.id,
91
+ inboxId: client.inboxId,
92
+ name: flags.name ?? "(unchanged)",
93
+ image: flags.image ?? "(unchanged)",
94
+ message: "Profile updated",
95
+ });
96
+ }
97
+ }
@@ -0,0 +1,26 @@
1
+ import { ConvosBaseCommand } from "../../baseCommand.js";
2
+ export default class ConversationsCreate extends ConvosBaseCommand {
3
+ static description: string;
4
+ static examples: {
5
+ command: string;
6
+ description: string;
7
+ }[];
8
+ static flags: {
9
+ name: import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
10
+ description: import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
11
+ "image-url": import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
12
+ permissions: import("@oclif/core/interfaces").OptionFlag<"all-members" | "admin-only", import("@oclif/core/interfaces").CustomOptions>;
13
+ identity: import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
14
+ label: import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
15
+ "profile-name": import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
16
+ "log-level": import("@oclif/core/interfaces").OptionFlag<"off" | "error" | "warn" | "info" | "debug" | "trace" | undefined, import("@oclif/core/interfaces").CustomOptions>;
17
+ "structured-logging": import("@oclif/core/interfaces").BooleanFlag<boolean>;
18
+ "app-version": import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
19
+ "env-file": import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
20
+ env: import("@oclif/core/interfaces").OptionFlag<"local" | "dev" | "production" | undefined, import("@oclif/core/interfaces").CustomOptions>;
21
+ "gateway-host": import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
22
+ json: import("@oclif/core/interfaces").BooleanFlag<boolean>;
23
+ verbose: import("@oclif/core/interfaces").BooleanFlag<boolean>;
24
+ };
25
+ run(): Promise<void>;
26
+ }
@@ -0,0 +1,165 @@
1
+ import { Flags } from "@oclif/core";
2
+ import { getAccountAddress } from "../../utils/xmtp.js";
3
+ import qrcode from "qrcode-terminal";
4
+ import { ConvosBaseCommand } from "../../baseCommand.js";
5
+ import { createClientForIdentity } from "../../utils/client.js";
6
+ import { createIdentityStore } from "../../utils/identities.js";
7
+ import { createInviteSlug } from "../../utils/invite.js";
8
+ import { serializeAppData, upsertProfile } from "../../utils/metadata.js";
9
+ import { randomAlphanumeric } from "../../utils/random.js";
10
+ export default class ConversationsCreate extends ConvosBaseCommand {
11
+ static description = `Create a new Convos conversation.
12
+
13
+ Creates a new conversation with a fresh per-conversation identity.
14
+ Each conversation gets its own XMTP inbox for privacy isolation
15
+ (per ADR 002).
16
+
17
+ This command:
18
+ 1. Creates a new identity (or uses an existing unlinked one)
19
+ 2. Initializes the XMTP client for that identity
20
+ 3. Creates a group conversation
21
+ 4. Links the identity to the conversation
22
+
23
+ The creator becomes super admin. Others join via invite links.`;
24
+ static examples = [
25
+ {
26
+ command: '<%= config.bin %> <%= command.id %> --name "Project Team"',
27
+ description: "Create a named conversation",
28
+ },
29
+ {
30
+ command: '<%= config.bin %> <%= command.id %> --name "Team" --description "Team discussion"',
31
+ description: "Create with metadata",
32
+ },
33
+ {
34
+ command: "<%= config.bin %> <%= command.id %> --identity <identity-id>",
35
+ description: "Use a pre-created identity",
36
+ },
37
+ {
38
+ command: '<%= config.bin %> <%= command.id %> --name "Private" --permissions admin-only --json',
39
+ description: "Create admin-only group with JSON output",
40
+ },
41
+ ];
42
+ static flags = {
43
+ ...ConvosBaseCommand.baseFlags,
44
+ name: Flags.string({
45
+ description: "Conversation name",
46
+ helpValue: "<name>",
47
+ }),
48
+ description: Flags.string({
49
+ description: "Conversation description",
50
+ helpValue: "<description>",
51
+ }),
52
+ "image-url": Flags.string({
53
+ description: "Conversation image URL",
54
+ helpValue: "<url>",
55
+ }),
56
+ permissions: Flags.option({
57
+ options: ["all-members", "admin-only"],
58
+ description: "Permission preset",
59
+ default: "all-members",
60
+ })(),
61
+ identity: Flags.string({
62
+ description: "Use an existing unlinked identity ID",
63
+ helpValue: "<id>",
64
+ }),
65
+ label: Flags.string({
66
+ description: "Local label for the identity",
67
+ helpValue: "<label>",
68
+ }),
69
+ "profile-name": Flags.string({
70
+ description: "Profile display name for this conversation",
71
+ helpValue: "<name>",
72
+ }),
73
+ };
74
+ async run() {
75
+ const { flags } = await this.parse(ConversationsCreate);
76
+ const config = this.getConvosConfig();
77
+ const store = createIdentityStore();
78
+ // Get or create identity
79
+ let identity;
80
+ if (flags.identity) {
81
+ identity = store.get(flags.identity);
82
+ if (!identity) {
83
+ this.error(`Identity not found: ${flags.identity}`);
84
+ }
85
+ if (identity.conversationId) {
86
+ this.error(`Identity ${flags.identity} is already linked to conversation ${identity.conversationId}`);
87
+ }
88
+ }
89
+ else {
90
+ identity = store.create({
91
+ label: flags.label ?? flags.name,
92
+ profileName: flags["profile-name"],
93
+ });
94
+ }
95
+ const client = await createClientForIdentity(identity, config);
96
+ const permissionsMap = {
97
+ "all-members": 0 /* GroupPermissionsOptions.Default */,
98
+ "admin-only": 1 /* GroupPermissionsOptions.AdminOnly */,
99
+ };
100
+ const options = {
101
+ groupName: flags.name,
102
+ groupDescription: flags.description,
103
+ groupImageUrlSquare: flags["image-url"],
104
+ permissions: permissionsMap[flags.permissions],
105
+ };
106
+ // Convos conversations start with just the creator
107
+ const group = await client.conversations.createGroup([], options);
108
+ // Generate invite tag
109
+ const inviteTag = randomAlphanumeric(10);
110
+ store.update(identity.id, {
111
+ conversationId: group.id,
112
+ inboxId: client.inboxId,
113
+ inviteTag,
114
+ label: flags.label ?? flags.name ?? identity.label,
115
+ profileName: flags["profile-name"] ?? identity.profileName,
116
+ });
117
+ // Store invite tag in appData
118
+ let metadata = { tag: inviteTag, profiles: [] };
119
+ // Write creator's profile to shared metadata so other members can see it
120
+ const profileName = flags["profile-name"];
121
+ if (profileName) {
122
+ metadata = upsertProfile(metadata, {
123
+ inboxId: client.inboxId,
124
+ name: profileName,
125
+ });
126
+ }
127
+ await group.updateAppData(serializeAppData(metadata));
128
+ // Generate invite slug and URL
129
+ const slug = await createInviteSlug(group.id, client.inboxId, inviteTag, identity.walletKey, {
130
+ name: flags.name || undefined,
131
+ description: flags.description || undefined,
132
+ });
133
+ const env = config.env ?? "dev";
134
+ const baseUrl = env === "production"
135
+ ? "https://popup.convos.org/v2"
136
+ : "https://dev.convos.org/v2";
137
+ const inviteUrl = `${baseUrl}?i=${encodeURIComponent(slug)}`;
138
+ // Display QR code unless --json
139
+ if (!flags.json) {
140
+ this.log(""); // blank line before QR
141
+ await new Promise((resolve) => {
142
+ qrcode.generate(inviteUrl, { small: true }, (code) => {
143
+ this.log(code);
144
+ resolve();
145
+ });
146
+ });
147
+ this.log(` ${inviteUrl}\n`);
148
+ }
149
+ this.output({
150
+ conversationId: group.id,
151
+ identityId: identity.id,
152
+ address: getAccountAddress(identity.walletKey),
153
+ inboxId: client.inboxId,
154
+ name: flags.name ?? "",
155
+ description: flags.description ?? "",
156
+ permissions: flags.permissions,
157
+ createdAt: group.createdAt.toISOString(),
158
+ invite: {
159
+ slug,
160
+ url: inviteUrl,
161
+ tag: inviteTag,
162
+ },
163
+ });
164
+ }
165
+ }
@@ -0,0 +1,27 @@
1
+ import { ConvosBaseCommand } from "../../baseCommand.js";
2
+ export default class ConversationsJoin extends ConvosBaseCommand {
3
+ static description: string;
4
+ static examples: {
5
+ command: string;
6
+ description: string;
7
+ }[];
8
+ static args: {
9
+ invite: import("@oclif/core/interfaces").Arg<string, Record<string, unknown>>;
10
+ };
11
+ static flags: {
12
+ "no-wait": import("@oclif/core/interfaces").BooleanFlag<boolean>;
13
+ timeout: import("@oclif/core/interfaces").OptionFlag<number, import("@oclif/core/interfaces").CustomOptions>;
14
+ label: import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
15
+ "profile-name": import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
16
+ identity: import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
17
+ "log-level": import("@oclif/core/interfaces").OptionFlag<"off" | "error" | "warn" | "info" | "debug" | "trace" | undefined, import("@oclif/core/interfaces").CustomOptions>;
18
+ "structured-logging": import("@oclif/core/interfaces").BooleanFlag<boolean>;
19
+ "app-version": import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
20
+ "env-file": import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
21
+ env: import("@oclif/core/interfaces").OptionFlag<"local" | "dev" | "production" | undefined, import("@oclif/core/interfaces").CustomOptions>;
22
+ "gateway-host": import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
23
+ json: import("@oclif/core/interfaces").BooleanFlag<boolean>;
24
+ verbose: import("@oclif/core/interfaces").BooleanFlag<boolean>;
25
+ };
26
+ run(): Promise<void>;
27
+ }
@@ -0,0 +1,232 @@
1
+ import { Args, Flags } from "@oclif/core";
2
+ import { getAccountAddress, isGroup } from "../../utils/xmtp.js";
3
+ import { ConvosBaseCommand } from "../../baseCommand.js";
4
+ import { createClientForIdentity } from "../../utils/client.js";
5
+ import { createIdentityStore } from "../../utils/identities.js";
6
+ import { parseInvite, verifyInvite, inviteToSlug } from "../../utils/invite.js";
7
+ import { parseAppData, serializeAppData, upsertProfile, } from "../../utils/metadata.js";
8
+ export default class ConversationsJoin extends ConvosBaseCommand {
9
+ static description = `Join a conversation using an invite slug or URL.
10
+
11
+ Implements the Convos join flow (per ADR 001):
12
+
13
+ 1. Parse and validate the invite (verify signature, check expiration)
14
+ 2. Create a new per-conversation identity for this conversation
15
+ 3. Send the invite slug as a DM to the creator's inbox
16
+ 4. Wait for the creator's client to process the join request and
17
+ add this identity to the conversation
18
+ 5. Link the identity to the conversation once joined
19
+
20
+ The creator's client (iOS app or another CLI instance running
21
+ 'convos conversations process-join-requests') must be online to
22
+ process the join request.
23
+
24
+ Accepts either a raw invite slug or a full URL containing the
25
+ slug as query parameter 'i'.`;
26
+ static examples = [
27
+ {
28
+ command: "<%= config.bin %> <%= command.id %> <invite-slug>",
29
+ description: "Join using a raw invite slug",
30
+ },
31
+ {
32
+ command: '<%= config.bin %> <%= command.id %> "https://popup.convos.org/v2?i=<slug>"',
33
+ description: "Join using a full invite URL",
34
+ },
35
+ {
36
+ command: "<%= config.bin %> <%= command.id %> <slug> --no-wait",
37
+ description: "Send join request without waiting for acceptance",
38
+ },
39
+ {
40
+ command: "<%= config.bin %> <%= command.id %> <slug> --timeout 120 --json",
41
+ description: "Wait up to 2 minutes for acceptance",
42
+ },
43
+ ];
44
+ static args = {
45
+ invite: Args.string({
46
+ description: "Invite slug or URL",
47
+ required: true,
48
+ }),
49
+ };
50
+ static flags = {
51
+ ...ConvosBaseCommand.baseFlags,
52
+ "no-wait": Flags.boolean({
53
+ description: "Send the join request but don't wait for the creator to accept",
54
+ default: false,
55
+ }),
56
+ timeout: Flags.integer({
57
+ description: "Seconds to wait for acceptance (default: 60)",
58
+ helpValue: "<seconds>",
59
+ default: 60,
60
+ }),
61
+ label: Flags.string({
62
+ description: "Local label for the new identity",
63
+ helpValue: "<label>",
64
+ }),
65
+ "profile-name": Flags.string({
66
+ description: "Profile display name for this conversation",
67
+ helpValue: "<name>",
68
+ }),
69
+ identity: Flags.string({
70
+ description: "Use an existing unlinked identity instead of creating one",
71
+ helpValue: "<id>",
72
+ }),
73
+ };
74
+ async run() {
75
+ const { args, flags } = await this.parse(ConversationsJoin);
76
+ const config = this.getConvosConfig();
77
+ const store = createIdentityStore();
78
+ // Step 1: Parse invite
79
+ const invite = parseInvite(args.invite);
80
+ // Validate
81
+ if (!(await verifyInvite(invite))) {
82
+ this.error("Invalid invite signature");
83
+ }
84
+ if (invite.expiresAt && invite.expiresAt < new Date()) {
85
+ this.error("Invite has expired");
86
+ }
87
+ if (invite.conversationExpiresAt && invite.conversationExpiresAt < new Date()) {
88
+ this.error("Conversation has expired");
89
+ }
90
+ this.log(`Invite parsed: tag=${invite.tag}` +
91
+ (invite.name ? ` name="${invite.name}"` : "") +
92
+ ` creator=${invite.creatorInboxId.slice(0, 12)}...`);
93
+ // Check if we've already joined this conversation (same invite tag)
94
+ const existingIdentity = store.getByInviteTag(invite.tag);
95
+ if (existingIdentity) {
96
+ this.error(`Already joined this conversation.\n` +
97
+ ` Identity: ${existingIdentity.id}\n` +
98
+ ` Conversation: ${existingIdentity.conversationId ?? "(pending)"}\n` +
99
+ ` Label: ${existingIdentity.label ?? ""}\n\n` +
100
+ `Use 'convos conversation send-text ${existingIdentity.conversationId ?? "<id>"}' to send messages.`);
101
+ }
102
+ // Step 2: Get or create identity
103
+ let identity;
104
+ if (flags.identity) {
105
+ identity = store.get(flags.identity);
106
+ if (!identity)
107
+ this.error(`Identity not found: ${flags.identity}`);
108
+ if (identity.conversationId) {
109
+ this.error(`Identity already linked to conversation ${identity.conversationId}`);
110
+ }
111
+ }
112
+ else {
113
+ identity = store.create({
114
+ label: flags.label ?? invite.name,
115
+ profileName: flags["profile-name"],
116
+ });
117
+ }
118
+ // Store invite tag immediately so duplicate detection works even if we exit early
119
+ store.update(identity.id, { inviteTag: invite.tag });
120
+ // Step 3: Create XMTP client and send DM to creator
121
+ const client = await createClientForIdentity(identity, config);
122
+ this.log(`Created identity ${identity.id.slice(0, 12)}... (${getAccountAddress(identity.walletKey)})`);
123
+ this.log(`Sending join request to creator inbox ${invite.creatorInboxId.slice(0, 12)}...`);
124
+ // Create DM with creator using their XMTP inbox ID
125
+ const dm = await client.conversations.createDm(invite.creatorInboxId);
126
+ // Send the invite slug as the join request
127
+ const slug = inviteToSlug(invite);
128
+ await dm.sendText(slug);
129
+ this.log("Join request sent.");
130
+ if (flags["no-wait"]) {
131
+ this.output({
132
+ status: "request_sent",
133
+ identityId: identity.id,
134
+ address: getAccountAddress(identity.walletKey),
135
+ inboxId: client.inboxId,
136
+ creatorInboxId: invite.creatorInboxId,
137
+ tag: invite.tag,
138
+ name: invite.name ?? null,
139
+ message: "Join request sent. The creator must accept it. " +
140
+ "Run 'convos conversations list --sync' to check if you've been added.",
141
+ });
142
+ return;
143
+ }
144
+ // Step 4: Wait for acceptance (poll for new group conversations)
145
+ this.log(`Waiting for acceptance (timeout: ${flags.timeout}s)...`);
146
+ const startTime = Date.now();
147
+ const timeoutMs = flags.timeout * 1000;
148
+ let conversationId;
149
+ while (Date.now() - startTime < timeoutMs) {
150
+ await client.conversations.sync();
151
+ const conversations = await client.conversations.list();
152
+ // Look for a new group conversation (not the DM we created)
153
+ for (const conv of conversations) {
154
+ if (conv.id !== dm.id) {
155
+ conversationId = conv.id;
156
+ break;
157
+ }
158
+ }
159
+ if (conversationId)
160
+ break;
161
+ // Poll every 2 seconds
162
+ await new Promise((resolve) => setTimeout(resolve, 2000));
163
+ }
164
+ if (!conversationId) {
165
+ this.log("Timed out waiting for acceptance.");
166
+ this.output({
167
+ status: "timeout",
168
+ identityId: identity.id,
169
+ address: getAccountAddress(identity.walletKey),
170
+ inboxId: client.inboxId,
171
+ tag: invite.tag,
172
+ message: "The creator has not yet accepted the join request. " +
173
+ "Run 'convos conversations list --sync' later to check.",
174
+ });
175
+ return;
176
+ }
177
+ // Step 5: Verify the group's invite tag matches the invite we used (ADR 001)
178
+ // This protects against being added to a different conversation than requested.
179
+ try {
180
+ const conv = await client.conversations.getConversationById(conversationId);
181
+ if (conv && isGroup(conv)) {
182
+ const appData = conv.appData ?? "";
183
+ const metadata = parseAppData(appData);
184
+ if (metadata.tag && metadata.tag !== invite.tag) {
185
+ this.warn(`Invite tag mismatch: expected "${invite.tag}" but group has "${metadata.tag}". ` +
186
+ "You may have been added to a different conversation than expected.");
187
+ }
188
+ }
189
+ }
190
+ catch {
191
+ // Non-fatal: tag verification is a safety check, don't block joining
192
+ }
193
+ // Step 6: Write joiner's profile to shared metadata
194
+ const profileName = flags["profile-name"];
195
+ if (profileName) {
196
+ try {
197
+ await client.conversations.sync();
198
+ const conv = await client.conversations.getConversationById(conversationId);
199
+ if (conv && isGroup(conv)) {
200
+ await conv.sync();
201
+ const appData = conv.appData ?? "";
202
+ const metadata = parseAppData(appData);
203
+ const updated = upsertProfile(metadata, {
204
+ inboxId: client.inboxId,
205
+ name: profileName,
206
+ });
207
+ await conv.updateAppData(serializeAppData(updated));
208
+ }
209
+ }
210
+ catch (error) {
211
+ this.warn(`Could not write profile to group metadata: ${error instanceof Error ? error.message : "unknown"}`);
212
+ }
213
+ }
214
+ // Step 7: Link identity to conversation
215
+ store.update(identity.id, {
216
+ conversationId,
217
+ inboxId: client.inboxId,
218
+ inviteTag: invite.tag,
219
+ label: flags.label ?? invite.name ?? identity.label,
220
+ profileName: flags["profile-name"] ?? identity.profileName,
221
+ });
222
+ this.output({
223
+ status: "joined",
224
+ conversationId,
225
+ identityId: identity.id,
226
+ address: getAccountAddress(identity.walletKey),
227
+ inboxId: client.inboxId,
228
+ tag: invite.tag,
229
+ name: invite.name ?? null,
230
+ });
231
+ }
232
+ }
@@ -0,0 +1,20 @@
1
+ import { ConvosBaseCommand } from "../../baseCommand.js";
2
+ export default class ConversationsList extends ConvosBaseCommand {
3
+ static description: string;
4
+ static examples: {
5
+ command: string;
6
+ description: string;
7
+ }[];
8
+ static flags: {
9
+ sync: import("@oclif/core/interfaces").BooleanFlag<boolean>;
10
+ "log-level": import("@oclif/core/interfaces").OptionFlag<"off" | "error" | "warn" | "info" | "debug" | "trace" | undefined, import("@oclif/core/interfaces").CustomOptions>;
11
+ "structured-logging": import("@oclif/core/interfaces").BooleanFlag<boolean>;
12
+ "app-version": import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
13
+ "env-file": import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
14
+ env: import("@oclif/core/interfaces").OptionFlag<"local" | "dev" | "production" | undefined, import("@oclif/core/interfaces").CustomOptions>;
15
+ "gateway-host": import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
16
+ json: import("@oclif/core/interfaces").BooleanFlag<boolean>;
17
+ verbose: import("@oclif/core/interfaces").BooleanFlag<boolean>;
18
+ };
19
+ run(): Promise<void>;
20
+ }