@andocorp/cli 0.2.0 → 0.3.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Ando Corp
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,13 +1,10 @@
1
- # Ando CLI (Agent-First)
1
+ # Ando CLI
2
2
 
3
- `ando` is a terminal client for humans and agents.
4
-
5
- This README documents the agent-first command surface where commands are predictable, flag-driven, and easy for automation to script. The classic human-friendly commands and interactive TUI are still supported.
3
+ `ando` provides agent-first terminal commands for Ando. It is built on the
4
+ public `@andocorp/sdk` package and authenticates with an Ando API key.
6
5
 
7
6
  ## Installation
8
7
 
9
- ### npm (Recommended)
10
-
11
8
  ```sh
12
9
  npm install -g @andocorp/cli
13
10
  ```
@@ -18,93 +15,102 @@ Or run without installing:
18
15
  npx @andocorp/cli
19
16
  ```
20
17
 
21
- ### Alternative: Standalone Binaries
22
-
23
- Standalone binaries are available for users who prefer not to install Node.js/npm. Contact hello@ando.so for access.
24
-
25
18
  ## Authentication
26
19
 
27
- Login once with email OTP:
20
+ Log in once:
28
21
 
29
22
  ```sh
30
- ando login --email alex@ando.so
23
+ ando login
31
24
  ```
32
25
 
33
- Optional login flags:
26
+ The command opens a browser authorization page, prints a verification code to
27
+ confirm in the browser, and stores the returned API key in the system keyring
28
+ by default. Non-secret metadata is stored at `~/.config/ando/config.json`.
34
29
 
35
- - `--workspace` / `-w`: pick workspace by id/slug/name
36
- - `--convex-url`: override Convex deployment URL
37
- - `--api-host`: override backend API host used for agent-first commands
30
+ For remote shells or agents that cannot open a browser directly, start a
31
+ two-step login:
38
32
 
39
- Session config is saved at `~/.config/ando/config.json`.
33
+ ```sh
34
+ ando login --no-browser
35
+ ```
40
36
 
41
- ## Agent-first command model
37
+ Open the printed URL, confirm the verification code, then run the printed
38
+ `ando login poll` command in the original shell to finish authentication.
42
39
 
43
- ### 1) `messages` fetch conversation messages
40
+ For headless scripts and CI, provide an API key directly:
44
41
 
45
42
  ```sh
46
- ando messages --channel engineering --limit 10
47
- ando messages -c engineering -m 10
48
- ando messages --dm alex --before <cursor> --json
49
- ando messages --conversation <conversation-id>
43
+ ando login --api-key ando_sk_...
50
44
  ```
51
45
 
52
- Flags:
46
+ You can also set `ANDO_API_KEY` for one-off or CI usage.
47
+
48
+ Set `ANDO_KEYRING=0` to use file-based auth instead of the system keyring. In
49
+ that mode the API key is stored in `auth.json` under the Ando config directory.
50
+ Set `ANDO_HOME` to override the config and auth state directory; otherwise the
51
+ CLI uses the platform config directory.
52
+
53
+ Optional endpoint flags:
53
54
 
54
- - `--channel` / `-c`
55
- - `--dm` / `-d`
56
- - `--conversation`
57
- - `--limit` / `-m`
58
- - `--before`
59
- - `--json`
55
+ - `--base-url`: login and SDK REST base URL
56
+ - `--api-host`: public API base URL for agent-first commands
57
+ - `--realtime-host`: realtime host for SDK subscriptions
60
58
 
61
- ### 2) `search` search entities
59
+ Check local auth health or remove local credentials:
62
60
 
63
61
  ```sh
