@andocorp/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.
package/src/format.ts ADDED
@@ -0,0 +1,611 @@
1
+ import * as Shared from "@ando/shared";
2
+ import { Membership, Message } from "./types.js";
3
+
4
+ type DateInput = Date | string | number | null | undefined;
5
+
6
+ export const LINK_START_MARKER = "\u0000";
7
+ export const LINK_END_MARKER = "\u0001";
8
+ export const LINK_SEPARATOR_MARKER = "\u0002";
9
+ const ANSI_RESET = "\u001b[0m";
10
+ const ANSI_LINK = "\u001b[34m";
11
+ const ANSI_CURRENT_MEMBER = "\u001b[38;2;0;224;224m";
12
+ const ANSI_OTHER_MEMBER = "\u001b[38;2;221;221;221m";
13
+ const ANSI_CODE_BLOCK_FOREGROUND = "\u001b[38;2;21;128;61m";
14
+ const ANSI_ESCAPE_PATTERN = new RegExp(`${ANSI_RESET[0]}\\[[0-9;]*m`, "g");
15
+ const ANSI_ESCAPE_AT_START_PATTERN = new RegExp(
16
+ `^${ANSI_RESET[0]}\\[[0-9;]*m`
17
+ );
18
+
19
+ type MessageBodyLine = {
20
+ text: string;
21
+ isCodeBlock?: boolean;
22
+ preserveWhitespace?: boolean;
23
+ };
24
+
25
+ function pad(value: number) {
26
+ return value.toString().padStart(2, "0");
27
+ }
28
+
29
+ function getMonthName(monthIndex: number) {
30
+ return new Intl.DateTimeFormat("en-US", {
31
+ month: "long",
32
+ }).format(new Date(2020, monthIndex, 1));
33
+ }
34
+
35
+ function stripLinkMarkers(text: string) {
36
+ let result = "";
37
+ let index = 0;
38
+
39
+ while (index < text.length) {
40
+ const character = text[index];
41
+ if (character == null) {
42
+ break;
43
+ }
44
+
45
+ if (character === LINK_START_MARKER) {
46
+ index += 1;
47
+ while (index < text.length && text[index] !== LINK_SEPARATOR_MARKER) {
48
+ index += 1;
49
+ }
50
+
51
+ if (text[index] === LINK_SEPARATOR_MARKER) {
52
+ index += 1;
53
+ }
54
+
55
+ while (index < text.length && text[index] !== LINK_END_MARKER) {
56
+ const linkCharacter = text[index];
57
+ if (linkCharacter != null) {
58
+ result += linkCharacter;
59
+ }
60
+ index += 1;
61
+ }
62
+
63
+ if (text[index] === LINK_END_MARKER) {
64
+ index += 1;
65
+ }
66
+
67
+ continue;
68
+ }
69
+
70
+ if (character !== LINK_END_MARKER && character !== LINK_SEPARATOR_MARKER) {
71
+ result += character;
72
+ }
73
+
74
+ index += 1;
75
+ }
76
+
77
+ return result;
78
+ }
79
+
80
+ export function stripAnsi(text: string) {
81
+ return text.replace(ANSI_ESCAPE_PATTERN, "");
82
+ }
83
+
84
+ export function getVisibleText(text: string) {
85
+ return stripAnsi(stripLinkMarkers(text));
86
+ }
87
+
88
+ export function getVisibleTextLength(text: string) {
89
+ return getVisibleText(text).length;
90
+ }
91
+
92
+ export function renderTerminalText(text: string) {
93
+ let rendered = "";
94
+ let index = 0;
95
+
96
+ while (index < text.length) {
97
+ const character = text[index];
98
+ if (character == null) {
99
+ break;
100
+ }
101
+
102
+ if (character === LINK_START_MARKER) {
103
+ index += 1;
104
+ let url = "";
105
+ while (index < text.length && text[index] !== LINK_SEPARATOR_MARKER) {
106
+ const urlCharacter = text[index];
107
+ if (urlCharacter != null) {
108
+ url += urlCharacter;
109
+ }
110
+ index += 1;
111
+ }
112
+
113
+ if (text[index] === LINK_SEPARATOR_MARKER) {
114
+ index += 1;
115
+ }
116
+
117
+ rendered += ANSI_LINK;
118
+ continue;
119
+ }
120
+
121
+ if (character === LINK_END_MARKER) {
122
+ rendered += ANSI_RESET;
123
+ index += 1;
124
+ continue;
125
+ }
126
+
127
+ if (character !== LINK_SEPARATOR_MARKER) {
128
+ rendered += character;
129
+ }
130
+
131
+ index += 1;
132
+ }
133
+
134
+ return rendered;
135
+ }
136
+
137
+ function formatLink(url: string, label: string) {
138
+ return `${LINK_START_MARKER}${url}${LINK_SEPARATOR_MARKER}${label}${LINK_END_MARKER}`;
139
+ }
140
+
141
+ function isUrlLikeText(text: string) {
142
+ try {
143
+ const url = new URL(text);
144
+ return url.protocol === "http:" || url.protocol === "https:";
145
+ } catch {
146
+ return false;
147
+ }
148
+ }
149
+
150
+ function getLinkLabel(part: Shared.Markup.LinkPart) {
151
+ const childText = part.children
152
+ .map((child) => (child.type === "text" ? child.value : "\n"))
153
+ .join("")
154
+ .trim();
155
+
156
+ if (childText === "") {
157
+ return part.url;
158
+ }
159
+
160
+ if (childText === part.url || isUrlLikeText(childText)) {
161
+ return childText;
162
+ }
163
+
164
+ return childText;
165
+ }
166
+
167
+ function getMessageBodyText(markdown: string) {
168
+ return Shared.Markup.parseParts(markdown)
169
+ .map((part) => {
170
+ if (part.type === "text") {
171
+ return part.value;
172
+ }
173
+
174
+ if (part.type === "linebreak") {
175
+ return "\n";
176
+ }
177
+
178
+ if (part.type === "mention") {
179
+ return `@${part.displayName}`;
180
+ }
181
+
182
+ if (part.type === "channel") {
183
+ return `#${part.displayName}`;
184
+ }
185
+
186
+ if (part.type === "image") {
187
+ return part.alt || "Image";
188
+ }
189
+
190
+ if (part.type === "link") {
191
+ return formatLink(part.url, getLinkLabel(part));
192
+ }
193
+
194
+ return "";
195
+ })
196
+ .join("");
197
+ }
198
+
199
+ function pushCurrentLine(lines: MessageBodyLine[], currentLine: string) {
200
+ lines.push({ text: currentLine });
201
+ }
202
+
203
+ export function renderCodeBlockText(text: string) {
204
+ return `${ANSI_CODE_BLOCK_FOREGROUND}${text}${ANSI_RESET}`;
205
+ }
206
+
207
+ function isCodeLikeLine(text: string) {
208
+ const trimmed = text.trim();
209
+ if (trimmed === "") {
210
+ return false;
211
+ }
212
+
213
+ return (
214
+ /^(Error|TypeError|ReferenceError|SyntaxError|RangeError|URIError|EvalError|AggregateError):/.test(
215
+ trimmed
216
+ ) ||
217
+ /^at\s+\S+/.test(trimmed) ||
218
+ /^Caused by:/.test(trimmed) ||
219
+ /^Traceback \(most recent call last\):/.test(trimmed) ||
220
+ /:[0-9]+(?::[0-9]+)?\)?$/.test(trimmed)
221
+ );
222
+ }
223
+
224
+ function startsImplicitCodeBlock(lines: string[], index: number) {
225
+ if (!isCodeLikeLine(lines[index] ?? "")) {
226
+ return false;
227
+ }
228
+
229
+ return isCodeLikeLine(lines[index + 1] ?? "");
230
+ }
231
+
232
+ function getRenderedLinesFromMarkdownSegment(markdown: string): MessageBodyLine[] {
233
+ const parts = Shared.Markup.parseParts(markdown);
234
+ const lines: MessageBodyLine[] = [];
235
+ let currentLine = "";
236
+
237
+ for (const part of parts) {
238
+ if (part.type === "text") {
239
+ currentLine += part.value;
240
+ continue;
241
+ }
242
+
243
+ if (part.type === "linebreak") {
244
+ pushCurrentLine(lines, currentLine);
245
+ currentLine = "";
246
+ continue;
247
+ }
248
+
249
+ if (part.type === "mention") {
250
+ currentLine += `@${part.displayName}`;
251
+ continue;
252
+ }
253
+
254
+ if (part.type === "channel") {
255
+ currentLine += `#${part.displayName}`;
256
+ continue;
257
+ }
258
+
259
+ if (part.type === "image") {
260
+ currentLine += part.alt || "Image";
261
+ continue;
262
+ }
263
+
264
+ if (part.type === "link") {
265
+ currentLine += formatLink(part.url, getLinkLabel(part));
266
+ }
267
+ }
268
+
269
+ pushCurrentLine(lines, currentLine);
270
+ return lines;
271
+ }
272
+
273
+ function parseMessageBodyLines(markdown: string): MessageBodyLine[] {
274
+ const normalizedMarkdown = markdown.replace(/\r\n/g, "\n");
275
+ const lines = normalizedMarkdown.split("\n");
276
+ const renderedLines: MessageBodyLine[] = [];
277
+ const markdownBuffer: string[] = [];
278
+ let codeFenceLanguage: string | null = null;
279
+ let codeBuffer: string[] = [];
280
+
281
+ const flushMarkdownBuffer = () => {
282
+ if (markdownBuffer.length === 0) {
283
+ return;
284
+ }
285
+
286
+ renderedLines.push(
287
+ ...getRenderedLinesFromMarkdownSegment(markdownBuffer.join("\n"))
288
+ );
289
+ markdownBuffer.length = 0;
290
+ };
291
+
292
+ const flushCodeBuffer = () => {
293
+ if (codeBuffer.length === 0) {
294
+ renderedLines.push({
295
+ text: "",
296
+ isCodeBlock: true,
297
+ preserveWhitespace: true,
298
+ });
299
+ } else {
300
+ for (const line of codeBuffer) {
301
+ renderedLines.push({
302
+ text: line,
303
+ isCodeBlock: true,
304
+ preserveWhitespace: true,
305
+ });
306
+ }
307
+ }
308
+
309
+ codeBuffer = [];
310
+ codeFenceLanguage = null;
311
+ };
312
+
313
+ for (let index = 0; index < lines.length; index += 1) {
314
+ const line = lines[index] ?? "";
315
+ const fenceMatch = /^```(.*)$/.exec(line);
316
+ if (fenceMatch) {
317
+ if (codeFenceLanguage == null) {
318
+ flushMarkdownBuffer();
319
+ codeFenceLanguage = fenceMatch[1]?.trim() || null;
320
+ codeBuffer = [];
321
+ } else {
322
+ flushCodeBuffer();
323
+ }
324
+ continue;
325
+ }
326
+
327
+ if (codeFenceLanguage != null) {
328
+ codeBuffer.push(line);
329
+ continue;
330
+ }
331
+
332
+ if (startsImplicitCodeBlock(lines, index)) {
333
+ flushMarkdownBuffer();
334
+ codeBuffer = [line];
335
+
336
+ let nextIndex = index + 1;
337
+ while (nextIndex < lines.length) {
338
+ const nextLine = lines[nextIndex] ?? "";
339
+ if (!isCodeLikeLine(nextLine)) {
340
+ break;
341
+ }
342
+
343
+ codeBuffer.push(nextLine);
344
+ nextIndex += 1;
345
+ }
346
+
347
+ flushCodeBuffer();
348
+ index = nextIndex - 1;
349
+ continue;
350
+ }
351
+
352
+ markdownBuffer.push(line);
353
+ }
354
+
355
+ flushMarkdownBuffer();
356
+ if (codeFenceLanguage != null) {
357
+ flushCodeBuffer();
358
+ }
359
+
360
+ return renderedLines;
361
+ }
362
+
363
+ export function formatDateTime(value: DateInput) {
364
+ if (value == null) {
365
+ return "";
366
+ }
367
+
368
+ const date = new Date(value);
369
+ if (Number.isNaN(date.getTime())) {
370
+ return "";
371
+ }
372
+
373
+ return `${pad(date.getDate())}/${pad(date.getMonth() + 1)} ${pad(
374
+ date.getHours()
375
+ )}:${pad(date.getMinutes())}`;
376
+ }
377
+
378
+ export function formatTime(value: DateInput) {
379
+ if (value == null) {
380
+ return "";
381
+ }
382
+
383
+ const date = new Date(value);
384
+ if (Number.isNaN(date.getTime())) {
385
+ return "";
386
+ }
387
+
388
+ return `${pad(date.getHours())}:${pad(date.getMinutes())}`;
389
+ }
390
+
391
+ export function formatDateSeparator(value: DateInput) {
392
+ if (value == null) {
393
+ return "";
394
+ }
395
+
396
+ const date = new Date(value);
397
+ if (Number.isNaN(date.getTime())) {
398
+ return "";
399
+ }
400
+
401
+ const baseLabel = `${getMonthName(date.getMonth())} ${date.getDate()}`;
402
+ const currentYear = new Date().getFullYear();
403
+
404
+ if (date.getFullYear() === currentYear) {
405
+ return baseLabel;
406
+ }
407
+
408
+ return `${baseLabel} '${date.getFullYear().toString().slice(-2)}`;
409
+ }
410
+
411
+ export function truncate(text: string, width: number) {
412
+ if (width <= 0) {
413
+ return "";
414
+ }
415
+
416
+ const visibleText = getVisibleText(text);
417
+ if (visibleText.length <= width) {
418
+ return text;
419
+ }
420
+
421
+ if (width <= 1) {
422
+ return visibleText.slice(0, width);
423
+ }
424
+
425
+ let visibleCount = 0;
426
+ let result = "";
427
+
428
+ for (let index = 0; index < text.length; index += 1) {
429
+ const character = text[index];
430
+ if (character == null) {
431
+ break;
432
+ }
433
+
434
+ if (character === "\u001b") {
435
+ const ansiMatch = ANSI_ESCAPE_AT_START_PATTERN.exec(text.slice(index));
436
+ if (ansiMatch != null) {
437
+ result += ansiMatch[0];
438
+ index += ansiMatch[0].length - 1;
439
+ continue;
440
+ }
441
+ }
442
+
443
+ if (character === LINK_START_MARKER || character === LINK_END_MARKER) {
444
+ result += character;
445
+ if (character === LINK_START_MARKER) {
446
+ index += 1;
447
+ while (index < text.length) {
448
+ const linkCharacter = text[index];
449
+ if (linkCharacter == null) {
450
+ break;
451
+ }
452
+
453
+ result += linkCharacter;
454
+ if (linkCharacter === LINK_SEPARATOR_MARKER) {
455
+ break;
456
+ }
457
+ index += 1;
458
+ }
459
+ }
460
+ continue;
461
+ }
462
+
463
+ if (visibleCount >= width - 1) {
464
+ break;
465
+ }
466
+
467
+ result += character;
468
+ visibleCount += 1;
469
+ }
470
+
471
+ return `${result}…`;
472
+ }
473
+
474
+ export function padVisibleEnd(text: string, width: number) {
475
+ const visibleLength = getVisibleTextLength(text);
476
+ if (visibleLength >= width) {
477
+ return text;
478
+ }
479
+
480
+ return `${text}${" ".repeat(width - visibleLength)}`;
481
+ }
482
+
483
+ export function getConversationLabel(membership: Membership) {
484
+ const prefix =
485
+ membership.conversation.type === Shared.Models.ConversationType.CHANNEL
486
+ ? "#"
487
+ : "@";
488
+
489
+ return `${prefix}${membership.conversation.name || membership.conversation.id}`;
490
+ }
491
+
492
+ export function getMessageBody(message: Message) {
493
+ const body = message.markdown_content?.trim();
494
+ if (body) {
495
+ return getMessageBodyText(body).replace(/\s+/g, " ").trim();
496
+ }
497
+
498
+ if (message.image_urls.length > 0) {
499
+ return `[${message.image_urls.length} image attachment${message.image_urls.length === 1 ? "" : "s"}]`;
500
+ }
501
+
502
+ if (message.files.length > 0) {
503
+ return `[${message.files.length} file attachment${message.files.length === 1 ? "" : "s"}]`;
504
+ }
505
+
506
+ if (message.call_id != null) {
507
+ return "[Jam started]";
508
+ }
509
+
510
+ return "[empty message]";
511
+ }
512
+
513
+ export function getMessageBodyLines(message: Message) {
514
+ const body = message.markdown_content?.trim();
515
+ if (body) {
516
+ return parseMessageBodyLines(body);
517
+ }
518
+
519
+ return [{ text: getMessageBody(message) }];
520
+ }
521
+
522
+ export function formatReactionSummary(message: Message) {
523
+ if (message.message_reactions.length === 0) {
524
+ return "";
525
+ }
526
+
527
+ const groupedEmojis: string[] = [];
528
+ let currentEmoji = "";
529
+ let currentCount = 0;
530
+
531
+ const pushCurrentEmoji = () => {
532
+ if (currentCount === 0) {
533
+ return;
534
+ }
535
+
536
+ groupedEmojis.push(currentEmoji.repeat(currentCount));
537
+ };
538
+
539
+ for (const reaction of message.message_reactions) {
540
+ if (reaction.emoji_text === currentEmoji) {
541
+ currentCount += 1;
542
+ continue;
543
+ }
544
+
545
+ pushCurrentEmoji();
546
+ currentEmoji = reaction.emoji_text;
547
+ currentCount = 1;
548
+ }
549
+
550
+ pushCurrentEmoji();
551
+
552
+ return groupedEmojis.join(" ");
553
+ }
554
+
555
+ export function formatMessageFooter(message: Message) {
556
+ const parts: string[] = [];
557
+ const reactions = formatReactionSummary(message);
558
+
559
+ if (message.replies_count > 0) {
560
+ parts.push(
561
+ `[${message.replies_count} ${message.replies_count === 1 ? "reply" : "replies"}]`
562
+ );
563
+ }
564
+
565
+ if (reactions !== "") {
566
+ parts.push(reactions);
567
+ }
568
+
569
+ return parts.join(" ");
570
+ }
571
+
572
+ export function formatMessageHeaderParts(
573
+ message: Message,
574
+ options?: {
575
+ currentMemberId?: string | null;
576
+ }
577
+ ) {
578
+ const timestamp = formatTime(message.created_at);
579
+ const author = message.author.display_name ?? message.author.email ?? "Unknown";
580
+ const renderedAuthor =
581
+ options?.currentMemberId != null && message.author.id === options.currentMemberId
582
+ ? `${ANSI_CURRENT_MEMBER}${author}${ANSI_RESET}`
583
+ : `${ANSI_OTHER_MEMBER}${author}${ANSI_RESET}`;
584
+
585
+ return {
586
+ timestamp,
587
+ author: renderedAuthor,
588
+ };
589
+ }
590
+
591
+ export function formatMessageLine(
592
+ message: Message,
593
+ options?: {
594
+ currentMemberId?: string | null;
595
+ }
596
+ ) {
597
+ const parts = [
598
+ formatDateTime(message.created_at),
599
+ options?.currentMemberId != null && message.author.id === options.currentMemberId
600
+ ? `${ANSI_CURRENT_MEMBER}${truncate(
601
+ message.author.display_name ?? message.author.email ?? "Unknown",
602
+ 20
603
+ )}${ANSI_RESET}`
604
+ : truncate(message.author.display_name ?? message.author.email ?? "Unknown", 20),
605
+ getMessageBody(message),
606
+ ];
607
+ const footer = formatMessageFooter(message);
608
+ const footerSuffix = footer === "" ? "" : ` | ${footer}`;
609
+
610
+ return renderTerminalText(`${parts.filter(Boolean).join(" | ")}${footerSuffix}`);
611
+ }
@@ -0,0 +1,13 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { buildHelpText } from "./help.js";
3
+
4
+ describe("help", () => {
5
+ it("describes interactive mode and command surface", () => {
6
+ const helpText = buildHelpText();
7
+
8
+ expect(helpText).toContain("Interactive Mode:");
9
+ expect(helpText).toContain("ando list-channels");
10
+ expect(helpText).toContain("[u] Older");
11
+ expect(helpText).toContain("--convex-url <url>");
12
+ });
13
+ });
package/src/help.ts ADDED
@@ -0,0 +1,48 @@
1
+ import { getConfigPath } from "./config.js";
2
+
3
+ export function buildHelpText() {
4
+ return `
5
+ ando
6
+
7
+ Usage:
8
+ ando Open the interactive terminal UI
9
+ ando login [--email <email>] [--workspace <id|slug|name>] [--convex-url <url>]
10
+ ando list-channels [--json]
11
+ ando list-dms [--json]
12
+ ando list-messages (--channel <query> | --dm <query> | --conversation <id>) [--limit <n>] [--json]
13
+ ando thread --message-id <id> [--json]
14
+ ando post-message (--channel <query> | --dm <query> | --conversation <id>) --text <markdown> [--json]
15
+ ando reply --message-id <id> --text <markdown> [--json]
16
+ ando react --message-id <id> --emoji <emoji> [--json]
17
+
18
+ Authentication:
19
+ login runs the same email-OTP + workspace-selection flow the web app uses,
20
+ then stores the selected workspace session in:
21
+ ${getConfigPath()}
22
+
23
+ Environment:
24
+ ANDO_CONVEX_URL Convex deployment URL
25
+ VITE_CONVEX_URL Fallback Convex URL
26
+ NEXT_PUBLIC_CONVEX_URL Fallback Convex URL
27
+ ANDO_SESSION_JWT Override saved session token for one run
28
+
29
+ Interactive Mode:
30
+ Running plain "ando" opens a keyboard-driven terminal client for browsing
31
+ channels and DMs, reading messages and threads, paging older and newer
32
+ history, posting messages, replying in threads, and adding reactions.
33
+
34
+ Interactive Keys:
35
+ [c] Channels [d] DMs [/] Search [Tab] Focus panes
36
+ [Enter] Open [t] Thread [u] Older [n] Newer
37
+ [p] Post [r] Reply [a] React [b] Back [q] Quit
38
+
39
+ Examples:
40
+ ando login --email alex@ando.so
41
+ ando list-channels
42
+ ando list-messages -c engineering -n 20
43
+ ando thread -m <message-id>
44
+ ando post-message -c engineering -t "hello"
45
+ ando reply -m <message-id> -t "on it"
46
+ ando react -m <message-id> -e 👍
47
+ `.trim();
48
+ }