64
- ando search "incident"
65
- ando search "alice" --type members
66
- ando search "standup" --type conversations
67
- ando search "launch checklist" --type clipboard
68
- ando search "migration" --type calls --after 2026-01-01T00:00:00.000Z
62
+ ando doctor
63
+ ando doctor --json
64
+ ando whoami
65
+ ando whoami --json
66
+ ando logout
69
67
  ```
70
68
 
71
- `search` defaults to `--type messages`.
69
+ `ando whoami` prints the member and workspace resolved by the active API key.
70
+ `ando logout` revokes browser-created CLI credentials before removing local auth
71
+ state. API keys supplied manually or through `ANDO_API_KEY` remain local-only.
72
72
 
73
- Supported types:
73
+ ## Commands
74
74
 
75
- - `messages`
76
- - `members`
77
- - `conversations`
78
- - `clipboard`
79
- - `calls`
80
-
81
- #### Message search filters
75
+ The raw public API escape hatch mirrors the generated Ando API surface. It is
76
+ useful for scripts and agents that need an endpoint before a dedicated CLI
77
+ workflow exists.
82
78
 
83
79
  ```sh
84
- ando search "oauth" --type messages \
85
- --author <member-id>[,<member-id>] \
86
- --conversation <conversation-id>[,<conversation-id>] \
87
- --thread <thread-root-message-id> \
88
- --after 2026-01-01T00:00:00.000Z \
89
- --before 2026-02-01T00:00:00.000Z \
90
- --mode semantic
80
+ ando api ls
81
+ ando api ls --json
82
+ ando api searchMessages q==incident mode==semantic
83
+ ando api getMember memberId=<member-id>
84
+ ando api /v1/search/messages q==incident mode==semantic
85
+ ando api /v1/conversations/<conversation-id>/messages \
86
+ markdown_content="hello" \
87
+ Idempotency-Key:<key>
88
+ echo '{"markdown_content":"hello"}' | ando api /v1/conversations/<conversation-id>/messages --data -
89
+ ando api /v1/search/messages --help
90
+ ando api /v1/search/messages --spec
91
91
  ```
92
92
 
93
- Common flags:
94
-
95
- - `--type` / `-t`
96
- - `--json`
93
+ `ando api` accepts `name==value` query parameters, `Header:Value` declared
94
+ request headers, `field=value` JSON string body fields, `field:=json` typed JSON
95
+ body fields, and `--data <json|->` for complete request bodies. The target can
96
+ be a public path or an operation ID from `ando api ls`; query and header names
97
+ are validated against the generated public API metadata. For operation ID
98
+ targets, supply required path parameters as `name=value`.
97
99
 
98
- Additional filters (depending on type):
99
-
100
- - `--author`
101
- - `--conversation`
102
- - `--thread`
103
- - `--after`
104
- - `--before`
105
- - `--mode` (`full-text` or `semantic`, message search only)
100
+ ```sh
101
+ ando messages --channel engineering --limit 10
102
+ ando messages -c engineering -m 10
103
+ ando messages --dm alex --before <cursor> --json
104
+ ando messages --conversation <conversation-id>
105
+ ```
106
106
 
107
- ### 3) `get` — fetch one entity by id
107
+ ```sh
108
+ ando search "incident"
109
+ ando search "alice" --type members
110
+ ando search "standup" --type conversations
111
+ ando search "launch checklist" --type clipboard
112
+ ando search "migration" --type calls --after 2026-01-01T00:00:00.000Z
113
+ ```
108
114
 
109
115
  ```sh
110
116
  ando get message <message-id>
@@ -114,88 +120,24 @@ ando get call <call-id>
114
120
  ando get transcript <call-id> --limit 100 --cursor <cursor>
115
121
  ```
116
122
 
117
- Entities:
118
-
119
- - `message`
120
- - `member`
121
- - `clipboard`
122
- - `call`
123
- - `transcript`
124
-
125
- Transcript flags:
126
-
127
- - `--limit`
128
- - `--cursor`
129
-
130
- ### 4) `thread` — fetch thread replies
131
-
132
123
  ```sh
133
124
  ando thread <thread-root-message-id>
134
125
  ando thread <thread-root-message-id> --limit 50 --after <cursor>
135
126
  ando thread -m <message-id> --json
136
127
  ```
137
128
 
138
- Flags:
129
+ Every agent-first command supports `--json` for machine-readable output.
130
+ Without `--json`, commands print tab-separated rows.
139
131
 
140
- - positional `<thread-root-message-id>` or `--message-id` / `-m`
141
- - `--limit`
142
- - `--after`
143
- - `--json`
132
+ ## Environment
144
133
 
145
- ## Output
134
+ - `ANDO_API_KEY`: API key for SDK and public API requests
135
+ - `ANDO_KEYRING`: set to `0` to use file-based auth instead of keyring
136
+ - `ANDO_HOME`: config and auth state directory
137
+ - `ANDO_BASE_URL`: login and SDK REST base URL
138
+ - `ANDO_API_HOST`: public API base URL for agent-first commands
139
+ - `ANDO_REALTIME_HOST`: realtime host for SDK subscriptions
140
+ - `XDG_CONFIG_HOME`: fallback base directory for config/auth state
146
141
 
147
- Every agent-first command supports `--json` for machine-readable output. Without `--json`, commands print tab-separated rows designed for piping into `awk`, `cut`, `jq`, etc.
148
-
149
- ## Human-friendly commands (still supported)
150
-
151
- The following commands are still available for humans working in a terminal:
152
-
153
- - `ando` — interactive terminal UI
154
- - `ando list-channels [--limit|-n <n>]` — 10 most recently active channels by default
155
- - `ando list-dms [--limit|-n <n>]` — 10 most recently active DMs by default
156
- - `ando list-messages` — requires `--channel`, `--dm`, or `--conversation`
157
- - `ando post-message` — requires `--channel/--dm/--conversation` and `--text`
158
- - `ando reply` — requires `--message-id` and `--text`
159
- - `ando react` — requires `--message-id` and `--emoji`
160
-
161
- Examples:
162
-
163
- ```sh
164
- ando list-channels
165
- ando list-dms --json
166
- ando list-messages -c engineering -n 20
167
- ando post-message -c engineering -t "hello"
168
- ando reply -m <message-id> -t "on it"
169
- ando react -m <message-id> -e 👍
170
- ```
171
-
172
- ## Environment variables
173
-
174
- - `ANDO_CONVEX_URL`: Convex deployment URL
175
- - `ANDO_API_HOST`: backend API host for agent-first commands (default: `https://api.app.ando.so`)
176
- - `VITE_CONVEX_URL`: fallback Convex URL
177
- - `NEXT_PUBLIC_CONVEX_URL`: fallback Convex URL
178
- - `ANDO_SESSION_JWT`: override saved session token for one run
179
-
180
- Flags take precedence over environment variables, and environment variables take precedence over the saved config.
181
-
182
- ## Interactive mode
183
-
184
- Running plain `ando` opens a keyboard-driven terminal client.
185
-
186
- ```sh
187
- ando
188
- ```
189
-
190
- Keys:
191
-
192
- - `[c]` Channels, `[d]` DMs, `[/]` Search, `[Tab]` Focus panes
193
- - `[Enter]` Open, `[t]` Thread, `[u]` Older, `[n]` Newer
194
- - `[p]` Post, `[r]` Reply, `[a]` React, `[b]` Back, `[q]` Quit
195
-
196
- ## Help
197
-
198
- ```sh
199
- ando help
200
- ando --help
201
- ```
142
+ Flags take precedence over environment variables, and environment variables
143
+ take precedence over saved config.
@@ -0,0 +1,297 @@
1
+ import { getStringFlag, hasFlag } from "./args.js";
2
+ import { resolveConversation } from "./cli-helpers.js";
3
+ import { printJson } from "./output.js";
4
+ async function resolveConversationId(cliClient, parsedArgs) {
5
+ const channelQuery = getStringFlag(parsedArgs, "channel", "c");
6
+ const dmQuery = getStringFlag(parsedArgs, "dm", "d");
7
+ const conversationQuery = getStringFlag(parsedArgs, "conversation");
8
+ if (conversationQuery != null && channelQuery == null && dmQuery == null) {
9
+ return conversationQuery;
10
+ }
11
+ const query = channelQuery ?? dmQuery ?? conversationQuery;
12
+ if (query == null) {
13
+ throw new Error("messages requires --channel, --dm, or --conversation.");
14
+ }
15
+ const memberships = await cliClient.getAllMemberships();
16
+ const membership = resolveConversation(memberships, {
17
+ allowChannels: dmQuery == null,
18
+ allowDms: channelQuery == null,
19
+ query,
20
+ });
21
+ return membership.conversation.id;
22
+ }
23
+ export async function runMessagesCommand(params) {
24
+ const conversationId = await resolveConversationId(params.cliClient, params.parsedArgs);
25
+ const limit = getStringFlag(params.parsedArgs, "limit", "m") ??
26
+ getStringFlag(params.parsedArgs, "n", "n");
27
+ const before = getStringFlag(params.parsedArgs, "before");
28
+ const messageParams = {
29
+ conversationId,
30
+ };
31
+ if (limit != null) {
32
+ messageParams.limit = limit;
33
+ }
34
+ if (before != null) {
35
+ messageParams.before = before;
36
+ }
37
+ const response = await params.apiClient.listConversationMessages(messageParams);
38
+ if (hasFlag(params.parsedArgs, "json")) {
39
+ printJson(response);
40
+ return;
41
+ }
42
+ printMessageResults(response.items);
43
+ }
44
+ function printMessageResults(items) {
45
+ for (const item of items) {
46
+ const author = item.author_name ?? "unknown";
47
+ const content = (item.content ?? "").replace(/\s+/g, " ").trim();
48
+ process.stdout.write(`${item.id}\t${item.created_at}\t${author}\t${content}\n`);
49
+ }
50
+ }
51
+ async function runSearchMessages(apiClient, parsedArgs, query, jsonFlag) {
52
+ const searchParams = { q: query };
53
+ const author = getStringFlag(parsedArgs, "author");
54
+ const conversation = getStringFlag(parsedArgs, "conversation");
55
+ const thread = getStringFlag(parsedArgs, "thread");
56
+ const after = getStringFlag(parsedArgs, "after");
57
+ const before = getStringFlag(parsedArgs, "before");
58
+ const mode = parseSearchMode(getStringFlag(parsedArgs, "mode"));
59
+ if (author != null) {
60
+ searchParams.author = author;
61
+ }
62
+ if (conversation != null) {
63
+ searchParams.conversation = conversation;
64
+ }
65
+ if (thread != null) {
66
+ searchParams.thread = thread;
67
+ }
68
+ if (after != null) {
69
+ searchParams.after = after;
70
+ }
71
+ if (before != null) {
72
+ searchParams.before = before;
73
+ }
74
+ if (mode != null) {
75
+ searchParams.mode = mode;
76
+ }
77
+ const response = await apiClient.searchMessages(searchParams);
78
+ if (jsonFlag) {
79
+ printJson(response);
80
+ return;
81
+ }
82
+ printMessageResults(response.items);
83
+ }
84
+ async function runSearchMembers(apiClient, query, jsonFlag) {
85
+ const response = await apiClient.searchMembers({ q: query });
86
+ if (jsonFlag) {
87
+ printJson(response);
88
+ return;
89
+ }
90
+ for (const item of response.items) {
91
+ const title = item.title == null || item.title === "" ? "" : ` (${item.title})`;
92
+ process.stdout.write(`${item.id}\t${item.display_name ?? ""}\t${item.email ?? ""}${title}\n`);
93
+ }
94
+ }
95
+ async function runSearchConversations(apiClient, query, jsonFlag) {
96
+ const response = await apiClient.searchConversations({ q: query });
97
+ if (jsonFlag) {
98
+ printJson(response);
99
+ return;
100
+ }
101
+ for (const item of response.items) {
102
+ process.stdout.write(`${item.id}\t${item.type}\t${item.name ?? ""}\t${item.human_members_count} members\n`);
103
+ }
104
+ }
105
+ async function runSearchClipboard(apiClient, query, jsonFlag) {
106
+ const response = await apiClient.searchClipboards({ q: query });
107
+ if (jsonFlag) {
108
+ printJson(response);
109
+ return;
110
+ }
111
+ for (const item of response.items) {
112
+ process.stdout.write(`${item.id}\t${item.created_at}\t${item.title ?? ""}\t${item.item_count} items\n`);
113
+ }
114
+ }
115
+ async function runSearchCalls(apiClient, parsedArgs, query, jsonFlag) {
116
+ const searchParams = { q: query };
117
+ const conversation = getStringFlag(parsedArgs, "conversation");
118
+ const after = getStringFlag(parsedArgs, "after");
119
+ const before = getStringFlag(parsedArgs, "before");
120
+ if (conversation != null) {
121
+ searchParams.conversation = conversation;
122
+ }
123
+ if (after != null) {
124
+ searchParams.after = after;
125
+ }
126
+ if (before != null) {
127
+ searchParams.before = before;
128
+ }
129
+ const response = await apiClient.searchCalls(searchParams);
130
+ if (jsonFlag) {
131
+ printJson(response);
132
+ return;
133
+ }
134
+ for (const item of response.items) {
135
+ process.stdout.write(`${item.id}\t${item.status}\t${item.conversation_name ?? ""}\t${item.duration_seconds ?? "-"}s\n`);
136
+ }
137
+ }
138
+ export async function runSearchCommand(params) {
139
+ const query = params.parsedArgs.positionals[1];
140
+ const type = (getStringFlag(params.parsedArgs, "type", "t") ?? "messages").toLowerCase();
141
+ const jsonFlag = hasFlag(params.parsedArgs, "json");
142
+ if (query == null || query.trim() === "") {
143
+ throw new Error('search requires a query, e.g. ando search "incident".');
144
+ }
145
+ switch (type) {
146
+ case "messages": {
147
+ await runSearchMessages(params.apiClient, params.parsedArgs, query, jsonFlag);
148
+ return;
149
+ }
150
+ case "members": {
151
+ await runSearchMembers(params.apiClient, query, jsonFlag);
152
+ return;
153
+ }
154
+ case "conversations": {
155
+ await runSearchConversations(params.apiClient, query, jsonFlag);
156
+ return;
157
+ }
158
+ case "clipboard": {
159
+ await runSearchClipboard(params.apiClient, query, jsonFlag);
160
+ return;
161
+ }
162
+ case "calls": {
163
+ await runSearchCalls(params.apiClient, params.parsedArgs, query, jsonFlag);
164
+ return;
165
+ }
166
+ default:
167
+ throw new Error(`Unknown search --type "${type}". Expected messages, members, conversations, clipboard, or calls.`);
168
+ }
169
+ }
170
+ function parseSearchMode(value) {
171
+ if (value == null) {
172
+ return undefined;
173
+ }
174
+ if (value === "full-text" || value === "semantic") {
175
+ return value;
176
+ }
177
+ throw new Error(`Unknown --mode "${value}". Expected full-text or semantic.`);
178
+ }
179
+ async function runGetMessage(apiClient, id, jsonFlag) {
180
+ const response = await apiClient.getConversationMessageResult(id);
181
+ if (jsonFlag) {
182
+ printJson(response);
183
+ return;
184
+ }
185
+ printMessageResults([response]);
186
+ }
187
+ async function runGetMember(apiClient, id, jsonFlag) {
188
+ const response = await apiClient.getMember(id);
189
+ if (jsonFlag) {
190
+ printJson(response);
191
+ return;
192
+ }
193
+ const title = response.title == null ? "" : ` (${response.title})`;
194
+ process.stdout.write(`${response.id}\t${response.display_name ?? ""}\t${response.email ?? ""}${title}\n`);
195
+ }
196
+ async function runGetClipboard(apiClient, id, jsonFlag) {
197
+ const response = await apiClient.getClipboard(id);
198
+ if (jsonFlag) {
199
+ printJson(response);
200
+ return;
201
+ }
202
+ process.stdout.write(`${response.id}\t${response.title ?? ""}\t${response.created_at}\n`);
203
+ for (const item of response.items) {
204
+ process.stdout.write(` - ${item.type}\t${item.id}\t${item.name ?? item.display_name ?? item.author_name ?? ""}\n`);
205
+ }
206
+ }
207
+ async function runGetCall(apiClient, id, jsonFlag) {
208
+ const response = await apiClient.getCall(id);
209
+ if (jsonFlag) {
210
+ printJson(response);
211
+ return;
212
+ }
213
+ process.stdout.write(`${response.id}\t${response.status}\t${response.conversation_name ?? ""}\t${response.duration_seconds ?? "-"}s\n`);
214
+ for (const participant of response.participants) {
215
+ process.stdout.write(` - ${participant.id}\t${participant.name ?? ""}\n`);
216
+ }
217
+ }
218
+ async function runGetTranscript(apiClient, parsedArgs, id, jsonFlag) {
219
+ const transcriptParams = {
220
+ callId: id,
221
+ };
222
+ const limit = getStringFlag(parsedArgs, "limit");
223
+ const cursor = getStringFlag(parsedArgs, "cursor");
224
+ if (limit != null) {
225
+ transcriptParams.limit = limit;
226
+ }
227
+ if (cursor != null) {
228
+ transcriptParams.cursor = cursor;
229
+ }
230
+ const response = await apiClient.getCallTranscript(transcriptParams);
231
+ if (jsonFlag) {
232
+ printJson(response);
233
+ return;
234
+ }
235
+ for (const segment of response.segments) {
236
+ process.stdout.write(`${segment.start_ms}\t${segment.end_ms}\t${segment.speaker_name ?? ""}\t${segment.content}\n`);
237
+ }
238
+ }
239
+ export async function runGetCommand(params) {
240
+ const entity = params.parsedArgs.positionals[1];
241
+ const id = params.parsedArgs.positionals[2];
242
+ const jsonFlag = hasFlag(params.parsedArgs, "json");
243
+ if (entity == null || id == null || id.trim() === "") {
244
+ throw new Error("get requires an entity type and id, e.g. ando get message <id>.");
245
+ }
246
+ switch (entity) {
247
+ case "message": {
248
+ await runGetMessage(params.apiClient, id, jsonFlag);
249
+ return;
250
+ }
251
+ case "member": {
252
+ await runGetMember(params.apiClient, id, jsonFlag);
253
+ return;
254
+ }
255
+ case "clipboard": {
256
+ await runGetClipboard(params.apiClient, id, jsonFlag);
257
+ return;
258
+ }
259
+ case "call": {
260
+ await runGetCall(params.apiClient, id, jsonFlag);
261
+ return;
262
+ }
263
+ case "transcript": {
264
+ await runGetTranscript(params.apiClient, params.parsedArgs, id, jsonFlag);
265
+ return;
266
+ }
267
+ default:
268
+ throw new Error(`Unknown get entity "${entity}". Expected message, member, clipboard, call, or transcript.`);
269
+ }
270
+ }
271
+ export async function runAgentThreadCommand(params) {
272
+ const positionalId = params.parsedArgs.positionals[1];
273
+ const messageId = positionalId ?? getStringFlag(params.parsedArgs, "message-id", "m");
274
+ if (messageId == null || messageId.trim() === "") {
275
+ throw new Error("thread requires a thread root message id.");
276
+ }
277
+ const threadParams = {
278
+ messageId,
279
+ };
280
+ const limit = getStringFlag(params.parsedArgs, "limit");
281
+ const after = getStringFlag(params.parsedArgs, "after");
282
+ if (limit != null) {
283
+ threadParams.limit = limit;
284
+ }
285
+ if (after != null) {
286
+ threadParams.after = after;
287
+ }
288
+ const response = await params.apiClient.listThreadReplies(threadParams);
289
+ if (hasFlag(params.parsedArgs, "json")) {
290
+ printJson(response);
291
+ return;
292
+ }
293
+ for (const reply of response.items) {
294
+ const content = (reply.content ?? "").replace(/\s+/g, " ").trim();
295
+ process.stdout.write(`${reply.id}\t${reply.created_at}\t${reply.author_name ?? ""}\t${content}\n`);
296
+ }
297
+ }